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