init mod
This commit is contained in:
commit
5e0a12b038
23 files changed
+1650
No files matched your search
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_interactive_base_name = Интерактивные объекты
|
||||||
|
mod_interactive_base_desc = Подсистема интерактивных объектов: JSON-описания зон наведения и клика с подсветкой и сценарными действиями, InteractiveManager и данные редактора интерактива.
|
||||||
@@ -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"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = Вы кликнули по лампе! Интерактивный объект работает.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user