From 8ba8d85276ea542bbabbece46ddd60b00da22b62 Mon Sep 17 00:00:00 2001 From: Pursche Date: Wed, 6 May 2026 21:54:38 +0200 Subject: [PATCH 1/4] Add UI Layout widget Add HorizontalLayout widget Add VerticalLayout widget Add GridLayout widget Add border to panels Fix some shutdown related Lua issues Improve CharacterSelect and CharacterCreation borders --- .../ECS/Components/UI/LayoutEventInfo.h | 16 + .../ECS/Components/UI/PanelTemplate.h | 3 + .../Game-Lib/ECS/Util/UIRefCleanup.cpp | 39 ++ .../Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.h | 13 + .../Rendering/Canvas/CanvasRenderer.cpp | 46 +- .../Rendering/Canvas/CanvasRenderer.h | 4 +- .../Game-Lib/Scripting/Handlers/UIHandler.cpp | 11 + .../Game-Lib/Game-Lib/Scripting/UI/Panel.cpp | 37 ++ Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h | 7 +- .../Game-Lib/Game-Lib/Scripting/UI/Widget.cpp | 26 + .../Game-Lib/Game-Lib/Scripting/UI/Widget.h | 11 +- .../Game-Lib/Scripting/Util/ZenithUtil.cpp | 6 + .../Game-Lib/Scripting/Util/ZenithUtil.h | 3 + .../Resources/Scripts/API/UI/GridLayout.luau | 303 +++++++++++ .../Scripts/API/UI/HorizontalLayout.luau | 25 + .../Scripts/API/UI/LayoutCommon.luau | 225 ++++++++ .../Scripts/API/UI/LinearLayout.luau | 481 ++++++++++++++++++ .../Scripts/API/UI/VerticalLayout.luau | 25 + .../Scripts/UI/CharacterCreation.luau | 325 ++++++------ .../Resources/Scripts/UI/CharacterSelect.luau | 89 ++-- Source/Resources/Scripts/UI/Chat.luau | 60 +-- Source/Resources/Scripts/UI/Demo.luau | 449 +++++++++++++++- Source/Resources/Scripts/UI/GameMenu.luau | 175 +++---- Source/Shaders/Shaders/UI/Widget.ps.slang | 48 +- 24 files changed, 2043 insertions(+), 384 deletions(-) create mode 100644 Source/Game-Lib/Game-Lib/ECS/Components/UI/LayoutEventInfo.h create mode 100644 Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.cpp create mode 100644 Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.h create mode 100644 Source/Resources/Scripts/API/UI/GridLayout.luau create mode 100644 Source/Resources/Scripts/API/UI/HorizontalLayout.luau create mode 100644 Source/Resources/Scripts/API/UI/LayoutCommon.luau create mode 100644 Source/Resources/Scripts/API/UI/LinearLayout.luau create mode 100644 Source/Resources/Scripts/API/UI/VerticalLayout.luau diff --git a/Source/Game-Lib/Game-Lib/ECS/Components/UI/LayoutEventInfo.h b/Source/Game-Lib/Game-Lib/ECS/Components/UI/LayoutEventInfo.h new file mode 100644 index 0000000..7d1142c --- /dev/null +++ b/Source/Game-Lib/Game-Lib/ECS/Components/UI/LayoutEventInfo.h @@ -0,0 +1,16 @@ +#pragma once +#include + +namespace ECS::Components::UI +{ + struct LayoutEventInfo + { + public: + // Lua function ref (LUA_REGISTRYINDEX) of the layout's refresh callback, + // installed by Lua-side LinearLayout/GridLayout via Widget:RegisterLayoutRefresh. + // Cleared on entity destroy by ReleaseLayoutEventInfoRefs (UIRefCleanup). + i32 onLayoutRefresh = -1; + }; + + struct DirtyLayoutTag {}; +} diff --git a/Source/Game-Lib/Game-Lib/ECS/Components/UI/PanelTemplate.h b/Source/Game-Lib/Game-Lib/ECS/Components/UI/PanelTemplate.h index 5c00568..2410bf1 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Components/UI/PanelTemplate.h +++ b/Source/Game-Lib/Game-Lib/ECS/Components/UI/PanelTemplate.h @@ -23,6 +23,7 @@ namespace ECS::Components::UI u8 cornerRadius : 1 = 0; u8 texCoords : 1 = 0; u8 nineSliceCoords : 1 = 0; + u8 border : 1 = 0; }; SetFlags setFlags; @@ -32,6 +33,8 @@ namespace ECS::Components::UI std::string foreground; Color color = Color::White; f32 cornerRadius = 0.0f; + Color borderColor = Color(0.0f, 0.0f, 0.0f, 1.0f); + f32 borderSize = 0.0f; ::UI::Box texCoords; ::UI::Box nineSliceCoords; diff --git a/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.cpp b/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.cpp new file mode 100644 index 0000000..49c6db9 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.cpp @@ -0,0 +1,39 @@ +#include "UIRefCleanup.h" +#include "Game-Lib/ECS/Components/UI/EventInputInfo.h" +#include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" + +#include + +namespace ECS::Util::UIRefCleanup +{ + void ReleaseEventInputInfoRefs(entt::registry& registry, entt::entity entity) + { + // Shutdown order: the registry may destroy entities after the LuaManager + // is gone. Skip rather than crash; the Lua state is being torn down anyway. + ::Scripting::Zenith* zenith = Scripting::Util::Zenith::GetGlobal(); + if (zenith == nullptr) return; + + auto& info = registry.get(entity); + Scripting::Util::Zenith::Unref(zenith, info.onMouseDownEvent); + Scripting::Util::Zenith::Unref(zenith, info.onMouseUpEvent); + Scripting::Util::Zenith::Unref(zenith, info.onMouseHeldEvent); + Scripting::Util::Zenith::Unref(zenith, info.onMouseScrollEvent); + Scripting::Util::Zenith::Unref(zenith, info.onHoverBeginEvent); + Scripting::Util::Zenith::Unref(zenith, info.onHoverEndEvent); + Scripting::Util::Zenith::Unref(zenith, info.onHoverHeldEvent); + Scripting::Util::Zenith::Unref(zenith, info.onFocusBeginEvent); + Scripting::Util::Zenith::Unref(zenith, info.onFocusEndEvent); + Scripting::Util::Zenith::Unref(zenith, info.onFocusHeldEvent); + Scripting::Util::Zenith::Unref(zenith, info.onKeyboardEvent); + } + + void ReleaseLayoutEventInfoRefs(entt::registry& registry, entt::entity entity) + { + ::Scripting::Zenith* zenith = Scripting::Util::Zenith::GetGlobal(); + if (zenith == nullptr) return; + + auto& info = registry.get(entity); + Scripting::Util::Zenith::Unref(zenith, info.onLayoutRefresh); + } +} diff --git a/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.h b/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.h new file mode 100644 index 0000000..19239a2 --- /dev/null +++ b/Source/Game-Lib/Game-Lib/ECS/Util/UIRefCleanup.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace ECS::Util::UIRefCleanup +{ + // entt on_destroy observers attached at UI registry construction + // (Application.cpp). Each iterates the i32 Lua-registry refs on the + // outgoing component and lua_unref's the populated ones, so callbacks + // installed via SetOnMouseUp / RegisterLayoutRefresh / etc. don't + // leak the registry slot when the widget entity is destroyed. + void ReleaseEventInputInfoRefs(entt::registry& registry, entt::entity entity); + void ReleaseLayoutEventInfoRefs(entt::registry& registry, entt::entity entity); +} diff --git a/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.cpp b/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.cpp index c588097..1c4d45c 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.cpp +++ b/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.cpp @@ -5,6 +5,7 @@ #include "Game-Lib/ECS/Components/UI/Canvas.h" #include "Game-Lib/ECS/Components/UI/Clipper.h" #include "Game-Lib/ECS/Components/UI/EventInputInfo.h" +#include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" #include "Game-Lib/ECS/Components/UI/Panel.h" #include "Game-Lib/ECS/Components/UI/Text.h" #include "Game-Lib/ECS/Components/UI/Widget.h" @@ -15,8 +16,11 @@ #include "Game-Lib/Rendering/Debug/DebugRenderer.h" #include "Game-Lib/Rendering/GameRenderer.h" #include "Game-Lib/Rendering/RenderResources.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" #include "Game-Lib/Util/ServiceLocator.h" +#include + #include #include @@ -65,6 +69,38 @@ void CanvasRenderer::Update(f32 deltaTime) entt::registry* uiRegistry = ServiceLocator::GetEnttRegistries()->uiRegistry; ECS::Transform2DSystem& transformSystem2D = ECS::Transform2DSystem::Get(*uiRegistry); + // Lua layouts: refresh any whose Lua-side state was invalidated since the last frame. + // Layouts are lazy-refresh-on-read in Lua (LinearLayout/GridLayout), so they otherwise + // never run unless something explicitly reads GetMeasured*. The refresh closure (set + // via Widget:RegisterLayoutRefresh) runs `self:Refresh()` which writes positions/sizes + // via SetPos/SetWidth/SetHeight; those calls cascade into DirtyCanvasTag / + // DirtyCanvasSort / DirtyWidgetData and are picked up by the passes below. + { + auto layoutView = uiRegistry->view(); + if (layoutView.begin() != layoutView.end()) + { + ZoneScopedN("CanvasRenderer::Update::LayoutRefresh"); + ::Scripting::Zenith* zenith = Scripting::Util::Zenith::GetGlobal(); + if (zenith != nullptr) + { + // TODO(layouts perf): each invalidated layout costs one Lua PCall. For + // deeply nested UI where a single mutation cascades up N levels, that's N + // PCalls per frame. Profile if it shows up; the obvious optimisation is to + // gather all dirty refresh closures into a single PCall (push the closures + // as a stack of values and let one Lua function dispatch them). + for (entt::entity entity : layoutView) + { + i32 ref = layoutView.get(entity).onLayoutRefresh; + if (ref == -1) continue; + + zenith->GetRawI(LUA_REGISTRYINDEX, ref); + zenith->PCall(0, 0); + } + uiRegistry->clear(); + } + } + } + uiRegistry->view().each([&](entt::entity entity, Widget& widget) { if (widget.type == WidgetType::Canvas) @@ -766,14 +802,20 @@ void CanvasRenderer::UpdatePanelData(entt::entity entity, ECS::Components::Trans panel.gpuDataIndex = _widgetDrawDatas.Add(); } vec2 size = transform.GetSize(); - vec2 cornerRadius = vec2(panelTemplate.cornerRadius / size.x, panelTemplate.cornerRadius / size.y); + vec2 cornerRadius = (size.x > 0.0f && size.y > 0.0f) + ? vec2(panelTemplate.cornerRadius / size.x, panelTemplate.cornerRadius / size.y) + : vec2(0.0f); + vec2 normalizedBorderSize = (panelTemplate.borderSize > 0.0f && size.x > 0.0f && size.y > 0.0f) + ? vec2(panelTemplate.borderSize / size.x, panelTemplate.borderSize / size.y) + : vec2(0.0f); // Update draw data auto& drawData = _widgetDrawDatas[panel.gpuDataIndex]; drawData.packed0.x = static_cast(WidgetDrawType::Panel); drawData.packed0.y = static_cast(panel.gpuVertexIndex); // vertexBase + drawData.packed1.y = panelTemplate.borderColor.ToRGBA32(); drawData.packed1.z = panelTemplate.color.ToRGBA32(); - drawData.cornerRadiusAndBorder = vec4(cornerRadius, 0.0f, 0.0f); + drawData.cornerRadiusAndBorder = vec4(cornerRadius, normalizedBorderSize); // Update textures u16 textureIndex = 0; diff --git a/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.h b/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.h index 0dd718a..a6a4674 100644 --- a/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.h +++ b/Source/Game-Lib/Game-Lib/Rendering/Canvas/CanvasRenderer.h @@ -109,10 +109,10 @@ class CanvasRenderer { public: uvec4 packed0 = uvec4(0, 0, 0, 0xFFFFFFFFu); // x: type, y: vertexBase, z: clipMaskTextureIndex, w: worldPositionIndex (i32 reinterpret as -1) - uvec4 packed1 = uvec4(0, 0, 0, 0); // Panel: x: textureIndex|additiveTextureIndex, z: color, w: textureScaleToWidgetSize (half2). Text: x: fontTextureIndex, z: textColor, w: borderColor + uvec4 packed1 = uvec4(0, 0, 0, 0); // Panel: x: textureIndex|additiveTextureIndex, y: borderColor, z: color, w: textureScaleToWidgetSize (half2). Text: x: fontTextureIndex, z: textColor, w: borderColor vec4 texCoord = vec4(0.0f); // Panel only vec4 slicingCoord = vec4(0.0f); // Panel only - vec4 cornerRadiusAndBorder = vec4(0.0f); // Panel: xy: cornerRadius. Text: x: borderSize, zw: unitRange + vec4 cornerRadiusAndBorder = vec4(0.0f); // Panel: xy: cornerRadius, zw: borderSize (normalized per-axis). Text: x: borderSize, zw: unitRange hvec4 clipRegionRect = hvec4(0.0f, 0.0f, 1.0f, 1.0f); // xy: min, zw: max hvec4 clipMaskRegionRect = hvec4(0.0f, 0.0f, 1.0f, 1.0f); // xy: min, zw: max }; diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp index 4ddc0a8..de9f7cb 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp @@ -1,9 +1,12 @@ #include "UIHandler.h" #include "Game-Lib/Application/EnttRegistries.h" #include "Game-Lib/ECS/Components/UI/Canvas.h" +#include "Game-Lib/ECS/Components/UI/EventInputInfo.h" +#include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" #include "Game-Lib/ECS/Singletons/InputSingleton.h" #include "Game-Lib/ECS/Singletons/UISingleton.h" #include "Game-Lib/ECS/Util/Transform2D.h" +#include "Game-Lib/ECS/Util/UIRefCleanup.h" #include "Game-Lib/ECS/Util/UIUtil.h" #include "Game-Lib/Rendering/GameRenderer.h" #include "Game-Lib/Rendering/Canvas/CanvasRenderer.h" @@ -34,6 +37,14 @@ namespace Scripting::UI registry->ctx().emplace(); registry->ctx().emplace(); + // Release Lua-registry refs (SetOnMouseUp, RegisterLayoutRefresh, etc.) + // when their owning entities are destroyed; otherwise the registry slot + // leaks for the lifetime of the Lua state. + registry->on_destroy() + .connect<&ECS::Util::UIRefCleanup::ReleaseEventInputInfoRefs>(); + registry->on_destroy() + .connect<&ECS::Util::UIRefCleanup::ReleaseLayoutEventInfoRefs>(); + // UI LuaMethodTable::Set(zenith, uiGlobalMethods, "UI"); diff --git a/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.cpp b/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.cpp index 334474a..88e9b50 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.cpp @@ -239,5 +239,42 @@ namespace Scripting::UI return 0; } + + i32 SetBorderColor(Zenith* zenith, Panel* panel) + { + entt::registry* registry = ServiceLocator::GetEnttRegistries()->uiRegistry; + auto& panelTemplate = registry->get(panel->entity); + registry->get_or_emplace(panel->entity); + + vec3 colorVec = zenith->CheckVal(2); + f32 alpha = zenith->IsNumber(3) ? zenith->Get(3) : -1.0f; + + Color colorWithAlpha = Color(colorVec.r, colorVec.g, colorVec.b, panelTemplate.borderColor.a); + if (alpha >= 0.0f) + { + colorWithAlpha.a = alpha; + } + panelTemplate.borderColor = colorWithAlpha; + panelTemplate.setFlags.border = 1; + + registry->emplace_or_replace(panel->canvasEntity); + + return 0; + } + + i32 SetBorderSize(Zenith* zenith, Panel* panel) + { + entt::registry* registry = ServiceLocator::GetEnttRegistries()->uiRegistry; + auto& panelTemplate = registry->get(panel->entity); + registry->get_or_emplace(panel->entity); + + f32 borderSize = zenith->CheckVal(2); + panelTemplate.borderSize = glm::max(borderSize, 0.0f); + panelTemplate.setFlags.border = 1; + + registry->emplace_or_replace(panel->canvasEntity); + + return 0; + } } } \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h b/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h index 956df90..893ebf8 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h +++ b/Source/Game-Lib/Game-Lib/Scripting/UI/Panel.h @@ -27,6 +27,8 @@ namespace Scripting::UI i32 SetTexCoords(Zenith* zenith, Panel* panel); i32 SetColor(Zenith* zenith, Panel* panel); i32 SetAlpha(Zenith* zenith, Panel* panel); + i32 SetBorderColor(Zenith* zenith, Panel* panel); + i32 SetBorderSize(Zenith* zenith, Panel* panel); }; static LuaRegister panelMethods[] = @@ -45,6 +47,9 @@ namespace Scripting::UI { "SetTexCoords", PanelMethods::SetTexCoords }, { "SetColor", PanelMethods::SetColor }, - { "SetAlpha", PanelMethods::SetAlpha } + { "SetAlpha", PanelMethods::SetAlpha }, + + { "SetBorderColor", PanelMethods::SetBorderColor }, + { "SetBorderSize", PanelMethods::SetBorderSize } }; } \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.cpp b/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.cpp index 29dedf4..101191f 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.cpp @@ -3,6 +3,7 @@ #include "Game-Lib/ECS/Components/UI/Canvas.h" #include "Game-Lib/ECS/Components/UI/Clipper.h" #include "Game-Lib/ECS/Components/UI/EventInputInfo.h" +#include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" #include "Game-Lib/ECS/Components/UI/Widget.h" #include "Game-Lib/ECS/Singletons/UISingleton.h" #include "Game-Lib/ECS/Util/Transform2D.h" @@ -11,6 +12,7 @@ #include "Game-Lib/Rendering/GameRenderer.h" #include "Game-Lib/Scripting/UI/Panel.h" #include "Game-Lib/Scripting/UI/Text.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" #include "Game-Lib/Util/ServiceLocator.h" #include @@ -171,6 +173,7 @@ i32 Scripting::UI::WidgetMethods::SetEnabled(Zenith* zenith, Widget* widget) registry->emplace_or_replace(widget->entity); registry->emplace_or_replace(widget->canvasEntity); + ECS::Util::UI::MarkCanvasSortDirty(registry, widget->canvasEntity); return 0; } @@ -192,6 +195,7 @@ i32 Scripting::UI::WidgetMethods::SetVisible(Zenith* zenith, Widget* widget) registry->emplace_or_replace(widget->entity); registry->emplace_or_replace(widget->canvasEntity); + ECS::Util::UI::MarkCanvasSortDirty(registry, widget->canvasEntity); return 0; } @@ -741,6 +745,28 @@ i32 Scripting::UI::WidgetMethods::ForceRefresh(Zenith* zenith, Widget* widget) return 0; } +i32 Scripting::UI::WidgetMethods::RegisterLayoutRefresh(Zenith* zenith, Widget* widget) +{ + i32 callback = zenith->IsFunction(2) ? zenith->GetRef(2) : -1; + + entt::registry* registry = ServiceLocator::GetEnttRegistries()->uiRegistry; + auto& layoutEventInfo = registry->get_or_emplace(widget->entity); + + // Replacing an existing ref leaks the old one; release first. + Scripting::Util::Zenith::Unref(zenith, layoutEventInfo.onLayoutRefresh); + layoutEventInfo.onLayoutRefresh = callback; + + return 0; +} + +i32 Scripting::UI::WidgetMethods::InvalidateLayout(Zenith* zenith, Widget* widget) +{ + entt::registry* registry = ServiceLocator::GetEnttRegistries()->uiRegistry; + registry->emplace_or_replace(widget->entity); + + return 0; +} + i32 Scripting::UI::WidgetInputMethods::SetOnMouseDown(Zenith* zenith, Widget* widget) { i32 callback = zenith->IsFunction(2) ? zenith->GetRef(2) : - 1; diff --git a/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.h b/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.h index 7ab4c8a..6e23408 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.h +++ b/Source/Game-Lib/Game-Lib/Scripting/UI/Widget.h @@ -92,6 +92,12 @@ namespace Scripting::UI i32 SetPos3D(Zenith* zenith, Widget* widget); i32 ForceRefresh(Zenith* zenith, Widget* widget); + + // Layout system hooks: Lua-side LinearLayout/GridLayout register a 0-arg + // refresh closure here; CanvasRenderer::Update fires it pre-frame for any + // widget tagged DirtyLayoutTag (set by InvalidateLayout from Lua's Invalidate). + i32 RegisterLayoutRefresh(Zenith* zenith, Widget* widget); + i32 InvalidateLayout(Zenith* zenith, Widget* widget); } static LuaRegister widgetMethods[] = @@ -141,7 +147,10 @@ namespace Scripting::UI { "SetPos3D", WidgetMethods::SetPos3D }, - { "ForceRefresh", WidgetMethods::ForceRefresh } + { "ForceRefresh", WidgetMethods::ForceRefresh }, + + { "RegisterLayoutRefresh", WidgetMethods::RegisterLayoutRefresh }, + { "InvalidateLayout", WidgetMethods::InvalidateLayout } }; namespace WidgetInputMethods diff --git a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp index f42768b..826562f 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp @@ -13,4 +13,10 @@ namespace Scripting::Util::Zenith ZenithInfoKey globalKey = ZenithInfoKey::MakeGlobal(0, 0); return luaManager->GetZenithStateManager().Get(globalKey); } + + void Unref(::Scripting::Zenith* zenith, i32 ref) + { + if (ref == -1) return; + lua_unref(zenith->state, ref); + } } diff --git a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.h b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.h index a2cc64e..e1f48b7 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.h +++ b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.h @@ -7,5 +7,8 @@ namespace Scripting namespace Util::Zenith { ::Scripting::Zenith* GetGlobal(); + + // No-op for ref == -1 (LUA_NOREF). zenith must be non-null. + void Unref(::Scripting::Zenith* zenith, i32 ref); } } \ No newline at end of file diff --git a/Source/Resources/Scripts/API/UI/GridLayout.luau b/Source/Resources/Scripts/API/UI/GridLayout.luau new file mode 100644 index 0000000..cebd3e3 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/GridLayout.luau @@ -0,0 +1,303 @@ +--[[ +GridLayout — uniform-cell grid. Pick one shape mode: + columns = N explicit column count, rows derived from child count + rows = N explicit row count, columns derived + cellSizeX/Y = N auto: derive cols/rows from container size + cell size + +Children fill in `direction` order ("rowMajor" default; "columnMajor"). Per-cell +alignment (cellAlignH / cellAlignV) places children smaller than the cell; +"stretch" resizes the child to the cell. + +Container sizing modes (widthMode / heightMode): + fixed — declared sizeX/sizeY at construction; ignores content. + shrink — sized to the cell-grid extent; ignores parent. + stretch — NOT IMPLEMENTED (errors at construction). Reserved for future + parent-driven stretch. + auto — max(grid_extent, current_panel_size). + +Same proxy + Invalidate + pre-render-driven Refresh model as LinearLayout, sharing +the LayoutCommon module's wrap and base-layout machinery. + +Coordinate system: anchor (0,0)=bottom-left, (1,1)=top-right. SetPos +Y is up. Grid +uses anchor (0,1) consistently — children are placed relative to top-left. +]] + +local LayoutCommon = require("@src/API/UI/LayoutCommon") + +export type SizeMode = "fixed" | "shrink" | "stretch" | "auto" +export type CellAlignKind = "start" | "center" | "end" | "stretch" +export type GridAlignKind = "start" | "center" | "end" + +export type GridChildOpts = +{ + skip : boolean?, + -- Per-child cell-alignment overrides. + cellAlignH : CellAlignKind?, + cellAlignV : CellAlignKind?, + aspectRatio : number?, -- shape-lock (cellAlignH/V = stretch only) +} + +export type GridLayoutOpts = +{ + spacing : number?, spacingX : number?, spacingY : number?, + padding : number?, + paddingTop : number?, paddingBottom : number?, paddingLeft : number?, paddingRight : number?, + + columns : number?, rows : number?, + cellSizeX : number?, cellSizeY : number?, + + cellAlign : CellAlignKind?, -- default "center" (both axes) + cellAlignH : CellAlignKind?, -- horizontal cell alignment + cellAlignV : CellAlignKind?, -- vertical cell alignment + + -- Where the entire cell group sits within the container, on each axis. + -- Useful when explicit cellSize fits content smaller than the container's inner area. + gridAlignH : GridAlignKind?, -- default "start" + gridAlignV : GridAlignKind?, -- default "start" + + direction : ("rowMajor" | "columnMajor")?, -- default "rowMajor" + + widthMode : SizeMode?, -- default "fixed" + heightMode : SizeMode?, -- default "fixed" + + panelTemplate : string?, + alpha : number?, +} + +local gridLayoutAPI = {} + +local function trySetWidth(raw, w) + local fn = raw.SetWidth + if type(fn) == "function" then fn(raw, w) end +end +local function trySetHeight(raw, h) + local fn = raw.SetHeight + if type(fn) == "function" then fn(raw, h) end +end + +local function PlaceChild(item, co, originX, originY, cellW, cellH, childW, childH, alignH, alignV) + -- originX/Y is the cell's top-left in "distance from container's top-left" (Y positive = down). + local raw = item.raw + local resized = false + local opts = co or {} + + local offX + if alignH == "start" then offX = 0 + elseif alignH == "end" then offX = cellW - childW + elseif alignH == "stretch" then + if opts.aspectRatio and opts.aspectRatio > 0 then + -- Aspect-locked: derive height from width if cellAlignH=stretch. + local newH = math.floor(cellW / opts.aspectRatio) + trySetWidth(raw, cellW) + trySetHeight(raw, newH) + childW = cellW + childH = newH + else + trySetWidth(raw, cellW) + childW = cellW + end + offX = 0 + resized = true + else offX = math.floor((cellW - childW) / 2) -- "center" default + end + + local offY + if alignV == "start" then offY = 0 + elseif alignV == "end" then offY = cellH - childH + elseif alignV == "stretch" then + if opts.aspectRatio and opts.aspectRatio > 0 and alignH ~= "stretch" then + local newW = math.floor(cellH * opts.aspectRatio) + trySetWidth(raw, newW) + trySetHeight(raw, cellH) + childW = newW + childH = cellH + else + trySetHeight(raw, cellH) + childH = cellH + end + offY = 0 + resized = true + else offY = math.floor((cellH - childH) / 2) + end + + -- Stretch raw-resized a nested layout's panel; re-position its grandchildren + -- against the new size via RefreshLocal (skips the upward Invalidate propagation). + if resized and item.proxy and item.proxy._innerLayout then + item.proxy._innerLayout:RefreshLocal() + end + + raw:SetAnchor(0.0, 1.0) + raw:SetRelativePoint(0.0, 1.0) + raw:SetPos(originX + offX, -(originY + offY)) +end + +local function DoRefresh(self) + self._dirty = false + local opts = self._opts + + local widthMode : SizeMode = opts.widthMode or "fixed" + local heightMode : SizeMode = opts.heightMode or "fixed" + if widthMode == "stretch" or heightMode == "stretch" then + -- Surface the unimplemented mode rather than silently behaving like "fixed". + error("GridLayout: 'stretch' container size mode is not implemented; use 'fixed', 'shrink', or 'auto'", 2) + end + + local pad = opts.padding or 0 + local pt = opts.paddingTop or pad + local pb = opts.paddingBottom or pad + local pl = opts.paddingLeft or pad + local pr = opts.paddingRight or pad + + local spacing = opts.spacing or 0 + local spacingX = opts.spacingX or spacing + local spacingY = opts.spacingY or spacing + + local defaults = nil -- Grid has no defaultChildOpts today; passing nil keeps MergeOpts a no-op. + + -- Filter active items, refresh inner layouts, auto-prune destroyed. + local activeItems = {} + local toPrune = nil + for i, item in self._items do + local ok, visible = pcall(function() return item.raw:IsVisible() end) + if not ok then + toPrune = toPrune or {} + table.insert(toPrune, i) + continue + end + + local co = item._mergedOpts + if co == nil then + co = LayoutCommon.MergeOpts(defaults, item.opts) + item._mergedOpts = co + end + + if not visible then continue end + if co and co.skip then continue end + + if item.proxy and item.proxy._innerLayout then + item.proxy._innerLayout:Refresh() + end + table.insert(activeItems, { item = item, co = co }) + end + + if toPrune then + for j = #toPrune, 1, -1 do + local idx = toPrune[j] + local proxy = self._items[idx].proxy + self._itemByProxy[proxy] = nil + table.remove(self._items, idx) + end + for i = toPrune[1], #self._items do + self._itemByProxy[self._items[i].proxy] = i + end + end + local n = #activeItems + + local containerW, containerH = self.panel:GetSize() + local innerW = containerW - pl - pr + local innerH = containerH - pt - pb + + -- Determine shape (cols, rows). + local cols, rows + if opts.columns then + cols = math.max(1, opts.columns) + rows = math.max(1, math.ceil(n / cols)) + elseif opts.rows then + rows = math.max(1, opts.rows) + cols = math.max(1, math.ceil(n / rows)) + elseif opts.cellSizeX and opts.cellSizeY then + cols = math.max(1, math.floor((innerW + spacingX) / (opts.cellSizeX + spacingX))) + rows = math.max(1, math.ceil(n / cols)) + else + -- Fallback: square-ish from sqrt(N). + cols = math.max(1, math.ceil(math.sqrt(math.max(1, n)))) + rows = math.max(1, math.ceil(n / cols)) + end + + -- Cell size: explicit overrides; otherwise distribute container space. + local cellW = opts.cellSizeX or math.floor((innerW - spacingX * (cols - 1)) / cols) + local cellH = opts.cellSizeY or math.floor((innerH - spacingY * (rows - 1)) / rows) + + -- Container size resolve (shrink/auto) — apply BEFORE per-child placement so + -- cell grouping uses the resolved container extents. + local gridW = pl + pr + (if cols > 0 then cols * cellW + (cols - 1) * spacingX else 0) + local gridH = pt + pb + (if rows > 0 then rows * cellH + (rows - 1) * spacingY else 0) + local newW, newH = containerW, containerH + if widthMode == "shrink" then newW = gridW + elseif widthMode == "auto" and gridW > containerW then newW = gridW end + if heightMode == "shrink" then newH = gridH + elseif heightMode == "auto" and gridH > containerH then newH = gridH end + if math.abs(newW - containerW) > 0.5 or math.abs(newH - containerH) > 0.5 then + self.panel:SetSize(newW, newH) + containerW, containerH = newW, newH + innerW = containerW - pl - pr + innerH = containerH - pt - pb + -- Re-derive cellW/H if they were size-driven (no explicit cellSize). + if not opts.cellSizeX then cellW = math.floor((innerW - spacingX * (cols - 1)) / cols) end + if not opts.cellSizeY then cellH = math.floor((innerH - spacingY * (rows - 1)) / rows) end + end + + local alignH = opts.cellAlignH or opts.cellAlign or "center" + local alignV = opts.cellAlignV or opts.cellAlign or "center" + local rowMajor = (opts.direction or "rowMajor") == "rowMajor" + + -- gridAlignH / gridAlignV: shift the entire cell group within the container + -- when the group is smaller than the inner area. + local groupW = cols * cellW + (cols - 1) * spacingX + local groupH = rows * cellH + (rows - 1) * spacingY + local extraX = math.max(0, innerW - groupW) + local extraY = math.max(0, innerH - groupH) + local groupOffsetX = if opts.gridAlignH == "center" then math.floor(extraX / 2) + elseif opts.gridAlignH == "end" then extraX + else 0 + local groupOffsetY = if opts.gridAlignV == "center" then math.floor(extraY / 2) + elseif opts.gridAlignV == "end" then extraY + else 0 + + for i, entry in activeItems do + local item, co = entry.item, entry.co + local zeroIdx = i - 1 + local col, row + if rowMajor then + col = zeroIdx % cols + row = math.floor(zeroIdx / cols) + else + row = zeroIdx % rows + col = math.floor(zeroIdx / rows) + end + local originX = pl + groupOffsetX + col * (cellW + spacingX) + local originY = pt + groupOffsetY + row * (cellH + spacingY) + + local childW, childH = item.raw:GetSize() + local thisAlignH = (co and co.cellAlignH) or alignH + local thisAlignV = (co and co.cellAlignV) or alignV + PlaceChild(item, co, originX, originY, cellW, cellH, childW, childH, thisAlignH, thisAlignV) + end + + self._measuredWidth = gridW + self._measuredHeight = gridH +end + +function gridLayoutAPI.NewGridLayout(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + + local panelTemplate = opts.panelTemplate or "DialogBox" + local panel = parent:NewPanel(posX, posY, sizeX, sizeY, layer or 0, panelTemplate) + if opts.alpha ~= nil then panel:SetAlpha(opts.alpha) end + + local layoutTable = LayoutCommon.MakeBaseLayout(panel, opts, DoRefresh) + layoutTable._measuredWidth = 0 + layoutTable._measuredHeight = 0 + + function layoutTable.GetMeasuredHeight(sf) sf:Refresh(); return sf._measuredHeight end + function layoutTable.GetMeasuredWidth(sf) sf:Refresh(); return sf._measuredWidth end + + if type(panel) == "table" and panel._raw ~= nil and panel._layout ~= nil then + layoutTable._parentLayout = panel._layout + panel._innerLayout = layoutTable + end + + return layoutTable +end + +return gridLayoutAPI diff --git a/Source/Resources/Scripts/API/UI/HorizontalLayout.luau b/Source/Resources/Scripts/API/UI/HorizontalLayout.luau new file mode 100644 index 0000000..513b36b --- /dev/null +++ b/Source/Resources/Scripts/API/UI/HorizontalLayout.luau @@ -0,0 +1,25 @@ +--[[ +HorizontalLayout — main axis is X. Children stack left-to-right by default; pass +`reverse=true` for right-to-left. See LinearLayout.luau for the full opts/childOpts +/algorithm reference. + +Example: + local h = UIHorizontalLayout.NewHorizontalLayout(parent, 0, 0, 600, 60, 0, { + spacing = 8, padding = 4, alignV = "center", + }) + local label = h:NewText("Name:", 0, 0, 0, "YellowText") + local field = h:NewPanel(0, 0, 200, 30, 0, "InputBoxBackground") + h:SetChildOpts(field, { modeX = "flex", weightX = 1 }) -- field fills remaining horizontal space +]] + +local LinearLayout = require("@src/API/UI/LinearLayout") + +local horizontalLayoutAPI = {} + +function horizontalLayoutAPI.NewHorizontalLayout(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + opts.axis = "x" + return LinearLayout.NewLinearLayout(parent, posX, posY, sizeX, sizeY, layer, opts) +end + +return horizontalLayoutAPI diff --git a/Source/Resources/Scripts/API/UI/LayoutCommon.luau b/Source/Resources/Scripts/API/UI/LayoutCommon.luau new file mode 100644 index 0000000..c6d1ed4 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/LayoutCommon.luau @@ -0,0 +1,225 @@ +--[[ +LayoutCommon — shared internals between LinearLayout and GridLayout. + +Owns: + * SIZE_AFFECTING setter list — proxy-intercepted to invalidate the layout. + * ResolveProxy(child) — find the layout proxy on a wrapper / userdata / proxy. + * WrapChild(raw, layout) — Lua proxy that forwards to the raw widget but invalidates + the layout on size-affecting setters. + * MergeOpts(defaults, opts) — shallow merge with `opts` winning. Used when a layout + supplies a `defaultChildOpts` table. + * MakeBaseLayout(panel, opts, doRefreshFn) + Returns a layout table preloaded with the everything-but-DoRefresh machinery: + Invalidate / Refresh / RefreshLocal / GetSize / Set{Size,Width,Height,Pos*,Anchor, + RelativePoint,Spacing,Padding,ChildOpts} / Remove / Add / NewPanel / NewText / + NewWidget / __index fall-through to .panel / RegisterLayoutRefresh hookup. + The caller (LinearLayout/GridLayout) supplies `doRefreshFn(self)` and tacks on + its own GetMeasuredHeight / GetMeasuredWidth / type-specific bits. +]] + +local layoutCommon = {} + +local SIZE_AFFECTING = { "SetSize", "SetWidth", "SetHeight", "SetText", "SetWrapWidth", "SetFontSize" } +layoutCommon.SIZE_AFFECTING = SIZE_AFFECTING + +-- Try to find the proxy associated with whatever the user handed in. +-- - A proxy itself ({ _raw = ..., _layout = ... }) → return it. +-- - A compound wrapper (UIButton/UIScrollBox/UIInputBox/UIComboBox/etc.) created with a +-- layout as its parent: its first proxy-valued field IS the layout child. Walk fields. +-- - Anything else: return nil. +local function ResolveProxy(child) + if type(child) ~= "table" then return nil end + if child._raw ~= nil then return child end + for _, v in pairs(child) do + if type(v) == "table" and v._raw ~= nil and v._layout ~= nil then return v end + end + return nil +end +layoutCommon.ResolveProxy = ResolveProxy + +local function WrapChild(raw, layout) + local proxy = setmetatable({ _raw = raw, _layout = layout }, { + __index = function(_, k) + local v = raw[k] + if type(v) ~= "function" then return v end + return function(_, ...) return v(raw, ...) end + end, + }) + for _, name in SIZE_AFFECTING do + local original = raw[name] + if type(original) == "function" then + proxy[name] = function(_, ...) + original(raw, ...) -- raw:(args), bypassing the proxy to avoid recursion + layout:Invalidate() + end + end + end + return proxy +end +layoutCommon.WrapChild = WrapChild + +-- Merge `defaults` beneath `itemOpts` (itemOpts wins). Returns nil only if both inputs +-- are nil. Result is a fresh table — DoRefresh callers should cache it on the item to +-- avoid per-frame allocation. +local function MergeOpts(defaults, itemOpts) + if not defaults then return itemOpts end + if not itemOpts then return defaults end + local merged = {} + for k, v in pairs(defaults) do merged[k] = v end + for k, v in pairs(itemOpts) do merged[k] = v end + return merged +end +layoutCommon.MergeOpts = MergeOpts + +function layoutCommon.MakeBaseLayout(panel, opts, doRefreshFn) + local self = { + panel = panel, + content = panel, -- alias for the escape-hatch usage + _opts = opts, + _items = {}, -- ordered list of { proxy, raw, opts, _mergedOpts } + _itemByProxy = {}, -- proxy -> index in _items (for SetChildOpts lookup) + -- _dirty starts FALSE so the first PushItem's Invalidate is a real false→true + -- transition that emplaces the C++ DirtyLayoutTag. Initialising to true would + -- short-circuit the dirty-guard and the C++ tag would never be emplaced, leaving + -- the layout invisible to the pre-render hook. + _dirty = false, + _parentLayout = nil, -- set when our `panel` is itself a wrapped proxy of another layout + } + + -- Invalidate is gated on `_dirty`: each dirty cycle emplaces the C++ DirtyLayoutTag + -- exactly once and propagates upward exactly once. Clearing happens at the top of + -- DoRefresh (`self._dirty = false`), so the next mutation re-arms the chain. + function self.Invalidate(sf) + if sf._dirty then return end + sf._dirty = true + sf.panel:InvalidateLayout() + if sf._parentLayout then sf._parentLayout:Invalidate() end + end + + function self.Refresh(sf) + if not sf._dirty then return end + doRefreshFn(sf) + end + + -- Force a refresh without going through Invalidate (skips the upward-propagation + -- that would re-mark our parent _dirty mid-pass). Used by an outer layout's + -- DoRefresh to re-position grandchildren after raw-resizing us. + function self.RefreshLocal(sf) + sf._dirty = true + doRefreshFn(sf) + end + + function self.GetSize(sf) + sf:Refresh() + return sf.panel:GetSize() + end + + -- Container size mutators invalidate (children may need repositioning). + function self.SetSize(sf, w, h) sf.panel:SetSize(w, h); sf:Invalidate() end + function self.SetWidth(sf, w) sf.panel:SetWidth(w); sf:Invalidate() end + function self.SetHeight(sf, h) sf.panel:SetHeight(h); sf:Invalidate() end + + -- Position / anchor mutators of the container itself don't affect the layout pass. + function self.SetPos(sf, x, y) sf.panel:SetPos(x, y) end + function self.SetPosX(sf, x) sf.panel:SetPosX(x) end + function self.SetPosY(sf, y) sf.panel:SetPosY(y) end + function self.SetAnchor(sf, x, y) sf.panel:SetAnchor(x, y) end + function self.SetRelativePoint(sf, x, y) sf.panel:SetRelativePoint(x, y) end + + function self.SetSpacing(sf, s) sf._opts.spacing = s; sf:Invalidate() end + function self.SetPadding(sf, p) sf._opts.padding = p; sf:Invalidate() end + + function self.SetChildOpts(sf, child, childOpts) + local proxy = ResolveProxy(child) + local idx = proxy and sf._itemByProxy[proxy] + if idx then + sf._items[idx].opts = childOpts + sf._items[idx]._mergedOpts = nil + sf:Invalidate() + end + end + + -- Explicit removal — symmetric with Add. Auto-prune in each layout's DoRefresh + -- (catches "destroyed without :Remove") is the safety net; this is the preferred path. + function self.Remove(sf, child) + local proxy = ResolveProxy(child) + local idx = proxy and sf._itemByProxy[proxy] + if not idx then return end + sf._itemByProxy[proxy] = nil + table.remove(sf._items, idx) + -- Reindex entries shifted by the table.remove. + for i = idx, #sf._items do + sf._itemByProxy[sf._items[i].proxy] = i + end + sf:Invalidate() + end + + local function PushItem(sf, raw, proxy, childOpts) + table.insert(sf._items, { proxy = proxy, raw = raw, opts = childOpts }) + sf._itemByProxy[proxy] = #sf._items + sf:Invalidate() + end + + function self.NewPanel(sf, ...) + local raw = sf.panel:NewPanel(...) + local proxy = WrapChild(raw, sf) + PushItem(sf, raw, proxy, nil) + return proxy + end + function self.NewText(sf, ...) + local raw = sf.panel:NewText(...) + local proxy = WrapChild(raw, sf) + PushItem(sf, raw, proxy, nil) + return proxy + end + function self.NewWidget(sf, ...) + local raw = sf.panel:NewWidget(...) + local proxy = WrapChild(raw, sf) + PushItem(sf, raw, proxy, nil) + return proxy + end + + -- Manual register: wrap (or reuse-wrap) a child added externally and append. + function self.Add(sf, child, childOpts) + local proxy = ResolveProxy(child) + if proxy then + if not sf._itemByProxy[proxy] then + PushItem(sf, proxy._raw, proxy, childOpts) + elseif childOpts then + local item = sf._items[sf._itemByProxy[proxy]] + item.opts = childOpts + item._mergedOpts = nil + sf:Invalidate() + end + return proxy + end + if type(child) == "userdata" then + local newProxy = WrapChild(child, sf) + PushItem(sf, child, newProxy, childOpts) + return newProxy + end + return child + end + + -- Fall through to .panel for any other widget method (GetWorldPos, SetClipChildren, etc.) + -- so the layout walks like a Widget when passed as a parent to compound constructors. + setmetatable(self, { + __index = function(t, k) + local p = rawget(t, "panel") + if p == nil then return nil end + local v = p[k] + if type(v) ~= "function" then return v end + return function(_, ...) return v(p, ...) end + end, + }) + + -- C++ pre-render hook: CanvasRenderer fires the registered closure for any panel + -- tagged DirtyLayoutTag. PushItem / SetChildOpts / Set* mutators all call Invalidate + -- which tags the panel via panel:InvalidateLayout, so the first frame's refresh runs + -- automatically as soon as a child is added. + panel:RegisterLayoutRefresh(function() self:Refresh() end) + + return self +end + +return layoutCommon diff --git a/Source/Resources/Scripts/API/UI/LinearLayout.luau b/Source/Resources/Scripts/API/UI/LinearLayout.luau new file mode 100644 index 0000000..2e74c40 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/LinearLayout.luau @@ -0,0 +1,481 @@ +--[[ +LinearLayout — shared implementation backing VerticalLayout and HorizontalLayout. + +Owns positioning of its direct children along a single axis (main = y for V, x for H), +with axis-absolute alignment (alignH/alignV), padding, spacing, fixed-or-flex per-child +sizing with weight + min/max + opt-in aspectRatio, and bottomUp/rightToLeft via `reverse`. + +Container sizing modes (per axis: widthMode / heightMode): + fixed — declared sizeX/sizeY at construction; ignores content and parent. + shrink — sized to content's intrinsic min; ignores parent allowance. + stretch — NOT IMPLEMENTED (errors at construction). Reserved for future + parent-driven stretch once we thread an "available space" parameter. + auto — max(content_intrinsic, current_panel_size). Fills, grows past if content demands. + +Child sizing modes (per axis: modeX / modeY): + fixed — sizeX/sizeY (or intrinsic if omitted). weight/min/max ignored. + flex — participates in slack distribution by weightX/Y; basis = sizeX/sizeY (or 0); + clamped by minX/maxX/minY/maxY. On the cross axis there's no sibling + competition so weight is silently inert and `flex` means "stretch to fill". + +Cycle rule: container shrink-on-an-axis with a same-axis flex child collapses the +child to its intrinsic size and warns. Cross-axis flex inside a main-axis shrink +container is fine — the rule is per-axis. + +aspectRatio (child opt-in): when set, cross = main / aspectRatio, clamped by cross +min/max. Overrides crossMode for that child. + +defaultChildOpts (container-level): a table of per-child opts merged into each +child's opts when the child does not override them. Useful for "default cross-fill" +patterns: e.g. `defaultChildOpts = { modeX = "flex" }` on a V layout makes every +child stretch its width. + +Children created via layout:NewPanel/NewText/NewWidget are wrapped in a Lua proxy +(see LayoutCommon.WrapChild) that forwards everything to the raw widget userdata via +__index but intercepts size-affecting setters to call layout:Invalidate(). Refresh is +event-driven via RegisterLayoutRefresh on the container's panel — the C++ pre-render +hook fires a registered closure for any panel tagged DirtyLayoutTag. + +Coordinate system: anchor/relativePoint use (0,0)=bottom-left, (1,1)=top-right. +SetPos offsets are in screen-pixel units where +X is right and +Y is UP. So a +top-anchored child needs SetPosY(-N) to move down by N pixels. +]] + +local LayoutCommon = require("@src/API/UI/LayoutCommon") + +export type SizeMode = "fixed" | "shrink" | "stretch" | "auto" +export type ChildMode = "fixed" | "flex" +export type AlignKind = "start" | "center" | "end" + +export type LinearChildOpts = +{ + modeX : ChildMode?, + modeY : ChildMode?, + sizeX : number?, + sizeY : number?, + weightX : number?, + weightY : number?, + minX : number?, maxX : number?, + minY : number?, maxY : number?, + + aspectRatio : number?, -- cross = main / aspectRatio (overrides crossMode) + alignH : AlignKind?, -- per-child cross-axis position override (V layout only) + alignV : AlignKind?, -- per-child cross-axis position override (H layout only) + skip : boolean?, +} + +export type LinearLayoutOpts = +{ + axis : ("x" | "y")?, + spacing : number?, + padding : number?, + paddingTop : number?, paddingBottom : number?, paddingLeft : number?, paddingRight : number?, + reverse : boolean?, + panelTemplate : string?, + alpha : number?, + + widthMode : SizeMode?, -- default "fixed" + heightMode : SizeMode?, -- default "fixed" + + alignH : AlignKind?, -- horizontal positioning (default "start") + alignV : AlignKind?, -- vertical positioning (default "start") + + defaultChildOpts : LinearChildOpts?, -- per-child opt defaults; child opts override +} + +local linearLayoutAPI = {} + +-- Run the layout pass. Idempotent given the same inputs; called via the +-- C++ pre-render hook (RegisterLayoutRefresh) and via outer-layout RefreshLocal. +local function DoRefresh(self) + self._dirty = false + + local opts = self._opts + local mainAxisIsY = (opts.axis == "y") + local reverse = opts.reverse or false + local spacing = opts.spacing or 0 + + local pad = opts.padding or 0 + local pt = opts.paddingTop or pad + local pb = opts.paddingBottom or pad + local pl = opts.paddingLeft or pad + local pr = opts.paddingRight or pad + + -- Leading/trailing pad along main axis (depends on direction). + local leadPad, trailPad + if mainAxisIsY then + leadPad = if reverse then pb else pt + trailPad = if reverse then pt else pb + else + leadPad = if reverse then pr else pl + trailPad = if reverse then pl else pr + end + local crossLeadPad = if mainAxisIsY then pl else pt + local crossTrailPad = if mainAxisIsY then pr else pb + + -- Translate axis-absolute opts to main/cross. + local mainMode : SizeMode = if mainAxisIsY then (opts.heightMode or "fixed") else (opts.widthMode or "fixed") + local crossMode : SizeMode = if mainAxisIsY then (opts.widthMode or "fixed") else (opts.heightMode or "fixed") + if mainMode == "stretch" or crossMode == "stretch" then + -- Surface the unimplemented mode rather than silently behaving like "fixed". + error("LinearLayout: 'stretch' container size mode is not implemented; use 'fixed', 'shrink', or 'auto'", 2) + end + local mainAlign : AlignKind = (if mainAxisIsY then opts.alignV else opts.alignH) or "start" + local crossAlign : AlignKind = (if mainAxisIsY then opts.alignH else opts.alignV) or "start" + + local defaults = opts.defaultChildOpts + + -- Phase A: enumerate active items + measure intrinsics. + -- If a child is itself a layout container, refresh it first so we measure its + -- post-refresh size. Invisible/disabled widgets are skipped. + -- A bare Widget (created via :NewWidget) has no GetSize/SetWidth/SetHeight; treat its + -- intrinsic size as 0 and skip size mutations later. + -- Auto-prune: if `raw:IsVisible()` errors (destroyed widget), drop the item from + -- _items / _itemByProxy after the pass. Explicit `:Remove` is preferred but this + -- keeps a forgotten DestroyWidget from crashing the layout. + local activeItems = {} + local toPrune = nil -- list of indices into self._items to remove after iteration + for i, item in self._items do + local ok, visible = pcall(function() return item.raw:IsVisible() end) + if not ok then + toPrune = toPrune or {} + table.insert(toPrune, i) + continue + end + + -- Lazy-merge defaultChildOpts under per-child opts; cache on the item so we don't + -- allocate a fresh table each refresh. SetChildOpts / Add nil out _mergedOpts. + local co = item._mergedOpts + if co == nil then + co = LayoutCommon.MergeOpts(defaults, item.opts) + item._mergedOpts = co + end + + if co and co.skip then continue end + if not visible then continue end + + if item.proxy and item.proxy._innerLayout then + item.proxy._innerLayout:Refresh() + end + + local hasSize = (type(item.raw.GetSize) == "function") + local ix, iy = 0, 0 + if hasSize then ix, iy = item.raw:GetSize() end + + table.insert(activeItems, { + raw = item.raw, proxy = item.proxy, opts = co, + intrinsicX = ix, intrinsicY = iy, hasSize = hasSize, + }) + end + + if toPrune then + -- Remove from highest index down so shifts don't invalidate the lower indices. + for j = #toPrune, 1, -1 do + local idx = toPrune[j] + local proxy = self._items[idx].proxy + self._itemByProxy[proxy] = nil + table.remove(self._items, idx) + end + -- Reindex everything after the lowest prune point. + for i = toPrune[1], #self._items do + self._itemByProxy[self._items[i].proxy] = i + end + end + + -- Phase B: container size resolve (shrink/auto write back to self.panel). + local intrinsicMainSum = 0 + local maxIntrinsicCross = 0 + for _, it in activeItems do + local im = if mainAxisIsY then it.intrinsicY else it.intrinsicX + local ic = if mainAxisIsY then it.intrinsicX else it.intrinsicY + intrinsicMainSum += im + if ic > maxIntrinsicCross then maxIntrinsicCross = ic end + end + local mainGap = if #activeItems > 1 then spacing * (#activeItems - 1) else 0 + local intrinsicMainExtent = leadPad + trailPad + intrinsicMainSum + mainGap + local intrinsicCrossExtent = crossLeadPad + crossTrailPad + maxIntrinsicCross + + local containerW, containerH = self.panel:GetSize() + local containerMain = if mainAxisIsY then containerH else containerW + local containerCross = if mainAxisIsY then containerW else containerH + + if mainMode == "shrink" then + containerMain = intrinsicMainExtent + elseif mainMode == "auto" then + if intrinsicMainExtent > containerMain then containerMain = intrinsicMainExtent end + end + + if crossMode == "shrink" then + containerCross = intrinsicCrossExtent + elseif crossMode == "auto" then + if intrinsicCrossExtent > containerCross then containerCross = intrinsicCrossExtent end + end + + -- Persist resolved container size to the panel widget. Calling Set on the panel + -- (which may be a proxy if our parent is a layout) propagates Invalidate upward; + -- the parent's Invalidate guard early-outs while the parent itself is mid-refresh. + local newW = if mainAxisIsY then containerCross else containerMain + local newH = if mainAxisIsY then containerMain else containerCross + if math.abs(newW - containerW) > 0.5 or math.abs(newH - containerH) > 0.5 then + self.panel:SetSize(newW, newH) + end + + local innerMain = containerMain - leadPad - trailPad + local innerCross = containerCross - crossLeadPad - crossTrailPad + + -- Cycle rule: shrink-on-main + flex children → coerce flex to fixed (intrinsic). + local containerShrinksOnMain = (mainMode == "shrink") + + -- Phase C: resolve per-child main-axis sizes. + local fixedSum = 0 + local flexItems = {} + local warnedShrinkFlex = false + for _, it in activeItems do + local co = it.opts or {} + local modeMain : ChildMode = (if mainAxisIsY then co.modeY else co.modeX) or "fixed" + local sizeMain = if mainAxisIsY then co.sizeY else co.sizeX + local intrinsicMain = if mainAxisIsY then it.intrinsicY else it.intrinsicX + + if containerShrinksOnMain and modeMain == "flex" then + if not warnedShrinkFlex then + warn("LinearLayout: flex children require a bounded main axis; container is shrink, falling back to intrinsic") + warnedShrinkFlex = true + end + modeMain = "fixed" + end + + if modeMain == "flex" then + it.modeMain = "flex" + it.basisMain = sizeMain or 0 + it.weight = (if mainAxisIsY then co.weightY else co.weightX) or 1 + it.minMain = if mainAxisIsY then co.minY else co.minX + it.maxMain = if mainAxisIsY then co.maxY else co.maxX + it.resolvedMain = it.basisMain + table.insert(flexItems, it) + else + it.modeMain = "fixed" + it.resolvedMain = sizeMain or intrinsicMain + fixedSum += it.resolvedMain + end + end + + -- Distribute slack to flex children — weight ratio with CSS-flex-style clamp loop. + if #flexItems > 0 then + local availableMain = innerMain - fixedSum - mainGap + local sumBasis = 0 + local sumWeight = 0 + for _, it in flexItems do + sumBasis += it.basisMain + sumWeight += it.weight + end + local slack = availableMain - sumBasis + for _, it in flexItems do + local share = if sumWeight > 0 then slack * it.weight / sumWeight else 0 + it.resolvedMain = it.basisMain + share + it._unclamped = true + end + + -- Iterate: each pass clamps min/max violations and redistributes the + -- freed/owed space to remaining unclamped flex items by weight. + for iter = 1, #flexItems + 2 do + local toRedistribute = 0 + local anyClampedThisPass = false + for _, it in flexItems do + if it._unclamped then + if it.minMain and it.resolvedMain < it.minMain then + toRedistribute -= (it.minMain - it.resolvedMain) + it.resolvedMain = it.minMain + it._unclamped = false + anyClampedThisPass = true + elseif it.maxMain and it.resolvedMain > it.maxMain then + toRedistribute += (it.resolvedMain - it.maxMain) + it.resolvedMain = it.maxMain + it._unclamped = false + anyClampedThisPass = true + end + end + end + if not anyClampedThisPass then break end + local sumW = 0 + for _, it in flexItems do + if it._unclamped then sumW += it.weight end + end + if sumW <= 0 then break end + for _, it in flexItems do + if it._unclamped then + it.resolvedMain += toRedistribute * it.weight / sumW + end + end + end + + for _, it in flexItems do + it.resolvedMain = math.floor(math.max(0, it.resolvedMain)) + it._unclamped = nil + end + end + + -- Phase D: resolve per-child cross-axis sizes. + for _, it in activeItems do + local co = it.opts or {} + local crossModeChild : ChildMode = (if mainAxisIsY then co.modeX else co.modeY) or "fixed" + local sizeCross = if mainAxisIsY then co.sizeX else co.sizeY + local intrinsicCross = if mainAxisIsY then it.intrinsicX else it.intrinsicY + local minCross = if mainAxisIsY then co.minX else co.minY + local maxCross = if mainAxisIsY then co.maxX else co.maxY + + local crossSize + if co.aspectRatio and co.aspectRatio > 0 then + crossSize = it.resolvedMain / co.aspectRatio + elseif crossModeChild == "flex" then + crossSize = innerCross + else + crossSize = sizeCross or intrinsicCross + end + + if minCross and crossSize < minCross then crossSize = minCross end + if maxCross and crossSize > maxCross then crossSize = maxCross end + it.resolvedCross = math.floor(math.max(0, crossSize)) + it.crossModeResolved = crossModeChild + end + + -- Apply size mutations to raw children (only when changed, to avoid extra + -- DirtyWidgetData churn). We mutate the RAW widget — proxy interception + -- targets the layout itself, not the raw, so this doesn't re-Invalidate. + for _, it in activeItems do + if it.hasSize then + -- Only resize an axis when the resolved size actually differs from + -- intrinsic — avoids redundant DirtyWidgetData churn and avoids + -- calling SetWidth/SetHeight on widgets that don't support them + -- (e.g. Text exposes GetSize but not the size setters; we leave + -- such widgets at their intrinsic size). + local intrinsicMain = if mainAxisIsY then it.intrinsicY else it.intrinsicX + local intrinsicCross = if mainAxisIsY then it.intrinsicX else it.intrinsicY + local mainSetterName = if mainAxisIsY then "SetHeight" else "SetWidth" + local crossSetterName = if mainAxisIsY then "SetWidth" else "SetHeight" + + if math.abs(it.resolvedMain - intrinsicMain) > 0.5 then + local setter = it.raw[mainSetterName] + if type(setter) == "function" then setter(it.raw, it.resolvedMain) end + end + if math.abs(it.resolvedCross - intrinsicCross) > 0.5 then + local setter = it.raw[crossSetterName] + if type(setter) == "function" then setter(it.raw, it.resolvedCross) end + end + end + + -- If this child is itself a layout, the raw resize bypassed its + -- layout-aware proxy and didn't invalidate it. Re-position its + -- grandchildren via RefreshLocal (skips the upward-propagation that + -- would re-mark our own _dirty mid-refresh). + if it.proxy and it.proxy._innerLayout then + it.proxy._innerLayout:RefreshLocal() + end + end + + -- Phase E: place children. + -- Anchor placed at the leading corner along main, and at start cross edge. + -- V topDown: (0, 1) V bottomUp: (0, 0) H L->R: (0, 1) H R->L: (1, 1) + local anchorMain = if mainAxisIsY then (if reverse then 0 else 1) else (if reverse then 1 else 0) + local anchorCross = if mainAxisIsY then 0 else 1 + local anchorX = if mainAxisIsY then anchorCross else anchorMain + local anchorY = if mainAxisIsY then anchorMain else anchorCross + + local cursor = leadPad + -- mainAlign shifts the starting cursor when content is shorter than the container + -- and there are no flex children (flex would consume all leftover otherwise). + if (mainAlign == "center" or mainAlign == "end") and #flexItems == 0 then + local content = 0 + for _, it in activeItems do content += it.resolvedMain end + content += mainGap + local leftover = math.max(0, innerMain - content) + cursor += if mainAlign == "center" then math.floor(leftover / 2) else leftover + end + + local maxChildCross = 0 + + for _, it in activeItems do + it.raw:SetAnchor(anchorX, anchorY) + it.raw:SetRelativePoint(anchorX, anchorY) + + local co = it.opts or {} + local childCrossAlignOpt : AlignKind? = if mainAxisIsY then co.alignH else co.alignV + local childCrossAlign = childCrossAlignOpt or crossAlign + + local crossDist + if it.crossModeResolved == "flex" and not co.aspectRatio then + -- Cross-axis flex fills the cross extent — sit at the cross start edge. + crossDist = crossLeadPad + elseif childCrossAlign == "center" then + crossDist = crossLeadPad + math.floor((innerCross - it.resolvedCross) / 2) + elseif childCrossAlign == "end" then + crossDist = containerCross - crossTrailPad - it.resolvedCross + else -- "start" + crossDist = crossLeadPad + end + + -- Convert (cursor, crossDist) → SetPos(x, y). + -- V (anchor.x=0): setX = +crossDist; H (anchor.y=1): setY = -crossDist + -- V topDown (ay=1): setY = -cursor; V bottomUp (ay=0): setY = +cursor + -- H L->R (ax=0): setX = +cursor; H R->L (ax=1): setX = -cursor + local setX, setY + if mainAxisIsY then + setX = crossDist + setY = if reverse then cursor else -cursor + else + setX = if reverse then -cursor else cursor + setY = -crossDist + end + it.raw:SetPos(setX, setY) + + cursor += it.resolvedMain + spacing + if it.resolvedCross > maxChildCross then maxChildCross = it.resolvedCross end + end + + -- Phase F: measured extents. + local mainExtent = leadPad + trailPad + if #activeItems > 0 then + local sum = 0 + for _, it in activeItems do sum += it.resolvedMain end + mainExtent += sum + spacing * (#activeItems - 1) + end + local crossExtent = crossLeadPad + maxChildCross + crossTrailPad + + self._measuredMain = mainExtent + self._measuredCross = crossExtent +end + +function linearLayoutAPI.NewLinearLayout(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + if opts.axis ~= "x" and opts.axis ~= "y" then opts.axis = "y" end + + local panelTemplate = opts.panelTemplate or "DialogBox" + local panel = parent:NewPanel(posX, posY, sizeX, sizeY, layer or 0, panelTemplate) + if opts.alpha ~= nil then panel:SetAlpha(opts.alpha) end + + local layoutTable = LayoutCommon.MakeBaseLayout(panel, opts, DoRefresh) + layoutTable._measuredMain = 0 + layoutTable._measuredCross = 0 + + function layoutTable.GetMeasuredHeight(sf) + sf:Refresh() + return if sf._opts.axis == "y" then sf._measuredMain else sf._measuredCross + end + function layoutTable.GetMeasuredWidth(sf) + sf:Refresh() + return if sf._opts.axis == "y" then sf._measuredCross else sf._measuredMain + end + + -- If our container panel is itself a wrapped proxy (i.e. parent was a layout that just + -- wrapped us in PushItem), hook the upward propagation chain so a deep mutation invalidates + -- every enclosing layout. + if type(panel) == "table" and panel._raw ~= nil and panel._layout ~= nil then + layoutTable._parentLayout = panel._layout + -- Tag the proxy with a back-reference so the outer layout's DoRefresh can refresh + -- us before measuring (handles shrink-driven inner panel sizes correctly). + panel._innerLayout = layoutTable + end + + return layoutTable +end + +return linearLayoutAPI diff --git a/Source/Resources/Scripts/API/UI/VerticalLayout.luau b/Source/Resources/Scripts/API/UI/VerticalLayout.luau new file mode 100644 index 0000000..dca324c --- /dev/null +++ b/Source/Resources/Scripts/API/UI/VerticalLayout.luau @@ -0,0 +1,25 @@ +--[[ +VerticalLayout — main axis is Y. Children stack top-down by default; pass `reverse=true` +for bottom-up. See LinearLayout.luau for the full opts/childOpts/algorithm reference. + +Example: + local v = UIVerticalLayout.NewVerticalLayout(parent, 0, 0, 400, 600, 0, { + spacing = 10, padding = 5, heightMode = "shrink", alignH = "center", + }) + local row1 = v:NewPanel(0, 0, 0, 40, 0, "DialogBox") + local row2 = v:NewText("hello", 0, 0, 0, "DefaultButtonText") + v:SetChildOpts(row1, { modeY = "flex", weightY = 1 }) -- row1 fills remaining vertical space + print(v:GetMeasuredHeight()) -- triggers Refresh, returns total content height +]] + +local LinearLayout = require("@src/API/UI/LinearLayout") + +local verticalLayoutAPI = {} + +function verticalLayoutAPI.NewVerticalLayout(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + opts.axis = "y" + return LinearLayout.NewLinearLayout(parent, posX, posY, sizeX, sizeY, layer, opts) +end + +return verticalLayoutAPI diff --git a/Source/Resources/Scripts/UI/CharacterCreation.luau b/Source/Resources/Scripts/UI/CharacterCreation.luau index af4614d..97f9fea 100644 --- a/Source/Resources/Scripts/UI/CharacterCreation.luau +++ b/Source/Resources/Scripts/UI/CharacterCreation.luau @@ -4,6 +4,9 @@ local UIComboBox = require("@src/API/UI/ComboBox") local UIInputBox = require("@src/API/UI/InputBox") local UIScrollBox : ScrollBoxAPI = require("@src/API/UI/ScrollBox") local UIStack : StackAPI = require("@src/API/UI/UIStack") +local UIVerticalLayout = require("@src/API/UI/VerticalLayout") +local UIHorizontalLayout = require("@src/API/UI/HorizontalLayout") +local UIGridLayout = require("@src/API/UI/GridLayout") type ClassInfo = { @@ -269,45 +272,27 @@ local appearanceOptions : AppearanceOptions = { } } -local function CreateFactionSelectPanel(characterCreationScreen : CharacterCreationScreen, factionInfoIndex : number, currentX : number, currentY : number, width : number, panelHeight : number, maxNumRaces : number) +local function CreateFactionSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, factionInfoIndex : number, columnWidth : number, panelHeight : number, maxNumRaces : number) local factionInfo : FactionInfo = factionInfos[factionInfoIndex]; - - -- Create the parent panel - local panel : Panel = characterCreationScreen.optionsPanel:NewPanel(currentX, currentY, width, panelHeight, 0, "DebugRed"); - panel:SetAnchor(0.0, 1.0); - panel:SetRelativePoint(0.0, 1.0); - panel:SetAlpha(0.0); - - -- Create header text at the top of the column for the faction name. - -- Here, 1.0 on the Y-axis is the top of the panel, so we anchor the label there. - local nameLabel : Text = panel:NewText(factionInfo.name, 0, 0, 0, "YellowText"); - nameLabel:SetAnchor(0.5, 1.0); - nameLabel:SetRelativePoint(0.5, 1.0); - -- Measure the text height - local headerHeight = select(2, nameLabel:GetSize()) + 10; + -- Square race icon: constrained by column width and a fraction of available height. + -- Approximates the original gapY-spread; spacing is uniform here rather than auto-distributed. + local s = math.floor(math.min(columnWidth, panelHeight / (maxNumRaces + 1))); + local spacing = math.floor((panelHeight - maxNumRaces * s - 30) / math.max(1, maxNumRaces)); - -- Calculate how much space is left for icons (below the header) - local availableHeight = panelHeight - headerHeight; - - -- Determine the size of each square race panel based on maxNumRaces - local s = math.min(width, availableHeight / (maxNumRaces + 1)); - local gapY = (availableHeight - maxNumRaces * s) / (maxNumRaces); + local column = UIVerticalLayout.NewVerticalLayout(parent, 0, 0, columnWidth, panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + alignH = "center", + spacing = spacing, + paddingTop = 0, + }); - -- The top "usable" coordinate for icons (just below the header) - local topOfIcons = panelHeight - headerHeight; + column:NewText(factionInfo.name, 0, 0, 0, "YellowText"); - -- Create each race panel as a child of the parent panel. - -- We'll position them from the top downward, leaving room for the header. for i = 1, #factionInfo.races do local race = factionInfo.races[i]; - local childX = (width - s) / 2; -- Center horizontally - -- We move downward from topOfIcons by i*(s + gapY), - -- so that the first icon is right under the header, and subsequent icons stack down. - local childY = topOfIcons - (i * s + (i - 1) * gapY); - - local raceButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(panel, childX, childY, s, s, 0, { + local raceButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(column, 0, 0, s, s, 0, { texture = factionInfo.raceAtlasTexture, numTexturesX = factionInfo.raceAtlasTextureNumTexturesX, numTexturesY = factionInfo.raceAtlasTextureNumTexturesY, @@ -321,113 +306,102 @@ local function CreateFactionSelectPanel(characterCreationScreen : CharacterCreat characterCreationScreen.raceIndexToFactionIndex[raceIndex] = factionInfoIndex; characterCreationScreen.raceIndexToFactionLocalRaceIndex[raceIndex] = i; end + + return column; end -local function CreateRaceSelectPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, optionsWidth : number, panelHeight : number) +local function CreateRaceSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, panelHeight : number) characterCreationScreen.buttons.raceButtons = {}; characterCreationScreen.raceIndexToFactionIndex = {}; characterCreationScreen.raceIndexToFactionLocalRaceIndex = {}; - - -- Number of factions - local numFactions = #factionInfos; - -- Max races in a faction + local numFactions = #factionInfos; local maxNumRaces = 0; for i = 1, numFactions do local numRaces = #factionInfos[i].races; - if numRaces > maxNumRaces then - maxNumRaces = numRaces; - end + if numRaces > maxNumRaces then maxNumRaces = numRaces end end - -- Adjustable horizontal padding + local optionsWidth = parent:GetWidth(); local paddingX = 5; + local factionSelectWidth = math.floor((optionsWidth - (numFactions + 1) * paddingX) / numFactions); - -- Calculate each faction column's width after subtracting all padding. - local factionSelectWidth = (optionsWidth - (numFactions + 1) * paddingX) / numFactions; - - -- Start at the left padding. - local currentX = paddingX; + -- Outer H layout splits the row into N equal-width columns with paddingX gutters. + local row = UIHorizontalLayout.NewHorizontalLayout(parent, 0, 0, optionsWidth, panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + spacing = paddingX, paddingLeft = paddingX, paddingRight = paddingX, + defaultChildOpts = { modeY = "flex" }, -- columns fill the row height + }); for i = 1, numFactions do - CreateFactionSelectPanel(characterCreationScreen, i, currentX, currentY, factionSelectWidth, panelHeight, maxNumRaces); - -- Move currentX by adding the width of the column plus an extra paddingX. - currentX = currentX + factionSelectWidth + paddingX; + local column = CreateFactionSelectPanel(characterCreationScreen, row, i, factionSelectWidth, panelHeight, maxNumRaces); + row:SetChildOpts(column, { sizeX = factionSelectWidth, modeY = "flex" }); end + + return row; end -local function CreateGenderSelectPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, optionsWidth : number, panelHeight : number) +local function CreateGenderSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, panelHeight : number) characterCreationScreen.buttons.genderButtons = {}; - - -- Create the parent panel - local panel : Panel = characterCreationScreen.optionsPanel:NewPanel(0, currentY, optionsWidth, panelHeight, 0, "DebugRed"); - panel:SetAnchor(0.5, 1.0); - panel:SetRelativePoint(0.5, 1.0); - panel:SetAlpha(0.0); local padding = 4; local iconSize = panelHeight - padding; - local iconOffsetX = (iconSize / 2) + (padding / 2); - local iconOffsetY = -padding / 2; - -- Male button - local maleButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(panel, -iconOffsetX, iconOffsetY, iconSize, iconSize, 0, { + -- H layout that shrinks horizontally to fit its 2 icons + spacing. The parent V layout + -- centers it horizontally via alignH="center" set by the caller (CreateLeftPanel). + -- H layout fills the full parent width; alignH="center" packs the icon pair into the + -- middle of the row instead of starting at the left edge. + local row = UIHorizontalLayout.NewHorizontalLayout(parent, 0, 0, parent:GetWidth(), panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + spacing = padding, alignV = "center", alignH = "center", + }); + + local maleButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(row, 0, 0, iconSize, iconSize, 0, { texture = "Data/Texture/interface/glues/charactercreate/ui-charactercreate-gender.dds", - numTexturesX = 2, - numTexturesY = 1, + numTexturesX = 2, numTexturesY = 1, }); - maleButton:SetAnchor(0.5, 1.0); - maleButton:SetRelativePoint(0.5, 1.0); maleButton:SetOnMouseUp(function(eventID, widget, ...) characterCreationScreen:SetGender(1); end); table.insert(characterCreationScreen.buttons.genderButtons, maleButton); - -- Female button - local femaleButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(panel, iconOffsetX, iconOffsetY, iconSize, iconSize, 0, { + local femaleButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(row, 0, 0, iconSize, iconSize, 0, { texture = "Data/Texture/interface/glues/charactercreate/ui-charactercreate-gender.dds", - numTexturesX = 2, - numTexturesY = 1, - atlasIndex = 2, + numTexturesX = 2, numTexturesY = 1, atlasIndex = 2, }); - femaleButton:SetAnchor(0.5, 1.0); - femaleButton:SetRelativePoint(0.5, 1.0); femaleButton:SetOnMouseUp(function(eventID, widget, ...) characterCreationScreen:SetGender(2); end); table.insert(characterCreationScreen.buttons.genderButtons, femaleButton); + + return row; end -local function CreateClassSelectPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, optionsWidth : number, panelHeight : number, maxIconSize : number) +local function CreateClassSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, panelHeight : number, maxIconSize : number) characterCreationScreen.buttons.classButtons = {}; - - -- Create the parent panel - local panel : Panel = characterCreationScreen.optionsPanel:NewPanel(0, currentY, optionsWidth, panelHeight, 0, "DebugRed"); - panel:SetAnchor(0.5, 1.0); - panel:SetRelativePoint(0.5, 1.0); - panel:SetAlpha(0.0); - - local padding = 5; -- adjustable padding variable + -- Compute per-cell size from class count + maxIconSize so the grid still hits the target + -- icon dimensions; GridLayout positions the cells with cellAlign="center". local numClasses = #classInfos; + local padding = 5; local columns = math.ceil(math.sqrt(numClasses)); local rows = math.ceil(numClasses / columns); + local optionsWidth = parent:GetWidth(); local availableWidthPerIcon = (optionsWidth - (columns - 1) * padding) / columns; local availableHeightPerIcon = (panelHeight - (rows - 1) * padding) / rows; - local iconSize = math.floor(math.min(availableWidthPerIcon, availableHeightPerIcon)); - iconSize = math.min(iconSize, maxIconSize); - - local totalGridWidth = columns * iconSize + (columns - 1) * padding; - local xOffset = (optionsWidth - totalGridWidth) / 2; - local totalGridHeight = rows * iconSize + (rows - 1) * padding; - local yOffset = (panelHeight - totalGridHeight) / 2; + local iconSize = math.min(math.floor(math.min(availableWidthPerIcon, availableHeightPerIcon)), maxIconSize); + + local grid = UIGridLayout.NewGridLayout(parent, 0, 0, optionsWidth, panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + columns = columns, + cellSizeX = iconSize, cellSizeY = iconSize, + spacing = padding, + cellAlign = "center", + gridAlignH = "center", gridAlignV = "center", + }); for i, classInfo in classInfos do - local col = (i - 1) % columns; - local row = math.floor((i - 1) / columns); - local x = xOffset + col * (iconSize + padding); - local y = yOffset + (rows - 1 - row) * (iconSize + padding); - local classButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(panel, x, y, iconSize, iconSize, 0, { + local classButton : AtlasPanel = UIAtlasPanel.NewAtlasPanel(grid, 0, 0, iconSize, iconSize, 0, { texture = classInfo.iconAtlasTexture, numTexturesX = classInfo.iconAtlasTextureNumTexturesX, numTexturesY = classInfo.iconAtlasTextureNumTexturesY, @@ -439,85 +413,88 @@ local function CreateClassSelectPanel(characterCreationScreen : CharacterCreatio end); table.insert(characterCreationScreen.buttons.classButtons, classButton); end -end -local function CreateAppearanceSelectPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, optionsWidth : number, panelHeight : number) - -- Create the parent panel - local panel : Panel = characterCreationScreen.optionsPanel:NewPanel(0, currentY, optionsWidth, panelHeight, 0, "DebugRed"); - panel:SetAnchor(0.5, 1.0); - panel:SetRelativePoint(0.5, 1.0); - panel:SetAlpha(0.0); + return grid; +end +local function CreateAppearanceSelectPanel(characterCreationScreen : CharacterCreationScreen, parent : any, panelHeight : number) + local optionsWidth = parent:GetWidth(); local padding = 5; - local currentY = 0; - local height = (panelHeight / #appearanceOptions) - padding; - local width = 200; - + local rowHeight = math.floor((panelHeight / #appearanceOptions) - padding); + local comboBoxWidth = 200; + + local panel = UIVerticalLayout.NewVerticalLayout(parent, 0, 0, optionsWidth, panelHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + spacing = padding, + }); + + -- World position used to anchor the combobox dropdowns to the right of the options panel. + -- Computed up-front because the combobox.selectionPanel positioning is independent of the row layout. local sidePanelPosX, sidePanelPosY = panel:GetWorldPos(); sidePanelPosX += optionsWidth; sidePanelPosY += panelHeight; - for i=1, #appearanceOptions do - local availableSpaceForName = optionsWidth - width; - - local optionName : Text = panel:NewText(appearanceOptions[i].name, availableSpaceForName/2.0, currentY, 0, "YellowText"); - optionName:SetAnchor(0.0, 1.0); - optionName:SetRelativePoint(0.5, 1.0); + for i = 1, #appearanceOptions do + local row = UIHorizontalLayout.NewHorizontalLayout(panel, 0, 0, optionsWidth, rowHeight, 0, { + panelTemplate = "DebugRed", alpha = 0.0, + alignV = "center", + paddingLeft = 5, paddingRight = padding, + }); + panel:SetChildOpts(row, { sizeY = rowHeight, modeX = "flex" }); - local comboBox = UIComboBox.NewComboBox(panel, -padding, currentY, width, height, 0, { + -- Order matters: create label, spacer (flex, fills remaining), then combobox at the right. + local optionName : Text = row:NewText(appearanceOptions[i].name, 0, 0, 0, "YellowText"); + local spacer = row:NewWidget(0, 0, 0); + local comboBox = UIComboBox.NewComboBox(row, 0, 0, comboBoxWidth, rowHeight, 0, { options = appearanceOptions[i].options, disableButtons = true, }); - comboBox:SetAnchor(1.0, 1.0); - comboBox:SetRelativePoint(1.0, 1.0); + + row:SetChildOpts(spacer, { modeX = "flex", weightX = 1 }); + row:SetChildOpts(comboBox, { sizeX = comboBoxWidth }); comboBox.selectionPanel:SetWorldPos(sidePanelPosX, sidePanelPosY); comboBox.selectionPanel:SetAnchor(0.0, 0.0); comboBox.selectionPanel:SetRelativePoint(0.0, 1.0); - - currentY -= height + padding; end + + return panel; end local function CreateLeftPanel(characterCreationScreen : CharacterCreationScreen) - -- Options panel local optionsWidth = 400; local optionsHeight = 980; - characterCreationScreen.optionsPanel = characterCreationScreen.widget:NewPanel(20, -80, optionsWidth, optionsHeight, 0, "DialogBox"); - characterCreationScreen.optionsPanel:SetAnchor(0.0, 1.0); - characterCreationScreen.optionsPanel:SetRelativePoint(0.0, 1.0); - - local currentY = 0; local maxIconSize = 80; + local spacing = 10; - -- Race select panel - local raceSelectHeight = 400; - CreateRaceSelectPanel(characterCreationScreen, currentY, optionsWidth, raceSelectHeight); - currentY -= raceSelectHeight; - - -- Padding - currentY -= 10; - - -- Gender select panel + local raceSelectHeight = 400; local genderSelectHeight = 80; - CreateGenderSelectPanel(characterCreationScreen, currentY, optionsWidth, genderSelectHeight); - currentY -= genderSelectHeight; + local classSelectHeight = 250; + -- Appearance fills whatever's left after the three above + spacing gutters. + local appearanceHeight = optionsHeight - raceSelectHeight - genderSelectHeight - classSelectHeight - spacing * 3; + + -- optionsPanel is a V layout: subpanels become its children stacked top-down. + characterCreationScreen.optionsPanel = UIVerticalLayout.NewVerticalLayout( + characterCreationScreen.widget, 20, -80, optionsWidth, optionsHeight, 0, + { panelTemplate = "DialogBox", spacing = spacing }); + characterCreationScreen.optionsPanel:SetAnchor(0.0, 1.0); + characterCreationScreen.optionsPanel:SetRelativePoint(0.0, 1.0); + characterCreationScreen.optionsPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + characterCreationScreen.optionsPanel:SetBorderSize(2); + + local optionsPanel = characterCreationScreen.optionsPanel; - -- Padding - currentY -= 10; + local raceSelect = CreateRaceSelectPanel(characterCreationScreen, optionsPanel, raceSelectHeight); + optionsPanel:SetChildOpts(raceSelect, { sizeY = raceSelectHeight, modeX = "flex" }); - -- Class select panel - local classSelectHeight = 250; - CreateClassSelectPanel(characterCreationScreen, currentY, optionsWidth, classSelectHeight, maxIconSize); - currentY -= classSelectHeight; + local genderSelect = CreateGenderSelectPanel(characterCreationScreen, optionsPanel, genderSelectHeight); + optionsPanel:SetChildOpts(genderSelect, { sizeY = genderSelectHeight, alignH = "center" }); - -- Padding - currentY -= 10; + local classSelect = CreateClassSelectPanel(characterCreationScreen, optionsPanel, classSelectHeight, maxIconSize); + optionsPanel:SetChildOpts(classSelect, { sizeY = classSelectHeight, modeX = "flex" }); - -- Appearance select panel - local appearanceSelectHeight = optionsHeight - math.abs(currentY); -- currentY to the end - CreateAppearanceSelectPanel(characterCreationScreen, currentY, optionsWidth, appearanceSelectHeight); - currentY -= appearanceSelectHeight; + local appearance = CreateAppearanceSelectPanel(characterCreationScreen, optionsPanel, appearanceHeight); + optionsPanel:SetChildOpts(appearance, { sizeY = appearanceHeight, modeX = "flex" }); end local function CreateMiddlePanel(characterCreationScreen : CharacterCreationScreen) @@ -532,6 +509,8 @@ local function CreateMiddlePanel(characterCreationScreen : CharacterCreationScre }); characterCreationScreen.nameInputBox.inputBox:SetAnchor(0.5, 0.0); characterCreationScreen.nameInputBox.button:SetRelativePoint(0.5, 0.0); + characterCreationScreen.nameInputBox.button.panel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + characterCreationScreen.nameInputBox.button.panel:SetBorderSize(2); end local function UpdateScrollBoxBasedOnContent(scrollBox : ScrollBox, description : Text) @@ -560,6 +539,8 @@ local function CreateFactionInfoPanel(characterCreationScreen : CharacterCreatio local factionInfoPanel : Panel = characterCreationScreen.characterInfoPanel:NewPanel(0, currentY, characterInfoWidth, factionInfoHeight, 0, "DialogBox"); factionInfoPanel:SetAnchor(1.0, 1.0); factionInfoPanel:SetRelativePoint(1.0, 1.0); + factionInfoPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + factionInfoPanel:SetBorderSize(2); -- Faction icon local factionIconSize = 50; @@ -596,6 +577,8 @@ local function CreateFactionInfoPanel(characterCreationScreen : CharacterCreatio characterCreationScreen.selectedFactionDescription:SetRelativePoint(0.0, 1.0); UpdateScrollBoxBasedOnContent(characterCreationScreen.selectedFactionDescriptionScrollBox, characterCreationScreen.selectedFactionDescription); + + return factionInfoPanel; end local function CreateRaceInfoPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, characterInfoWidth : number, raceInfoHeight : number) @@ -605,6 +588,8 @@ local function CreateRaceInfoPanel(characterCreationScreen : CharacterCreationSc local raceInfoPanel : Panel = characterCreationScreen.characterInfoPanel:NewPanel(0, currentY, characterInfoWidth, raceInfoHeight, 0, "DialogBox"); raceInfoPanel:SetAnchor(1.0, 1.0); raceInfoPanel:SetRelativePoint(1.0, 1.0); + raceInfoPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + raceInfoPanel:SetBorderSize(2); -- Race icon local raceIconSize = 50; @@ -639,6 +624,8 @@ local function CreateRaceInfoPanel(characterCreationScreen : CharacterCreationSc characterCreationScreen.selectedRaceDescription:SetRelativePoint(0.0, 1.0); UpdateScrollBoxBasedOnContent(characterCreationScreen.selectedRaceDescriptionScrollBox, characterCreationScreen.selectedRaceDescription); + + return raceInfoPanel; end local function CreateClassInfoPanel(characterCreationScreen : CharacterCreationScreen, currentY : number, characterInfoWidth : number, classInfoHeight : number) @@ -647,6 +634,8 @@ local function CreateClassInfoPanel(characterCreationScreen : CharacterCreationS local classInfoPanel : Panel = characterCreationScreen.characterInfoPanel:NewPanel(0, currentY, characterInfoWidth, classInfoHeight, 0, "DialogBox"); classInfoPanel:SetAnchor(1.0, 1.0); classInfoPanel:SetRelativePoint(1.0, 1.0); + classInfoPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + classInfoPanel:SetBorderSize(2); -- Class icon local classIconSize = 50; @@ -681,39 +670,38 @@ local function CreateClassInfoPanel(characterCreationScreen : CharacterCreationS characterCreationScreen.selectedClassDescription:SetRelativePoint(0.0, 1.0); UpdateScrollBoxBasedOnContent(characterCreationScreen.selectedClassDescriptionScrollBox, characterCreationScreen.selectedClassDescription); + + return classInfoPanel; end local function CreateRightPanel(characterCreationScreen : CharacterCreationScreen) - -- Character info panel local characterInfoWidth = 430; local characterInfoPanelHeight = 900; - characterCreationScreen.characterInfoPanel = characterCreationScreen.widget:NewPanel(-20, -40, characterInfoWidth, characterInfoPanelHeight, 0, "DialogBox"); - characterCreationScreen.characterInfoPanel:SetAnchor(1.0, 1.0); - characterCreationScreen.characterInfoPanel:SetRelativePoint(1.0, 1.0); - characterCreationScreen.characterInfoPanel:SetAlpha(0.2); + local spacing = 15; - local currentY = 0; - - -- Faction info panel local factionInfoHeight = 250; - CreateFactionInfoPanel(characterCreationScreen, currentY, characterInfoWidth, factionInfoHeight); - currentY -= factionInfoHeight; + local raceInfoHeight = 410; + local classInfoHeight = characterInfoPanelHeight - factionInfoHeight - raceInfoHeight - spacing * 2; - -- Padding - currentY -= 15; + -- characterInfoPanel becomes a V layout. The three info subpanels stack with `spacing` between them. + -- alpha=0.2 preserves the dialog tint that the original SetAlpha(0.2) set on the container. + characterCreationScreen.characterInfoPanel = UIVerticalLayout.NewVerticalLayout( + characterCreationScreen.widget, -20, -40, characterInfoWidth, characterInfoPanelHeight, 0, + { panelTemplate = "DialogBox", alpha = 0.2, spacing = spacing }); + characterCreationScreen.characterInfoPanel:SetAnchor(1.0, 1.0); + characterCreationScreen.characterInfoPanel:SetRelativePoint(1.0, 1.0); - -- Race info panel - local raceInfoHeight = 410; - CreateRaceInfoPanel(characterCreationScreen, currentY, characterInfoWidth, raceInfoHeight); - currentY -= raceInfoHeight; + local right = characterCreationScreen.characterInfoPanel; - -- Padding - currentY -= 15; + -- Sub-info functions still take (currentY, width, height) but currentY is ignored — + -- the V layout owns positioning. Each returns the panel it created so we can SetChildOpts. + local factionPanel = CreateFactionInfoPanel(characterCreationScreen, 0, characterInfoWidth, factionInfoHeight); + local racePanel = CreateRaceInfoPanel(characterCreationScreen, 0, characterInfoWidth, raceInfoHeight); + local classPanel = CreateClassInfoPanel(characterCreationScreen, 0, characterInfoWidth, classInfoHeight); - -- Class info panel - local classInfoHeight = characterInfoPanelHeight - math.abs(currentY); -- currentY to the end - CreateClassInfoPanel(characterCreationScreen, currentY, characterInfoWidth, classInfoHeight); - currentY -= classInfoHeight; + right:SetChildOpts(factionPanel, { sizeY = factionInfoHeight, modeX = "flex" }); + right:SetChildOpts(racePanel, { sizeY = raceInfoHeight, modeX = "flex" }); + right:SetChildOpts(classPanel, { sizeY = classInfoHeight, modeX = "flex" }); -- Accept button local acceptButton = UIButton.NewButton(characterCreationScreen.widget, -20, 80, 200, 40, 0, { @@ -878,6 +866,15 @@ local function CreateCharacterCreationScreen(stack : Stack) -- Methods characterCreationScreen = CreateCharacterCreationScreenMethods(characterCreationScreen); + -- Force the layouts to settle before the first SetText calls below. Text widgets emit + -- their GPU vertices using the parent's world position at SetText time, and a parent + -- transform change does not propagate DirtyWidgetTransform to descendants — so without + -- this, descriptions whose owning sub-panel moves during the first layout pass end up + -- with stale vertices and stay invisible until the user manually re-triggers SetText + -- (e.g. by selecting a different race/class). + characterCreationScreen.optionsPanel:Refresh(); + characterCreationScreen.characterInfoPanel:Refresh(); + -- Default options characterCreationScreen:SetRace(1); characterCreationScreen:SetGender(1); diff --git a/Source/Resources/Scripts/UI/CharacterSelect.luau b/Source/Resources/Scripts/UI/CharacterSelect.luau index 5bcdaa5..0e9d2d4 100644 --- a/Source/Resources/Scripts/UI/CharacterSelect.luau +++ b/Source/Resources/Scripts/UI/CharacterSelect.luau @@ -1,6 +1,7 @@ local UIButton = require("@src/API/UI/Button") local UIScrollBox = require("@src/API/UI/ScrollBox") local UIStack = require("@src/API/UI/UIStack") +local UIVerticalLayout = require("@src/API/UI/VerticalLayout") type CharacterSelectScreen = { @@ -12,21 +13,22 @@ type CharacterSelectScreen = canvas : Canvas, widget : Widget, - currentY : number, characters : { Character }, selectedCharacterName : string | nil, selectedCharacterNameText : TextWidget, characterListScrollBox : UIScrollBox.ScrollBox, + characterListLayout : any, -- VerticalLayout instance } -type Character = +type Character = { index : number, name : string, level : number, class : string, location : string, - panel : Panel | nil, + entryLayout : any, -- VerticalLayout per-entry; nil until first build + panel : Panel | nil, -- alias of entryLayout.panel kept for SelectCharacterByName nameText : TextWidget | nil, levelAndClassText : TextWidget | nil, locationText : TextWidget | nil, @@ -137,54 +139,34 @@ local function RegisterTemplates() end local function CreateOrModifyCharacterEntry(characterSelectScreen : CharacterSelectScreen, character : Character) - local localY = -15; - if (character.panel == nil) then - -- Background - character.panel = characterSelectScreen.characterListScrollBox.content:NewPanel(0, characterSelectScreen.currentY, 390, 100, 0, "CharacterEntryButtonPanel"); - character.panel:SetAnchor(0.5, 1.0); - character.panel:SetRelativePoint(0.5, 1.0); - character.panel:SetOnMouseUp(function(eventID, widget, ...) + if (character.entryLayout == nil) then + -- Each entry is a VerticalLayout: panel template provides the background, paddings + -- frame the texts (top=15, bottom=25, left=25), heightMode="shrink" sizes to content. + local entry = UIVerticalLayout.NewVerticalLayout( + characterSelectScreen.characterListLayout, 0, 0, 390, 0, 0, + { + panelTemplate = "CharacterEntryButtonPanel", + heightMode = "shrink", + paddingTop = 15, paddingBottom = 25, paddingLeft = 25, + spacing = 0, + }); + entry:SetOnMouseUp(function(eventID, widget, ...) characterSelectScreen:SelectCharacterByName(character.name); end); - -- Name - character.nameText = character.panel:NewText(character.name, 25, localY, 0, "NameText"); - character.nameText:SetAnchor(0.0, 1.0); - character.nameText:SetRelativePoint(0.0, 1.0); - localY -= select(2, character.nameText:GetSize()); - - -- Level and class - character.levelAndClassText = character.panel:NewText(`Level {character.level} {character.class}`, 25, localY, 0, "LevelText"); - character.levelAndClassText:SetAnchor(0.0, 1.0); - character.levelAndClassText:SetRelativePoint(0.0, 1.0); - localY -= select(2, character.levelAndClassText:GetSize()); - - -- Location - character.locationText = character.panel:NewText(character.location, 25, localY, 0, "LocationText"); - character.locationText:SetAnchor(0.0, 1.0); - character.locationText:SetRelativePoint(0.0, 1.0); - localY -= select(2, character.locationText:GetSize()); - - local padding = 25; - localY -= padding; - character.panel:SetHeight(math.abs(localY)); - else - character.panel:SetPos(0, characterSelectScreen.currentY); + character.nameText = entry:NewText(character.name, 0, 0, 0, "NameText"); + character.levelAndClassText = entry:NewText(`Level {character.level} {character.class}`, 0, 0, 0, "LevelText"); + character.locationText = entry:NewText(character.location, 0, 0, 0, "LocationText"); + character.entryLayout = entry; + character.panel = entry.panel; -- alias kept so SelectCharacterByName's :SetForeground still works + else + -- Update path: just swap text on the existing proxies. SetText is size-affecting, + -- so the proxy invalidates the entry layout (and the outer list layout) automatically. character.nameText:SetText(character.name); - character.nameText:SetPos(25, localY); - localY -= select(2, character.nameText:GetSize()); - character.levelAndClassText:SetText(`Level {character.level} {character.class}`); - character.levelAndClassText:SetPos(25, localY); - localY -= select(2, character.levelAndClassText:GetSize()); - character.locationText:SetText(character.location); - character.locationText:SetPos(25, localY); - localY -= select(2, character.locationText:GetSize()); end - - characterSelectScreen.currentY += localY; end local function CreateLeftPanel(characterSelectScreen : CharacterSelectScreen) @@ -250,6 +232,8 @@ local function CreateRightPanel(characterSelectScreen : CharacterSelectScreen) local characterSelectPanel = characterSelectScreen.widget:NewPanel(-20, -40, 400, 850, 0, "DialogBox"); characterSelectPanel:SetAnchor(1.0, 1.0); characterSelectPanel:SetRelativePoint(1.0, 1.0); + characterSelectPanel:SetBorderColor(vector.create(0.1, 0.1, 0.1)); + characterSelectPanel:SetBorderSize(2); local currentY = 0; @@ -276,6 +260,16 @@ local function CreateRightPanel(characterSelectScreen : CharacterSelectScreen) characterSelectScreen.characterListScrollBox:SetAnchor(0.0, 1.0); characterSelectScreen.characterListScrollBox:SetRelativePoint(0.0, 1.0); + -- Outer list layout: stacks character entries top-down inside the scrollbox content. + -- heightMode="shrink" makes its measured height match the sum of entry heights, which we + -- feed back to the scrollbox in RefreshCharacterList. + characterSelectScreen.characterListLayout = UIVerticalLayout.NewVerticalLayout( + characterSelectScreen.characterListScrollBox.content, 0, 0, + characterSelectScreen.characterListScrollBox.width, 0, 0, + { heightMode = "shrink", spacing = 0 }); + characterSelectScreen.characterListLayout:SetAnchor(0.0, 1.0); + characterSelectScreen.characterListLayout:SetRelativePoint(0.0, 1.0); + -- Create New Character button local loginButton = UIButton.NewButton(characterSelectPanel, -40, 20, 250, 40, 0, { panelTemplate = "DefaultButtonPanel", @@ -337,22 +331,19 @@ local function CreateCharacterSelectScreen(stack : UIStack.Stack) characterSelectScreen.widget:SetRelativePoint(0.5, 0.5); -- Variables - characterSelectScreen.currentY = 0; characterSelectScreen.characters = setmetatable({}, {__index = table}); -- Methods characterSelectScreen.RefreshCharacterList = function(self : CharacterSelectScreen) - self.currentY = 0; - for i, character in self.characters do CreateOrModifyCharacterEntry(self, character); end + local contentHeight = self.characterListLayout:GetMeasuredHeight(); local scrollBoxHeight = self.characterListScrollBox.height; - local visibleScrollBar = math.abs(self.currentY) > scrollBoxHeight; - self.characterListScrollBox:SetVerticalScrollBarVisible(visibleScrollBar); - self.characterListScrollBox:SetContentHeight(math.abs(self.currentY)); + self.characterListScrollBox:SetVerticalScrollBarVisible(contentHeight > scrollBoxHeight); + self.characterListScrollBox:SetContentHeight(contentHeight); end characterSelectScreen.AddCharacter = function(self : CharacterSelectScreen, name : string, level : number, class : string, location : string) diff --git a/Source/Resources/Scripts/UI/Chat.luau b/Source/Resources/Scripts/UI/Chat.luau index 609ad41..b522e90 100644 --- a/Source/Resources/Scripts/UI/Chat.luau +++ b/Source/Resources/Scripts/UI/Chat.luau @@ -1,6 +1,7 @@ local UIButton = require("@src/API/UI/Button"); local UIInputBox = require("@src/API/UI/InputBox"); --local UIScrollBox : ScrollBoxAPI = require("@src/API/UI/ScrollBox"); +local UIVerticalLayout = require("@src/API/UI/VerticalLayout"); local ChatListener = require("@src/API/Game/ChatListener"); local OptionsContext = require("@src/API/OptionsContext"); @@ -79,10 +80,7 @@ local function FormatMessage(message : ChatMessage, showTimestamp : boolean) : s end local function RefreshText(chat : Chat) - local yPadding = 5; - local currentY = yPadding; -- Add a bit of padding to the bottom of the "scroll box" - - local startIndex = #chat.chatHistoryQueue - chat.chatScrollIndex + 1; -- Calculate the starting index based on the scroll index + local startIndex = #chat.chatHistoryQueue - chat.chatScrollIndex + 1; local optionCategory = OptionsContext:GetCategory(OptionCategoryType.Gameplay); local optionGroup = optionCategory:Get("Chat"); @@ -91,41 +89,36 @@ local function RefreshText(chat : Chat) local showTimestamp = (optionSection.options:Has("Show Timestamps") and optionSection:GetOption("Show Timestamps"):Get()) or false; local textSize = (optionSection.options:Has("Text Size") and optionSection:GetOption("Text Size"):Get()) or 18.0; - -- Loop through the chat history queue backwards beginning at the startIndex + -- Iterate newest-to-oldest. With the layout's reverse=true the first-added child sits + -- at the bottom, so the newest message ends up at the bottom (matches original behavior). + -- Each text mutation invalidates the layout via the proxy; GetMeasuredHeight triggers + -- a Refresh and tells us when we've filled the visible area so we can stop. local widgetIndex = 1; for i = startIndex, 1, -1 do - if (currentY <= chat.scrollBoxHeight) then - local message = chat.chatHistoryQueue[i]; - - -- Create a new text widget if we need to display more messages than we have text widgets - if (chat.chatHistoryTexts[widgetIndex] == nil) then - chat.chatHistoryTexts[widgetIndex] = chat.clipPanel:NewText("", 0, currentY, 0, "ChatText"); - chat.chatHistoryTexts[widgetIndex]:SetAnchor(0.0, 0.0); - chat.chatHistoryTexts[widgetIndex]:SetRelativePoint(0.0, 0.0); - chat.chatHistoryTexts[widgetIndex]:SetWrapWidth(chat.scrollBoxWidth); -- Set the wrap width to fit the scroll box - chat.chatHistoryTexts[widgetIndex]:SetWrapIndent(4); - end - chat.chatHistoryTexts[widgetIndex]:SetPosY(currentY); -- Set the Y position of the text widget + local message = chat.chatHistoryQueue[i]; - -- Update the text widget with the message content - chat.chatHistoryTexts[widgetIndex]:SetFontSize(textSize); -- Set the font size from options - chat.chatHistoryTexts[widgetIndex]:SetText(FormatMessage(message, showTimestamp)); + if (chat.chatHistoryTexts[widgetIndex] == nil) then + local text = chat.layout:NewText("", 0, 0, 0, "ChatText"); + text:SetWrapWidth(chat.scrollBoxWidth); + text:SetWrapIndent(4); + chat.chatHistoryTexts[widgetIndex] = text; + end - -- Update the text color based on the message - local textColor = GetTextColor(message); - chat.chatHistoryTexts[widgetIndex]:SetColor(textColor); - chat.chatHistoryTexts[widgetIndex]:SetEnabled(true); -- Enable the text widget + local text = chat.chatHistoryTexts[widgetIndex]; + text:SetEnabled(true); + text:SetFontSize(textSize); + text:SetText(FormatMessage(message, showTimestamp)); + text:SetColor(GetTextColor(message)); - currentY += chat.chatHistoryTexts[widgetIndex]:GetHeight(); -- Move down for the next message - widgetIndex += 1; - else + widgetIndex += 1; + + if chat.layout:GetMeasuredHeight() > chat.scrollBoxHeight then break; end end for j = widgetIndex, #chat.chatHistoryTexts do - -- Disable any remaining text widget that are not used - chat.chatHistoryTexts[j]:SetEnabled(false); -- Disable the text widget + chat.chatHistoryTexts[j]:SetEnabled(false); end end @@ -198,6 +191,15 @@ local function CreateChat(chatCanvas : Canvas) chat.clipPanel:SetClipChildren(true); -- Enable clipping for the panel chat.clipPanel:SetAlpha(0.3); + -- VerticalLayout(reverse=true): children stack bottom-up so newest message is at the bottom. + -- heightMode="shrink" lets the layout grow upward as messages arrive; the clipPanel's clip + -- mask hides anything that grows past its top edge. + chat.layout = UIVerticalLayout.NewVerticalLayout(chat.clipPanel, 0, 0, chat.scrollBoxWidth, chat.scrollBoxHeight, 0, { + reverse = true, heightMode = "shrink", + }); + chat.layout:SetAnchor(0.0, 0.0); + chat.layout:SetRelativePoint(0.0, 0.0); + -- Add scroll handlers chat.clipPanel:SetOnMouseScroll(function(eventID : number, widget : Widget, deltaX : number, deltaY : number) -- Handle scrolling with mouse wheel diff --git a/Source/Resources/Scripts/UI/Demo.luau b/Source/Resources/Scripts/UI/Demo.luau index 8b62506..2a0ec7e 100644 --- a/Source/Resources/Scripts/UI/Demo.luau +++ b/Source/Resources/Scripts/UI/Demo.luau @@ -1,6 +1,9 @@ local UIStack = require("@src/API/UI/UIStack") local UIButton = require("@src/API/UI/Button") local UILoadingScreen = require("@src/API/UI/LoadingScreen") +local UIVerticalLayout = require("@src/API/UI/VerticalLayout") +local UIHorizontalLayout = require("@src/API/UI/HorizontalLayout") +local UIGridLayout = require("@src/API/UI/GridLayout") -- TODO: Borders :( @@ -110,6 +113,28 @@ local function Demo() CreateMultiLineDemo(rtCanvas) end +-- 3x3 demo cell grid shared by SortingDemo and LayoutsDemo. Cells read +-- left-to-right, top-to-bottom; row 0 is the top row. +local DEMO_CANVAS_H = 1080 +local DEMO_TOP_MARGIN = 28 +local DEMO_CELL_W, DEMO_CELL_H = 618, 330 +local DEMO_PAD_X, DEMO_PAD_Y = 16, 16 + +local function MakeDemoCell(canvas, col, row, label, bgTemplate) + local x = DEMO_PAD_X + col * (DEMO_CELL_W + DEMO_PAD_X) + local y = (DEMO_CANVAS_H - DEMO_TOP_MARGIN) - (DEMO_CELL_H + DEMO_PAD_Y) * (row + 1) + + local cell = canvas:NewPanel(x, y, DEMO_CELL_W, DEMO_CELL_H, 0, bgTemplate) + cell:SetAnchor(0.0, 0.0); cell:SetRelativePoint(0.0, 0.0) + cell:SetBorderColor(vector.create(0.75, 0.75, 0.75)) + cell:SetBorderSize(2) + + local titleWidget = cell:NewText(label, 10, -8, 0, "DefaultButtonText") + titleWidget:SetAnchor(0.0, 1.0); titleWidget:SetRelativePoint(0.0, 1.0) + + return cell +end + -- ===================================================================== -- SortingDemo -- @@ -180,27 +205,7 @@ local function CreateSortingDemo() -- 3 columns x 3 rows. row 0 = TOP, row 2 = bottom. col 0 = left, col 2 = right. -- Numbering reads left-to-right, top-to-bottom (row*3 + col + 1). - local canvasH = 1080 - local topMargin = 28 -- room for title bar above the cells - local cellW, cellH = 618, 330 - local padX, padY = 16, 16 - - local function makeCell(col, row, label) - local x = padX + col * (cellW + padX) - local y = (canvasH - topMargin) - (cellH + padY) * (row + 1) - - -- Cell background frame - local cell = canvas:NewPanel(x, y, cellW, cellH, 0, "SortDemoCellBG") - cell:SetAnchor(0.0, 0.0) - cell:SetRelativePoint(0.0, 0.0) - - -- Cell title (top-left corner of cell) - local titleWidget = cell:NewText(label, 10, -8, 0, "DefaultButtonText") - titleWidget:SetAnchor(0.0, 1.0) - titleWidget:SetRelativePoint(0.0, 1.0) - - return cell - end + local function makeCell(col, row, label) return MakeDemoCell(canvas, col, row, label, "SortDemoCellBG") end -- ========================================================= -- Test 1 [top-left]: Sibling panels - 4-step staircase @@ -512,6 +517,407 @@ the two canvases interleaves at the leaves.]] end +-- ===================================================================== +-- LayoutsDemo +-- +-- Visual reference / regression case for Vertical/Horizontal/GridLayout. +-- Each cell is a focused feature combination; the hint text on the right +-- describes what a correct render should look like. If you change any +-- LinearLayout / GridLayout code, run this and eyeball every cell. +-- +-- 3x3 grid (618x330 cells), reading left-to-right, top-to-bottom. +-- ===================================================================== +local function CreateLayoutsDemo() + UI.RegisterPanelTemplate("LayoutDemoCellBG", { cornerRadius = 0.0, color = vector.create(0.10, 0.10, 0.13) }) + UI.RegisterPanelTemplate("LayoutDemoFrame", { cornerRadius = 0.0, color = vector.create(0.20, 0.20, 0.26) }) + UI.RegisterPanelTemplate("LayoutDemoChild", { cornerRadius = 0.0, color = vector.create(0.30, 0.55, 0.85) }) + UI.RegisterPanelTemplate("LayoutDemoChildAlt", { cornerRadius = 0.0, color = vector.create(0.85, 0.55, 0.30) }) + + local canvas : Canvas = UI.GetCanvas("LayoutsDemo", 0, 0, 1920, 1080) + + local title = canvas:NewText("LayoutsDemo - V/H/Grid layout features", 0, -6, 0, "DefaultButtonText") + title:SetAnchor(0.5, 1.0); title:SetRelativePoint(0.5, 1.0) + + local function makeCell(col, row, label) return MakeDemoCell(canvas, col, row, label, "LayoutDemoCellBG") end + + -- Add a child panel to a layout with an optional centered label. + local function addChild(layout, w, h, label, template) + template = template or "LayoutDemoChild" + local p = layout:NewPanel(0, 0, w, h, 0, template) + if label then + local t = p:NewText(label, 0, 0, 0, "DefaultButtonText") + t:SetAnchor(0.5, 0.5); t:SetRelativePoint(0.5, 0.5) + end + return p + end + + -- Hint text in the right half of the cell, top-down. + local function makeHint(cell, text) + local hint = cell:NewText(text, 310, -30, 0, "DefaultDebugText") + hint:SetAnchor(0.0, 1.0); hint:SetRelativePoint(0.0, 1.0) + end + + -- ========================================================= + -- Test 1 [top-left]: V layout - spacing + padding + alignH=center + -- 3 children of differing widths stacked top-down. alignH=center + -- centers each child horizontally regardless of own width. + -- padding leaves a uniform 12px frame inside. + -- ========================================================= + do + local cell = makeCell(0, 0, "1: V spacing + padding + alignH=center") + + local v = UIVerticalLayout.NewVerticalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 8, padding = 12, alignH = "center", + }) + v:SetAnchor(0.0, 0.0); v:SetRelativePoint(0.0, 0.0) + addChild(v, 80, 40, "A") + addChild(v, 200, 40, "B (wide)") + addChild(v, 120, 40, "C") + + makeHint(cell, [[ +V layout (medium-grey frame): + spacing = 8 + padding = 12 + alignH = center + +Children top-down: + A (80w) X-centered + B (wide) (200w) X-centered + C (120w) X-centered + +Frame leaves a uniform 12px border; +gaps between children are 8px.]]) + end + + -- ========================================================= + -- Test 2 [top-mid]: H layout - mixed flex weight + intrinsic + -- Two intrinsic-sized children (60w each) bracket two flex + -- children (weight 1, 2). After fixed sums + spacing, the + -- remaining width is split 1:2 between the flex ones. + -- defaultChildOpts.modeY=flex stretches children to full row height. + -- ========================================================= + do + local cell = makeCell(1, 0, "2: H flex weight + fixed (modeY=flex)") + + local h = UIHorizontalLayout.NewHorizontalLayout(cell, 10, 30, 290, 60, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 6, padding = 6, + defaultChildOpts = { modeY = "flex" }, + }) + h:SetAnchor(0.0, 0.0); h:SetRelativePoint(0.0, 0.0) + + addChild(h, 60, 0, "60") + local w1 = addChild(h, 0, 0, "w=1", "LayoutDemoChildAlt") + local w2 = addChild(h, 0, 0, "w=2", "LayoutDemoChildAlt") + addChild(h, 60, 0, "60") + h:SetChildOpts(w1, { modeX = "flex", weightX = 1 }) + h:SetChildOpts(w2, { modeX = "flex", weightX = 2 }) + + makeHint(cell, [[ +H layout, 4 children: + [ 60 | w=1 | w=2 | 60 ] + spacing = 6, padding = 6 + defaultChildOpts: modeY=flex + (children stretch row height) + +Inner = 290 - 12 - 3*6 = 260 +Fixed total = 120, remainder = 140 + w=1 -> ~46 w=2 -> ~93 + +Orange = flex children +Blue = fixed children]]) + end + + -- ========================================================= + -- Test 3 [top-right]: V alignV (main-axis position) start/center/end + -- Three V samples side by side inside an outer H. Each sample + -- has identical content but a different alignV, so the same + -- content block sits at top / middle / bottom of its column. + -- Also exercises nesting. + -- ========================================================= + do + local cell = makeCell(2, 0, "3: V alignV (start/center/end)") + + local h = UIHorizontalLayout.NewHorizontalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoCellBG", alpha = 0.0, + spacing = 8, + }) + h:SetAnchor(0.0, 0.0); h:SetRelativePoint(0.0, 0.0) + + local sampleW = 91 -- (290 - 2*8) / 3 + local function makeSample(alignV) + local v = UIVerticalLayout.NewVerticalLayout(h, 0, 0, sampleW, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 6, padding = 6, + alignH = "center", alignV = alignV, + }) + v:NewText(alignV, 0, 0, 0, "DefaultDebugText") + addChild(v, 50, 30) + addChild(v, 50, 30) + end + + makeSample("start") + makeSample("center") + makeSample("end") + + makeHint(cell, [[ +3 vertical samples (91w each, fixed) +in an outer H. Outer H spacing=8. + +Same content per column: + text label + 2 small panels. + +alignV positions the block on the +main (vertical) axis: + start - block flush to top + center - block vertically centered + end - block flush to bottom + +Outer H exercises layout nesting.]]) + end + + -- ========================================================= + -- Test 4 [mid-left]: reverse on H and on V + -- Left half: H with reverse=true. Insertion order 1..4 lays + -- out RIGHT to LEFT, so "1" sits at the right edge. + -- Right half: V with reverse=true. Insertion order 1..4 + -- lays out BOTTOM to TOP, so "1" sits at the bottom. + -- ========================================================= + do + local cell = makeCell(0, 1, "4: reverse (RTL + bottom-up)") + + local outer = UIHorizontalLayout.NewHorizontalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoCellBG", alpha = 0.0, + spacing = 10, + }) + outer:SetAnchor(0.0, 0.0); outer:SetRelativePoint(0.0, 0.0) + + -- Inner layouts get explicit sizes (see Test 3 for why). + local halfW = 140 -- (290 - 10) / 2 + local hRev = UIHorizontalLayout.NewHorizontalLayout(outer, 0, 0, halfW, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 4, padding = 6, reverse = true, alignV = "center", + }) + for i = 1, 4 do addChild(hRev, 26, 26, tostring(i)) end + + local vRev = UIVerticalLayout.NewVerticalLayout(outer, 0, 0, halfW, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 4, padding = 6, reverse = true, alignH = "center", + }) + for i = 1, 4 do addChild(vRev, 50, 26, tostring(i)) end + + makeHint(cell, [[ +Left half: H reverse=true + insertion order 1,2,3,4 lays out + RIGHT to LEFT -> "1" at right. + +Right half: V reverse=true + insertion order 1,2,3,4 lays out + BOTTOM to TOP -> "1" at bottom. + +Outer H holds two equal-width +columns (140w each, spacing=10).]]) + end + + -- ========================================================= + -- Test 5 [mid-mid]: per-child cross-axis position overrides + -- Layout default is alignH=start, but each child overrides + -- with its own alignH (start / center / end). The last child + -- sets modeX=flex to stretch its width to the container. + -- ========================================================= + do + local cell = makeCell(1, 1, "5: per-child alignH overrides + modeX=flex") + + local v = UIVerticalLayout.NewVerticalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 8, padding = 8, alignH = "start", + }) + v:SetAnchor(0.0, 0.0); v:SetRelativePoint(0.0, 0.0) + + local cs = addChild(v, 80, 30, "start"); v:SetChildOpts(cs, { alignH = "start" }) + local cc = addChild(v, 80, 30, "center"); v:SetChildOpts(cc, { alignH = "center" }) + local ce = addChild(v, 80, 30, "end"); v:SetChildOpts(ce, { alignH = "end" }) + local css = addChild(v, 80, 30, "stretch", "LayoutDemoChildAlt"); v:SetChildOpts(css, { modeX = "flex" }) + + makeHint(cell, [[ +V alignH=start (default); each child +overrides cross-axis position: + + alignH=start -> flush left + alignH=center -> X-centered + alignH=end -> flush right + modeX=flex -> width = inner + (orange to highlight) + +Stretched child's intrinsic 80w is +overridden by the layout to 274w.]]) + end + + -- ========================================================= + -- Test 6 [mid-right]: Grid columns + per-cell cellAlignH + -- 4-column grid, 8 children. Top row uses cellAlignH=start + -- (children flush to left of each cell). Bottom row uses + -- cellAlignH=stretch (children resized to cell width). + -- ========================================================= + do + local cell = makeCell(2, 1, "6: Grid columns + per-cell cellAlignH") + + local grid = UIGridLayout.NewGridLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + columns = 4, spacing = 6, padding = 8, + cellAlign = "center", + }) + grid:SetAnchor(0.0, 0.0); grid:SetRelativePoint(0.0, 0.0) + + for i = 1, 4 do + local p = addChild(grid, 30, 30, tostring(i)) + grid:SetChildOpts(p, { cellAlignH = "start" }) + end + for i = 5, 8 do + local p = addChild(grid, 30, 30, tostring(i), "LayoutDemoChildAlt") + grid:SetChildOpts(p, { cellAlignH = "stretch" }) + end + + makeHint(cell, [[ +Grid: columns=4, 8 children. +Layout default cellAlign=center. + +Row 1 (1..4): cellAlignH=start + 30w panel pushed to LEFT of cell. + +Row 2 (5..8): cellAlignH=stretch + panel resized to cell width + (orange to highlight). + +Y axis still uses default center +on both rows.]]) + end + + -- ========================================================= + -- Test 7 [bot-left]: Grid columnMajor + asymmetric spacing + -- rows=3 + direction=columnMajor means fill goes down then + -- right: column 0 is 1,2,3 top-bottom; column 1 is 4,5,6. + -- spacingX=24 vs spacingY=4 makes the column gap visibly + -- larger than the row gap. + -- ========================================================= + do + local cell = makeCell(0, 2, "7: Grid columnMajor + asym spacing") + + local grid = UIGridLayout.NewGridLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + rows = 3, direction = "columnMajor", + spacingX = 24, spacingY = 4, + padding = 8, + cellAlign = "center", + }) + grid:SetAnchor(0.0, 0.0); grid:SetRelativePoint(0.0, 0.0) + + for i = 1, 6 do addChild(grid, 60, 60, tostring(i)) end + + makeHint(cell, [[ +Grid: rows=3, direction=columnMajor. +6 children fill DOWN then RIGHT: + col0 (top-bottom) = 1, 2, 3 + col1 (top-bottom) = 4, 5, 6 + +spacingX=24, spacingY=4 + -> the gap BETWEEN COLUMNS is + much wider than the gap + between rows (visually clear). + +Children are 60x60 in cells of +~125x82 (cellAlign=center).]]) + end + + -- ========================================================= + -- Test 8 [bot-mid]: Grid cellSize + gridAlignH/V + cellAlign=stretch + -- Explicit cellSize=50x50, 6 children in 3 columns. The + -- 162x106 group is much smaller than the inner area, so + -- gridAlignH / gridAlignV center the whole group. + -- cellAlign=stretch forces children to fill each cell. + -- ========================================================= + do + local cell = makeCell(1, 2, "8: Grid cellSize + gridAlignH/V=center") + + local grid = UIGridLayout.NewGridLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + columns = 3, cellSizeX = 50, cellSizeY = 50, + spacing = 6, padding = 12, + gridAlignH = "center", gridAlignV = "center", + cellAlign = "stretch", + }) + grid:SetAnchor(0.0, 0.0); grid:SetRelativePoint(0.0, 0.0) + + for i = 1, 6 do addChild(grid, 0, 0, tostring(i)) end + + makeHint(cell, [[ +Grid: explicit cellSizeX/Y=50. +columns=3, 6 children, padding=12. + +Group size = 3*50 + 2*6 = 162 wide + 2*50 + 1*6 = 106 tall +Inner area = 266 x 236 (lots extra). + +gridAlignH=center + -> group X-centered in container. +gridAlignV=center + -> group Y-centered in container. + +cellAlign=stretch fills each cell.]]) + end + + -- ========================================================= + -- Test 9 [bot-right]: NewWidget spacer (flex) + skip + -- Header / subheader at top, footer at bottom, separated + -- by an invisible NewWidget spacer with modeY=flex weightY=1 + -- that consumes all remaining vertical space. A skipped child + -- between spacer and footer is removed from layout entirely. + -- defaultChildOpts.modeX=flex stretches each child's width. + -- ========================================================= + do + local cell = makeCell(2, 2, "9: NewWidget flex spacer + skip") + + local v = UIVerticalLayout.NewVerticalLayout(cell, 10, 30, 290, 260, 0, { + panelTemplate = "LayoutDemoFrame", + spacing = 6, padding = 8, + defaultChildOpts = { modeX = "flex" }, + }) + v:SetAnchor(0.0, 0.0); v:SetRelativePoint(0.0, 0.0) + + addChild(v, 0, 30, "header") + addChild(v, 0, 30, "subheader") + + local spacer = v:NewWidget(0, 0, 0) + v:SetChildOpts(spacer, { modeY = "flex", weightY = 1 }) + + local skipped = addChild(v, 0, 30, "SKIPPED", "LayoutDemoChildAlt") + v:SetChildOpts(skipped, { skip = true }) + + addChild(v, 0, 30, "footer") + + makeHint(cell, [[ +V padding=8, spacing=6. +defaultChildOpts: modeX=flex + (each child fills width) + +Children in insertion order: + header panel (visible) + subheader panel (visible) + spacer NewWidget flex weight=1 + (no size, fills slot) + SKIPPED panel skip=true + (orange, INVISIBLE, + removed from layout) + footer panel (visible) + +Result: header and subheader at top, +footer pushed to bottom by spacer's +expanded slot. Skipped contributes +zero space.]]) + end +end + local function CreateClippingDemo() local canvas = UI.GetCanvas("ClippingDemo", 0, 0, 1920, 1080); @@ -543,6 +949,7 @@ local function OnGameLoaded(eventID : number, data : any) --CreateClippingDemo(); --Demo(); --CreateSortingDemo(); + --CreateLayoutsDemo(); --CreateGameMenu(stack); --CreateOptionsMenu(stack); diff --git a/Source/Resources/Scripts/UI/GameMenu.luau b/Source/Resources/Scripts/UI/GameMenu.luau index ca5d92c..ac9b150 100644 --- a/Source/Resources/Scripts/UI/GameMenu.luau +++ b/Source/Resources/Scripts/UI/GameMenu.luau @@ -5,159 +5,131 @@ local UISlider = require("@src/API/UI/Slider") local UICheckbox = require("@src/API/UI/Checkbox") local UIComboBox = require("@src/API/UI/ComboBox") local UIInputBox = require("@src/API/UI/InputBox") +local UIVerticalLayout = require("@src/API/UI/VerticalLayout") +local UIHorizontalLayout = require("@src/API/UI/HorizontalLayout") local OptionsContext = require("@src/API/OptionsContext") -local function CreateOption(optionsMenu, currentY, option) +local function CreateOption(parent : any, option) local optionType = option.type; local optionName = option.name; local optionValue = option.value; - local optionDefaultValue = option.defaultValue; - local currentX = 0; + -- Each option is a horizontal row: label first, then control(s). Row height = 40 to fit + -- the tallest control (input box / combo box). alignV="center" vertically centers everything. + local rowHeight = 40; + local row = UIHorizontalLayout.NewHorizontalLayout(parent, 0, 0, parent:GetWidth(), rowHeight, 0, { + alignV = "center", spacing = 5, + }); + parent:SetChildOpts(row, { sizeY = rowHeight, modeX = "flex" }); - local optionText = optionsMenu.mainPanelContent.content:NewText(optionName, currentX, currentY, 0, "DefaultButtonText"); - optionText:SetRelativePoint(0.0, 1.0); - currentX += select(1, optionText:GetSize()) + 5; + row:NewText(optionName, 0, 0, 0, "DefaultButtonText"); if (optionType == OptionType.InputBox) then - local inputBox = UIInputBox.NewInputBox(optionsMenu.mainPanelContent.content, currentX, currentY, 200, 40, 0, { + local inputBox = UIInputBox.NewInputBox(row, 0, 0, 200, 40, 0, { defaultText = optionValue, }); - inputBox.button:SetRelativePoint(0.0, 1.0); + row:SetChildOpts(inputBox, { sizeX = 200 }); inputBox:SetOnSubmit(function(inputBoxTable) local previousValue = option.value; - option.value = inputBoxTable.text; - local result = option:InvokeOnValueChanged(inputBoxTable.text); if (not result) then - option.value = previousValue; -- Revert to previous state - return false; -- Returning false will prevent the value from changing + option.value = previousValue; + return false; end - - return true; -- Returning false will prevent the value from changing + return true; end) elseif (optionType == OptionType.Checkbox) then - local checkbox = UICheckbox.NewCheckbox(optionsMenu.mainPanelContent.content, currentX, currentY, 24, 24, 0, - { + local checkbox = UICheckbox.NewCheckbox(row, 0, 0, 24, 24, 0, { backgroundTemplate = "DefaultCheckboxBackground", fillTemplate = "DefaultCheckboxFill", isChecked = optionValue, }); - checkbox.background:SetRelativePoint(0.0, 1.0); + row:SetChildOpts(checkbox, { sizeX = 24 }); checkbox:SetOnValueChanged(function(checkboxTable, state) local previousState = option.value; - option.value = state; - local result = option:InvokeOnValueChanged(state); if (not result) then - option.value = previousState; -- Revert to previous state - return false; -- Returning false will prevent the value from changing + option.value = previousState; + return false; end - - return true; -- Returning false will prevent the value from changing + return true; end) elseif (optionType == OptionType.Slider) then - local valueText = optionsMenu.mainPanelContent.content:NewText(optionValue, currentX, currentY, 0, "DefaultButtonText"); - valueText:SetRelativePoint(0.0, 1.0); - currentX += select(1, valueText:GetSize()) + 20; - - local minValueText = optionsMenu.mainPanelContent.content:NewText(option.settings.Min, currentX, currentY, 0, "DefaultButtonText"); - minValueText:SetRelativePoint(0.0, 1.0); - currentX += select(1, minValueText:GetSize()) + 5; - - local slider = UISlider.NewSlider(optionsMenu.mainPanelContent.content, currentX, currentY, 200, 25, 0, { + local valueText = row:NewText(optionValue, 0, 0, 0, "DefaultButtonText"); + local minValueText = row:NewText(option.settings.Min, 0, 0, 0, "DefaultButtonText"); + local slider = UISlider.NewSlider(row, 0, 0, 200, 25, 0, { backgroundTemplate = "DefaultSliderBackground", fillTemplate = "DefaultSliderFill", Min = option.settings.Min, Max = option.settings.Max, Step = option.settings.Step, }); - slider.background:SetRelativePoint(0.0, 1.0); slider:SetValue(optionValue); - - local maxValueText = optionsMenu.mainPanelContent.content:NewText(option.settings.Max, currentX + 200, currentY, 0, "DefaultButtonText"); - maxValueText:SetRelativePoint(0.0, 1.0); - currentX += select(1, maxValueText:GetSize()) + 5; - - --slider:SetProgress(optionValue); - --slider:SetMinMax(option.settings.Min, option.settings.Max, option.settings.Step); + local maxValueText = row:NewText(option.settings.Max, 0, 0, 0, "DefaultButtonText"); + row:SetChildOpts(slider, { sizeX = 200 }); slider:SetOnValueChanged(function(sliderTable, value) local previousValue = option.value; - option.value = value; - local result = option:InvokeOnValueChanged(value); if (not result) then - option.value = previousValue; -- Revert to previous state - return false; -- Returning false will prevent the value from changing + option.value = previousValue; + return false; end - - valueText:SetText(value); -- Update text with new value - return true; -- Returning false will prevent the value from changing + valueText:SetText(value); + return true; end) elseif (optionType == OptionType.ComboBox) then - local comboBox = UIComboBox.NewComboBox(optionsMenu.mainPanelContent.content, currentX, currentY, 200, 40, 0, { + local comboBox = UIComboBox.NewComboBox(row, 0, 0, 200, 40, 0, { options = option.settings.options, disableButtons = true, }); - comboBox.button:SetRelativePoint(0.0, 1.0); + row:SetChildOpts(comboBox, { sizeX = 200 }); comboBox:SelectOption(optionValue); comboBox:SetOnValueChanged(function(comboBoxTable, index) local previousValue = option.value; - option.value = index; - local result = option:InvokeOnValueChanged(index); if (not result) then - option.value = previousValue; -- Revert to previous state - return false; -- Returning false will prevent the value from changing + option.value = previousValue; + return false; end - - return true; -- Returning false will prevent the value from changing + return true; end) end - - currentY -= select(2, optionText:GetSize()) + 5; - return currentY; end local function UpdateMainPanel(optionsMenu, category, group, section) - -- Set title local title = category.name; - if (group) then - title = title .. "/" .. group.name; - end - if (section) then - title = title .. "/" .. section.name; - end + if (group) then title = title .. "/" .. group.name end + if (section) then title = title .. "/" .. section.name end optionsMenu.mainPanelTitle:SetText(title); - -- Clear content + -- Clear previous content (rebuilds the V layout from scratch each navigation). for i, child in optionsMenu.mainPanelContent.content:GetChildrenRecursive() do UI.DestroyWidget(child); end - -- Create new content - local currentY = 0; + local content = optionsMenu.mainPanelContent.content; + local layout = UIVerticalLayout.NewVerticalLayout(content, 0, 0, content:GetWidth(), 0, 0, { + heightMode = "shrink", spacing = 5, paddingTop = 5, + }); + layout:SetAnchor(0.0, 1.0); + layout:SetRelativePoint(0.0, 1.0); for i, option in section.options.list do - currentY = CreateOption(optionsMenu, currentY, option); + CreateOption(layout, option); end - -- Update scrollbox - local currentHeight = math.abs(currentY); - if (currentHeight > optionsMenu.mainPanelContent.content:GetHeight()) then - optionsMenu.mainPanelContent:SetVerticalScrollBarVisible(true); - else - optionsMenu.mainPanelContent:SetVerticalScrollBarVisible(false); - end - optionsMenu.mainPanelContent:SetContentHeight(currentHeight+5); + -- Implicit Refresh via GetMeasuredHeight feeds the scrollbox. + local currentHeight = layout:GetMeasuredHeight(); + optionsMenu.mainPanelContent:SetVerticalScrollBarVisible(currentHeight > optionsMenu.mainPanelContent.content:GetHeight()); + optionsMenu.mainPanelContent:SetContentHeight(currentHeight + 5); end function ToggleEscapeMenu(panelWrapper) @@ -313,50 +285,43 @@ local function CreateSidePanel(parent : Panel, width : number, optionsMenu) optionsMenu.sidePanel:SetRelativePoint(0.0, 1.0); optionsMenu.sidePanel.scrollBox:SetAlpha(1.0); - -- Categories + -- Tree: categories at root indent, groups indented +15, sections indented +15 again. + -- Nested V layouts let each level's paddingLeft compose naturally instead of every line + -- carrying its own absolute X offset. + local treeLayout = UIVerticalLayout.NewVerticalLayout(optionsMenu.sidePanel.content, 0, 0, width, 0, 0, { + heightMode = "shrink", spacing = 5, + }); + treeLayout:SetAnchor(0.0, 1.0); + treeLayout:SetRelativePoint(0.0, 1.0); + local categories = OptionsContext:GetCategories(); - local currentY = 0; for i, category in categories do - local categoryText = optionsMenu.sidePanel.content:NewText(category.name, 0, currentY, 0, "DefaultButtonText"); - categoryText:SetAnchor(0.0, 1.0); - categoryText:SetRelativePoint(0.0, 1.0); - --categoryText:SetOnMouseUp(function(eventID, widget, ...) - -- UpdateMainPanel(optionsMenu, category, nil, nil); - --end) + treeLayout:NewText(category.name, 0, 0, 0, "DefaultButtonText"); - currentY -= select(2, categoryText:GetSize()) + 5; + local groupsContainer = UIVerticalLayout.NewVerticalLayout(treeLayout, 0, 0, width, 0, 0, { + heightMode = "shrink", paddingLeft = 15, spacing = 5, + }); for j, group in category.groups.list do - local groupText = optionsMenu.sidePanel.content:NewText(group.name, 15, currentY, 0, "DefaultButtonText"); - groupText:SetAnchor(0.0, 1.0); - groupText:SetRelativePoint(0.0, 1.0); - --groupText:SetOnMouseUp(function(eventID, widget, ...) - -- UpdateMainPanel(optionsMenu, category, group, nil); - --end) + groupsContainer:NewText(group.name, 0, 0, 0, "DefaultButtonText"); - currentY -= select(2, groupText:GetSize()) + 5; + local sectionsContainer = UIVerticalLayout.NewVerticalLayout(groupsContainer, 0, 0, width, 0, 0, { + heightMode = "shrink", paddingLeft = 15, spacing = 5, + }); for k, section in group.sections.list do - local sectionText = optionsMenu.sidePanel.content:NewText(section.name, 30, currentY, 0, "DefaultButtonText"); - sectionText:SetAnchor(0.0, 1.0); - sectionText:SetRelativePoint(0.0, 1.0); + local sectionText = sectionsContainer:NewText(section.name, 0, 0, 0, "DefaultButtonText"); sectionText:SetOnMouseUp(function(eventID, widget, ...) UpdateMainPanel(optionsMenu, category, group, section); end) - - currentY -= select(2, sectionText:GetSize()) + 5; end end end - local currentHeight = math.abs(currentY); - if (currentHeight > height) then - optionsMenu.sidePanel:SetVerticalScrollBarVisible(true); - else - optionsMenu.sidePanel:SetVerticalScrollBarVisible(false); - end - optionsMenu.sidePanel:SetContentHeight(currentHeight+5); + local currentHeight = treeLayout:GetMeasuredHeight(); + optionsMenu.sidePanel:SetVerticalScrollBarVisible(currentHeight > height); + optionsMenu.sidePanel:SetContentHeight(currentHeight + 5); return optionsMenu; end diff --git a/Source/Shaders/Shaders/UI/Widget.ps.slang b/Source/Shaders/Shaders/UI/Widget.ps.slang index bc648d9..0e1cdc7 100644 --- a/Source/Shaders/Shaders/UI/Widget.ps.slang +++ b/Source/Shaders/Shaders/UI/Widget.ps.slang @@ -7,10 +7,10 @@ struct WidgetDrawData { uint4 packed0; // x: type, y: vertexBase, z: clipMaskTextureIndex, w: worldPositionIndex (int reinterpret) - uint4 packed1; // Panel: x: textureIndex & additiveTextureIndex, z: color, w: textureScaleToWidgetSize (half2). Text: x: fontTextureIndex, z: textColor, w: borderColor + uint4 packed1; // Panel: x: textureIndex & additiveTextureIndex, y: borderColor, z: color, w: textureScaleToWidgetSize (half2). Text: x: fontTextureIndex, z: textColor, w: borderColor float4 texCoord; // Panel only float4 slicingCoord; // Panel only - float4 cornerRadiusAndBorder; // Panel: xy: cornerRadius. Text: x: borderSize, zw: unitRange + float4 cornerRadiusAndBorder; // Panel: xy: cornerRadius, zw: borderSize (normalized per-axis). Text: x: borderSize, zw: unitRange uint4 packed2; // x: clipRegionMinXY, y: clipRegionMaxXY, z: clipMaskRegionMinXY, w: clipMaskRegionMaxXY }; [[vk::binding(2, PER_PASS)]] StructuredBuffer _widgetDrawDatas; @@ -95,21 +95,49 @@ float4 ShadePanel(WidgetDrawData drawData, VertexOutput input) color.a = max(color.a, additiveIntensity); float2 cornerRadius = drawData.cornerRadiusAndBorder.xy; + float2 normalizedBorderSize = drawData.cornerRadiusAndBorder.zw; // Calculate distance to nearest edge float2 edgeDist = min(uv, 1.0 - uv); - if (cornerRadius.x > 0 && cornerRadius.y > 0) + bool inCornerZone = cornerRadius.x > 0 && cornerRadius.y > 0 + && edgeDist.x < cornerRadius.x && edgeDist.y < cornerRadius.y; + + if (inCornerZone) + { + float2 normalizedDist = 1.0 - (edgeDist / cornerRadius); + float distToCorner = length(normalizedDist); + + if (distToCorner > 1.0) + { + discard; + } + } + + if (normalizedBorderSize.x > 0.0 || normalizedBorderSize.y > 0.0) { - if (edgeDist.x < cornerRadius.x && edgeDist.y < cornerRadius.y) + float4 borderColor = PackedUnormsToFloat4(drawData.packed1.y); + bool inBorder; + + if (inCornerZone) + { + // Corner zone: pixel is in the border ring if it lies outside + // the inner corner ellipse (radii shrunken by borderSize). + float2 innerR = max(cornerRadius - normalizedBorderSize, float2(1e-5, 1e-5)); + float2 toInner = 1.0 - edgeDist / innerR; + bool insideInnerBox = toInner.x > 0.0 && toInner.y > 0.0; + inBorder = !insideInnerBox || length(toInner) > 1.0; + } + else { - float2 normalizedDist = 1.0 - ((edgeDist) / cornerRadius); - float distToCorner = length(normalizedDist); + inBorder = edgeDist.x < normalizedBorderSize.x + || edgeDist.y < normalizedBorderSize.y; + } - if (distToCorner > 1.0) - { - discard; - } + if (inBorder) + { + color.rgb = lerp(color.rgb, borderColor.rgb, borderColor.a); + color.a = max(color.a, borderColor.a); } } From 0cfcc4bfe155f405aa05230f596a549b9e9080d6 Mon Sep 17 00:00:00 2001 From: Pursche Date: Sun, 10 May 2026 20:24:33 +0200 Subject: [PATCH 2/4] Start implementing Editor functionality to Lua scripts Add Lua based DevTopBar editor Add Lua based Clock Editor Remove old ImGui based Clock Editor Add EditorRegistry which keeps track of all Lua Editors Improve on time drift in DayNightCycle, reducing ways for it to run faster or slower --- .../Game-Lib/Application/Application.cpp | 9 + Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp | 6 +- .../Game-Lib/ECS/Singletons/DayNightCycle.h | 11 +- .../Systems/CalculateShadowCameraMatrices.cpp | 2 +- .../Game-Lib/ECS/Systems/UpdateAreaLights.cpp | 2 +- .../ECS/Systems/UpdateDayNightCycle.cpp | 36 ++- .../ECS/Systems/UpdateDayNightCycle.h | 4 +- Source/Game-Lib/Game-Lib/Editor/Clock.cpp | 80 ------- Source/Game-Lib/Game-Lib/Editor/Clock.h | 15 -- .../Game-Lib/Editor/EditorHandler.cpp | 3 +- .../Game-Lib/Scripting/Handlers/UIHandler.cpp | 117 ++++++++++ .../Game-Lib/Scripting/Handlers/UIHandler.h | 28 +++ .../Game-Lib/Scripting/Util/ZenithUtil.cpp | 2 + Source/Resources/Scripts/API/UI/Button.luau | 2 + .../Scripts/API/UI/EditorButton.luau | 118 ++++++++++ .../Scripts/API/UI/EditorRegistry.luau | 48 ++++ Source/Resources/Scripts/API/UI/MenuBar.luau | 206 ++++++++++++++++++ .../Scripts/API/UI/WidgetExtensions.luau | 64 ++++++ Source/Resources/Scripts/Bootstrap/Init.luau | 1 + .../Resources/Scripts/Editor/ClockEditor.luau | 175 +++++++++++++++ .../Resources/Scripts/Editor/DevTopBar.luau | 41 ++++ 21 files changed, 861 insertions(+), 109 deletions(-) delete mode 100644 Source/Game-Lib/Game-Lib/Editor/Clock.cpp delete mode 100644 Source/Game-Lib/Game-Lib/Editor/Clock.h create mode 100644 Source/Resources/Scripts/API/UI/EditorButton.luau create mode 100644 Source/Resources/Scripts/API/UI/EditorRegistry.luau create mode 100644 Source/Resources/Scripts/API/UI/MenuBar.luau create mode 100644 Source/Resources/Scripts/API/UI/WidgetExtensions.luau create mode 100644 Source/Resources/Scripts/Editor/ClockEditor.luau create mode 100644 Source/Resources/Scripts/Editor/DevTopBar.luau diff --git a/Source/Game-Lib/Game-Lib/Application/Application.cpp b/Source/Game-Lib/Game-Lib/Application/Application.cpp index 06dab48..690eb36 100644 --- a/Source/Game-Lib/Game-Lib/Application/Application.cpp +++ b/Source/Game-Lib/Game-Lib/Application/Application.cpp @@ -67,6 +67,7 @@ AutoCVar_Int CVAR_ApplicationNumThreads(CVarCategory::Client, "numThreads", "num AutoCVar_Int CVAR_ClientDBSaveMethod(CVarCategory::Client, "clientDBSaveMethod", "specifies when clientDBs are saved. (0 = Immediately, 1 = Every x Seconds, 2 = On Shutdown, 3+ = Disabled, default is 1)", 1); AutoCVar_Float CVAR_ClientDBSaveTimer(CVarCategory::Client, "clientDBSaveTimer", "specifies how often clientDBs are saved when using save method 1 (Specified in seconds, default is 5 seconds)", 5.0f); AutoCVar_String CVAR_ImguiTheme(CVarCategory::Client, "imguiTheme", "specifies the current imgui theme", "Blue Teal", CVarFlags::Hidden); +AutoCVar_Int CVAR_DeveloperMode(CVarCategory::Client, "developerMode", "enables developer-only Luau APIs (Editor, Time) and dev-tool scripts under Resources/Scripts/Editor", 1, CVarFlags::EditCheckbox); Application::Application() : _messagesInbound(256), _messagesOutbound(256) { } Application::~Application() @@ -361,6 +362,14 @@ bool Application::Init() auto globalKey = Scripting::ZenithInfoKey::MakeGlobal(0, 0); _luaManager->GetZenithStateManager().Add(globalKey); + _luaManager->SetDeveloperMode(CVAR_DeveloperMode.Get() != 0); + + CVarSystem::Get()->AddOnIntValueChanged(CVarCategory::Client, "developerMode"_h, + [this](const i32& value) + { + _luaManager->SetDeveloperMode(value != 0); + }); + _luaManager->Init(); } diff --git a/Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp b/Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp index 40b3881..7fdf074 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Scheduler.cpp @@ -87,7 +87,11 @@ namespace ECS joltState.updateTimer += glm::clamp(clampedDeltaTime, 0.0f, Singletons::JoltState::FixedDeltaTime); - Systems::UpdateDayNightCycle::Update(gameRegistry, clampedDeltaTime); + // Day/night cycle is a wall-clock-style timer: pass the unclamped deltaTime so + // the in-game time tracks real seconds 1:1. Clamping (as the rest of this + // function uses for physics/animation stability) makes the cycle drift behind + // wall-clock whenever the framerate dips below 60 FPS. + Systems::UpdateDayNightCycle::Update(gameRegistry, deltaTime); Systems::NetworkConnection::Update(gameRegistry, clampedDeltaTime); Systems::DrawDebugMesh::Update(gameRegistry, clampedDeltaTime); Systems::Animation::Update(gameRegistry, clampedDeltaTime); diff --git a/Source/Game-Lib/Game-Lib/ECS/Singletons/DayNightCycle.h b/Source/Game-Lib/Game-Lib/ECS/Singletons/DayNightCycle.h index 8ce5a01..ecb796e 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Singletons/DayNightCycle.h +++ b/Source/Game-Lib/Game-Lib/ECS/Singletons/DayNightCycle.h @@ -11,7 +11,14 @@ namespace ECS::Singletons static constexpr f32 SecondsPerMinute = 60.0f; static constexpr f32 SecondsPerDay = SecondsPerMinute * MinutesPerHour * HoursPerDay; - f32 timeInSeconds = 0.0f; + // f64: f32 ULP at 86400 is ~4-8 ms, comparable to deltaTime, so an f32 + // accumulator drifts by seconds per minute. + f64 timeInSeconds = 0.0; f32 speedModifier = 1.0f; + + // -1 so the first Update fires the second-changed callback. + i32 lastIntegerSecond = -1; + + f32 GetTimeInSecondsF32() const { return static_cast(timeInSeconds); } }; -} \ No newline at end of file +} diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/CalculateShadowCameraMatrices.cpp b/Source/Game-Lib/Game-Lib/ECS/Systems/CalculateShadowCameraMatrices.cpp index 72e447c..e368a42 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/CalculateShadowCameraMatrices.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/CalculateShadowCameraMatrices.cpp @@ -75,7 +75,7 @@ namespace ECS::Systems auto& dayNightCycle = ctx.get(); // Get light settings - vec3 lightDirection = UpdateAreaLights::GetLightDirection(dayNightCycle.timeInSeconds); + vec3 lightDirection = UpdateAreaLights::GetLightDirection(dayNightCycle.GetTimeInSecondsF32()); // Get active render camera auto& activeCamera = ctx.get(); diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateAreaLights.cpp b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateAreaLights.cpp index 8226dc6..c700d2e 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateAreaLights.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateAreaLights.cpp @@ -309,7 +309,7 @@ namespace ECS::Systems MaterialRenderer* materialRenderer = ServiceLocator::GetGameRenderer()->GetMaterialRenderer(); - vec3 direction = GetLightDirection(dayNightCycle.timeInSeconds); + vec3 direction = GetLightDirection(dayNightCycle.GetTimeInSecondsF32()); const vec3& diffuseColor = glm::normalize(areaLightInfo.finalColorData.diffuseColor); const vec3& ambientColor = glm::normalize(areaLightInfo.finalColorData.ambientColor); vec3 groundAmbientColor = ambientColor * 0.7f; diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.cpp b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.cpp index ad713e3..9921d80 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.cpp @@ -1,5 +1,14 @@ #include "UpdateDayNightCycle.h" +#include "Game-Lib/Application/EnttRegistries.h" #include "Game-Lib/ECS/Singletons/DayNightCycle.h" +#include "Game-Lib/Scripting/Handlers/UIHandler.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" +#include "Game-Lib/Util/ServiceLocator.h" + +#include + +#include +#include #include #include @@ -34,17 +43,34 @@ namespace ECS::Systems entt::registry::context& context = registry.ctx(); auto& dayNightCycle = context.get(); - dayNightCycle.timeInSeconds += (1.0f * dayNightCycle.speedModifier) * deltaTime; + dayNightCycle.timeInSeconds += static_cast(dayNightCycle.speedModifier) * static_cast(deltaTime); while (dayNightCycle.timeInSeconds > Singletons::DayNightCycle::SecondsPerDay) { dayNightCycle.timeInSeconds -= Singletons::DayNightCycle::SecondsPerDay; } + + i32 currentInteger = static_cast(dayNightCycle.timeInSeconds); + if (currentInteger != dayNightCycle.lastIntegerSecond) + { + dayNightCycle.lastIntegerSecond = currentInteger; + + Scripting::LuaManager* luaManager = ServiceLocator::GetLuaManager(); + Scripting::Zenith* zenith = Scripting::Util::Zenith::GetGlobal(); + if (luaManager && zenith) + { + Scripting::UI::UIHandler* uiHandler = luaManager->GetLuaHandler(static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::UI)); + if (uiHandler) + { + uiHandler->OnSecondChanged(zenith, dayNightCycle.timeInSeconds); + } + } + } } void UpdateDayNightCycle::SetTimeToDefault(entt::registry& registry) { - f32 secondsSinceMidnightUTC = static_cast(GetSecondsSinceMidnightUTC()); + f64 secondsSinceMidnightUTC = static_cast(GetSecondsSinceMidnightUTC()); while (secondsSinceMidnightUTC > Singletons::DayNightCycle::SecondsPerDay) { secondsSinceMidnightUTC -= Singletons::DayNightCycle::SecondsPerDay; @@ -53,7 +79,7 @@ namespace ECS::Systems SetTime(registry, secondsSinceMidnightUTC); } - void UpdateDayNightCycle::SetTime(entt::registry& registry, f32 time) + void UpdateDayNightCycle::SetTime(entt::registry& registry, f64 time) { entt::registry::context& context = registry.ctx(); auto& dayNightCycle = context.get(); @@ -67,9 +93,9 @@ namespace ECS::Systems dayNightCycle.speedModifier = speedModifier; } - void UpdateDayNightCycle::SetTimeAndSpeedModifier(entt::registry& registry, f32 time, f32 speedModifier) + void UpdateDayNightCycle::SetTimeAndSpeedModifier(entt::registry& registry, f64 time, f32 speedModifier) { SetTime(registry, time); SetSpeedModifier(registry, speedModifier); } -} \ No newline at end of file +} diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.h b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.h index 069968f..cecc581 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.h +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/UpdateDayNightCycle.h @@ -11,8 +11,8 @@ namespace ECS::Systems static void Update(entt::registry& registry, f32 deltaTime); static void SetTimeToDefault(entt::registry& registry); - static void SetTime(entt::registry& registry, f32 time); + static void SetTime(entt::registry& registry, f64 time); static void SetSpeedModifier(entt::registry& registry, f32 speedModifier); - static void SetTimeAndSpeedModifier(entt::registry& registry, f32 time, f32 speedModifier); + static void SetTimeAndSpeedModifier(entt::registry& registry, f64 time, f32 speedModifier); }; } \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Editor/Clock.cpp b/Source/Game-Lib/Game-Lib/Editor/Clock.cpp deleted file mode 100644 index 0582561..0000000 --- a/Source/Game-Lib/Game-Lib/Editor/Clock.cpp +++ /dev/null @@ -1,80 +0,0 @@ -#include "Clock.h" - -#include "Game-Lib/Application/EnttRegistries.h" -#include "Game-Lib/ECS/Singletons/DayNightCycle.h" -#include "Game-Lib/ECS/Systems/UpdateDayNightCycle.h" -#include "Game-Lib/Util/ServiceLocator.h" - -#include -#include - -#include - -using namespace ECS; - -namespace Editor -{ - Clock::Clock() - : BaseEditor(GetName()) - { - - } - - void Clock::DrawImGui() - { - if (ImGui::Begin(GetName(), &IsVisible())) - { - EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); - entt::registry& registry = *registries->gameRegistry; - entt::registry::context& ctx = registry.ctx(); - - auto& dayNightCycle = ctx.get(); - - // Direct Time Manipulations - { - if (ImGui::Button("Reset Time")) - { - Systems::UpdateDayNightCycle::SetTimeToDefault(registry); - } - - ImGui::SameLine(); - - if (ImGui::Button("Set Time to Noon")) - { - f32 noonTime = Singletons::DayNightCycle::SecondsPerDay / 2.0f; - Systems::UpdateDayNightCycle::SetTime(registry, noonTime); - } - } - - // Time Speed Manipulation - { - f32 timeMultiplier = dayNightCycle.speedModifier; - - bool resetTime = ImGui::Button("Reset Speed"); - if (resetTime) - { - timeMultiplier = 1.0; - } - - ImGui::SameLine(); - bool inputChanged = ImGui::InputFloat("##", &timeMultiplier, 1.0f, 10.f, "%.2f"); - - if (resetTime || inputChanged) - dayNightCycle.speedModifier = timeMultiplier; - } - - u32 totalSeconds = static_cast(dayNightCycle.timeInSeconds); - - u32 hours = totalSeconds / 60 / 60; - u32 mins = (totalSeconds / 60) - hours * 60; - u32 seconds = totalSeconds - (hours * 60 * 60) - (mins * 60); - - std::string hoursStr = hours < 10 ? std::string("0").append(std::to_string(hours)) : std::to_string(hours); - std::string minsStr = mins < 10 ? std::string("0").append(std::to_string(mins)) : std::to_string(mins); - std::string secondsStr = seconds < 10 ? std::string("0").append(std::to_string(seconds)) : std::to_string(seconds); - - ImGui::Text("%s:%s:%s", hoursStr.c_str(), minsStr.c_str(), secondsStr.c_str()); - } - ImGui::End(); - } -} \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Editor/Clock.h b/Source/Game-Lib/Game-Lib/Editor/Clock.h deleted file mode 100644 index a3d1395..0000000 --- a/Source/Game-Lib/Game-Lib/Editor/Clock.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include "BaseEditor.h" - -namespace Editor -{ - class Clock : public BaseEditor - { - public: - Clock(); - - virtual const char* GetName() override { return "Clock"; } - - virtual void DrawImGui() override; - }; -} \ No newline at end of file diff --git a/Source/Game-Lib/Game-Lib/Editor/EditorHandler.cpp b/Source/Game-Lib/Game-Lib/Editor/EditorHandler.cpp index 65e7e27..640d288 100644 --- a/Source/Game-Lib/Game-Lib/Editor/EditorHandler.cpp +++ b/Source/Game-Lib/Game-Lib/Editor/EditorHandler.cpp @@ -2,9 +2,9 @@ #include "ActionStack.h" #include "AnimationController.h" #include "AssetBrowser.h" +#include "BaseEditor.h" #include "CameraInfo.h" #include "CDBEditor.h" -#include "Clock.h" #include "CVarEditor.h" #include "EaseCurveTool.h" #include "Hierarchy.h" @@ -50,7 +50,6 @@ namespace Editor _editors.push_back(new CVarEditor()); _editors.push_back(new CameraInfo()); _editors.push_back(new CDBEditor()); - _editors.push_back(new Clock()); _editors.push_back(new PerformanceDiagnostics()); _editors.push_back(new MapSelector()); _editors.push_back(new NetworkedInfo()); diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp index de9f7cb..4f316a5 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.cpp @@ -3,8 +3,10 @@ #include "Game-Lib/ECS/Components/UI/Canvas.h" #include "Game-Lib/ECS/Components/UI/EventInputInfo.h" #include "Game-Lib/ECS/Components/UI/LayoutEventInfo.h" +#include "Game-Lib/ECS/Singletons/DayNightCycle.h" #include "Game-Lib/ECS/Singletons/InputSingleton.h" #include "Game-Lib/ECS/Singletons/UISingleton.h" +#include "Game-Lib/ECS/Systems/UpdateDayNightCycle.h" #include "Game-Lib/ECS/Util/Transform2D.h" #include "Game-Lib/ECS/Util/UIRefCleanup.h" #include "Game-Lib/ECS/Util/UIUtil.h" @@ -14,6 +16,7 @@ #include "Game-Lib/Scripting/UI/Canvas.h" #include "Game-Lib/Scripting/UI/Panel.h" #include "Game-Lib/Scripting/UI/Text.h" +#include "Game-Lib/Scripting/Util/ZenithUtil.h" #include "Game-Lib/UI/Box.h" #include "Game-Lib/Util/ServiceLocator.h" @@ -59,6 +62,20 @@ namespace Scripting::UI CreateUIInputEventTable(zenith); + LuaManager* luaManager = ServiceLocator::GetLuaManager(); + const bool inDeveloperMode = luaManager && luaManager->IsDeveloperMode(); + const Scripting::LuaMethodFlags excludeFlags = inDeveloperMode + ? Scripting::LuaMethodFlags::None + : Scripting::LuaMethodFlags::DeveloperOnly; + + LuaMethodTable::Set(zenith, timeGlobalMethods, "Time", excludeFlags); + + zenith->GetGlobalKey("Time"); + zenith->AddTableField("SecondsPerDay", ECS::Singletons::DayNightCycle::SecondsPerDay); + zenith->Pop(); + + _timeOnSecondChangedRef = LUA_NOREF; + // Setup Cursor Canvas { auto& uiSingleton = registry->ctx().get(); @@ -91,6 +108,8 @@ namespace Scripting::UI transformSystem.ClearQueue(); ServiceLocator::GetGameRenderer()->GetCanvasRenderer()->Clear(); + _timeOnSecondChangedRef = LUA_NOREF; + if (ctx.contains()) { ECS::Singletons::UISingleton& uiSingleton = ctx.get(); @@ -1015,4 +1034,102 @@ namespace Scripting::UI zenith->Pop(); } } + + static ECS::Singletons::DayNightCycle* GetDayNightCycle() + { + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (!registries || !registries->gameRegistry) + return nullptr; + auto& ctx = registries->gameRegistry->ctx(); + if (!ctx.contains()) + return nullptr; + return &ctx.get(); + } + + i32 UIHandler::TimeGetSeconds(Zenith* zenith) + { + ECS::Singletons::DayNightCycle* dnc = GetDayNightCycle(); + zenith->Push(dnc ? dnc->timeInSeconds : 0.0); + return 1; + } + + i32 UIHandler::TimeSetSeconds(Zenith* zenith) + { + f64 seconds = zenith->CheckVal(1); + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (registries && registries->gameRegistry) + ECS::Systems::UpdateDayNightCycle::SetTime(*registries->gameRegistry, seconds); + return 0; + } + + i32 UIHandler::TimeReset(Zenith* /*zenith*/) + { + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (registries && registries->gameRegistry) + ECS::Systems::UpdateDayNightCycle::SetTimeToDefault(*registries->gameRegistry); + return 0; + } + + i32 UIHandler::TimeSetToNoon(Zenith* /*zenith*/) + { + EnttRegistries* registries = ServiceLocator::GetEnttRegistries(); + if (registries && registries->gameRegistry) + { + f64 noon = static_cast(ECS::Singletons::DayNightCycle::SecondsPerDay) / 2.0; + ECS::Systems::UpdateDayNightCycle::SetTime(*registries->gameRegistry, noon); + } + return 0; + } + + i32 UIHandler::TimeGetSpeedModifier(Zenith* zenith) + { + ECS::Singletons::DayNightCycle* dnc = GetDayNightCycle(); + zenith->Push(dnc ? dnc->speedModifier : 1.0f); + return 1; + } + + i32 UIHandler::TimeSetSpeedModifier(Zenith* zenith) + { + f32 multiplier = zenith->CheckVal(1); + ECS::Singletons::DayNightCycle* dnc = GetDayNightCycle(); + if (dnc) + dnc->speedModifier = multiplier; + return 0; + } + + i32 UIHandler::TimeGetSecondsPerDay(Zenith* zenith) + { + zenith->Push(ECS::Singletons::DayNightCycle::SecondsPerDay); + return 1; + } + + i32 UIHandler::TimeSetOnSecondChanged(Zenith* zenith) + { + LuaManager* luaManager = ServiceLocator::GetLuaManager(); + UIHandler* self = luaManager + ? luaManager->GetLuaHandler(static_cast(MetaGen::Game::Lua::LuaHandlerTypeEnum::UI)) + : nullptr; + if (!self) + return 0; + + Scripting::Util::Zenith::Unref(zenith, self->_timeOnSecondChangedRef); + self->_timeOnSecondChangedRef = LUA_NOREF; + + // Pass `nil` to clear; otherwise expects a function. Single-slot. + if (zenith->IsFunction(1)) + { + self->_timeOnSecondChangedRef = zenith->GetRef(1); + } + return 0; + } + + void UIHandler::OnSecondChanged(Zenith* zenith, f64 timeInSeconds) + { + if (_timeOnSecondChangedRef == LUA_NOREF) + return; + + zenith->GetRawI(LUA_REGISTRYINDEX, _timeOnSecondChangedRef); + zenith->Push(timeInSeconds); + zenith->PCall(1); + } } diff --git a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.h b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.h index f3298d7..d84118f 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.h +++ b/Source/Game-Lib/Game-Lib/Scripting/Handlers/UIHandler.h @@ -79,6 +79,16 @@ namespace Scripting::UI static i32 AddOnKeyboard(Zenith* zenith); + // Time + static i32 TimeGetSeconds(Zenith* zenith); + static i32 TimeSetSeconds(Zenith* zenith); + static i32 TimeReset(Zenith* zenith); + static i32 TimeSetToNoon(Zenith* zenith); + static i32 TimeGetSpeedModifier(Zenith* zenith); + static i32 TimeSetSpeedModifier(Zenith* zenith); + static i32 TimeGetSecondsPerDay(Zenith* zenith); + static i32 TimeSetOnSecondChanged(Zenith* zenith); + // Event calls void CallUIInputEvent(Zenith* zenith, i32 eventRef, UIInputEvent inputEvent, Widget* widget); void CallUIInputEvent(Zenith* zenith, i32 eventRef, UIInputEvent inputEvent, Widget* widget, i32 value); @@ -92,8 +102,26 @@ namespace Scripting::UI void CallSendMessageToChat(Zenith* zenith, i32 eventRef, const std::string& channel, const std::string& playerName, const std::string& text, bool isOutgoing); + public: + // Called by UpdateDayNightCycle when the integer game-second flips. + void OnSecondChanged(Zenith* zenith, f64 timeInSeconds); + private: void CreateUIInputEventTable(Zenith* zenith); + + i32 _timeOnSecondChangedRef = LUA_NOREF; + }; + + static LuaRegister<> timeGlobalMethods[] = + { + { "GetSeconds", UIHandler::TimeGetSeconds }, + { "SetSeconds", UIHandler::TimeSetSeconds, Scripting::LuaMethodFlags::DeveloperOnly }, + { "Reset", UIHandler::TimeReset, Scripting::LuaMethodFlags::DeveloperOnly }, + { "SetToNoon", UIHandler::TimeSetToNoon, Scripting::LuaMethodFlags::DeveloperOnly }, + { "GetSpeedModifier", UIHandler::TimeGetSpeedModifier }, + { "SetSpeedModifier", UIHandler::TimeSetSpeedModifier, Scripting::LuaMethodFlags::DeveloperOnly }, + { "GetSecondsPerDay", UIHandler::TimeGetSecondsPerDay }, + { "SetOnSecondChanged", UIHandler::TimeSetOnSecondChanged }, }; static LuaRegister<> uiGlobalMethods[] = diff --git a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp index 826562f..b6bdd87 100644 --- a/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp +++ b/Source/Game-Lib/Game-Lib/Scripting/Util/ZenithUtil.cpp @@ -17,6 +17,8 @@ namespace Scripting::Util::Zenith void Unref(::Scripting::Zenith* zenith, i32 ref) { if (ref == -1) return; + // lua_State is transiently null between Zenith::Clear and the next LoadBytecode. + if (zenith == nullptr || zenith->state == nullptr) return; lua_unref(zenith->state, ref); } } diff --git a/Source/Resources/Scripts/API/UI/Button.luau b/Source/Resources/Scripts/API/UI/Button.luau index d95b9b0..ef8d933 100644 --- a/Source/Resources/Scripts/API/UI/Button.luau +++ b/Source/Resources/Scripts/API/UI/Button.luau @@ -210,6 +210,8 @@ function buttonAPI.NewButton(parent, posX, posY, sizeX, sizeY, layer, buttonTemp local textTemplate = buttonTemplateTable["textTemplate"] or "DefaultButtonText"; buttonTable.panel = parent:NewPanel(posX, posY, sizeX, sizeY, 0, panelTemplate); + -- +3 Y compensates for the drop shadow in DefaultButtonPanel — the visible + -- frame ends ~3px above the panel extent. buttonTable.text = buttonTable.panel:NewText(text, 0, 3, 0, textTemplate); buttonTable.text:SetAnchor(0.5, 0.5); buttonTable.text:SetRelativePoint(0.5, 0.5); diff --git a/Source/Resources/Scripts/API/UI/EditorButton.luau b/Source/Resources/Scripts/API/UI/EditorButton.luau new file mode 100644 index 0000000..2bbf601 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/EditorButton.luau @@ -0,0 +1,118 @@ +--[[ +EditorButton — flat-panel button for dev tool UI. Default look is "invisible +until hovered" (base alpha 0, hover alpha 0.45 with a light-blue tint). +Returned table mirrors the slice of Button.luau used by MenuBar/Clock plus a +SetHighlighted(bool) for "menu open" states. +]] + +local editorButtonAPI = {} + +local DEFAULT_BASE_COLOR = vector.create(0.30, 0.30, 0.35) +local DEFAULT_HOVER_COLOR = vector.create(0.30, 0.55, 0.85) +local DEFAULT_BASE_ALPHA_INVISIBLE = 0.0 -- "invisible until hovered" — see docblock +local DEFAULT_HOVER_ALPHA = 0.45 + +function editorButtonAPI.NewEditorButton(parent, posX, posY, sizeX, sizeY, layer, opts) + opts = opts or {} + + local label = opts.text or "" + local textTemplate = opts.textTemplate or "DefaultDebugText" + local textAlign = opts.textAlign or "center" -- "left", "center", "right" + local textPadX = opts.textPadX or 6 -- inset for left/right alignment + local baseColor = opts.color or DEFAULT_BASE_COLOR + local hoverColor = opts.hoverColor or DEFAULT_HOVER_COLOR + local baseAlpha = opts.alpha or DEFAULT_BASE_ALPHA_INVISIBLE + local hoverAlpha = opts.hoverAlpha or DEFAULT_HOVER_ALPHA + + local panel = parent:NewPanel(posX, posY, sizeX, sizeY, layer or 0, "DebugDarkGrey") + panel:SetColor(baseColor) + panel:SetAlpha(baseAlpha) + + if opts.borderColor ~= nil and opts.borderSize ~= nil then + panel:SetBorderColor(opts.borderColor) + panel:SetBorderSize(opts.borderSize) + end + + local text = panel:NewText(label, 0, 0, 0, textTemplate) + if textAlign == "left" then + text:SetAnchor(0.0, 0.5) + text:SetRelativePoint(0.0, 0.5) + text:SetPosX(textPadX) + elseif textAlign == "right" then + text:SetAnchor(1.0, 0.5) + text:SetRelativePoint(1.0, 0.5) + text:SetPosX(-textPadX) + else + text:SetAnchor(0.5, 0.5) + text:SetRelativePoint(0.5, 0.5) + end + + local self = { + panel = panel, + text = text, + _baseColor = baseColor, + _hoverColor = hoverColor, + _baseAlpha = baseAlpha, + _hoverAlpha = hoverAlpha, + _highlighted = false, + _hovered = false, + } + + local function ApplyVisualState() + if self._highlighted or self._hovered then + self.panel:SetColor(self._hoverColor) + self.panel:SetAlpha(self._hoverAlpha) + else + self.panel:SetColor(self._baseColor) + self.panel:SetAlpha(self._baseAlpha) + end + end + + panel:SetOnHoverBegin(function() + self._hovered = true + ApplyVisualState() + end) + panel:SetOnHoverEnd(function() + self._hovered = false + ApplyVisualState() + end) + + -- Lock the hover-style appearance on regardless of cursor state. + function self:SetHighlighted(on) + self._highlighted = on and true or false + ApplyVisualState() + end + + function self:SetText(t) self.text:SetText(t) end + function self:SetOnMouseUp(fn) self.panel:SetOnMouseUp(fn) end + function self:SetOnMouseDown(fn) self.panel:SetOnMouseDown(fn) end + function self:SetOnHoverBegin(fn) + -- Wrap so the visual state still updates alongside the user's fn. + self.panel:SetOnHoverBegin(function() + self._hovered = true + ApplyVisualState() + if fn then fn() end + end) + end + function self:SetOnHoverEnd(fn) + self.panel:SetOnHoverEnd(function() + self._hovered = false + ApplyVisualState() + if fn then fn() end + end) + end + function self:SetAnchor(x, y) self.panel:SetAnchor(x, y) end + function self:SetRelativePoint(x, y) self.panel:SetRelativePoint(x, y) end + function self:SetPos(x, y) self.panel:SetPos(x, y) end + function self:SetPosX(x) self.panel:SetPosX(x) end + function self:SetPosY(y) self.panel:SetPosY(y) end + function self:GetPosX() return self.panel:GetPosX() end + function self:GetPosY() return self.panel:GetPosY() end + function self:SetVisible(v) self.panel:SetVisible(v) end + function self:SetInteractable(v) self.panel:SetInteractable(v) end + function self:SetSize(w, h) self.panel:SetSize(w, h) end + + return self +end + +return editorButtonAPI diff --git a/Source/Resources/Scripts/API/UI/EditorRegistry.luau b/Source/Resources/Scripts/API/UI/EditorRegistry.luau new file mode 100644 index 0000000..4769743 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/EditorRegistry.luau @@ -0,0 +1,48 @@ +--[[ +EditorRegistry — registry of Lua editor windows. + + local Editor = require("@src/API/UI/EditorRegistry") + Editor.Register("Clock", { Show = ..., Hide = ..., IsVisible = ... }) + Editor.Toggle("Clock") + +In user mode, Editor/-folder scripts don't load so nothing registers, and +Toggle/IsVisible naturally no-op on the empty registry. +]] + +local M = {} + +local registered = {} -- name -> { Show = fn, Hide = fn, IsVisible = fn } + +function M.Register(name, methods) + registered[name] = methods +end + +function M.Unregister(name) + registered[name] = nil +end + +function M.IsVisible(name) + local m = registered[name] + return m ~= nil and m:IsVisible() +end + +function M.Toggle(name) + local m = registered[name] + if not m then return false end + if m:IsVisible() then + m:Hide() + else + m:Show() + end + return m:IsVisible() +end + +function M.GetEditors() + local out = {} + for k in registered do + table.insert(out, k) + end + return out +end + +return M diff --git a/Source/Resources/Scripts/API/UI/MenuBar.luau b/Source/Resources/Scripts/API/UI/MenuBar.luau new file mode 100644 index 0000000..d6425c8 --- /dev/null +++ b/Source/Resources/Scripts/API/UI/MenuBar.luau @@ -0,0 +1,206 @@ +--[[ +MenuBar — horizontal menu bar with click-to-open dropdowns. + +Click-outside dismissal uses a transparent full-screen "shield" panel as a +sibling of the bar. Bar buttons and dropdown items still receive their clicks +because they sit deeper in the widget hierarchy than the shield (hierarchy +depth dominates the canvas hit-test ranking). + + local bar = MenuBar.NewMenuBar(parent, 0, -22, 1280, 22, 1000, { + menus = { + { label = "Debug", items = { + { label = "Reload Scripts", onClick = fn, enabled = true }, + { label = "Reload Shaders", enabled = false }, -- greyed + }}, + }, + }) +]] + +local EditorButton = require("@src/API/UI/EditorButton") + +local menuBarAPI = {} + +UI.RegisterTextTemplate("DevToolsText", +{ + font = "Data/Fonts/OpenSans-Regular.ttf", + size = 8.0, + borderSize = 1.0, + borderColor = vector.create(0.0, 0.0, 0.0), +}) + +local TOP_BTN_W = 56 +local TOP_BTN_LAYER = 1 +local TEXT_TEMPLATE = "DevToolsText" +local ITEM_H = 18 +local ITEM_PAD_X = 16 -- horizontal padding around the longest label in a dropdown +local ITEM_MIN_W = 60 -- floor for very short labels +local DROPDOWN_LAYER = 2 +local SHIELD_DRAW_OFFSET = -1 +local SHIELD_SIZE = 8192 + +local BAR_BG_COLOR = vector.create(0.10, 0.10, 0.12) +local BAR_BG_ALPHA = 0.92 +local DROPDOWN_BG_COLOR = vector.create(0.10, 0.10, 0.12) +local DROPDOWN_BG_ALPHA = 0.96 +local BORDER_COLOR = vector.create(0.30, 0.30, 0.35) +local BORDER_SIZE = 1 + +local function CloseMenu(self) + if self._dropdown ~= nil then + UI.DestroyWidget(self._dropdown) + self._dropdown = nil + end + if self._shield ~= nil then + UI.DestroyWidget(self._shield) + self._shield = nil + end + if self._openIdx ~= nil then + self._buttons[self._openIdx]:SetHighlighted(false) + self._openIdx = nil + end +end + +local function CreateDropdown(self, idx) + local menuDef = self._menus[idx] + local items = menuDef.items or {} + local btn = self._buttons[idx] + + local widest = 0 + for _, it in items do + local w, _ = UI.CalculateTextSize(it.label or "", TEXT_TEMPLATE) + if w > widest then widest = w end + end + local itemW = math.max(ITEM_MIN_W, math.ceil(widest) + ITEM_PAD_X) + + -- Child of the bar so hierarchy depth ranks dropdown clicks above the shield. + local dropdownH = #items * ITEM_H + local btnX = btn:GetPosX() + local dropdown = self.panel:NewPanel(btnX, 0, itemW, dropdownH, DROPDOWN_LAYER, "DebugDarkGrey") + dropdown:SetAnchor(0.0, 0.0) + dropdown:SetRelativePoint(0.0, 1.0) + dropdown:SetColor(DROPDOWN_BG_COLOR) + dropdown:SetAlpha(DROPDOWN_BG_ALPHA) + dropdown:SetBorderColor(BORDER_COLOR) + dropdown:SetBorderSize(BORDER_SIZE) + dropdown:SetClipChildren(true) + + for i, it in items do + local itemBtn = EditorButton.NewEditorButton(dropdown, 0, -((i - 1) * ITEM_H), itemW, ITEM_H, 0, { + text = it.label or "", + textTemplate = TEXT_TEMPLATE, + textAlign = "left", + }) + itemBtn:SetAnchor(0.0, 1.0) + itemBtn:SetRelativePoint(0.0, 1.0) + + if it.enabled == false then + itemBtn.text:SetAlpha(0.4) + itemBtn:SetInteractable(false) + else + local onClick = it.onClick + itemBtn:SetOnMouseUp(function() + CloseMenu(self) + if onClick ~= nil then onClick() end + end) + end + end + + return dropdown +end + +local function CreateShield(self) + local shield = self._parent:NewPanel(0, 0, SHIELD_SIZE, SHIELD_SIZE, self._layer + SHIELD_DRAW_OFFSET, "DebugDarkGrey") + shield:SetAnchor(0.0, 0.0) + shield:SetRelativePoint(0.0, 0.0) + shield:SetAlpha(0) + shield:SetOnMouseUp(function() CloseMenu(self) end) + return shield +end + +local function OpenDropdown(self, idx) + if self._openIdx == idx then return end + + if self._dropdown ~= nil then + UI.DestroyWidget(self._dropdown) + self._dropdown = nil + end + + if self._openIdx ~= nil then + self._buttons[self._openIdx]:SetHighlighted(false) + end + + self._openIdx = idx + self._buttons[idx]:SetHighlighted(true) + self._dropdown = CreateDropdown(self, idx) + + if self._shield == nil then + self._shield = CreateShield(self) + end +end + +local function ToggleDropdown(self, idx) + if self._openIdx == idx then + CloseMenu(self) + else + OpenDropdown(self, idx) + end +end + +function menuBarAPI.NewMenuBar(parent, posX, posY, width, height, layer, opts) + opts = opts or {} + + local barPanel = parent:NewPanel(posX, posY, width, height, layer, "DebugDarkGrey") + barPanel:SetColor(BAR_BG_COLOR) + barPanel:SetAlpha(BAR_BG_ALPHA) + + local self = { + panel = barPanel, + _parent = parent, + _opts = opts, + _menus = opts.menus or {}, + _buttons = {}, + _dropdown = nil, + _shield = nil, + _openIdx = nil, + _layer = layer, + } + + local paddingLeft = opts.paddingLeft or 4 + local spacing = opts.spacing or 0 + + local cursorX = paddingLeft + for i, m in self._menus do + local btn = EditorButton.NewEditorButton(barPanel, cursorX, 0, TOP_BTN_W, height, TOP_BTN_LAYER, { + text = m.label or "", + textTemplate = TEXT_TEMPLATE, + }) + btn:SetAnchor(0.0, 0.0) + btn:SetRelativePoint(0.0, 0.0) + + btn:SetOnMouseUp(function() ToggleDropdown(self, i) end) + btn:SetOnHoverBegin(function() + -- While a dropdown is open, hovering a sibling switches without a click. + if self._openIdx ~= nil and self._openIdx ~= i then + OpenDropdown(self, i) + end + end) + + self._buttons[i] = btn + cursorX += TOP_BTN_W + spacing + end + + -- Forward unknown method calls to .panel so the bar walks like a Widget when used as a parent. + setmetatable(self, { + __index = function(t, k) + local p = rawget(t, "panel") + if p == nil then return nil end + local v = p[k] + if type(v) ~= "function" then return v end + return function(_, ...) return v(p, ...) end + end, + }) + + return self +end + +return menuBarAPI diff --git a/Source/Resources/Scripts/API/UI/WidgetExtensions.luau b/Source/Resources/Scripts/API/UI/WidgetExtensions.luau new file mode 100644 index 0000000..60aa8da --- /dev/null +++ b/Source/Resources/Scripts/API/UI/WidgetExtensions.luau @@ -0,0 +1,64 @@ +--[[ +WidgetExtensions — adds Lua-defined methods to widget metatables in place. + +luaL_sandbox seals globals and the string metatable but does NOT iterate +LUA_REGISTRYINDEX, so the per-type *MetaTable entries that LuaMetaTable:: +Register stores in the registry stay writable post-sandbox. We grab them via +getmetatable() on a probe instance and write the methods directly. After +Install(), every Panel — including ones created later — has the methods. + +Currently Panel only; Text/Widget have separate metatables. + +Methods on Panel: + panel:MakeDraggable(handle?) + Wire up drag behavior. The handle drives the drag; the panel updates + its own SetPos every frame while held. Omit `handle` to use the panel + itself. Engine implicitly captures the mouse — Held keeps firing past + the handle's edges until MouseUp. +]] + +local M = {} + +local function MakeDraggable(self, handle) + handle = handle or self + local mx0, my0 = 0, 0 + local wx0, wy0 = 0, 0 + + handle:SetOnMouseDown(function() + mx0, my0 = UI.GetMousePos() + wx0, wy0 = self:GetPosX(), self:GetPosY() + end) + handle:SetOnMouseHeld(function() + local mx, my = UI.GetMousePos() + self:SetPos(wx0 + (mx - mx0), wy0 + (my - my0)) + end) +end + +M.MakeDraggable = MakeDraggable + +local function ExtendMetatable(probe, methods) + local mt = getmetatable(probe) + if mt == nil then + error("WidgetExtensions: probe widget has no metatable; sandbox or registration changed?", 2) + end + for name, fn in methods do + mt[name] = fn + end +end + +local installed = false +function M.Install() + if installed then return end + installed = true + + -- Probe to grab the Panel metatable. Canvas leaks (no Lua API to destroy + -- a canvas) but is 1x1 and off-screen; the probe panel is destroyed below. + local probeCanvas = UI.GetCanvas("__widget_ext_probe", 0, 0, 1, 1, false) + local probePanel = probeCanvas:NewPanel(0, 0, 1, 1, 0, "DebugDarkGrey") + ExtendMetatable(probePanel, { MakeDraggable = MakeDraggable }) + UI.DestroyWidget(probePanel) +end + +M.Install() + +return M diff --git a/Source/Resources/Scripts/Bootstrap/Init.luau b/Source/Resources/Scripts/Bootstrap/Init.luau index c768d3f..49180a9 100644 --- a/Source/Resources/Scripts/Bootstrap/Init.luau +++ b/Source/Resources/Scripts/Bootstrap/Init.luau @@ -1,6 +1,7 @@ local UITemplates = require("@src/API/UI/Templates") UITemplates.RegisterTemplates(); +local _ = require("@src/API/UI/WidgetExtensions") local _ = require("@src/API/UI/CursorInfo") local _ = require("@src/API/UI/ItemSlot") local _ = require("@src/API/UI/ActionBar") diff --git a/Source/Resources/Scripts/Editor/ClockEditor.luau b/Source/Resources/Scripts/Editor/ClockEditor.luau new file mode 100644 index 0000000..3ed676e --- /dev/null +++ b/Source/Resources/Scripts/Editor/ClockEditor.luau @@ -0,0 +1,175 @@ +--[[ +ClockEditor — Luau Clock window, hidden by default; toggled via EditorRegistry. + +Layer 1100 sits above the dev top bar (1000) so the window draws over the bar +and open dropdowns. Speed control is +/- step buttons rather than a slider: +the Slider widget's inner-panel anchoring doesn't compose with non-default +anchors on the slider itself, follow-up once NumericInput exists. +]] + +local EditorButton = require("@src/API/UI/EditorButton") +local EditorRegistry = require("@src/API/UI/EditorRegistry") + +-- MenuBar's module-load side effect registers the DevToolsText template we use below. +require("@src/API/UI/MenuBar") + +local PANEL_W = 220 +local PANEL_H = 80 +local TITLE_H = 16 +local ROW_H = 20 +local SIDE_PAD = 6 +local ROW3_PAD = 5 +local BTN_TEXT_TPL = "DevToolsText" +local SPEED_STEP = 0.25 +local LAYER = 1100 + +local WINDOW_BG_COLOR = vector.create(0.10, 0.10, 0.12) +local WINDOW_BG_ALPHA = 0.96 +local BORDER_COLOR = vector.create(0.30, 0.30, 0.35) +local BORDER_SIZE = 1 + +local BTN_OPTS = { + textTemplate = BTN_TEXT_TPL, + borderColor = BORDER_COLOR, + borderSize = BORDER_SIZE, +} + +local function MakeBtnOpts(text) + -- Shallow-copy so per-button `text` doesn't smash the shared template. + return { + text = text, + textTemplate = BTN_OPTS.textTemplate, + borderColor = BTN_OPTS.borderColor, + borderSize = BTN_OPTS.borderSize, + } +end + +local function FormatTime(seconds) + local total = math.floor(seconds) + local h = math.floor(total / 3600) + local m = math.floor(total / 60) % 60 + local s = total % 60 + return string.format("%02d:%02d:%02d", h, m, s) +end + +local function FormatSpeed(speed) + return string.format("Speed: %.2fx", speed) +end + +local canvas = UI.GetCanvas("ClockEditor", 0, 0, 1920, 1080, false) + +local windowPanel = canvas:NewPanel(420, -80, PANEL_W, PANEL_H, LAYER, "DebugDarkGrey") +windowPanel:SetAnchor(0.0, 1.0) +windowPanel:SetRelativePoint(0.0, 1.0) +windowPanel:SetVisible(false) +windowPanel:SetColor(WINDOW_BG_COLOR) +windowPanel:SetAlpha(WINDOW_BG_ALPHA) +windowPanel:SetBorderColor(BORDER_COLOR) +windowPanel:SetBorderSize(BORDER_SIZE) + +-- Title bar — drag handle for the window. +local titleBar = windowPanel:NewPanel(0, 0, PANEL_W, TITLE_H, 1, "DebugDarkGrey") +titleBar:SetAnchor(0.0, 1.0) +titleBar:SetRelativePoint(0.0, 1.0) +titleBar:SetColor(vector.create(0.06, 0.06, 0.08)) +titleBar:SetAlpha(WINDOW_BG_ALPHA) + +local titleText = titleBar:NewText("Clock", SIDE_PAD, 0, 1, BTN_TEXT_TPL) +titleText:SetAnchor(0.0, 0.5) +titleText:SetRelativePoint(0.0, 0.5) +-- Without this, clicking the label would block the title-bar drag. +titleText:SetInteractable(false) + +local CLOSE_BTN_SIZE = 12 +local closeBtn = EditorButton.NewEditorButton(titleBar, -2, 0, CLOSE_BTN_SIZE, CLOSE_BTN_SIZE, 1, MakeBtnOpts("X")) +closeBtn:SetAnchor(1.0, 0.5) +closeBtn:SetRelativePoint(1.0, 0.5) +-- Single-glyph widgets bake the glyph's right-side bearing into their width, +-- which leaves the visible glyph ~1px left of center. +closeBtn.text:SetPosX(1) + +windowPanel:MakeDraggable(titleBar) + +-- Row 1: Reset Time | Set Time to Noon +local row1Y = -(TITLE_H + 2) +local btnW = (PANEL_W - SIDE_PAD * 2 - 4) / 2 +local resetTimeBtn = EditorButton.NewEditorButton(windowPanel, SIDE_PAD, row1Y, btnW, ROW_H, 1, MakeBtnOpts("Reset Time")) +resetTimeBtn:SetAnchor(0.0, 1.0) +resetTimeBtn:SetRelativePoint(0.0, 1.0) +resetTimeBtn:SetOnMouseUp(function() Time.Reset() end) + +local setNoonBtn = EditorButton.NewEditorButton(windowPanel, -SIDE_PAD, row1Y, btnW, ROW_H, 1, MakeBtnOpts("Set To Noon")) +setNoonBtn:SetAnchor(1.0, 1.0) +setNoonBtn:SetRelativePoint(1.0, 1.0) +setNoonBtn:SetOnMouseUp(function() Time.SetToNoon() end) + +-- Row 2: Reset Speed | Speed - | Speed + +local row2Y = row1Y - ROW_H - 2 +local stepBtnW = (btnW - 4) / 2 + +local resetSpeedBtn = EditorButton.NewEditorButton(windowPanel, SIDE_PAD, row2Y, btnW, ROW_H, 1, MakeBtnOpts("Reset Speed")) +resetSpeedBtn:SetAnchor(0.0, 1.0) +resetSpeedBtn:SetRelativePoint(0.0, 1.0) + +local speedDownBtn = EditorButton.NewEditorButton(windowPanel, -SIDE_PAD - stepBtnW - 4, row2Y, stepBtnW, ROW_H, 1, MakeBtnOpts("-")) +speedDownBtn:SetAnchor(1.0, 1.0) +speedDownBtn:SetRelativePoint(1.0, 1.0) +speedDownBtn.text:SetPosX(1) -- single-glyph nudge, see closeBtn + +local speedUpBtn = EditorButton.NewEditorButton(windowPanel, -SIDE_PAD, row2Y, stepBtnW, ROW_H, 1, MakeBtnOpts("+")) +speedUpBtn:SetAnchor(1.0, 1.0) +speedUpBtn:SetRelativePoint(1.0, 1.0) +speedUpBtn.text:SetPosX(1) + +-- Row 3: Speed readout (left) and HH:MM:SS readout (right). +local row3Y = row2Y - ROW_H - ROW3_PAD +local speedText = windowPanel:NewText("Speed: 1.00x", SIDE_PAD, row3Y, 1, BTN_TEXT_TPL) +speedText:SetAnchor(0.0, 1.0) +speedText:SetRelativePoint(0.0, 1.0) + +local timeText = windowPanel:NewText("00:00:00", -SIDE_PAD, row3Y, 1, BTN_TEXT_TPL) +timeText:SetAnchor(1.0, 1.0) +timeText:SetRelativePoint(1.0, 1.0) + +local function RefreshTimeText(seconds) + timeText:SetText(FormatTime(seconds)) +end + +local function RefreshSpeedText() + speedText:SetText(FormatSpeed(Time.GetSpeedModifier())) +end + +resetSpeedBtn:SetOnMouseUp(function() + Time.SetSpeedModifier(1.0) + RefreshSpeedText() +end) +speedDownBtn:SetOnMouseUp(function() + Time.SetSpeedModifier(math.max(0.0, Time.GetSpeedModifier() - SPEED_STEP)) + RefreshSpeedText() +end) +speedUpBtn:SetOnMouseUp(function() + Time.SetSpeedModifier(Time.GetSpeedModifier() + SPEED_STEP) + RefreshSpeedText() +end) + +local methods = {} + +function methods:Show() + windowPanel:SetVisible(true) + RefreshTimeText(Time.GetSeconds()) + RefreshSpeedText() + Time.SetOnSecondChanged(RefreshTimeText) +end + +function methods:Hide() + windowPanel:SetVisible(false) + Time.SetOnSecondChanged(nil) +end + +function methods:IsVisible() + return windowPanel:IsVisible() +end + +closeBtn:SetOnMouseUp(function() methods:Hide() end) + +EditorRegistry.Register("Clock", methods) diff --git a/Source/Resources/Scripts/Editor/DevTopBar.luau b/Source/Resources/Scripts/Editor/DevTopBar.luau new file mode 100644 index 0000000..cab0008 --- /dev/null +++ b/Source/Resources/Scripts/Editor/DevTopBar.luau @@ -0,0 +1,41 @@ +--[[ +DevTopBar — dev menu bar drawn below the ImGui main menu during the +ImGui->Lua migration. Greyed entries are not yet ported. +]] + +local MenuBar = require("@src/API/UI/MenuBar") +local EditorRegistry = require("@src/API/UI/EditorRegistry") + +-- TODO: query ImGui::GetFrameHeight() instead of hardcoding 11. +local IMGUI_MENU_BAR_HEIGHT = 11 +local BAR_LAYER = 1000 +local BAR_HEIGHT = 22 +local CANVAS_W = 1920 +local CANVAS_H = 1080 + +local canvas = UI.GetCanvas("DevTopBar", 0, 0, CANVAS_W, CANVAS_H, false) + +local bar = MenuBar.NewMenuBar(canvas, 0, -IMGUI_MENU_BAR_HEIGHT, CANVAS_W, BAR_HEIGHT, BAR_LAYER, { + paddingLeft = 8, + spacing = 4, + menus = { + { label = "Debug", items = { + { label = "Reload Scripts", enabled = false }, + { label = "Reload Shaders", enabled = false }, + }}, + { label = "Window", items = { + { label = "Clock", enabled = true, onClick = function() EditorRegistry.Toggle("Clock") end }, + }}, + { label = "Editor", items = { + { label = "(none)", enabled = false }, + }}, + { label = "Theme", items = { + { label = "(none)", enabled = false }, + }}, + { label = "Help", items = { + { label = "(none)", enabled = false }, + }}, + }, +}) +bar:SetAnchor(0.0, 1.0) +bar:SetRelativePoint(0.0, 1.0) From 705d6ae33ebbae267082f764ff467d603584b0b3 Mon Sep 17 00:00:00 2001 From: Pursche Date: Sun, 10 May 2026 20:47:28 +0200 Subject: [PATCH 3/4] Fix Lua widget input sorting issue Fix issue where Lua input events did not resolve widget ordering in the same way as the rendering --- .../Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Source/Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp b/Source/Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp index 36ebff6..67a1dc7 100644 --- a/Source/Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp +++ b/Source/Game-Lib/Game-Lib/ECS/Systems/UI/HandleInput.cpp @@ -439,15 +439,12 @@ namespace ECS::Systems::UI if (isWithin) { - Components::Transform2D& transform = registry.get(childEntity); - vec2 middlePoint = (min + max) * 0.5f; + u32 distanceToMouse = static_cast(glm::distance(middlePoint, mousePos)); - u16 numParents = std::numeric_limits::max() - static_cast(transform.GetHierarchyDepth()); - u16 layer = std::numeric_limits::max() - static_cast(transform.GetLayer()); - u32 distanceToMouse = static_cast(glm::distance(middlePoint, mousePos)); // Distance in pixels - - u64 key = (static_cast(numParents) << 48) | (static_cast(layer) << 32) | distanceToMouse; + // Invert sortKey: higher sortKey draws on top, but the map dispatches smallest first. + u32 invertedSortKey = std::numeric_limits::max() - widget.sortKey; + u64 key = (static_cast(invertedSortKey) << 32) | distanceToMouse; allHoveredEntities[key] = childEntity; } From ace956e2a8a84908a55594d341df04beee9cdfb2 Mon Sep 17 00:00:00 2001 From: Pursche Date: Sun, 10 May 2026 20:51:29 +0200 Subject: [PATCH 4/4] Update Engine submodule Update Engine submodule --- Submodules/Engine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Submodules/Engine b/Submodules/Engine index 644e978..97c1cbe 160000 --- a/Submodules/Engine +++ b/Submodules/Engine @@ -1 +1 @@ -Subproject commit 644e9784925f632dd9764b4cfd29c98f42aa0760 +Subproject commit 97c1cbe84fa2b056ff663226f9b8fb608625dca4