commit 5e0a12b03859db62ac8bce1b0f7413c39b05f8b1 Author: iisaev Date: Sun Oct 18 12:41:36 2026 +0300 init mod diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..cf60837 --- /dev/null +++ b/locale/en.properties @@ -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. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..88c247b --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_interactive_base_name = Интерактивные объекты +mod_interactive_base_desc = Подсистема интерактивных объектов: JSON-описания зон наведения и клика с подсветкой и сценарными действиями, InteractiveManager и данные редактора интерактива. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..f2903c9 --- /dev/null +++ b/manifest.json @@ -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"]} + ] +} diff --git a/src/HitAreaType.cs b/src/HitAreaType.cs new file mode 100644 index 0000000..d5572f5 --- /dev/null +++ b/src/HitAreaType.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Core.Interactive +{ + /// Enumeration of supported hit-area shapes for interactive objects. + public enum HitAreaType + { + /// Full rectangular area matching the control bounds. + Rect, + + /// Circular area centred within the control bounds. + Circle, + + /// Custom polygon defined by vertex array. + Polygon + } +} diff --git a/src/InteractiveDefinition.cs b/src/InteractiveDefinition.cs new file mode 100644 index 0000000..279bfd8 --- /dev/null +++ b/src/InteractiveDefinition.cs @@ -0,0 +1,169 @@ +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Core.Interactive +{ + /// + /// 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 {"Items": [...]} envelope; + /// unlike other entity kinds they are not part of the kind-routed pipeline. + /// + public class InteractiveDefinition : IIdentifiable + { + /// Unique identifier used in scenario commands and registry lookups. + [JsonPropertyName("id")] + public string ID { get; set; } + + /// Owning mod identifier set by ModManager during collection. + [JsonIgnore] + public string ModId { get; set; } + + /// Texture key resolved via ModManager.ResolveTexture. Full-screen with alpha. + [JsonPropertyName("texture")] + public string Texture { get; set; } + + /// Visual layer: "background", "foreground" or "ui". Defaults to "foreground". + [JsonPropertyName("layer")] + public string Layer { get; set; } = "foreground"; + + /// Z-index for ordering within the layer. Higher = on top. + [JsonPropertyName("zIndex")] + public int ZIndex { get; set; } + + /// Whether the object is initially enabled (responds to input). Default true. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + /// Whether the object is initially visible. Default true. + [JsonPropertyName("visible")] + public bool Visible { get; set; } = true; + + /// Mouse cursor override when hovering (Godot CursorShape enum name, e.g. "PointingHand"). + [JsonPropertyName("cursor")] + public string Cursor { get; set; } + + /// Collider shape and position in normalised viewport coordinates (0..1). + [JsonPropertyName("hitArea")] + public HitAreaDefinition HitArea { get; set; } + + /// Visual highlight settings applied on hover. + [JsonPropertyName("highlight")] + public HighlightDefinition Highlight { get; set; } + + /// Actions triggered by pointer events. + [JsonPropertyName("actions")] + public InteractiveActionDefinition Actions { get; set; } + } + + /// + /// 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. + /// + public class HitAreaDefinition + { + /// "rect", "circle" or "polygon". Defaults to "rect". + [JsonPropertyName("type")] + public string Type { get; set; } = "rect"; + + /// X position of the collider origin (top-left for rect, centre for circle). Normalised 0..1. + [JsonPropertyName("x")] + public float X { get; set; } + + /// Y position of the collider origin. Normalised 0..1. + [JsonPropertyName("y")] + public float Y { get; set; } + + /// Width for rect type. Normalised 0..1. + [JsonPropertyName("width")] + public float Width { get; set; } = 0.1f; + + /// Height for rect type. Normalised 0..1. + [JsonPropertyName("height")] + public float Height { get; set; } = 0.1f; + + /// Radius for circle type. Normalised 0..1 (relative to viewport min dimension). + [JsonPropertyName("radius")] + public float Radius { get; set; } = 0.05f; + + /// Polygon vertices as an array of normalised Vector2 points. Only for "polygon" type. + [JsonPropertyName("vertices")] + [JsonConverter(typeof(Vector2ArrayJsonConverter))] + public Vector2[] Vertices { get; set; } + } + + /// + /// Highlight effect applied when the pointer hovers over the collider. + /// Uses a shader-based colour modulation on the full-screen texture. + /// + public class HighlightDefinition + { + /// Modulate colour as a hex string (e.g. "#FFFF0033"). Parsed to Godot.Color at runtime. + [JsonPropertyName("color")] + public string ColorHex { get; set; } = "#FFFF0033"; + + /// Pulse scale multiplier applied during hover (e.g. 1.02 = 2% bigger). + [JsonPropertyName("scale")] + public float Scale { get; set; } = 1.02f; + + /// Duration in seconds of the hover/unhover animation. + [JsonPropertyName("duration")] + public float Duration { get; set; } = 0.15f; + + /// Parsed Godot.Color from ColorHex. Computed at load time, not serialised. + [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); + } + } + } + + /// + /// Actions dispatched on pointer events. + /// + public class InteractiveActionDefinition + { + /// Action executed on left-click. + [JsonPropertyName("onClick")] + public ClickAction OnClick { get; set; } + + /// Inline DSL commands executed on mouse enter. + [JsonPropertyName("onHoverEnter")] + public string OnHoverEnter { get; set; } + + /// Inline DSL commands executed on mouse leave. + [JsonPropertyName("onHoverLeave")] + public string OnHoverLeave { get; set; } + } + + /// + /// Click action descriptor. Supports a referenced .scenario file + /// and/or inline DSL commands. + /// + public class ClickAction + { + /// Path to a .scenario file within a mod. + [JsonPropertyName("scenario")] + public string Scenario { get; set; } + + /// Inline DSL commands to execute (one command per array element). + [JsonPropertyName("commands")] + public string[] Commands { get; set; } + } +} + diff --git a/src/InteractiveFactory.cs b/src/InteractiveFactory.cs new file mode 100644 index 0000000..682e6e4 --- /dev/null +++ b/src/InteractiveFactory.cs @@ -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 +{ + /// + /// Factory for InteractiveDefinition lookups. Wraps + /// ModManager.CollectInteractives() with a thread-safe singleton + /// accessor. Since the underlying collection is already cached by + /// ModManager, this factory is a thin forwarding layer that + /// provides the same Instance.Get(id) API convention as every + /// other factory in the project. + /// + /// The single instance lives in this mod assembly as a static lazy + /// singleton; the static facade stays as the public API. + /// + public class InteractiveFactory + { + private static InteractiveFactory _instance; + + /// Static lazy singleton; kept as the public API. + public static InteractiveFactory Instance => _instance ??= new InteractiveFactory(); + + private InteractiveFactory() + { + } + + /// + /// Looks up an interactive definition by its unique ID. + /// Returns null if no definition with that ID exists + /// in any mod. + /// + /// The ID from the definition JSON. + 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; + } + } +} diff --git a/src/InteractiveManager.cs b/src/InteractiveManager.cs new file mode 100644 index 0000000..91e80f2 --- /dev/null +++ b/src/InteractiveManager.cs @@ -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 +{ + /// + /// Central registry for active interactive objects on the current scene. + /// Manages the full lifecycle: creation from InteractiveDefinition, + /// placement into ViewBox layer containers, callback wiring for + /// hover/click actions, and cleanup on scene change. + /// + /// Created and owned by SceneContextNode so its state is + /// automatically cleared when the scene changes. + /// + /// Access via InteractiveManager.Instance (set during + /// SceneContextNode._Ready). + /// + public class InteractiveManager + { + private static InteractiveManager _instance; + + /// + /// Singleton instance, valid only while a scene is active. + /// Set by SceneContextNode on scene enter, cleared on scene exit. + /// + public static InteractiveManager Instance => _instance; + + private readonly Dictionary _objects = new(); + private bool _showDebugBounds; + + /// + /// Initialises the singleton and performs one-time setup. + /// Called by SceneContextNode._Ready. + /// + public static void Initialize() + { + _instance = new InteractiveManager(); + } + + /// + /// Tears down active objects but keeps the singleton alive. + /// Called by SceneContextNode.ClearData on scene change. + /// The manager instance persists for the full game session; only + /// the tracked objects are cleaned up on each scene transition. + /// + public static void Shutdown() + { + _instance?.ClearAll(); + } + + /// + /// Toggles debug visualisation of hit-area bounds and object IDs. + /// When enabled, every active object draws a coloured overlay. + /// + public bool ShowDebugBounds + { + get => _showDebugBounds; + set + { + _showDebugBounds = value; + foreach (var kv in _objects) + kv.Value.UpdateDebugVisual(value); + } + } + + /// + /// Number of currently active interactive objects. + /// + public int Count => _objects.Count; + + /// + /// Instantiates an interactive object from its definition ID and adds it + /// to the appropriate InteractiveLayer in the ViewBox. + /// If an object with the same ID is already active, returns the existing one. + /// + /// The ID from the InteractiveDefinition JSON. + /// The created or existing InteractiveObject, or null if the definition is not found. + 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"); + 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; + } + + /// + /// Removes and destroys the interactive object with the given definition ID. + /// Publishes an only if the object + /// actually existed and was removed, so condition caches invalidate on real + /// removal rather than on redundant calls. + /// + /// The definition ID to remove. + 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); + } + } + + /// + /// Removes and destroys all active interactive objects. + /// Called automatically on scene change via Shutdown. Publishes one + /// per removed object (before + /// destruct/clear) so condition caches invalidate for each disappearance. + /// + 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(); + } + + /// + /// Looks up an active interactive object by its definition ID. + /// + /// The definition ID to find. + /// The InteractiveObject or null. + public InteractiveObject Get(string definitionId) + { + _objects.TryGetValue(definitionId, out var obj); + return obj; + } + + /// + /// Enables or disables an active interactive object. + /// + /// The definition ID. + /// New enabled state. + public void SetEnabled(string definitionId, bool enabled) + { + if (_objects.TryGetValue(definitionId, out var obj)) + obj.IsEnabled = enabled; + } + + /// + /// Shows or hides an active interactive object. + /// + /// The definition ID. + /// New visibility state. + 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("/root/DialogRunner") as IInteractiveDialogRunner; + } + + private readonly Dictionary _hoverVariables = new(); + + /// + /// Simple variable store for hover-event DSL commands (e.g. set cursor_hint=door). + /// UI elements can subscribe or poll these values to show contextual hints. + /// + public string GetHoverVariable(string name) + { + _hoverVariables.TryGetValue(name, out var val); + return val; + } + + /// + /// Creates a custom interactive object programmatically using a fluent + /// builder. For use in wrapper-template C# code. + /// + /// Unique instance ID. + /// An InteractiveObjectBuilder for fluent configuration. + public InteractiveObjectBuilder CreateCustom(string id) + { + return new InteractiveObjectBuilder(id, this); + } + + /// Tracks a programmatically built object (from ) and wires default callbacks where none were set. + internal void RegisterBuiltObject(InteractiveObject obj) + { + if (obj == null) + return; + + _objects[obj.ID] = obj; + obj.OnHoverEnterCallback ??= ExecuteHoverDsl; + obj.OnHoverLeaveCallback ??= ExecuteHoverDsl; + obj.OnClickCallback ??= ExecuteClick; + } + } +} diff --git a/src/InteractiveModInitializer.cs b/src/InteractiveModInitializer.cs new file mode 100644 index 0000000..2ab37af --- /dev/null +++ b/src/InteractiveModInitializer.cs @@ -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 +{ + /// + /// Engine-mod initializer for the interactive subsystem. Takes over the + /// lifecycle SceneContextNode owned before the migration: on + /// resources-ready it initializes the InteractiveManager singleton + /// and subscribes the per-scene teardown (InteractiveManager.Shutdown) + /// to SceneExitedEvent, so tracked interactive objects are destroyed + /// on every scene change. All operations are idempotent: + /// ModManager.Reload() re-runs OnModResourcesReady, so repeat + /// invocations must not corrupt state. + /// + public class InteractiveModInitializer : IModInitializer + { + private static bool initialized; + + /// No per-mod setup is required; lifecycle happens on resources-ready. + public void OnModLoaded(string modId) { } + + /// + /// Initializes the manager singleton and wires the scene-exit teardown. + /// Safe to call again after a mod reload. + /// + public void OnModResourcesReady() + { + if (initialized) + return; + initialized = true; + + InteractiveManager.Initialize(); + GameLogger.Log("INTERACTIVE", "InteractiveModInitializer: manager initialized"); + + ModEventBus.Subscribe(_ => InteractiveManager.Shutdown()); + } + } +} diff --git a/src/InteractiveObject.cs b/src/InteractiveObject.cs new file mode 100644 index 0000000..94d1639 --- /dev/null +++ b/src/InteractiveObject.cs @@ -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 +{ + /// + /// 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 _HasPoint overrides verified against global + /// mouse position and the pixel-space collider geometry. + /// + public partial class InteractiveObject : Control, IEventObject + { + private static readonly Color _transparent = new(0f, 0f, 0f, 0f); + + /// Unique instance identifier, used for registry and IEventObject tracking. + public string ID { get; set; } + + /// Reference back to the InteractiveDefinition.ID that spawned this object. + public string DefinitionId { get; private set; } + + /// Whether the object responds to mouse input. + public bool IsEnabled { get; set; } = true; + + /// True while the pointer is within the collider. + public bool IsHovered { get; private set; } + + /// Invoked on hover enter with the definition's hover-enter DSL (wired by ). + internal Action OnHoverEnterCallback { get; set; } + /// Invoked on hover leave with the definition's hover-leave DSL. + internal Action OnHoverLeaveCallback { get; set; } + /// Invoked on click with the definition id (wired by ). + internal Action 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; + + /// Prepares the full-screen visual, highlight material and mouse handlers. + 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}"); + } + + /// + /// 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. + /// + 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}"); + } + + /// + /// Checks whether the global mouse position falls within the collider + /// geometry in pixel space. Bypasses Godot's local coordinate issues. + /// + 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; + } + + /// + /// Unregisters from the SceneContextNode event-object tracker and + /// frees the Godot node. Called by InteractiveManager.ClearAll on + /// scene transition and by InteractiveManager.Remove for targeted + /// cleanup, ensuring no dangling references survive scene teardown. + /// + public void Destruct() + { + SceneContextNode.Instance?.RemoveEventObject(ID); + QueueFree(); + } + + /// + /// Creates or hides a debug overlay showing the hit-area bounds rectangle + /// and the object's ID label. The rectangle colour changes to + /// yellow when the pointer is inside the collider. Used by + /// InteractiveManager.ShowDebugBounds to toggle all objects at once. + /// + /// Whether to create/show or hide the debug visuals. + 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(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(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 + ); + } + } + + /// Handles left-click press: fires the click callback and consumes the event. + 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(); + } + } + } +} + + diff --git a/src/InteractiveObjectBuilder.cs b/src/InteractiveObjectBuilder.cs new file mode 100644 index 0000000..850ec45 --- /dev/null +++ b/src/InteractiveObjectBuilder.cs @@ -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 +{ + /// + /// 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. + /// + 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 _onClick; + private Action _onHoverEnter; + private Action _onHoverLeave; + private string _onHoverEnterDsl; + private string _onHoverLeaveDsl; + + internal InteractiveObjectBuilder(string id, InteractiveManager manager) + { + _id = id; + _manager = manager; + } + + /// Sets the visual layer ("background", "foreground" or "ui"). Default is "foreground". + public InteractiveObjectBuilder SetLayer(string layer) { _layer = layer; return this; } + + /// Sets the full-screen texture key (resolved via ModManager.ResolveTexture). + public InteractiveObjectBuilder SetTexture(string textureKey) { _texture = textureKey; return this; } + + /// Sets the full-screen texture directly. + public InteractiveObjectBuilder SetTexture(Texture2D texture) { _textureDirect = texture; return this; } + + /// Sets the Z-index for ordering within the layer. + public InteractiveObjectBuilder SetZIndex(int z) { _zIndex = z; return this; } + + /// Sets whether the object responds to input. Default true. + public InteractiveObjectBuilder SetEnabled(bool enabled) { _enabled = enabled; return this; } + + /// Sets whether the object is visible. Default true. + public InteractiveObjectBuilder SetVisible(bool visible) { _visible = visible; return this; } + + /// Sets the mouse cursor shape (Godot CursorShape enum name, e.g. "PointingHand"). + public InteractiveObjectBuilder SetCursor(string cursor) { _cursor = cursor; return this; } + + /// Sets a rectangular collider at normalised viewport coordinates (0..1). + public InteractiveObjectBuilder SetHitRect(float x, float y, float width, float height) + { + _hitType = HitAreaType.Rect; + _hitX = x; _hitY = y; _hitW = width; _hitH = height; + return this; + } + + /// Sets a circular collider centred at normalised coordinates with normalised radius. + public InteractiveObjectBuilder SetHitCircle(float x, float y, float radius) + { + _hitType = HitAreaType.Circle; + _hitX = x; _hitY = y; _hitRadius = radius; + return this; + } + + /// Sets a polygon collider from an array of normalised vertices (0..1). + public InteractiveObjectBuilder SetHitPolygon(Vector2[] vertices) + { + _hitType = HitAreaType.Polygon; + _hitVertices = vertices; + return this; + } + + /// Configures the highlight effect. + public InteractiveObjectBuilder WithHighlight(Color color, float scale = 1.02f, float duration = 0.15f) + { + _highlightColor = color; + _highlightScale = scale; + _highlightDuration = duration; + return this; + } + + /// Sets the click callback. Receives the object's ID. + public InteractiveObjectBuilder OnClick(Action callback) { _onClick = callback; return this; } + + /// Sets the hover-enter callback. + public InteractiveObjectBuilder OnHoverEnter(Action callback) { _onHoverEnter = callback; return this; } + + /// Sets the hover-leave callback. + public InteractiveObjectBuilder OnHoverLeave(Action callback) { _onHoverLeave = callback; return this; } + + /// Sets inline DSL command executed on hover enter. + public InteractiveObjectBuilder SetHoverEnterDsl(string dsl) { _onHoverEnterDsl = dsl; return this; } + + /// Sets inline DSL command executed on hover leave. + public InteractiveObjectBuilder SetHoverLeaveDsl(string dsl) { _onHoverLeaveDsl = dsl; return this; } + + /// + /// Finalises configuration, creates the Godot node, registers it with + /// InteractiveManager, and returns the built InteractiveObject. + /// + public InteractiveObject Build() + { + var viewBox = SceneContextNode.FindNode("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("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 + } + }; + } + } +} diff --git a/src/InteractiveResolver.cs b/src/InteractiveResolver.cs new file mode 100644 index 0000000..50ef126 --- /dev/null +++ b/src/InteractiveResolver.cs @@ -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 +{ + /// + /// Collects interactive object definitions from mods. + /// Interactives are environment objects that the player can interact + /// with in scenes — they are defined in .json files under + /// interactives/ 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. + /// + public class InteractiveResolver : ModAssetResolver + { + /// + public override string Id => "interactives"; + + private Dictionary _cache; + + /// + /// 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. + /// + public Dictionary Collect() + { + Context.Initialize(); + + if (_cache != null) + return _cache; + + var result = new Dictionary(); + + 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; + } + + /// + public override void ClearCache() + { + _cache = null; + } + + private void CollectInteractiveDefsRecursive(IModFileProvider provider, string modId, string dir, Dictionary 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>(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); + } + } + } + } + + /// + /// Static convenience accessor for , + /// proxying through . + /// + public static class Interactives + { + /// Collects every interactive definition from all mods, keyed by id (later mods override). + public static Dictionary Collect() + => ModManager.Instance.GetResolver()?.Collect(); + } +} diff --git a/src/SubsystemRegistration.cs b/src/SubsystemRegistration.cs new file mode 100644 index 0000000..27df965 --- /dev/null +++ b/src/SubsystemRegistration.cs @@ -0,0 +1,13 @@ +namespace Cthangover.Mods.Interactive +{ + /// + /// Subsystem migration marker: declares that the interactive subsystem now + /// lives in this engine mod. Consumed by SubsystemsBridge for the + /// coverage registry (see SubsystemsCoverageTestSetup.DeclaredTests). + /// + public static class SubsystemRegistration + { + /// Stable subsystem identifier used by the coverage registry. + public static string SubsystemId => "interactive"; + } +} diff --git a/src/Vector2JsonConverter.cs b/src/Vector2JsonConverter.cs new file mode 100644 index 0000000..640d908 --- /dev/null +++ b/src/Vector2JsonConverter.cs @@ -0,0 +1,94 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Godot; + +namespace Cthangover.Core.Interactive +{ + /// + /// Custom JSON converter for Godot.Vector2. System.Text.Json cannot + /// natively deserialise Godot's Vector2 from {"x":0.72,"y":0.45} + /// because its X/Y members are fields, not properties. This converter + /// handles the {"x":...,"y":...} object format used throughout + /// interactive definition JSON files. + /// + public class Vector2JsonConverter : JsonConverter + { + /// Reads a JSON object with "x" and "y" number members into a Vector2. + 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); + } + + /// Writes a Vector2 as {"x":...,"y":...}. + public override void Write(Utf8JsonWriter writer, Vector2 value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteNumber("x", value.X); + writer.WriteNumber("y", value.Y); + writer.WriteEndObject(); + } + } + + /// + /// JSON converter for Vector2[] arrays. Each element uses the + /// Vector2JsonConverter for deserialisation. + /// + public class Vector2ArrayJsonConverter : JsonConverter + { + /// Reads a JSON array of {"x":...,"y":...} objects into a Vector2[]. + 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(); + 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(); + } + + /// Writes a Vector2[] as an array of {"x":...,"y":...} objects. + 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(); + } + } +} diff --git a/test_suites/interactives/interactives/kitchen_lamp.json b/test_suites/interactives/interactives/kitchen_lamp.json new file mode 100644 index 0000000..96f7e79 --- /dev/null +++ b/test_suites/interactives/interactives/kitchen_lamp.json @@ -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 \"\"" + } + }] +} diff --git a/test_suites/interactives/locale/en.properties b/test_suites/interactives/locale/en.properties new file mode 100644 index 0000000..beb0195 --- /dev/null +++ b/test_suites/interactives/locale/en.properties @@ -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. diff --git a/test_suites/interactives/locale/ru.properties b/test_suites/interactives/locale/ru.properties new file mode 100644 index 0000000..8010308 --- /dev/null +++ b/test_suites/interactives/locale/ru.properties @@ -0,0 +1,2 @@ +mod_interactive_base__interactives_name = Тест интерактивных объектов +mod_interactive_base__interactives_desc = Интеграционный тест системы интерактивных объектов — кликабельная лампа на сцене с подсветкой. diff --git a/test_suites/interactives/locale/scenarios_en.properties b/test_suites/interactives/locale/scenarios_en.properties new file mode 100644 index 0000000..55afe48 --- /dev/null +++ b/test_suites/interactives/locale/scenarios_en.properties @@ -0,0 +1 @@ +test_interactive/click = You clicked the lamp! The interactive object works. diff --git a/test_suites/interactives/locale/scenarios_ru.properties b/test_suites/interactives/locale/scenarios_ru.properties new file mode 100644 index 0000000..2b7821a --- /dev/null +++ b/test_suites/interactives/locale/scenarios_ru.properties @@ -0,0 +1 @@ +test_interactive/click = Вы кликнули по лампе! Интерактивный объект работает. diff --git a/test_suites/interactives/manifest.json b/test_suites/interactives/manifest.json new file mode 100644 index 0000000..e216fc8 --- /dev/null +++ b/test_suites/interactives/manifest.json @@ -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"] +} diff --git a/test_suites/interactives/scenarios/kitchen_add_interactive.scenario b/test_suites/interactives/scenarios/kitchen_add_interactive.scenario new file mode 100644 index 0000000..b672a4c --- /dev/null +++ b/test_suites/interactives/scenarios/kitchen_add_interactive.scenario @@ -0,0 +1,6 @@ +scene: interactives_test +priority: 10 +is_one_run: true +--- +interactive_add kitchen_lamp +end diff --git a/test_suites/interactives/scenarios/kitchen_clicked.scenario b/test_suites/interactives/scenarios/kitchen_clicked.scenario new file mode 100644 index 0000000..2b0b476 --- /dev/null +++ b/test_suites/interactives/scenarios/kitchen_clicked.scenario @@ -0,0 +1,3 @@ +set lamp_clicked yes +text "Вы кликнули по лампе! Интерактивный объект работает." key=test_interactive/click +end diff --git a/test_suites/interactives/scenes/interactives_test.json b/test_suites/interactives/scenes/interactives_test.json new file mode 100644 index 0000000..1b5b9da --- /dev/null +++ b/test_suites/interactives/scenes/interactives_test.json @@ -0,0 +1,4 @@ +{ + "name": "interactives_test", + "defaultBackground": "home/kitchen" +} diff --git a/test_suites/interactives/src/InteractivesTest.cs b/test_suites/interactives/src/InteractivesTest.cs new file mode 100644 index 0000000..f3d2f8b --- /dev/null +++ b/test_suites/interactives/src/InteractivesTest.cs @@ -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 +{ + /// + /// Entry point of the interactives itest: the suite is mounted (and this + /// initializer compiled) only with --itest=interactives; run it on + /// --scene=interactives_test, where the suite scenario adds the lamp. + /// + 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()); + } + } + + /// + /// Drives the lamp like a player: waits until the scenario command + /// interactive_add kitchen_lamp placed it, hovers it, clicks it + /// through the object's own input handler and expects the onClick + /// scenario to run in the shared DialogBox (its set lamp_clicked=yes + /// lands in the box runtime). Prints [TEST_RESULT] name=interactives. + /// + 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"); + 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); + } + } +}