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; } /// /// Drawing order inside the layer (the definition's zIndex). It is a /// place among the layer's children, not a Godot z index: a z index is global /// to the canvas and would lift the object over the dialog box and the panels. /// public int Order { get; private 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; PlaceByOrder(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. /// private void PlaceByOrder(int order) { Order = order; var parent = GetParent(); if (parent == null) return; var index = parent.GetChildCount() - 1; foreach (var child in parent.GetChildren()) { if (child != this && child is InteractiveObject other && other.Order > order) { index = other.GetIndex(); break; } } parent.MoveChild(this, index > GetIndex() ? index - 1 : index); } 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(); } } } }