diff --git a/locale/ui_en.properties b/locale/ui_en.properties index 585e7c9..a810984 100644 --- a/locale/ui_en.properties +++ b/locale/ui_en.properties @@ -1,6 +1,6 @@ -# --- scenes/battle/winground.tscn --- +# --- scenes/battle/winground.tscn --- mod_interface_name = Interface -mod_interface_desc = Game interface +mod_interface_desc = Engine UI subsystem: widget toolkit (Widget/ModWidget/lists), ViewBox background viewport, ToolBox toolbar, GameMenu/SaveIcon, notification panels, UiKit design system and DeviceInput. ui/battle/victory = Victory ui/battle/victory/return = Continue ui/battle/victory/loot_title = Loot: diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties index 07aa7c7..19d13fb 100644 --- a/locale/ui_ru.properties +++ b/locale/ui_ru.properties @@ -1,4 +1,4 @@ -# --- scenes/battle/winground.tscn --- +# --- scenes/battle/winground.tscn --- ui/battle/victory = Победа ui/battle/victory/return = Продолжить ui/battle/victory/loot_title = Добыча: @@ -258,4 +258,4 @@ ui/journal/finished = Завершено ui/journal/seals = Печати ui/journal/no_entries = Страницы этой записи ещё пусты. Чернила появятся, когда история сдвинется с места. mod_interface_name = Интерфейс -mod_interface_desc = Игровой интерфейс +mod_interface_desc = Подсистема UI движка: набор виджетов (Widget/ModWidget, списки), ViewBox фона, тулбар ToolBox, GameMenu/SaveIcon, панели уведомлений, дизайн-система UiKit и DeviceInput. Ядро работает с модом через контракты IWidget/IToolbarHost и мосты. diff --git a/manifest.json b/manifest.json index 1b52ad2..9debc69 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,10 @@ "name": "Interface", "version": "1.0.0", "author": "ct", - "description": "Game interface", + "description": "Engine UI subsystem: widget toolkit (Widget/ModWidget/lists), ViewBox background viewport, ToolBox toolbar, GameMenu/SaveIcon, notification panels, UiKit design system, DeviceInput. Kernel keeps only contracts (IWidget, IDialogBox, IToolbarHost, ISaveIconHost, IBackgroundView, IUiLazyConstruct, MenuRegistry, ToolFactory, NotificationBuffer, SceneEventController, CthangoverTheme/UiTokens boot styling) and drives this mod through those seams.", "category": "ui", - "depends": ["visual_novel_core>=1"] + "depends": [], + "sources": [ + "src/**/*.cs" + ] } diff --git a/src/Base/InputHandlerNode.cs b/src/Base/InputHandlerNode.cs new file mode 100644 index 0000000..1c6efd4 --- /dev/null +++ b/src/Base/InputHandlerNode.cs @@ -0,0 +1,21 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Base +{ + /// + /// Forwarder pattern for Godot input: proxies _Input to a virtual OnInput + /// so derived classes can handle input events without needing to know about + /// Godot's _Input override mechanics. + /// + public partial class InputHandlerNode : Node + { + /// Engine entry point: forwards the input event to . + public override void _Input(InputEvent @event) + { + OnInput(@event); + } + + /// Override in derived classes to receive Godot input events. Called by which proxies from the engine. + protected virtual void OnInput(InputEvent @event) { } + } +} diff --git a/src/Base/Lists/Border.cs b/src/Base/Lists/Border.cs new file mode 100644 index 0000000..61ca9fc --- /dev/null +++ b/src/Base/Lists/Border.cs @@ -0,0 +1,19 @@ +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Serializable margin/padding struct for list layout calculations. + /// Marked [System.Serializable] for potential JSON serialization in mod configs. + /// + [System.Serializable] + public struct Border + { + /// Left margin or padding, in pixels. + public float left; + /// Right margin or padding, in pixels. + public float right; + /// Top margin or padding, in pixels. + public float top; + /// Bottom margin or padding, in pixels. + public float bottom; + } +} diff --git a/src/Base/Lists/ICellListWidget.cs b/src/Base/Lists/ICellListWidget.cs new file mode 100644 index 0000000..c0bd2ef --- /dev/null +++ b/src/Base/Lists/ICellListWidget.cs @@ -0,0 +1,22 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Extends IListWidget with grid/cell dimensions. CellSize drives item sizing; + /// ViewCellsCount determines how many cells fit in the visible area (used for + /// scroll/viewport calculations). + /// + public interface ICellListWidget : IListWidget + where TItem : class, IListItem + { + + /// Pixel dimensions of each cell in the grid. Drives item sizing; derived from content width and aspect ratio. + Vector2 CellSize { get; } + + /// Number of cells visible in the viewport (columns × rows). Used for scroll range and visibility calculations. + Vector2I ViewCellsCount { get; } + + } + +} diff --git a/src/Base/Lists/IListItem.cs b/src/Base/Lists/IListItem.cs new file mode 100644 index 0000000..c6c116b --- /dev/null +++ b/src/Base/Lists/IListItem.cs @@ -0,0 +1,27 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Contract for a single item in a list widget. Construct binds a model; + /// Destruct tears down (typically QueueFree). The Rect getter exposes the + /// item's Control for layout positioning by the parent list. + /// + public interface IListItem + { + + /// The Control that this item occupies. Used by the parent list to position the item in layout. + Control Rect { get; } + + /// The bound data model. Set via . + TModel Model { get; } + + /// Binds the item to a data model. Called by the list widget after instantiation, before layout positioning. + void Construct(TModel model); + + /// Cleans up the item — typically disconnects signals and calls QueueFree. Called when the list is destroyed or refreshed. + void Destruct(); + + } + +} diff --git a/src/Base/Lists/IListWidget.cs b/src/Base/Lists/IListWidget.cs new file mode 100644 index 0000000..7cd0391 --- /dev/null +++ b/src/Base/Lists/IListWidget.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Godot; + +using Cthangover.Core.UI; + +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Generic list widget contract: binds a collection of models to Item widgets. + /// The two-step CreateModels() → CreateContent() pattern separates data sourcing + /// from instantiation, so subclasses can pull models from anywhere (runtime state, + /// config, save data) while the framework handles layout. ConstructUI/DestructUI + /// manage the full lifecycle of child items. + /// + public interface IListWidget : IWidget + where TItem : class, IListItem + { + + /// Padding between items in pixels, stored as Vector2(X, Y). The X component is horizontal gap, Y is vertical gap. + Vector2 ItemsCellPadding { get; } + + /// The container Control that holds all child item nodes. Items are added as children of this node. + Control Content { get; } + + /// The collection of instantiated item widgets. Populated by and cleared by . + List Items { get; set; } + + /// Implementations return the collection of data models to display. Called once per cycle. + ICollection CreateModels(); + + /// Instantiates item widgets from the model collection. Separates model sourcing from instantiation so subclasses control both steps. + List CreateContent(ICollection models); + + /// Calculates the total content size in pixels for items, used for scroll container sizing. + Vector2 GetContentSize(int count); + + /// Builds the full item tree: creates models, instantiates items, and positions them via layout. + void ConstructUI(); + + /// Tears down all child items by calling each item's and clearing the Items list. + void DestructUI(); + + } + +} diff --git a/src/Base/Lists/Impls/ColumnCellListWidget.cs b/src/Base/Lists/Impls/ColumnCellListWidget.cs new file mode 100644 index 0000000..a6953a9 --- /dev/null +++ b/src/Base/Lists/Impls/ColumnCellListWidget.cs @@ -0,0 +1,93 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Base.Lists.Impls +{ + /// + /// Grid-based list layout with configurable column count, aspect-ratio cells, + /// and border/padding offsets. Cell dimensions are computed dynamically from + /// the content area width, so they scale with the parent. GetContentSize + /// calculates the total scrollable height from item count and cell geometry, + /// enabling vertical scrolling through a grid. PutToLayout positions items + /// by flat-index-to-grid conversion. The isScaledCanvas flag supports + /// resolution-independent layouts. + /// + public abstract partial class ColumnCellListWidget : ListWidget, ICellListWidget + where TItem : Control, IListItem + { + [Export] private int columnCount; + [Export] private float aspectHeight = 1f; + [Export] private float borderLeft; + [Export] private float borderRight; + [Export] private float borderTop; + [Export] private float borderBottom; + [Export] private bool isScaledCanvas = true; + + private Border contentBorder => new() { left = borderLeft, right = borderRight, top = borderTop, bottom = borderBottom }; + + /// Dynamically computed cell dimensions in pixels. Width = (content width - borders - padding * (columns - 1)) / columns; height = width * aspectHeight. + public Vector2 CellSize => GetCellSize(Content.Size); + + private Vector2 GetCellSize(Vector2 contentSize) + { + var size = contentSize; + var effectiveWidth = size.X - contentBorder.left - contentBorder.right; + var cellWidth = (effectiveWidth - (columnCount - 1) * ItemsCellPadding.X) / columnCount; + var cellHeight = cellWidth * aspectHeight; + return new Vector2(cellWidth, cellHeight); + } + + /// Number of cells visible in the viewport: X = columnCount, Y = floor((content height - borders) / (cell height + padding)). + public Vector2I ViewCellsCount + { + get + { + var size = Content.Size; + return new Vector2I( + columnCount, + Mathf.FloorToInt((size.Y - contentBorder.top - contentBorder.bottom) / (CellSize.Y + ItemsCellPadding.Y)) + ); + } + } + + /// Calculates total scrollable content height: top border + bottom border + rows * cellHeight + (rows - 1) * padding. + public override Vector2 GetContentSize(int count) + { + if (count == 0) return Vector2.Zero; + + var rect = Content.Size; + var effectiveWidth = rect.X - contentBorder.left - contentBorder.right; + var cellWidth = (effectiveWidth - (columnCount - 1) * ItemsCellPadding.X) / columnCount; + var cellHeight = cellWidth * aspectHeight; + + var rows = Mathf.CeilToInt((float)count / columnCount); + var contentHeight = contentBorder.top + contentBorder.bottom + rows * cellHeight + (rows - 1) * ItemsCellPadding.Y; + var contentWidth = rect.X; + + return new Vector2(contentWidth, contentHeight); + } + + protected override void PutToLayout(TItem item, int index, Control container, Vector2 contentSize) + { + var rectTransform = item; + var cellIndex = GetIndex(index); + var cellSize = GetCellSize(contentSize); + + if (rectTransform.GetParent() != container) + rectTransform.Reparent(container, false); + rectTransform.Position = new Vector2( + contentBorder.left + cellIndex.X * (cellSize.X + ItemsCellPadding.X), + contentBorder.top + cellIndex.Y * (cellSize.Y + ItemsCellPadding.Y) + ); + rectTransform.Size = cellSize; + } + + private Vector2I GetIndex(int flatIndex) + { + var count = ViewCellsCount; + return new Vector2I( + flatIndex % count.X, + flatIndex / count.X + ); + } + } +} diff --git a/src/Base/Lists/Impls/VerticalListWidget.cs b/src/Base/Lists/Impls/VerticalListWidget.cs new file mode 100644 index 0000000..330415c --- /dev/null +++ b/src/Base/Lists/Impls/VerticalListWidget.cs @@ -0,0 +1,33 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Base.Lists.Impls +{ + /// + /// Simple vertical stack layout. Items are placed top-to-bottom with padding + /// between them, offset so the list grows upward from the content center. + /// Content height is the sum of all item heights plus padding — no pooling + /// or virtualization, so it's intended for short lists. + /// + public abstract partial class VerticalListWidget : ListWidget + where TItem : Control, IListItem + { + + protected override void PutToLayout(TItem item, int index, Control container, Vector2 contentSize) + { + var posY = index * (PrefabRect.Size.Y + ItemsCellPadding.Y); + var itemRect = item; + var pos = new Vector2(itemRect.Position.X, -posY + contentSize.Y * 0.5f); + itemRect.Position = pos; + } + + /// Content height = count * (prefab height + Y padding). Width is inherited from the Content container. + public override Vector2 GetContentSize(int count) + { + var boxSize = Content.Size; + boxSize.Y = count * (PrefabRect.Size.Y + ItemsCellPadding.Y); + return boxSize; + } + + } + +} diff --git a/src/Base/Lists/ListItem.cs b/src/Base/Lists/ListItem.cs new file mode 100644 index 0000000..141163d --- /dev/null +++ b/src/Base/Lists/ListItem.cs @@ -0,0 +1,35 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Base list item: stores the bound model and provides a Rect for layout. + /// The virtual Construct saves the model reference; Destruct is abstract so + /// subclasses must explicitly implement cleanup (signal disconnection, QueueFree). + /// + public abstract partial class ListItem : Control, IListItem + { + + /// The data model bound to this list item. Set by . + public TModel Model { get; private set; } + /// The Control that this item occupies for layout. Returns itself — ListItem is its own layout root. + public Control Rect => this; + + /// Saves the model reference. Override in subclasses to apply model data to UI elements. + public virtual void Construct(TModel model) + { + Model = model; + } + + /// Required cleanup method. Subclasses must implement — typically disconnects signals and calls QueueFree. + public abstract void Destruct(); + + /// Engine callback; forwards to so mod subclasses receive the ready signal without their own engine bindings. + public override void _Ready() => OnReady(); + + /// Forwarded from . Override for mod item initialization. + protected virtual void OnReady() { } + + } + +} diff --git a/src/Base/Lists/ListWidget.cs b/src/Base/Lists/ListWidget.cs new file mode 100644 index 0000000..e113c0b --- /dev/null +++ b/src/Base/Lists/ListWidget.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using Godot; + +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Abstract model-driven list: instantiates items from a PackedScene prefab + /// for each model in CreateModels(), then delegates positioning to the abstract + /// PutToLayout (defined by layout subclasses like ColumnCellListWidget or + /// VerticalListWidget). Refresh() tears down and rebuilds the list in place + /// for reactive updates. The virtual Create/Init hooks let subclasses inject + /// extra setup between instantiation and model binding without overriding + /// CreateContent. + /// + public abstract partial class ListWidget : Widget, IListWidget + where T : Control, IListItem + { + + [Export] + private Control content; + + [Export] + private PackedScene itemScene; + + [Export] private Vector2 itemsCellPadding = new(0f, 1f); + + /// Editor-configured padding between items; exposed for layout size math. + public Vector2 ItemsCellPadding => itemsCellPadding; + + /// Prefab instance for layout measurement. Instantiated on each access — not cached. Returns null if itemScene is not set. + protected T Prefab => itemScene?.Instantiate(); + /// The Control rect of the item prefab, used for layout size calculations. + protected Control PrefabRect => Prefab; + + /// The container Control that holds child items. Set via [Export] in the Godot editor. + public Control Content => content; + + /// Calculates the total content size for a given item count. Subclass-specific — different layouts have different formulas. + public abstract Vector2 GetContentSize(int count); + + /// The instantiated item collection. Set by and cleared by . + public List Items { get; set; } + + /// Shows the widget and rebuilds the UI tree via . + public override void Show() + { + base.Show(); + ConstructUI(); + } + + /// Tears down child items via then hides via base . + public override void Hide() + { + DestructUI(); + base.Hide(); + } + + /// Destructs and reconstructs the item tree in place. Use for reactive updates when the model collection changes. + public void Refresh() + { + if (!Visible) + return; + + DestructUI(); + ConstructUI(); + } + + /// Iterates and calls for each, building the item widget collection. + public List CreateContent(ICollection models) + { + var items = new List(); + + foreach (var model in models) + { + var item = Create(model); + item.Construct(model); + items.Add(item); + } + + return items; + } + + /// + /// Full build pipeline: gets models via , instantiates items via , + /// sets the content container size via to size, then positions each item via . + /// + public void ConstructUI() + { + var rect = Content; + var models = CreateModels(); + + var items = CreateContent(models); + var size = GetContentSize(items.Count); + rect.CallDeferred("set_size", size); + + for (int i = 0; i < items.Count; i++) + PutToLayout(items[i], i, rect, size); + + Items = items; + } + + /// Subclasses implement positioning logic. Called for each item with its flat index, the content container, and total content size. + protected abstract void PutToLayout(T item, int index, Control container, Vector2 contentSize); + + /// Subclasses implement model sourcing. Called by before item instantiation. + public abstract ICollection CreateModels(); + + /// Hook for subclasses to inject setup between instantiation and model binding. Called by . + protected virtual void Init(T item, M model) { } + + /// Instantiates the PackedScene prefab, adds it to , and calls for subclass setup. + protected virtual T Create(M model) + { + var instance = itemScene.Instantiate(); + Content.AddChild(instance); + Init(instance, model); + return instance; + } + + /// Calls on each child item, clears the list, and sets Items to null. + public void DestructUI() + { + if (Items == null || Items.Count == 0) + return; + + foreach (var item in Items) + item.Destruct(); + + Items.Clear(); + Items = null; + } + + } +} diff --git a/src/Base/Lists/ModWidget.cs b/src/Base/Lists/ModWidget.cs new file mode 100644 index 0000000..5fe4f2b --- /dev/null +++ b/src/Base/Lists/ModWidget.cs @@ -0,0 +1,38 @@ +using Godot; + +using Cthangover.Core.UI; + +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Lightweight alternative to Widget for mod-authored controls. Has a simpler + /// lifecycle — just Construct() on first access and Destruct() on tree exit — + /// without the visibility interception and Show/Hide pipeline that Widget + /// carries. Use when the heavy Widget state machine would be overkill or + /// when the control's visibility is managed by Godot's native system. + /// + public abstract partial class ModWidget : Control, IUiLazyConstruct + { + private bool _constructed; + + /// Idempotent construction gate: calls exactly once, on the first invocation. Thread-safe for repeated external calls. + public void EnsureConstructed() + { + if (_constructed) + return; + _constructed = true; + + Construct(); + } + + /// Runs once when the constructed control leaves the tree. + public override void _ExitTree() + { + if (_constructed) + Destruct(); + } + + protected abstract void Construct(); + protected virtual void Destruct() { } + } +} diff --git a/src/Base/Lists/TransitionWidget.cs b/src/Base/Lists/TransitionWidget.cs new file mode 100644 index 0000000..926bfe6 --- /dev/null +++ b/src/Base/Lists/TransitionWidget.cs @@ -0,0 +1,128 @@ +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Shader-based scene transition: animates a "progress" uniform on the + /// background's ShaderMaterial via a Godot Tween, then reveals content after + /// a configurable delay. The shader is resolved through the mod system + /// (ModManager.ResolveShader), allowing mods to replace the transition effect. + /// Uses an easeInOut curve for the progress ramp. Material is duplicated on + /// first use so each instance gets its own shader parameters. On tree exit, + /// the duplicated material is disposed to avoid leaks. + /// + public abstract partial class TransitionWidget : Widget + { + private static readonly StringName ProgressParam = "progress"; + + [Export] private TextureRect imgBackground; + [Export] private Control contentGroup; + [Export] private float transitionDuration = 0.8f; + [Export] private float contentDelay = 0.05f; + + private ShaderMaterial materialInstance; + private Tween transitionTween; + + /// + /// Shows the widget base, then creates or reuses a shader material for the background and starts the transition tween. + /// Material is duplicated from imgBackground.Material on first call so each instance gets independent shader parameters. + /// + public override void Show() + { + base.Show(); + + if (materialInstance == null && imgBackground != null) + { + materialInstance = imgBackground.Material?.Duplicate() as ShaderMaterial; + if (materialInstance == null) + { + materialInstance = new ShaderMaterial(); + var shader = Shaders.Resolve("scene_transition"); + if (shader != null) + materialInstance.Shader = shader; + } + imgBackground.Material = materialInstance; + materialInstance.SetShaderParameter(ProgressParam, 0f); + } + + if (transitionTween != null) + { + transitionTween.Kill(); + transitionTween = null; + } + + RunShowAnimation(); + } + + /// Kills the active transition tween if one is running, then calls base . + public override void Hide() + { + if (transitionTween != null) + { + transitionTween.Kill(); + transitionTween = null; + } + base.Hide(); + } + + private void RunShowAnimation() + { + if (contentGroup != null) + { + contentGroup.Modulate = UiTokens.Alpha(UiTokens.White, 0f); + contentGroup.MouseFilter = MouseFilterEnum.Ignore; + } + + transitionTween = CreateTween(); + transitionTween.SetProcessMode(Tween.TweenProcessMode.Physics); + transitionTween.TweenMethod(Callable.From(OnProgressUpdate), 0f, 1f, transitionDuration); + transitionTween.Finished += OnTweenFinished; + } + + private void OnProgressUpdate(float t) + { + float curvedT = EaseInOut(t); + if (imgBackground?.Material is ShaderMaterial mat) + mat.SetShaderParameter(ProgressParam, curvedT); + } + + private void OnTweenFinished() + { + if (imgBackground?.Material is ShaderMaterial mat) + mat.SetShaderParameter(ProgressParam, 1f); + + var timer = GetTree().CreateTimer(contentDelay); + timer.Timeout += () => + { + if (contentGroup != null) + { + contentGroup.Modulate = UiTokens.White; + contentGroup.MouseFilter = MouseFilterEnum.Stop; + } + }; + + transitionTween = null; + } + + private static float EaseInOut(float t) + { + return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f; + } + + /// Disposes the per-instance shader material created for the transition. + public override void _ExitTree() + { + if (materialInstance != null) + { + materialInstance.Dispose(); + materialInstance = null; + } + base._ExitTree(); + } + } +} + + diff --git a/src/Base/Lists/Widget.cs b/src/Base/Lists/Widget.cs new file mode 100644 index 0000000..be38bb1 --- /dev/null +++ b/src/Base/Lists/Widget.cs @@ -0,0 +1,194 @@ +using Cthangover.Core.Utils; +using Godot; + +using Cthangover.Core.UI; + +namespace Cthangover.Mods.Interface.Base.Lists +{ + /// + /// Base widget with a lazy construct/destruct lifecycle around Show/Hide. + /// On first Show(), OnceConstruct() fires once via EnsureConstructed — subsequent + /// Show/Hide calls trigger ShowConstruct/HideDestruct instead, allowing repeated + /// state reset without full reconstruction. Overrides Godot's built-in Visible + /// property to intercept visibility changes and route them through the lifecycle. + /// Maintains an independent isVisible flag because Godot's visibility has quirks + /// (e.g. parent visibility propagation) that would break the state machine. + /// The #if TOOLS _ValidateProperty syncs the editor inspector with the override. + /// + public abstract partial class Widget : Control, IWidget + { + private bool isConstructed; + + /// Syncs the internal visibility flag with the scene-file state and hides properly when the widget starts hidden. + public override void _Ready() + { + GameLogger.Log("WIDGET", $"_Ready: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug); + isVisible = base.Visible; + if (!isVisible) + Hide(); + OnReady(); + } + + /// Tears down a constructed widget when it leaves the tree, so hidden-while-freed widgets still release their resources. + public override void _ExitTree() + { + if (isConstructed) + OnceDestruct(); + OnExitTree(); + } + + /// Engine callback; forwards to so mod subclasses receive input without their own engine bindings. + public override void _Input(InputEvent @event) => OnInput(@event); + + /// Forwarded from . Override for mod widget initialization. + protected virtual void OnReady() { } + + /// Forwarded from . Override for mod widget teardown. + protected virtual void OnExitTree() { } + + /// Forwarded from . Override to handle input in mod widgets. + protected virtual void OnInput(InputEvent @event) { } + + /// Toggles visibility: calls if visible, otherwise. + public void Switch() + { + if (Visible) + Hide(); + else + Show(); + } + + /// + /// Makes visible and runs lifecycle. On first call, fires . + /// Subsequent calls trigger without reconstruction. Sets both the internal isVisible flag + /// and Godot's native visibility, then explicitly shows the Body canvas item. + /// + public new virtual void Show() + { + GameLogger.Log("WIDGET", $"Show ENTER: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug); + var wasConstructed = isConstructed; + EnsureConstructed(); + + if (Visible) + { + if (!wasConstructed) + ShowConstruct(); + GameLogger.Log("WIDGET", $"Show SKIP: already visible", LogLevel.Debug); + return; + } + + ShowConstruct(); + + isVisible = true; + base.Visible = true; + + var canvasBody = Body as CanvasItem; + if (canvasBody != null) + canvasBody.Visible = true; + + (Body as Control)?.QueueRedraw(); + + GameLogger.Log("WIDGET", $"Show EXIT: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug); + } + + /// Hides the widget, fires if constructed, sets internal and Godot visibility to false, and hides the Body canvas item. + public new virtual void Hide() + { + if (!Visible) + return; + + if (isConstructed) + HideDestruct(); + + isVisible = false; + base.Visible = false; + + var canvasBody = Body as CanvasItem; + if (canvasBody != null) + canvasBody.Visible = false; + } + + /// Idempotent construction gate: calls exactly once, on the first invocation. + public void EnsureConstructed() + { + if (!isConstructed) + { + isConstructed = true; + OnceConstruct(); + } + } + + /// Called exactly once on the first . Override to perform one-time initialization. + protected virtual void OnceConstruct() + { + } + + /// Called once on tree exit if constructed. Override for one-time cleanup of resources allocated in . + protected virtual void OnceDestruct() + { + } + + [Export] private bool isVisible = true; + + [Export] private Node body; + + /// + /// Shadows Godot's built-in visibility. Setting to true calls , setting to false calls . + /// Maintains an independent isVisible flag because Godot's native visibility propagates through the parent chain, + /// which would break the explicit lifecycle state machine. + /// + public new bool Visible + { + get => isVisible; + set + { + if (isVisible == value) + return; + + if (value) + Show(); + else + Hide(); + } + } + + /// The rendered content subtree. May be a child node separate from the Rect layout root. + public Node Body => body; + + /// The Control used for layout. For Widget this is the node itself, enabling it to serve as both layout root and widget. + public Control Rect => this; + + /// Called each time runs after initial construction. Override to refresh state (e.g. reload data, re-enable input). + protected virtual void ShowConstruct() + { + } + + /// Called each time runs when constructed. Override to clean up display-only state without destroying permanent resources. + protected virtual void HideDestruct() + { + } + +#if TOOLS + /// Keeps the inspector in sync with the overridden visibility: touching visible or isVisible in the editor routes through the lifecycle. + public override void _ValidateProperty(Godot.Collections.Dictionary property) + { + base._ValidateProperty(property); + if (property.TryGetValue("visible", out var _)) + { + isVisible = base.Visible; + if (isVisible) + Show(); + else + Hide(); + } + else if (property.TryGetValue("isVisible", out var _)) + { + if (isVisible) + Show(); + else + Hide(); + } + } +#endif + } +} diff --git a/src/Base/ModControl.cs b/src/Base/ModControl.cs new file mode 100644 index 0000000..7d24db8 --- /dev/null +++ b/src/Base/ModControl.cs @@ -0,0 +1,38 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Base +{ + /// + /// Lifecycle forwarder for Control scripts compiled from mods — the Control + /// counterpart of . Godot's C# source generator only + /// runs in the main game assembly, so engine callbacks overridden inside a + /// runtime-compiled mod are never registered with the engine. Mod controls + /// derive from this base (which has bindings) and override the OnXxx methods. + /// + public partial class ModControl : Control + { + /// Engine callback; forwards to . + public override void _EnterTree() => OnEnterTree(); + + /// Engine callback; forwards to . + public override void _ExitTree() => OnExitTree(); + + /// Engine callback; forwards to . + public override void _Ready() => OnReady(); + + /// Engine callback; forwards to . + public override void _Process(double delta) => OnProcess(delta); + + /// Forwarded from . Override in mod controls. + protected virtual void OnEnterTree() { } + + /// Forwarded from . Override in mod controls. + protected virtual void OnExitTree() { } + + /// Forwarded from . Override in mod controls. + protected virtual void OnReady() { } + + /// Forwarded from . Override in mod controls. + protected virtual void OnProcess(double delta) { } + } +} \ No newline at end of file diff --git a/src/Base/ModNode.cs b/src/Base/ModNode.cs new file mode 100644 index 0000000..ae0766b --- /dev/null +++ b/src/Base/ModNode.cs @@ -0,0 +1,44 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Base +{ + /// + /// Lifecycle forwarder for Node scripts compiled from mods. Godot's C# + /// source generator only runs in the main game assembly, so virtual methods + /// overridden inside a runtime-compiled mod (e.g. _Ready, _Process) + /// are never registered with the engine and therefore never called. A mod node + /// must instead derive from a core base class — the engine drives the base + /// (which has bindings) and the base forwards every lifecycle callback to + /// virtual OnXxx methods the mod can override. + /// + /// The same trick is used by for input events + /// and by for Controls. Use this wrapper for plain + /// scripts that need enter/exit/ready/process hooks. + /// + public partial class ModNode : Node + { + /// Engine callback; forwards to . + public override void _EnterTree() => OnEnterTree(); + + /// Engine callback; forwards to . + public override void _ExitTree() => OnExitTree(); + + /// Engine callback; forwards to . + public override void _Ready() => OnReady(); + + /// Engine callback; forwards to . + public override void _Process(double delta) => OnProcess(delta); + + /// Forwarded from . Override in mod nodes. + protected virtual void OnEnterTree() { } + + /// Forwarded from . Override in mod nodes. + protected virtual void OnExitTree() { } + + /// Forwarded from . Override in mod nodes. + protected virtual void OnReady() { } + + /// Forwarded from . Override in mod nodes. + protected virtual void OnProcess(double delta) { } + } +} diff --git a/src/Event/DeviceInput.cs b/src/Event/DeviceInput.cs new file mode 100644 index 0000000..ec29d46 --- /dev/null +++ b/src/Event/DeviceInput.cs @@ -0,0 +1,137 @@ +using Godot; + +namespace Cthangover.Mods.Interface.Event +{ + /// + /// Stateless input abstraction that maps mouse to a virtual single-touch source. + /// Tracks mouse-down state between frames to derive touch phases (Began/Moved/ + /// Ended/Stationary), enabling touch-aware code to work with mouse-only devices. + /// Provides both UI-space (UITouchPosition) and screen-space (TouchPosition) + /// coordinate access. Update() must be called each frame to refresh the + /// wasMouseDownLastFrame flag. + /// + public static class DeviceInput + { + /// + /// Index of the active touch (always 0 for the mouse-backed single-touch + /// model). Exposed for API compatibility with potential multi-touch support. + /// + public static int CurrentTouchIndex { get; set; } + + /// + /// Derives a from mouse button state across frames. + /// Compares the current left-button state against wasMouseDownLastFrame: + /// Began on fresh press, Moved on hold, Ended on release. + /// + public static TouchPhase GetTouchPhase + { + get + { + if (Input.IsMouseButtonPressed(MouseButton.Left)) + { + if (Input.IsMouseButtonPressed(MouseButton.Left) && !wasMouseDownLastFrame) + return TouchPhase.Began; + if (Input.IsMouseButtonPressed(MouseButton.Left) && wasMouseDownLastFrame) + return TouchPhase.Moved; + } + else + { + if (wasMouseDownLastFrame) + return TouchPhase.Ended; + } + return TouchPhase.Stationary; + } + } + + private static bool wasMouseDownLastFrame; + + /// + /// Must be called each frame to snapshot the mouse button state. The saved + /// state drives for press/release transitions. + /// + public static void Update() + { + wasMouseDownLastFrame = Input.IsMouseButtonPressed(MouseButton.Left); + } + + /// + /// Returns 1 when the left mouse button is pressed, 0 otherwise. + /// Maps the mouse to a single-touch model. + /// + public static int TouchCount + { + get + { + return Input.IsMouseButtonPressed(MouseButton.Left) ? 1 : 0; + } + } + + /// + /// Mouse position in UI space (top-left origin, Control coordinate system). + /// Used for hit-testing against Control nodes in the UI layer. + /// + public static Vector2 UITouchPosition + { + get + { + var mp = GetViewport()?.GetMousePosition() ?? Vector2.Zero; + // In Godot, UI coordinates match mouse coordinates (top-left origin in Control nodes) + return mp; + } + } + + /// + /// Mouse position in screen space. Used for raycasting against 2D/3D + /// world objects. + /// + public static Vector2 TouchPosition + { + get + { + return GetViewport()?.GetMousePosition() ?? Vector2.Zero; + } + } + + /// + /// Converts to a 3D vector (z=0) for ray-origin + /// calculations in the 3D input pipeline. + /// + public static Vector3 TouchPosition3D + { + get + { + var pos = TouchPosition; + return new Vector3(pos.X, pos.Y, 0); + } + } + + /// + /// Last-frame mouse velocity from Godot's Input.GetLastMouseVelocity(). + /// Used by drag handlers to compute movement deltas. + /// + public static Vector2 TouchDelta + { + get + { + return Input.GetLastMouseVelocity(); + } + } + + /// + /// Returns the current touch position. The parameter + /// is ignored in the single-touch model — always returns . + /// Exists for multi-touch API compatibility. + /// + /// Touch index (ignored in current implementation). + public static Vector2 GetTouchPosition(int index) + { + return TouchPosition; + } + + private static Viewport GetViewport() + { + var tree = Godot.Engine.GetMainLoop() as SceneTree; + return tree?.Root?.GetViewport(); + } + } +} diff --git a/src/Event/TouchPhase.cs b/src/Event/TouchPhase.cs new file mode 100644 index 0000000..9063ec0 --- /dev/null +++ b/src/Event/TouchPhase.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Mods.Interface.Event +{ + /// + /// iOS-style touch lifecycle phases. Used by DeviceInput to derive phase + /// from mouse button state between frames. + /// + public enum TouchPhase + { + Began, + Moved, + Stationary, + Ended, + Canceled + } +} diff --git a/src/Menu/EmptyBlackBehaviour.cs b/src/Menu/EmptyBlackBehaviour.cs new file mode 100644 index 0000000..10fd5e4 --- /dev/null +++ b/src/Menu/EmptyBlackBehaviour.cs @@ -0,0 +1,14 @@ +using Godot; +using Cthangover.Core.UI.Menu; + +namespace Cthangover.Mods.Interface.Menu +{ + /// + /// Full-screen black overlay Control. Acts as a blocking backdrop — placed + /// behind menus to darken the game view and absorb input. No custom logic + /// beyond what Control provides; scene configuration handles sizing/color. + /// + public partial class EmptyBlackBehaviour : Control + { + } +} diff --git a/src/Menu/GameMenu.cs b/src/Menu/GameMenu.cs new file mode 100644 index 0000000..eea7b4c --- /dev/null +++ b/src/Menu/GameMenu.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using Cthangover.Core.Scenes; +using Godot; +using Cthangover.Core.UI.Menu; + +namespace Cthangover.Mods.Interface.Menu +{ + /// + /// In-game pause menu: save/load/settings/quit. The settings and save/load + /// panels are not referenced by type: they are registered in the + /// (by the base_menu mod or any other mod + /// that wants to provide them) and instantiated here by id, so the panel + /// implementations can live entirely inside mods. Both panels stay hidden + /// children created on _Ready to avoid loading delays when the player opens + /// the menu. RefreshSaveButton dynamically disables save if the current + /// scene doesn't allow saves (checked via + /// SceneManager.IsSaveAllowedForCurrentScene). Button text is translated + /// with a refresh-on-language-change subscription. + /// + public partial class GameMenu : Control + { + private ISettingsPanel _settingsPanel; + private ISaveLoadPanel _saveLoadPanel; + private Button _saveBtn; + private readonly Dictionary _btnKeys = new(); + + /// Wires menu buttons, instantiates the hidden settings and save/load panels from the , and subscribes to language changes. + public override void _Ready() + { + _saveBtn = GetNode