This commit is contained in:
iisaev committed 2026-10-18 12:41:36 +03:00
commit 5e0a12b038
23 files changed
+1650

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
mod_interactive_base_name = Interactive Base
mod_interactive_base_desc = Interactive objects subsystem: JSON-driven scene interactives (hover/click hit areas, highlight effects) with DSL/scenario click actions, the InteractiveManager and the interactive editor data plane.
+2
View File
@@ -0,0 +1,2 @@
mod_interactive_base_name = Интерактивные объекты
mod_interactive_base_desc = Подсистема интерактивных объектов: JSON-описания зон наведения и клика с подсветкой и сценарными действиями, InteractiveManager и данные редактора интерактива.
+14
View File
@@ -0,0 +1,14 @@
{
"id": "interactive_base",
"name": "Interactive Base",
"version": "1.0.0",
"author": "ct",
"description": "Interactive objects subsystem: JSON-driven scene interactives (hover/click hit areas, highlight effects) with DSL/scenario click actions. Owns InteractiveManager, the interactive definition resolver/factory and the InteractiveEditor data plane.",
"category": "engine, library",
"depends": ["base2d>=1", "interface>=1"],
"sources": [ "src/**/*.cs" ],
"ui": [],
"test_suites": [
{"id": "interactives", "activate": ["--itest=interactives"]}
]
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Core.Interactive
{
/// <summary>Enumeration of supported hit-area shapes for interactive objects.</summary>
public enum HitAreaType
{
/// <summary>Full rectangular area matching the control bounds.</summary>
Rect,
/// <summary>Circular area centred within the control bounds.</summary>
Circle,
/// <summary>Custom polygon defined by vertex array.</summary>
Polygon
}
}
+169
View File
@@ -0,0 +1,169 @@
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Core.Interactive
{
/// <summary>
/// JSON-driven definition of an interactive scene object loaded from mods.
/// The texture is always full-screen (alpha determines visibility outside the collider).
/// The collider defines the clickable region in normalised 0..1 viewport coordinates.
/// Interactives keep their own resolver and <c>{"Items": [...]}</c> envelope;
/// unlike other entity kinds they are not part of the kind-routed pipeline.
/// </summary>
public class InteractiveDefinition : IIdentifiable
{
/// <summary>Unique identifier used in scenario commands and registry lookups.</summary>
[JsonPropertyName("id")]
public string ID { get; set; }
/// <summary>Owning mod identifier set by ModManager during collection.</summary>
[JsonIgnore]
public string ModId { get; set; }
/// <summary>Texture key resolved via <c>ModManager.ResolveTexture</c>. Full-screen with alpha.</summary>
[JsonPropertyName("texture")]
public string Texture { get; set; }
/// <summary>Visual layer: "background", "foreground" or "ui". Defaults to "foreground".</summary>
[JsonPropertyName("layer")]
public string Layer { get; set; } = "foreground";
/// <summary>Z-index for ordering within the layer. Higher = on top.</summary>
[JsonPropertyName("zIndex")]
public int ZIndex { get; set; }
/// <summary>Whether the object is initially enabled (responds to input). Default true.</summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
/// <summary>Whether the object is initially visible. Default true.</summary>
[JsonPropertyName("visible")]
public bool Visible { get; set; } = true;
/// <summary>Mouse cursor override when hovering (Godot CursorShape enum name, e.g. "PointingHand").</summary>
[JsonPropertyName("cursor")]
public string Cursor { get; set; }
/// <summary>Collider shape and position in normalised viewport coordinates (0..1).</summary>
[JsonPropertyName("hitArea")]
public HitAreaDefinition HitArea { get; set; }
/// <summary>Visual highlight settings applied on hover.</summary>
[JsonPropertyName("highlight")]
public HighlightDefinition Highlight { get; set; }
/// <summary>Actions triggered by pointer events.</summary>
[JsonPropertyName("actions")]
public InteractiveActionDefinition Actions { get; set; }
}
/// <summary>
/// Shape and position of the clickable collider in normalised viewport
/// coordinates (0..1), where (0,0) is the top-left corner and (1,1) is bottom-right.
/// </summary>
public class HitAreaDefinition
{
/// <summary><c>"rect"</c>, <c>"circle"</c> or <c>"polygon"</c>. Defaults to <c>"rect"</c>.</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "rect";
/// <summary>X position of the collider origin (top-left for rect, centre for circle). Normalised 0..1.</summary>
[JsonPropertyName("x")]
public float X { get; set; }
/// <summary>Y position of the collider origin. Normalised 0..1.</summary>
[JsonPropertyName("y")]
public float Y { get; set; }
/// <summary>Width for rect type. Normalised 0..1.</summary>
[JsonPropertyName("width")]
public float Width { get; set; } = 0.1f;
/// <summary>Height for rect type. Normalised 0..1.</summary>
[JsonPropertyName("height")]
public float Height { get; set; } = 0.1f;
/// <summary>Radius for circle type. Normalised 0..1 (relative to viewport min dimension).</summary>
[JsonPropertyName("radius")]
public float Radius { get; set; } = 0.05f;
/// <summary>Polygon vertices as an array of normalised Vector2 points. Only for "polygon" type.</summary>
[JsonPropertyName("vertices")]
[JsonConverter(typeof(Vector2ArrayJsonConverter))]
public Vector2[] Vertices { get; set; }
}
/// <summary>
/// Highlight effect applied when the pointer hovers over the collider.
/// Uses a shader-based colour modulation on the full-screen texture.
/// </summary>
public class HighlightDefinition
{
/// <summary>Modulate colour as a hex string (e.g. "#FFFF0033"). Parsed to Godot.Color at runtime.</summary>
[JsonPropertyName("color")]
public string ColorHex { get; set; } = "#FFFF0033";
/// <summary>Pulse scale multiplier applied during hover (e.g. 1.02 = 2% bigger).</summary>
[JsonPropertyName("scale")]
public float Scale { get; set; } = 1.02f;
/// <summary>Duration in seconds of the hover/unhover animation.</summary>
[JsonPropertyName("duration")]
public float Duration { get; set; } = 0.15f;
/// <summary>Parsed Godot.Color from ColorHex. Computed at load time, not serialised.</summary>
[JsonIgnore]
public Color Color => ParseColor(ColorHex);
private static Color ParseColor(string hex)
{
if (string.IsNullOrEmpty(hex))
return UiTokens.Alpha(UiTokens.Accent, 0.3f);
try
{
return new Color(hex);
}
catch
{
return UiTokens.Alpha(UiTokens.Accent, 0.3f);
}
}
}
/// <summary>
/// Actions dispatched on pointer events.
/// </summary>
public class InteractiveActionDefinition
{
/// <summary>Action executed on left-click.</summary>
[JsonPropertyName("onClick")]
public ClickAction OnClick { get; set; }
/// <summary>Inline DSL commands executed on mouse enter.</summary>
[JsonPropertyName("onHoverEnter")]
public string OnHoverEnter { get; set; }
/// <summary>Inline DSL commands executed on mouse leave.</summary>
[JsonPropertyName("onHoverLeave")]
public string OnHoverLeave { get; set; }
}
/// <summary>
/// Click action descriptor. Supports a referenced <c>.scenario</c> file
/// and/or inline DSL commands.
/// </summary>
public class ClickAction
{
/// <summary>Path to a <c>.scenario</c> file within a mod.</summary>
[JsonPropertyName("scenario")]
public string Scenario { get; set; }
/// <summary>Inline DSL commands to execute (one command per array element).</summary>
[JsonPropertyName("commands")]
public string[] Commands { get; set; }
}
}
+50
View File
@@ -0,0 +1,50 @@
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Interactive;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Factories.Impls
{
/// <summary>
/// Factory for <c>InteractiveDefinition</c> lookups. Wraps
/// <c>ModManager.CollectInteractives()</c> with a thread-safe singleton
/// accessor. Since the underlying collection is already cached by
/// <c>ModManager</c>, this factory is a thin forwarding layer that
/// provides the same <c>Instance.Get(id)</c> API convention as every
/// other factory in the project.
/// <para/>
/// The single instance lives in this mod assembly as a static lazy
/// singleton; the static facade stays as the public API.
/// </summary>
public class InteractiveFactory
{
private static InteractiveFactory _instance;
/// <summary>Static lazy singleton; kept as the public API.</summary>
public static InteractiveFactory Instance => _instance ??= new InteractiveFactory();
private InteractiveFactory()
{
}
/// <summary>
/// Looks up an interactive definition by its unique ID.
/// Returns <c>null</c> if no definition with that ID exists
/// in any mod.
/// </summary>
/// <param name="id">The <c>ID</c> from the definition JSON.</param>
public InteractiveDefinition Get(string id)
{
if (string.IsNullOrEmpty(id))
return null;
var dict = Interactives.Collect();
if (dict != null && dict.TryGetValue(id, out var def))
return def;
GameLogger.Log("FACTORY", $"InteractiveFactory: definition '{id}' not found", LogLevel.Error);
return null;
}
}
}
+307
View File
@@ -0,0 +1,307 @@
using System.Collections.Generic;
using Cthangover.Core.Events;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Dialog;
using Cthangover.Mods.Interface.View;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Interactive
{
/// <summary>
/// Central registry for active interactive objects on the current scene.
/// Manages the full lifecycle: creation from <c>InteractiveDefinition</c>,
/// placement into <c>ViewBox</c> layer containers, callback wiring for
/// hover/click actions, and cleanup on scene change.
///
/// Created and owned by <c>SceneContextNode</c> so its state is
/// automatically cleared when the scene changes.
///
/// Access via <c>InteractiveManager.Instance</c> (set during
/// <c>SceneContextNode._Ready</c>).
/// </summary>
public class InteractiveManager
{
private static InteractiveManager _instance;
/// <summary>
/// Singleton instance, valid only while a scene is active.
/// Set by <c>SceneContextNode</c> on scene enter, cleared on scene exit.
/// </summary>
public static InteractiveManager Instance => _instance;
private readonly Dictionary<string, InteractiveObject> _objects = new();
private bool _showDebugBounds;
/// <summary>
/// Initialises the singleton and performs one-time setup.
/// Called by <c>SceneContextNode._Ready</c>.
/// </summary>
public static void Initialize()
{
_instance = new InteractiveManager();
}
/// <summary>
/// Tears down active objects but keeps the singleton alive.
/// Called by <c>SceneContextNode.ClearData</c> on scene change.
/// The manager instance persists for the full game session; only
/// the tracked objects are cleaned up on each scene transition.
/// </summary>
public static void Shutdown()
{
_instance?.ClearAll();
}
/// <summary>
/// Toggles debug visualisation of hit-area bounds and object IDs.
/// When enabled, every active object draws a coloured overlay.
/// </summary>
public bool ShowDebugBounds
{
get => _showDebugBounds;
set
{
_showDebugBounds = value;
foreach (var kv in _objects)
kv.Value.UpdateDebugVisual(value);
}
}
/// <summary>
/// Number of currently active interactive objects.
/// </summary>
public int Count => _objects.Count;
/// <summary>
/// Instantiates an interactive object from its definition ID and adds it
/// to the appropriate <c>InteractiveLayer</c> in the <c>ViewBox</c>.
/// If an object with the same ID is already active, returns the existing one.
/// </summary>
/// <param name="definitionId">The <c>ID</c> from the <c>InteractiveDefinition</c> JSON.</param>
/// <returns>The created or existing <c>InteractiveObject</c>, or <c>null</c> if the definition is not found.</returns>
public InteractiveObject Add(string definitionId)
{
if (string.IsNullOrEmpty(definitionId))
return null;
if (_objects.TryGetValue(definitionId, out var existing))
return existing;
var def = InteractiveFactory.Instance.Get(definitionId);
if (def == null)
{
GameLogger.Log("INTERACTIVE", $"Add: definition '{definitionId}' not found", LogLevel.Error);
return null;
}
var viewBox = SceneContextNode.FindNode<ViewBox>("ViewBox");
if (viewBox == null)
{
GameLogger.Log("INTERACTIVE", "Add: ViewBox not found", LogLevel.Error);
return null;
}
var layerContainer = viewBox.GetInteractiveLayer(def.Layer);
if (layerContainer == null)
{
GameLogger.Log("INTERACTIVE", $"Add: layer '{def.Layer}' not found, trying foreground", LogLevel.Warning);
layerContainer = viewBox.GetInteractiveLayer("foreground");
}
if (layerContainer == null)
{
GameLogger.Log("INTERACTIVE", "Add: no interactive layer containers available (ViewBox layers not ready?)", LogLevel.Error);
return null;
}
var contentSize = viewBox.LogicalSize != Vector2I.Zero
? new Vector2(viewBox.LogicalSize.X, viewBox.LogicalSize.Y)
: viewBox.Content?.Size ?? new Vector2(1920f, 1024f);
var obj = new InteractiveObject
{
Name = "Interactive_" + def.ID,
ID = def.ID
};
obj.OnHoverEnterCallback = ExecuteHoverDsl;
obj.OnHoverLeaveCallback = ExecuteHoverDsl;
obj.OnClickCallback = ExecuteClick;
layerContainer.AddChild(obj);
obj.Configure(def, contentSize);
_objects[definitionId] = obj;
GameLogger.Log("INTERACTIVE", $"Add: '{definitionId}' created on layer '{def.Layer}' fullscreen={contentSize.X:F0}x{contentSize.Y:F0} hitType={def.HitArea?.Type ?? "rect"}");
return obj;
}
/// <summary>
/// Removes and destroys the interactive object with the given definition ID.
/// Publishes an <see cref="InteractiveStateChangedEvent"/> only if the object
/// actually existed and was removed, so condition caches invalidate on real
/// removal rather than on redundant calls.
/// </summary>
/// <param name="definitionId">The definition ID to remove.</param>
public void Remove(string definitionId)
{
if (_objects.TryGetValue(definitionId, out var obj))
{
GameLogger.Log("INTERACTIVE", $"Remove: '{definitionId}'");
ModEventBus.Publish(new InteractiveStateChangedEvent { Id = definitionId });
obj.Destruct();
_objects.Remove(definitionId);
}
}
/// <summary>
/// Removes and destroys all active interactive objects.
/// Called automatically on scene change via <c>Shutdown</c>. Publishes one
/// <see cref="InteractiveStateChangedEvent"/> per removed object (before
/// destruct/clear) so condition caches invalidate for each disappearance.
/// </summary>
public void ClearAll()
{
GameLogger.Log("INTERACTIVE", $"ClearAll: destroying {_objects.Count} object(s)");
if (_objects.Count > 0)
{
foreach (var kv in _objects)
ModEventBus.Publish(new InteractiveStateChangedEvent { Id = kv.Key });
}
// Objects live inside the scene: leaving it to the main menu (or
// switching games) frees them with the scene before this runs.
foreach (var kv in _objects)
{
if (GodotObject.IsInstanceValid(kv.Value))
kv.Value.Destruct();
}
_objects.Clear();
}
/// <summary>
/// Looks up an active interactive object by its definition ID.
/// </summary>
/// <param name="definitionId">The definition ID to find.</param>
/// <returns>The <c>InteractiveObject</c> or <c>null</c>.</returns>
public InteractiveObject Get(string definitionId)
{
_objects.TryGetValue(definitionId, out var obj);
return obj;
}
/// <summary>
/// Enables or disables an active interactive object.
/// </summary>
/// <param name="definitionId">The definition ID.</param>
/// <param name="enabled">New enabled state.</param>
public void SetEnabled(string definitionId, bool enabled)
{
if (_objects.TryGetValue(definitionId, out var obj))
obj.IsEnabled = enabled;
}
/// <summary>
/// Shows or hides an active interactive object.
/// </summary>
/// <param name="definitionId">The definition ID.</param>
/// <param name="visible">New visibility state.</param>
public void SetVisible(string definitionId, bool visible)
{
if (_objects.TryGetValue(definitionId, out var obj))
obj.Visible = visible;
}
private void ExecuteHoverDsl(string dsl)
{
if (string.IsNullOrEmpty(dsl))
return;
GameLogger.Log("INTERACTIVE", $"ExecuteHoverDsl: '{dsl}'");
GetDialogRunner()?.ExecuteInlineCommands(dsl);
}
private void ExecuteClick(string definitionId)
{
if (string.IsNullOrEmpty(definitionId))
return;
var def = InteractiveFactory.Instance.Get(definitionId);
if (def?.Actions?.OnClick == null)
{
GameLogger.Log("INTERACTIVE", $"ExecuteClick: no action defined for '{definitionId}'");
return;
}
var clickAction = def.Actions.OnClick;
var hasScenario = !string.IsNullOrEmpty(clickAction.Scenario);
var hasCommands = clickAction.Commands != null && clickAction.Commands.Length > 0;
if (!hasScenario && !hasCommands)
return;
if (!hasScenario && hasCommands)
{
var inlineDsl = string.Join("\n", clickAction.Commands);
GetDialogRunner()?.ExecuteInlineCommands(inlineDsl);
return;
}
var runner = GetDialogRunner();
if (runner == null)
{
GameLogger.Log("INTERACTIVE", "ExecuteClick: dialog runner not available (dialog_base mod not loaded)", LogLevel.Error);
return;
}
runner.TryExecuteClickDialog(definitionId);
}
private IInteractiveDialogRunner GetDialogRunner()
{
var tree = Godot.Engine.GetMainLoop() as SceneTree;
return tree?.Root?.GetNodeOrNull<Node>("/root/DialogRunner") as IInteractiveDialogRunner;
}
private readonly Dictionary<string, string> _hoverVariables = new();
/// <summary>
/// Simple variable store for hover-event DSL commands (e.g. <c>set cursor_hint=door</c>).
/// UI elements can subscribe or poll these values to show contextual hints.
/// </summary>
public string GetHoverVariable(string name)
{
_hoverVariables.TryGetValue(name, out var val);
return val;
}
/// <summary>
/// Creates a custom interactive object programmatically using a fluent
/// builder. For use in wrapper-template C# code.
/// </summary>
/// <param name="id">Unique instance ID.</param>
/// <returns>An <c>InteractiveObjectBuilder</c> for fluent configuration.</returns>
public InteractiveObjectBuilder CreateCustom(string id)
{
return new InteractiveObjectBuilder(id, this);
}
/// <summary>Tracks a programmatically built object (from <see cref="CreateCustom"/>) and wires default callbacks where none were set.</summary>
internal void RegisterBuiltObject(InteractiveObject obj)
{
if (obj == null)
return;
_objects[obj.ID] = obj;
obj.OnHoverEnterCallback ??= ExecuteHoverDsl;
obj.OnHoverLeaveCallback ??= ExecuteHoverDsl;
obj.OnClickCallback ??= ExecuteClick;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
using Cthangover.Core.Events;
using Cthangover.Core.Interactive;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Interactive
{
/// <summary>
/// Engine-mod initializer for the interactive subsystem. Takes over the
/// lifecycle <c>SceneContextNode</c> owned before the migration: on
/// resources-ready it initializes the <c>InteractiveManager</c> singleton
/// and subscribes the per-scene teardown (<c>InteractiveManager.Shutdown</c>)
/// to <c>SceneExitedEvent</c>, so tracked interactive objects are destroyed
/// on every scene change. All operations are idempotent:
/// <c>ModManager.Reload()</c> re-runs <c>OnModResourcesReady</c>, so repeat
/// invocations must not corrupt state.
/// </summary>
public class InteractiveModInitializer : IModInitializer
{
private static bool initialized;
/// <summary>No per-mod setup is required; lifecycle happens on resources-ready.</summary>
public void OnModLoaded(string modId) { }
/// <summary>
/// Initializes the manager singleton and wires the scene-exit teardown.
/// Safe to call again after a mod reload.
/// </summary>
public void OnModResourcesReady()
{
if (initialized)
return;
initialized = true;
InteractiveManager.Initialize();
GameLogger.Log("INTERACTIVE", "InteractiveModInitializer: manager initialized");
ModEventBus.Subscribe<SceneExitedEvent>(_ => InteractiveManager.Shutdown());
}
}
}
+399
View File
@@ -0,0 +1,399 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Utils;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Core.Interactive
{
/// <summary>
/// Runtime node for an interactive scene object. The Control fills its parent
/// (the interactive layer container) and displays a full-screen texture with
/// alpha. The collider (rect, circle, or polygon) defines the clickable region
/// in normalised viewport coordinates, converted to pixels at runtime.
///
/// Hit detection uses <c>_HasPoint</c> overrides verified against global
/// mouse position and the pixel-space collider geometry.
/// </summary>
public partial class InteractiveObject : Control, IEventObject
{
private static readonly Color _transparent = new(0f, 0f, 0f, 0f);
/// <summary>Unique instance identifier, used for registry and <c>IEventObject</c> tracking.</summary>
public string ID { get; set; }
/// <summary>Reference back to the <c>InteractiveDefinition.ID</c> that spawned this object.</summary>
public string DefinitionId { get; private set; }
/// <summary>Whether the object responds to mouse input.</summary>
public bool IsEnabled { get; set; } = true;
/// <summary>True while the pointer is within the collider.</summary>
public bool IsHovered { get; private set; }
/// <summary>Invoked on hover enter with the definition's hover-enter DSL (wired by <see cref="InteractiveManager"/>).</summary>
internal Action<string> OnHoverEnterCallback { get; set; }
/// <summary>Invoked on hover leave with the definition's hover-leave DSL.</summary>
internal Action<string> OnHoverLeaveCallback { get; set; }
/// <summary>Invoked on click with the definition id (wired by <see cref="InteractiveManager"/>).</summary>
internal Action<string> OnClickCallback { get; set; }
private TextureRect _visual;
private ShaderMaterial _highlightMaterial;
private Tween _highlightTween;
private HitAreaType _hitType = HitAreaType.Rect;
private float _hitRadiusPx;
private Vector2[] _hitPolygonPx;
private Rect2 _hitRectPx;
private Vector2 _contentSize;
private Color _highlightColor = _transparent;
private float _highlightScale = 1f;
private float _highlightDuration = 0.15f;
private string _onHoverEnterDsl;
private string _onHoverLeaveDsl;
private ColorRect _debugRect;
private Label _debugLabel;
private bool _visualReady;
/// <summary>Prepares the full-screen visual, highlight material and mouse handlers.</summary>
public override void _Ready()
{
MouseFilter = MouseFilterEnum.Stop;
ClipContents = true;
EnsureVisualReady();
MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited;
GameLogger.Log("INTERACTIVE", $"_Ready: id='{ID}' size=({Size.X:F0}x{Size.Y:F0}) visible={Visible} enabled={IsEnabled}");
}
/// <summary>
/// Applies a definition to this instance. The Control is made full-screen
/// within its parent, the texture fills the entire area, and the collider
/// geometry is computed from normalised coordinates multiplied by the
/// logical viewport size.
/// </summary>
public void Configure(InteractiveDefinition def, Vector2 contentSize)
{
if (def == null)
return;
DefinitionId = def.ID;
_contentSize = contentSize;
EnsureVisualReady();
AnchorLeft = 0f;
AnchorTop = 0f;
AnchorRight = 1f;
AnchorBottom = 1f;
ZIndex = def.ZIndex;
Visible = def.Visible;
IsEnabled = def.Enabled;
if (!string.IsNullOrEmpty(def.Texture))
{
var tex = Textures.Resolve(def.Texture);
if (tex != null)
_visual.Texture = tex;
else
GameLogger.Log("INTERACTIVE", $"Configure: texture '{def.Texture}' for '{def.ID}' not found in any mod", LogLevel.Warning);
}
ApplyHitArea(def.HitArea, contentSize);
if (def.Highlight != null)
{
_highlightColor = def.Highlight.Color;
_highlightScale = def.Highlight.Scale;
_highlightDuration = def.Highlight.Duration;
}
_onHoverEnterDsl = def.Actions?.OnHoverEnter;
_onHoverLeaveDsl = def.Actions?.OnHoverLeave;
SetCursor(def.Cursor);
GameLogger.Log("INTERACTIVE", $"Configure: id='{def.ID}' fullScreen contentSize=({contentSize.X:F0},{contentSize.Y:F0}) hitType={_hitType}");
}
/// <summary>
/// Checks whether the global mouse position falls within the collider
/// geometry in pixel space. Bypasses Godot's local coordinate issues.
/// </summary>
public override bool _HasPoint(Vector2 point)
{
if (!IsEnabled || !Visible)
return false;
var globalMouse = GetGlobalMousePosition();
var globalPos = GlobalPosition;
switch (_hitType)
{
case HitAreaType.Rect:
{
var rect = new Rect2(globalPos + _hitRectPx.Position, _hitRectPx.Size);
return rect.HasPoint(globalMouse);
}
case HitAreaType.Circle:
{
var center = globalPos + new Vector2(_hitRectPx.Position.X + _hitRectPx.Size.X / 2f, _hitRectPx.Position.Y + _hitRectPx.Size.Y / 2f);
return (globalMouse - center).Length() <= _hitRadiusPx;
}
case HitAreaType.Polygon:
{
if (_hitPolygonPx == null || _hitPolygonPx.Length < 3)
return false;
var local = globalMouse - globalPos;
return Geometry2D.IsPointInPolygon(local, _hitPolygonPx);
}
}
return false;
}
/// <summary>
/// Unregisters from the <c>SceneContextNode</c> event-object tracker and
/// frees the Godot node. Called by <c>InteractiveManager.ClearAll</c> on
/// scene transition and by <c>InteractiveManager.Remove</c> for targeted
/// cleanup, ensuring no dangling references survive scene teardown.
/// </summary>
public void Destruct()
{
SceneContextNode.Instance?.RemoveEventObject(ID);
QueueFree();
}
/// <summary>
/// Creates or hides a debug overlay showing the hit-area bounds rectangle
/// and the object's <c>ID</c> label. The rectangle colour changes to
/// yellow when the pointer is inside the collider. Used by
/// <c>InteractiveManager.ShowDebugBounds</c> to toggle all objects at once.
/// </summary>
/// <param name="show">Whether to create/show or hide the debug visuals.</param>
public void UpdateDebugVisual(bool show)
{
if (show)
{
if (_debugRect == null)
{
_debugRect = new ColorRect
{
Name = "DebugRect",
MouseFilter = MouseFilterEnum.Ignore,
ZIndex = 100
};
AddChild(_debugRect);
}
if (_debugLabel == null)
{
_debugLabel = new Label
{
Name = "DebugLabel",
MouseFilter = MouseFilterEnum.Ignore,
ZIndex = 101,
Position = new Vector2(2f, 2f)
};
AddChild(_debugLabel);
}
_debugRect.Position = _hitRectPx.Position;
_debugRect.Size = _hitRectPx.Size;
_debugRect.Visible = true;
_debugRect.Color = IsHovered
? UiTokens.Alpha(UiTokens.Accent, 0.35f)
: UiTokens.Alpha(UiTokens.Success, 0.2f);
_debugLabel.Visible = true;
_debugLabel.Text = DefinitionId ?? ID;
}
else
{
if (_debugRect != null)
_debugRect.Visible = false;
if (_debugLabel != null)
_debugLabel.Visible = false;
}
}
private void SetupHighlightMaterial()
{
var shader = Shaders.Resolve("interactive_highlight");
if (shader == null)
{
GameLogger.Log("INTERACTIVE", "highlight shader 'interactive_highlight' not found", LogLevel.Warning);
return;
}
_highlightMaterial = new ShaderMaterial { Shader = shader };
_highlightMaterial.SetShaderParameter("modulate_color", _transparent);
_visual.Material = _highlightMaterial;
}
private void EnsureVisualReady()
{
if (_visualReady)
return;
_visualReady = true;
_visual = new TextureRect
{
Name = "Visual",
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.Scale,
MouseFilter = MouseFilterEnum.Ignore,
AnchorRight = 1f,
AnchorBottom = 1f
};
AddChild(_visual);
SetupHighlightMaterial();
}
private void ApplyHitArea(HitAreaDefinition hitDef, Vector2 contentSize)
{
_hitType = HitAreaType.Rect;
var vpW = contentSize.X;
var vpH = contentSize.Y;
if (hitDef == null)
{
_hitRectPx = new Rect2(0, 0, vpW, vpH);
return;
}
switch (hitDef.Type?.ToLowerInvariant())
{
case "circle":
_hitType = HitAreaType.Circle;
_hitRadiusPx = hitDef.Radius * Mathf.Min(vpW, vpH);
var cx = hitDef.X * vpW;
var cy = hitDef.Y * vpH;
_hitRectPx = new Rect2(cx - _hitRadiusPx, cy - _hitRadiusPx, _hitRadiusPx * 2f, _hitRadiusPx * 2f);
break;
case "polygon":
_hitType = HitAreaType.Polygon;
if (hitDef.Vertices != null && hitDef.Vertices.Length >= 3)
{
_hitPolygonPx = new Vector2[hitDef.Vertices.Length];
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
for (var i = 0; i < hitDef.Vertices.Length; i++)
{
var v = new Vector2(hitDef.Vertices[i].X * vpW, hitDef.Vertices[i].Y * vpH);
_hitPolygonPx[i] = v;
minX = Mathf.Min(minX, v.X);
minY = Mathf.Min(minY, v.Y);
maxX = Mathf.Max(maxX, v.X);
maxY = Mathf.Max(maxY, v.Y);
}
_hitRectPx = new Rect2(minX, minY, maxX - minX, maxY - minY);
}
break;
default:
_hitType = HitAreaType.Rect;
var rx = hitDef.X * vpW;
var ry = hitDef.Y * vpH;
var rw = hitDef.Width * vpW;
var rh = hitDef.Height * vpH;
_hitRectPx = new Rect2(rx, ry, rw, rh);
break;
}
}
private void SetCursor(string cursorName)
{
if (string.IsNullOrEmpty(cursorName))
return;
if (Enum.TryParse<CursorShape>(cursorName, true, out var shape))
MouseDefaultCursorShape = shape;
}
private void OnMouseEntered()
{
GameLogger.Log("INTERACTIVE", $"OnMouseEntered: id='{ID}' enabled={IsEnabled} hitType={_hitType}");
if (!IsEnabled)
return;
IsHovered = true;
ApplyHighlight(true);
UpdateDebugVisual(InteractiveManager.Instance?.ShowDebugBounds ?? false);
OnHoverEnterCallback?.Invoke(_onHoverEnterDsl);
}
private void OnMouseExited()
{
GameLogger.Log("INTERACTIVE", $"OnMouseExited: id='{ID}'");
if (!IsEnabled)
return;
IsHovered = false;
ApplyHighlight(false);
UpdateDebugVisual(InteractiveManager.Instance?.ShowDebugBounds ?? false);
OnHoverLeaveCallback?.Invoke(_onHoverLeaveDsl);
}
private void ApplyHighlight(bool active)
{
if (_highlightMaterial == null)
return;
_highlightTween?.Kill();
var targetScale = active ? _highlightScale : 1f;
_highlightTween = CreateTween();
_highlightTween.SetParallel(true);
_highlightTween.TweenProperty(this, "scale", Vector2.One * targetScale, _highlightDuration);
if (_highlightColor.A > 0f)
{
_highlightTween.TweenMethod(
Callable.From<float>(f =>
{
var c = new Color(_highlightColor.R, _highlightColor.G, _highlightColor.B, _highlightColor.A * f);
_highlightMaterial.SetShaderParameter("modulate_color", c);
}),
active ? 0f : 1f,
active ? 1f : 0f,
_highlightDuration
);
}
}
/// <summary>Handles left-click press: fires the click callback and consumes the event.</summary>
public override void _GuiInput(InputEvent @event)
{
if (!IsEnabled)
return;
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left && mb.Pressed)
{
GameLogger.Log("INTERACTIVE", $"clicked: '{DefinitionId}'");
OnClickCallback?.Invoke(DefinitionId);
AcceptEvent();
}
}
}
}
+220
View File
@@ -0,0 +1,220 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Mods.Interface.View;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Interactive
{
/// <summary>
/// Fluent builder for creating interactive objects programmatically from
/// wrapper-template C# code. The texture is always full-screen with alpha;
/// the collider defines the clickable region in normalised viewport coordinates.
/// </summary>
public class InteractiveObjectBuilder
{
private readonly string _id;
private readonly InteractiveManager _manager;
private string _layer = "foreground";
private string _texture;
private Texture2D _textureDirect;
private int _zIndex;
private bool _enabled = true;
private bool _visible = true;
private string _cursor;
private HitAreaType _hitType = HitAreaType.Rect;
private float _hitX, _hitY, _hitW = 0.1f, _hitH = 0.1f;
private float _hitRadius = 0.05f;
private Vector2[] _hitVertices;
private Color _highlightColor = new(1f, 1f, 0f, 0.3f);
private float _highlightScale = 1.02f;
private float _highlightDuration = 0.15f;
private Action<string> _onClick;
private Action<string> _onHoverEnter;
private Action<string> _onHoverLeave;
private string _onHoverEnterDsl;
private string _onHoverLeaveDsl;
internal InteractiveObjectBuilder(string id, InteractiveManager manager)
{
_id = id;
_manager = manager;
}
/// <summary>Sets the visual layer ("background", "foreground" or "ui"). Default is "foreground".</summary>
public InteractiveObjectBuilder SetLayer(string layer) { _layer = layer; return this; }
/// <summary>Sets the full-screen texture key (resolved via ModManager.ResolveTexture).</summary>
public InteractiveObjectBuilder SetTexture(string textureKey) { _texture = textureKey; return this; }
/// <summary>Sets the full-screen texture directly.</summary>
public InteractiveObjectBuilder SetTexture(Texture2D texture) { _textureDirect = texture; return this; }
/// <summary>Sets the Z-index for ordering within the layer.</summary>
public InteractiveObjectBuilder SetZIndex(int z) { _zIndex = z; return this; }
/// <summary>Sets whether the object responds to input. Default true.</summary>
public InteractiveObjectBuilder SetEnabled(bool enabled) { _enabled = enabled; return this; }
/// <summary>Sets whether the object is visible. Default true.</summary>
public InteractiveObjectBuilder SetVisible(bool visible) { _visible = visible; return this; }
/// <summary>Sets the mouse cursor shape (Godot CursorShape enum name, e.g. "PointingHand").</summary>
public InteractiveObjectBuilder SetCursor(string cursor) { _cursor = cursor; return this; }
/// <summary>Sets a rectangular collider at normalised viewport coordinates (0..1).</summary>
public InteractiveObjectBuilder SetHitRect(float x, float y, float width, float height)
{
_hitType = HitAreaType.Rect;
_hitX = x; _hitY = y; _hitW = width; _hitH = height;
return this;
}
/// <summary>Sets a circular collider centred at normalised coordinates with normalised radius.</summary>
public InteractiveObjectBuilder SetHitCircle(float x, float y, float radius)
{
_hitType = HitAreaType.Circle;
_hitX = x; _hitY = y; _hitRadius = radius;
return this;
}
/// <summary>Sets a polygon collider from an array of normalised vertices (0..1).</summary>
public InteractiveObjectBuilder SetHitPolygon(Vector2[] vertices)
{
_hitType = HitAreaType.Polygon;
_hitVertices = vertices;
return this;
}
/// <summary>Configures the highlight effect.</summary>
public InteractiveObjectBuilder WithHighlight(Color color, float scale = 1.02f, float duration = 0.15f)
{
_highlightColor = color;
_highlightScale = scale;
_highlightDuration = duration;
return this;
}
/// <summary>Sets the click callback. Receives the object's ID.</summary>
public InteractiveObjectBuilder OnClick(Action<string> callback) { _onClick = callback; return this; }
/// <summary>Sets the hover-enter callback.</summary>
public InteractiveObjectBuilder OnHoverEnter(Action<string> callback) { _onHoverEnter = callback; return this; }
/// <summary>Sets the hover-leave callback.</summary>
public InteractiveObjectBuilder OnHoverLeave(Action<string> callback) { _onHoverLeave = callback; return this; }
/// <summary>Sets inline DSL command executed on hover enter.</summary>
public InteractiveObjectBuilder SetHoverEnterDsl(string dsl) { _onHoverEnterDsl = dsl; return this; }
/// <summary>Sets inline DSL command executed on hover leave.</summary>
public InteractiveObjectBuilder SetHoverLeaveDsl(string dsl) { _onHoverLeaveDsl = dsl; return this; }
/// <summary>
/// Finalises configuration, creates the Godot node, registers it with
/// <c>InteractiveManager</c>, and returns the built <c>InteractiveObject</c>.
/// </summary>
public InteractiveObject Build()
{
var viewBox = SceneContextNode.FindNode<ViewBox>("ViewBox");
if (viewBox == null)
{
GameLogger.Log("INTERACTIVE", "InteractiveObjectBuilder.Build: ViewBox not found", LogLevel.Error);
return null;
}
var layerContainer = viewBox.GetInteractiveLayer(_layer);
if (layerContainer == null)
{
GameLogger.Log("INTERACTIVE", $"InteractiveObjectBuilder.Build: layer '{_layer}' not found", LogLevel.Error);
return null;
}
var contentSize = viewBox.LogicalSize != Vector2I.Zero
? new Vector2(viewBox.LogicalSize.X, viewBox.LogicalSize.Y)
: viewBox.Content?.Size ?? new Vector2(1920f, 1024f);
var obj = new InteractiveObject
{
Name = "Interactive_" + _id,
ID = _id
};
layerContainer.AddChild(obj);
var def = BuildDefinition();
obj.Configure(def, contentSize);
if (_textureDirect != null)
{
var visual = obj.GetNodeOrNull<TextureRect>("Visual");
if (visual != null)
visual.Texture = _textureDirect;
}
if (_onClick != null)
obj.OnClickCallback = _onClick;
if (_onHoverEnter != null)
obj.OnHoverEnterCallback = _onHoverEnter;
if (_onHoverLeave != null)
obj.OnHoverLeaveCallback = _onHoverLeave;
_manager?.RegisterBuiltObject(obj);
GameLogger.Log("INTERACTIVE", $"InteractiveObjectBuilder.Build: '{_id}' created");
return obj;
}
private InteractiveDefinition BuildDefinition()
{
var hitArea = new HitAreaDefinition
{
Type = _hitType.ToString().ToLowerInvariant()
};
switch (_hitType)
{
case HitAreaType.Rect:
hitArea.X = _hitX;
hitArea.Y = _hitY;
hitArea.Width = _hitW;
hitArea.Height = _hitH;
break;
case HitAreaType.Circle:
hitArea.X = _hitX;
hitArea.Y = _hitY;
hitArea.Radius = _hitRadius;
break;
case HitAreaType.Polygon:
hitArea.Vertices = _hitVertices;
break;
}
return new InteractiveDefinition
{
ID = _id,
Texture = _texture,
Layer = _layer,
ZIndex = _zIndex,
Enabled = _enabled,
Visible = _visible,
Cursor = _cursor,
HitArea = hitArea,
Highlight = new HighlightDefinition
{
ColorHex = $"#{_highlightColor.ToHtml(false)}",
Scale = _highlightScale,
Duration = _highlightDuration
}
};
}
}
}
+117
View File
@@ -0,0 +1,117 @@
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Interactive;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Mods.Resolvers
{
/// <summary>
/// Collects interactive object definitions from mods.
/// Interactives are environment objects that the player can interact
/// with in scenes — they are defined in <c>.json</c> files under
/// <c>interactives/</c> directories. Each definition specifies an
/// interaction type, position, appearance, and behaviour.
///
/// Collected in mod load order — later mods override earlier ones
/// for the same interactive ID, enabling mods to replace or extend
/// base game interactives.
/// </summary>
public class InteractiveResolver : ModAssetResolver
{
/// <inheritdoc />
public override string Id => "interactives";
private Dictionary<string, InteractiveDefinition> _cache;
/// <summary>
/// Collects all interactive definitions from every loaded mod,
/// keyed by interactive ID. Later mods in load order override
/// earlier ones for the same ID. Caches the result.
/// </summary>
public Dictionary<string, InteractiveDefinition> Collect()
{
Context.Initialize();
if (_cache != null)
return _cache;
var result = new Dictionary<string, InteractiveDefinition>();
foreach (var modId in Context.GetOrderedModIds())
{
var provider = Context.Mods[modId].FileProvider;
CollectInteractiveDefsRecursive(provider, modId, "interactives", result);
}
_cache = result;
GameLogger.Log("MODS", $"CollectInteractives -> {result.Count} definition(s)");
return result;
}
/// <inheritdoc />
public override void ClearCache()
{
_cache = null;
}
private void CollectInteractiveDefsRecursive(IModFileProvider provider, string modId, string dir, Dictionary<string, InteractiveDefinition> result)
{
foreach (var entry in provider.ListFiles(dir))
{
if (entry.EndsWith("/"))
{
var subDir = entry.TrimEnd('/');
CollectInteractiveDefsRecursive(provider, modId, subDir, result);
continue;
}
if (!entry.EndsWith(".json", System.StringComparison.OrdinalIgnoreCase))
continue;
var text = Context.ReadResolvedText(modId, entry, provider);
if (text == null)
continue;
try
{
var fileData = JsonSerializer.Deserialize<FileData<InteractiveDefinition>>(text, JsonOptionsProvider.ReadOptions);
if (fileData?.Items == null || fileData.Items.Count == 0)
continue;
foreach (var item in fileData.Items)
{
if (string.IsNullOrEmpty(item.ID))
continue;
item.ModId = modId;
if (result.ContainsKey(item.ID))
{
GameLogger.Log("MODS", $"interactive '{item.ID}' from mod '{modId}' overrides previous definition", LogLevel.Warning);
AppServices.Current.Conflicts.RecordConflict("interactive", item.ID, modId, result[item.ID].ModId, false);
}
result[item.ID] = item;
GameLogger.Log("MODS", $"interactive '{item.ID}' from mod '{modId}' ({entry})");
}
}
catch (JsonException ex)
{
GameLogger.Log("MODS", $"JSON parse failed for interactive '{modId}/{entry}': {ex.Message}", LogLevel.Error);
}
}
}
}
/// <summary>
/// Static convenience accessor for <see cref="InteractiveResolver"/>,
/// proxying through <see cref="ModManager.Instance.GetResolver{T}"/>.
/// </summary>
public static class Interactives
{
/// <summary>Collects every interactive definition from all mods, keyed by id (later mods override).</summary>
public static Dictionary<string, InteractiveDefinition> Collect()
=> ModManager.Instance.GetResolver<InteractiveResolver>()?.Collect();
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Cthangover.Mods.Interactive
{
/// <summary>
/// Subsystem migration marker: declares that the interactive subsystem now
/// lives in this engine mod. Consumed by <c>SubsystemsBridge</c> for the
/// coverage registry (see SubsystemsCoverageTestSetup.DeclaredTests).
/// </summary>
public static class SubsystemRegistration
{
/// <summary>Stable subsystem identifier used by the coverage registry.</summary>
public static string SubsystemId => "interactive";
}
}
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Godot;
namespace Cthangover.Core.Interactive
{
/// <summary>
/// Custom JSON converter for <c>Godot.Vector2</c>. System.Text.Json cannot
/// natively deserialise Godot's Vector2 from <c>{"x":0.72,"y":0.45}</c>
/// because its X/Y members are fields, not properties. This converter
/// handles the <c>{"x":...,"y":...}</c> object format used throughout
/// interactive definition JSON files.
/// </summary>
public class Vector2JsonConverter : JsonConverter<Vector2>
{
/// <summary>Reads a JSON object with "x" and "y" number members into a Vector2.</summary>
public override Vector2 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Expected object for Vector2");
float x = 0f;
float y = 0f;
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
break;
if (reader.TokenType == JsonTokenType.PropertyName)
{
var prop = reader.GetString();
reader.Read();
if (string.Equals(prop, "x", StringComparison.OrdinalIgnoreCase))
x = reader.GetSingle();
else if (string.Equals(prop, "y", StringComparison.OrdinalIgnoreCase))
y = reader.GetSingle();
else
reader.Skip();
}
}
return new Vector2(x, y);
}
/// <summary>Writes a Vector2 as {"x":...,"y":...}.</summary>
public override void Write(Utf8JsonWriter writer, Vector2 value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteNumber("x", value.X);
writer.WriteNumber("y", value.Y);
writer.WriteEndObject();
}
}
/// <summary>
/// JSON converter for <c>Vector2[]</c> arrays. Each element uses the
/// <c>Vector2JsonConverter</c> for deserialisation.
/// </summary>
public class Vector2ArrayJsonConverter : JsonConverter<Vector2[]>
{
/// <summary>Reads a JSON array of <c>{"x":...,"y":...}</c> objects into a Vector2[].</summary>
public override Vector2[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartArray)
throw new JsonException("Expected array for Vector2[]");
var list = new System.Collections.Generic.List<Vector2>();
var vecConverter = new Vector2JsonConverter();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
break;
list.Add(vecConverter.Read(ref reader, typeof(Vector2), options));
}
return list.ToArray();
}
/// <summary>Writes a Vector2[] as an array of {"x":...,"y":...} objects.</summary>
public override void Write(Utf8JsonWriter writer, Vector2[] value, JsonSerializerOptions options)
{
var vecConverter = new Vector2JsonConverter();
writer.WriteStartArray();
foreach (var v in value)
vecConverter.Write(writer, v, options);
writer.WriteEndArray();
}
}
}
@@ -0,0 +1,28 @@
{
"Items": [{
"id": "kitchen_lamp",
"texture": "lamp",
"layer": "foreground",
"zIndex": 10,
"hitArea": {
"type": "rect",
"x": 0.68,
"y": 0.40,
"width": 0.08,
"height": 0.08
},
"highlight": {
"color": "#FFFF0033",
"scale": 1.1,
"duration": 0.12
},
"cursor": "PointingHand",
"actions": {
"onClick": {
"scenario": "scenarios/kitchen_clicked.scenario"
},
"onHoverEnter": "set cursor_hint Кликни_лампу",
"onHoverLeave": "set cursor_hint \"\""
}
}]
}
@@ -0,0 +1,2 @@
mod_interactive_base__interactives_name = Test Interactives
mod_interactive_base__interactives_desc = Integration test of the interactive object system — a clickable lamp on the scene with highlight.
@@ -0,0 +1,2 @@
mod_interactive_base__interactives_name = Тест интерактивных объектов
mod_interactive_base__interactives_desc = Интеграционный тест системы интерактивных объектов — кликабельная лампа на сцене с подсветкой.
@@ -0,0 +1 @@
test_interactive/click = You clicked the lamp! The interactive object works.
@@ -0,0 +1 @@
test_interactive/click = Вы кликнули по лампе! Интерактивный объект работает.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Interactives test",
"description": "Integration test of the interactive object system: the scenario command interactive_add places a clickable lamp on interactives_test; the driver checks the definition, hover DSL and that a click runs the onClick scenario. Mounted by --itest=interactives.",
"sources": ["src/*.cs"],
"depends": ["visual_novel_core>=1", "dialog_base>=1"]
}
@@ -0,0 +1,6 @@
scene: interactives_test
priority: 10
is_one_run: true
---
interactive_add kitchen_lamp
end
@@ -0,0 +1,3 @@
set lamp_clicked yes
text "Вы кликнули по лампе! Интерактивный объект работает." key=test_interactive/click
end
@@ -0,0 +1,4 @@
{
"name": "interactives_test",
"defaultBackground": "home/kitchen"
}
@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog;
using Godot;
namespace Mods.InteractiveBase.Tests
{
/// <summary>
/// Entry point of the interactives itest: the suite is mounted (and this
/// initializer compiled) only with <c>--itest=interactives</c>; run it on
/// <c>--scene=interactives_test</c>, where the suite scenario adds the lamp.
/// </summary>
public class InteractivesTestInitializer : IModInitializer
{
private static bool queued;
public void OnModLoaded(string modId) { }
public void OnModResourcesReady()
{
if (queued || !OS.GetCmdlineUserArgs().Contains("--itest=interactives"))
return;
queued = true;
GameLogger.Log("TEST", "InteractivesTest: activating");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new InteractivesTestDriverNode());
}
}
/// <summary>
/// Drives the lamp like a player: waits until the scenario command
/// <c>interactive_add kitchen_lamp</c> placed it, hovers it, clicks it
/// through the object's own input handler and expects the <c>onClick</c>
/// scenario to run in the shared DialogBox (its <c>set lamp_clicked=yes</c>
/// lands in the box runtime). Prints <c>[TEST_RESULT] name=interactives</c>.
/// </summary>
public partial class InteractivesTestDriverNode : Node
{
private const string ResultName = "interactives";
private const string LampId = "kitchen_lamp";
private const double TimeoutSeconds = 60;
private int passed;
private int failed;
private double elapsed;
private double stepStarted;
private int step;
private InteractiveObject lamp;
public override void _Process(double delta)
{
if (step < 0)
return;
elapsed += delta;
if (elapsed > TimeoutSeconds)
{
Check($"finished in {TimeoutSeconds:0}s (stuck at step {step})", false);
Finish();
return;
}
try
{
Step();
}
catch (Exception ex)
{
Check($"step {step}: no exception ({ex.GetType().Name}: {ex.Message})", false);
Finish();
}
}
private void Step()
{
switch (step)
{
case 0:
if (!Boot.IsReady || (GetTree().CurrentScene == null))
return;
Check("definition kitchen_lamp is collected", Interactives.Collect().ContainsKey(LampId));
Next();
return;
case 1:
lamp = InteractiveManager.Instance?.Get(LampId);
if (lamp == null)
{
if (Waited(15))
{
Check("interactive_add placed kitchen_lamp", false);
Finish();
}
return;
}
Check("interactive_add placed kitchen_lamp", true);
Check("lamp is enabled and inside the tree", lamp.IsEnabled && lamp.IsInsideTree());
lamp.EmitSignal(Control.SignalName.MouseEntered);
Next();
return;
case 2:
// Hover DSL runs in a throw-away runtime: the observable part is that it does not break the object.
lamp.EmitSignal(Control.SignalName.MouseExited);
Check("hover enter/exit handled, lamp still enabled", lamp.IsEnabled && lamp.IsInsideTree());
lamp._GuiInput(new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = lamp.Size / 2 });
Next();
return;
case 3:
var box = SceneContextNode.FindNode<DialogBox>("DialogBox");
var clicked = box?.Runtime.GetVariable("lamp_clicked");
if (clicked != "yes" && !Waited(5))
return;
Check("click started the onClick dialog (waits on its text)", box is { IsDialogEnded: false });
Check("onClick scenario ran (lamp_clicked=yes)" + (clicked == "yes" ? string.Empty : $" (got '{clicked}')"), clicked == "yes");
Finish();
return;
}
}
private bool Waited(double seconds) => elapsed - stepStarted > seconds;
private void Next()
{
step++;
stepStarted = elapsed;
}
private void Check(string what, bool ok)
{
if (ok)
passed++;
else
failed++;
GameLogger.Log("TEST", $" [{(ok ? "PASS" : "FAIL")}] {what}");
}
private void Finish()
{
step = -1;
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
GetTree().Quit(failed == 0 ? 0 : 1);
}
}
}