From 8c64ad2eeb6ddb6549452d9e5f190e8c3670485e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 00:24:11 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(ui):=20AcDream.UI.Abstractions=20layer?= =?UTF-8?q?=20=E2=80=94=20IPanel=20/=20IPanelRenderer=20/=20VitalsVM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the backend-agnostic UI contract layer called for by the 2026-04-24 staged UI strategy (docs/plans/2026-04-24-ui-framework.md). This is the stable layer both the Phase D.2a Hexa.NET.ImGui backend and the later D.2b custom retail-look backend implement. New module `src/AcDream.UI.Abstractions/`: * IPanel — a drawable panel (id/title/visible/Render) * IPanelHost — owns the panel list, drives per-frame dispatch * IPanelRenderer — drawing primitives (Begin/End/Text/SameLine/ Separator/ProgressBar). Kept small + retail-friendly on purpose — if a widget can't be expressed with dat-sourced sprites+fonts later, don't add it here. * ICommandBus — user-intent publisher; NullCommandBus is D.2a default * PanelContext — per-frame record struct (DeltaSeconds + Commands) * Panels/Vitals/ VitalsVM — reads CombatState.GetHealthPercent for the local player. Stamina/Mana return null in D.2a; they await a LocalPlayerState cache of PlayerDescription (0x0013) which is filed as a follow-up issue. VitalsPanel — first real panel. HP bar always drawn; Stam/Mana appear automatically when the VM returns non-null. Invariant documented in IPanel's XML doc: no `using Hexa.NET.ImGui` in panel files, ever. If a widget needs something IPanelRenderer can't express, the interface grows; panels never reach through. References AcDream.Core for CombatState. Zero runtime/UI dependencies — this project compiles headless, perfect for unit testing the ViewModels. No visible change yet. Next commits: (2) tests, (3) ImGui backend, (4) GameWindow hookup + visible panel behind ACDREAM_DEVTOOLS=1. --- AcDream.slnx | 1 + .../AcDream.UI.Abstractions.csproj | 12 +++ src/AcDream.UI.Abstractions/ICommandBus.cs | 24 ++++++ src/AcDream.UI.Abstractions/IPanel.cs | 37 +++++++++ src/AcDream.UI.Abstractions/IPanelHost.cs | 36 +++++++++ src/AcDream.UI.Abstractions/IPanelRenderer.cs | 43 +++++++++++ src/AcDream.UI.Abstractions/NullCommandBus.cs | 21 ++++++ src/AcDream.UI.Abstractions/PanelContext.cs | 15 ++++ .../Panels/Vitals/VitalsPanel.cs | 69 +++++++++++++++++ .../Panels/Vitals/VitalsVM.cs | 75 +++++++++++++++++++ 10 files changed, 333 insertions(+) create mode 100644 src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj create mode 100644 src/AcDream.UI.Abstractions/ICommandBus.cs create mode 100644 src/AcDream.UI.Abstractions/IPanel.cs create mode 100644 src/AcDream.UI.Abstractions/IPanelHost.cs create mode 100644 src/AcDream.UI.Abstractions/IPanelRenderer.cs create mode 100644 src/AcDream.UI.Abstractions/NullCommandBus.cs create mode 100644 src/AcDream.UI.Abstractions/PanelContext.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs diff --git a/AcDream.slnx b/AcDream.slnx index e7fd39ac..760229e3 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -6,6 +6,7 @@ + diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj new file mode 100644 index 00000000..0fc070c7 --- /dev/null +++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + latest + true + + + + + diff --git a/src/AcDream.UI.Abstractions/ICommandBus.cs b/src/AcDream.UI.Abstractions/ICommandBus.cs new file mode 100644 index 00000000..71742969 --- /dev/null +++ b/src/AcDream.UI.Abstractions/ICommandBus.cs @@ -0,0 +1,24 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Publishes user-intent commands from panels to the systems that handle +/// them (WorldSession, ChatService, Inventory, ...). Panels never touch +/// those systems directly — they a record +/// and the bus dispatches. +/// +/// +/// D.2a scaffolding: is the default wire-up +/// — commands are accepted but dropped. Real routing lands alongside +/// chat and inventory (Sprint 2 of the UI plan) when we actually need +/// commands flowing server-ward. +/// +/// +public interface ICommandBus +{ + /// + /// Publish a command record. The bus routes by runtime type via + /// registered handlers. Never blocks; handlers run on the publish + /// thread today (render thread for panel-triggered commands). + /// + void Publish(T command) where T : notnull; +} diff --git a/src/AcDream.UI.Abstractions/IPanel.cs b/src/AcDream.UI.Abstractions/IPanel.cs new file mode 100644 index 00000000..5a4a37f7 --- /dev/null +++ b/src/AcDream.UI.Abstractions/IPanel.cs @@ -0,0 +1,37 @@ +namespace AcDream.UI.Abstractions; + +/// +/// A UI panel — chat window, inventory, vitals HUD, character sheet, etc. +/// Panels are backend-agnostic: they only call into +/// primitives, never reach through to a specific UI library (Hexa.NET.ImGui +/// in Phase D.2a, a custom retail-look toolkit in Phase D.2b). +/// +/// +/// Hard rule: no using Hexa.NET.ImGui inside a panel file. If a +/// widget needs a feature the abstraction doesn't expose, extend +/// ; do not import the backend. See +/// docs/plans/2026-04-24-ui-framework.md. +/// +/// +public interface IPanel +{ + /// Stable, globally-unique identifier. Convention: acdream.{name}. + string Id { get; } + + /// Human-readable window title shown in the chrome of the panel. + string Title { get; } + + /// + /// Whether the panel is currently visible. Backends read this per frame; + /// panels may mutate it in response to their own close-button handling. + /// + bool IsVisible { get; set; } + + /// + /// Draw the panel for one frame. Called by + /// on the render thread once ImGui's (or the future custom backend's) + /// frame has begun. Panels issue drawing calls through + /// and publish user-intent actions through .. + /// + void Render(PanelContext ctx, IPanelRenderer renderer); +} diff --git a/src/AcDream.UI.Abstractions/IPanelHost.cs b/src/AcDream.UI.Abstractions/IPanelHost.cs new file mode 100644 index 00000000..bbd685f7 --- /dev/null +++ b/src/AcDream.UI.Abstractions/IPanelHost.cs @@ -0,0 +1,36 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Owns the set of live s and drives per-frame draw +/// dispatch. The backend (Hexa.NET.ImGui in D.2a, custom in D.2b) implements +/// this; GameWindow creates one at startup and registers panels. +/// +/// +/// Does not call ImGui.NewFrame / ImGui.Render — those +/// belong to the caller so GL-state ownership is unambiguous. Caller pattern: +/// +/// +/// +/// // per frame, render thread +/// inputBridge.BeginFrame(size, dt); +/// ImGui.NewFrame(); +/// panelHost.RenderAll(ctx); +/// ImGui.Render(); +/// ImGuiImplOpenGL3.RenderDrawData(ImGui.GetDrawData()); +/// +/// +public interface IPanelHost +{ + /// Register a panel for per-frame rendering. Idempotent by . + void Register(IPanel panel); + + /// Remove the panel with the matching id. No-op if not present. + void Unregister(string panelId); + + /// + /// Iterate every visible panel and call . Call + /// order within a frame is the registration order; panels with + /// set to false are skipped entirely. + /// + void RenderAll(PanelContext ctx); +} diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs new file mode 100644 index 00000000..93e25847 --- /dev/null +++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs @@ -0,0 +1,43 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Drawing primitives exposed to panels. The only API panels use to +/// emit pixels. The ImGui backend maps these straight onto ImGui calls; the +/// later custom retail-look backend will map the same primitives onto its +/// own retained-mode toolkit using retail dat-sourced fonts / sprites. +/// +/// +/// Keep this interface small and retail-friendly. If a widget requires a +/// feature the custom backend couldn't express with dat assets, don't add +/// it — find a different widget shape that both backends can satisfy. +/// +/// +public interface IPanelRenderer +{ + /// + /// Begin a top-level window. Matches retail's root UiPanel + + /// ImGui's Begin. Returns false if the window is collapsed + /// — the caller must still call to balance. + /// + bool Begin(string title); + + /// Close the most recent . + void End(); + + /// Draw a single line of text. No formatting / markdown. + void Text(string text); + + /// Keep the next widget on the same line as the previous one. + void SameLine(); + + /// Horizontal rule separator. + void Separator(); + + /// + /// A filled progress bar. + /// is clamped by the backend to [0, 1]. + /// is the pixel width of the full bar. + /// is optional text (e.g. "54%") rendered on top. + /// + void ProgressBar(float fraction, float width, string? overlay = null); +} diff --git a/src/AcDream.UI.Abstractions/NullCommandBus.cs b/src/AcDream.UI.Abstractions/NullCommandBus.cs new file mode 100644 index 00000000..2c111ea8 --- /dev/null +++ b/src/AcDream.UI.Abstractions/NullCommandBus.cs @@ -0,0 +1,21 @@ +namespace AcDream.UI.Abstractions; + +/// +/// No-op . Accepts any published command and +/// discards it. Used as the default in D.2a until chat / inventory panels +/// need real command routing. +/// +public sealed class NullCommandBus : ICommandBus +{ + /// Shared singleton — the bus is stateless. + public static readonly NullCommandBus Instance = new(); + + private NullCommandBus() { } + + /// + public void Publish(T command) where T : notnull + { + // Intentionally empty. Panel-emitted commands in D.2a are + // read-only diagnostics; nothing routes server-ward yet. + } +} diff --git a/src/AcDream.UI.Abstractions/PanelContext.cs b/src/AcDream.UI.Abstractions/PanelContext.cs new file mode 100644 index 00000000..37caba1d --- /dev/null +++ b/src/AcDream.UI.Abstractions/PanelContext.cs @@ -0,0 +1,15 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Per-frame context passed to each call. +/// Struct + record for zero-allocation per frame. Add fields here as new +/// capabilities become panel-facing — e.g. a future IGameState +/// handle once we need richer data than individual ViewModels can carry. +/// +/// +/// Carried by value; cheap. Passed per-render; do not cache across frames. +/// +/// +public readonly record struct PanelContext( + float DeltaSeconds, + ICommandBus Commands); diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs new file mode 100644 index 00000000..5040c7ea --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs @@ -0,0 +1,69 @@ +namespace AcDream.UI.Abstractions.Panels.Vitals; + +/// +/// First real UI panel — shows the local player's vitals as progress bars. +/// Backend-agnostic; renders exclusively through +/// so the same file works under Hexa.NET.ImGui (D.2a) and the future custom +/// retail-look toolkit (D.2b). +/// +/// +/// D.2a shows only HP (percent). / +/// return null until a +/// LocalPlayerState is wired (follow-up issue). When they start +/// returning non-null, this panel picks them up automatically. +/// +/// +public sealed class VitalsPanel : IPanel +{ + private const float BarWidth = 200f; + + private readonly VitalsVM _vm; + + public VitalsPanel(VitalsVM vm) + { + _vm = vm ?? throw new ArgumentNullException(nameof(vm)); + } + + /// + public string Id => "acdream.vitals"; + + /// + public string Title => "Vitals"; + + /// + public bool IsVisible { get; set; } = true; + + /// + public void Render(PanelContext ctx, IPanelRenderer renderer) + { + if (!renderer.Begin(Title)) + { + renderer.End(); + return; + } + + // HP — always available from CombatState. + float hp = _vm.HealthPercent; + renderer.Text("HP"); + renderer.SameLine(); + renderer.ProgressBar(hp, BarWidth, overlay: $"{hp * 100f:F0}%"); + + // Stamina — show only when the VM has a real value. + if (_vm.StaminaPercent is float stam) + { + renderer.Text("Stam"); + renderer.SameLine(); + renderer.ProgressBar(stam, BarWidth, overlay: $"{stam * 100f:F0}%"); + } + + // Mana — show only when the VM has a real value. + if (_vm.ManaPercent is float mana) + { + renderer.Text("Mana"); + renderer.SameLine(); + renderer.ProgressBar(mana, BarWidth, overlay: $"{mana * 100f:F0}%"); + } + + renderer.End(); + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs new file mode 100644 index 00000000..6d512f29 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs @@ -0,0 +1,75 @@ +using AcDream.Core.Combat; + +namespace AcDream.UI.Abstractions.Panels.Vitals; + +/// +/// ViewModel for the vitals HUD panel. Reads live health percentage for the +/// local player from (which is fed by the server's +/// UpdateHealth (0x01C0) GameEvent). +/// +/// +/// D.2a scope limits: +/// +/// +/// +/// HP comes from and is percent-only +/// (0..1). Absolute current/max HP is not wired yet. +/// Stamina / Mana are always null — those values live in +/// AppraiseInfoParser.CreatureProfile (parsed from +/// PlayerDescription (0x0013)) but the parsed record is +/// currently discarded. Wiring a LocalPlayerState cache is +/// a separate follow-up; see docs/ISSUES.md. +/// +/// +/// +/// GUID timing: the local player's server GUID isn't known at +/// OnLoad (pre-login). Construct with +/// left as 0; GameWindow calls the setter when the live session +/// receives its guid at EnterWorld. Before the GUID is set, +/// returns 1.0 (via CombatState's safe +/// default for unknown guids) — the bar reads "full", which is harmless. +/// +/// +public sealed class VitalsVM +{ + private readonly CombatState _combat; + private uint _localPlayerGuid; + + /// + /// Build a VitalsVM bound to a instance. The + /// GUID starts at 0; call once the + /// live session assigns it. + /// + public VitalsVM(CombatState combat) + { + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _localPlayerGuid = 0; + } + + /// + /// Push the authoritative local-player GUID from WorldSession. + /// One-way setter — only GameWindow should call it, exactly once + /// per live session. + /// + public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; + + /// + /// Current health percent (0..1) for the local player. Returns 1.0 + /// before login or if the server has never sent an UpdateHealth for + /// this GUID. + /// + public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid); + + /// + /// Stamina percent (0..1) or null when absolute values aren't wired. + /// D.2a always returns null; to be populated by a future + /// LocalPlayerState that caches PlayerDescription (0x0013). + /// + public float? StaminaPercent => null; + + /// + /// Mana percent (0..1) or null when absolute values aren't wired. + /// Same status as . + /// + public float? ManaPercent => null; +} From fc03fa377b62246faa0f5a51d5230c656a59e858 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 00:25:26 +0200 Subject: [PATCH 2/5] test(ui): AcDream.UI.Abstractions unit tests (11 tests green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the pure-logic surface of commit 1: VitalsVMTests * HealthPercent reads from CombatState.GetHealthPercent * safe-default 1.0 when GUID unknown / not yet set / never updated * SetLocalPlayerGuid reroutes lookup to the new guid (no stale cache) * StaminaPercent / ManaPercent are null for D.2a scope * ctor throws ArgumentNullException on null CombatState PanelContextTests * record-struct fields round-trip * value-based equality NullCommandBusTests * Publish accepts any record type without throwing * Instance is a true singleton csproj template mirrors AcDream.Core.Tests (xUnit 2.9.3, Test.Sdk 17.14.1, runner 3.1.4, coverlet 6.0.4, implicit Using Xunit). References only AcDream.UI.Abstractions — no runtime / GL dependency, tests run fast. --- AcDream.slnx | 1 + .../AcDream.UI.Abstractions.Tests.csproj | 25 ++++++ .../NullCommandBusTests.cs | 22 +++++ .../PanelContextTests.cs | 24 ++++++ .../VitalsVMTests.cs | 80 +++++++++++++++++++ 5 files changed, 152 insertions(+) create mode 100644 tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj create mode 100644 tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs diff --git a/AcDream.slnx b/AcDream.slnx index 760229e3..5a5029dd 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -15,5 +15,6 @@ + diff --git a/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj b/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj new file mode 100644 index 00000000..f99c6e22 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs b/tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs new file mode 100644 index 00000000..6f34d5ea --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs @@ -0,0 +1,22 @@ +namespace AcDream.UI.Abstractions.Tests; + +public sealed class NullCommandBusTests +{ + private sealed record FakeCmd(int Value); + + [Fact] + public void Publish_DoesNotThrow_OnAnyRecordType() + { + var bus = NullCommandBus.Instance; + + bus.Publish(new FakeCmd(42)); + bus.Publish("a string command"); + bus.Publish(12345); + } + + [Fact] + public void Instance_IsSingleton() + { + Assert.Same(NullCommandBus.Instance, NullCommandBus.Instance); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs b/tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs new file mode 100644 index 00000000..2a665c93 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs @@ -0,0 +1,24 @@ +namespace AcDream.UI.Abstractions.Tests; + +public sealed class PanelContextTests +{ + [Fact] + public void Fields_RoundTripThroughConstructor() + { + var ctx = new PanelContext(DeltaSeconds: 0.016f, Commands: NullCommandBus.Instance); + + Assert.Equal(0.016f, ctx.DeltaSeconds); + Assert.Same(NullCommandBus.Instance, ctx.Commands); + } + + [Fact] + public void RecordEquality_ByValue() + { + var a = new PanelContext(1f / 60f, NullCommandBus.Instance); + var b = new PanelContext(1f / 60f, NullCommandBus.Instance); + + // Record-struct equality is value-based on DeltaSeconds + reference-based + // on Commands (since ICommandBus is a reference type, same instance → equal). + Assert.Equal(a, b); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs new file mode 100644 index 00000000..3abf2c35 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs @@ -0,0 +1,80 @@ +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Panels.Vitals; + +namespace AcDream.UI.Abstractions.Tests; + +public sealed class VitalsVMTests +{ + [Fact] + public void HealthPercent_ReturnsCombatStateValue_AfterUpdateHealth() + { + var combat = new CombatState(); + uint guid = 0x5000_0042u; + combat.OnUpdateHealth(guid, 0.42f); + + var vm = new VitalsVM(combat); + vm.SetLocalPlayerGuid(guid); + + Assert.Equal(0.42f, vm.HealthPercent, precision: 3); + } + + [Fact] + public void HealthPercent_ReturnsOne_WhenGuidUnknown() + { + var combat = new CombatState(); + var vm = new VitalsVM(combat); + + // No SetLocalPlayerGuid call — defaults to 0 which CombatState has never seen. + Assert.Equal(1f, vm.HealthPercent); + } + + [Fact] + public void HealthPercent_ReturnsOne_WhenGuidSetButNeverUpdated() + { + var combat = new CombatState(); + var vm = new VitalsVM(combat); + vm.SetLocalPlayerGuid(0xDEAD_BEEFu); + + Assert.Equal(1f, vm.HealthPercent); + } + + [Fact] + public void StaminaPercent_IsNull_ForD2aScope() + { + // D.2a explicitly defers Stamina until LocalPlayerState + PlayerDescription + // wiring. When that arrives VitalsVM.StaminaPercent becomes non-null and + // VitalsPanel starts drawing the Stam bar automatically. + var vm = new VitalsVM(new CombatState()); + Assert.Null(vm.StaminaPercent); + } + + [Fact] + public void ManaPercent_IsNull_ForD2aScope() + { + var vm = new VitalsVM(new CombatState()); + Assert.Null(vm.ManaPercent); + } + + [Fact] + public void SetLocalPlayerGuid_ReroutesHealthLookup_WithoutStaleCache() + { + // Simulate the realistic GameWindow flow: VM is constructed pre-login + // with GUID=0, then SetLocalPlayerGuid is called at EnterWorld. + var combat = new CombatState(); + uint playerGuid = 0x5003_E219u; + combat.OnUpdateHealth(playerGuid, 0.75f); + + var vm = new VitalsVM(combat); + // Before SetLocalPlayerGuid — reads GUID=0 → returns safe 1.0. + Assert.Equal(1f, vm.HealthPercent); + + vm.SetLocalPlayerGuid(playerGuid); + Assert.Equal(0.75f, vm.HealthPercent, precision: 3); + } + + [Fact] + public void Constructor_ThrowsOnNullCombat() + { + Assert.Throws(() => new VitalsVM(null!)); + } +} From a7dbce3474f8857e67c6a15489b9e581b6e089ef Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 00:29:09 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(ui):=20AcDream.UI.ImGui=20backend=20?= =?UTF-8?q?=E2=80=94=20Hexa.NET.ImGui=20+=20Silk.NET=20input=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second piece of Phase D.2a: the ImGui-specific backend that implements AcDream.UI.Abstractions' IPanelRenderer / IPanelHost. No GameWindow hookup yet — compiles standalone for clean review before integration. Packages: * Hexa.NET.ImGui 2.2.9 (auto-generated from cimgui 1.92.2b) * Hexa.NET.ImGui.Backends 1.0.18 (consolidated — OpenGL3 is here) * Silk.NET.Input 2.23.0 + Silk.NET.OpenGL 2.23.0 (matches AcDream.App) Files: ImGuiBootstrapper.cs One-shot static Initialize(glslVersion) / Shutdown() pair. Creates the ImGui context, applies dark style, enables NavEnableKeyboard, and boots ImGuiImplOpenGL3. Re-init is a no-op. SilkInputBridge.cs Event-driven Silk.NET -> ImGui IO bridge. Subscribes on construction; Dispose() unsubscribes. Covers: - KeyDown/Up -> ImGui.AddKeyEvent with modifier latching (Ctrl/Shift/Alt/Super routed via both ModXxx flags AND named key events so both IsKeyPressed checks and ImGui shortcut matching work) - KeyChar -> AddInputCharacter for text fields - MouseMove -> AddMousePosEvent - MouseDown/Up -> AddMouseButtonEvent (L=0, R=1, M=2) - Scroll -> AddMouseWheelEvent (both axes) Silk.NET.Input.Key -> ImGuiKey map covers WASD, arrows, modifiers, letters, digits, function keys. Unmapped keys silently ignored. BeginFrame(displaySize, dt) sets IO.DisplaySize + IO.DeltaTime. ImGuiPanelRenderer.cs IPanelRenderer impl — one-line wrappers on ImGui.Begin/End, TextUnformatted, SameLine, Separator, ProgressBar. The ONLY place Hexa.NET.ImGui types appear outside bootstrap/input plumbing. Panels still never import ImGui. ImGuiPanelHost.cs IPanelHost impl. Dictionary keyed by IPanel.Id for idempotent Register. RenderAll iterates visible panels and calls their Render. Does NOT call ImGui.NewFrame / ImGui.Render — ownership belongs to the caller (GameWindow) so GL state is explicit. Diagnostic `Count` property. No behavior change yet; next commit wires this into GameWindow behind ACDREAM_DEVTOOLS=1 and ships the first visible VitalsPanel. --- AcDream.slnx | 1 + src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj | 23 +++ src/AcDream.UI.ImGui/ImGuiBootstrapper.cs | 62 ++++++ src/AcDream.UI.ImGui/ImGuiPanelHost.cs | 46 +++++ src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 41 ++++ src/AcDream.UI.ImGui/SilkInputBridge.cs | 188 +++++++++++++++++++ 6 files changed, 361 insertions(+) create mode 100644 src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj create mode 100644 src/AcDream.UI.ImGui/ImGuiBootstrapper.cs create mode 100644 src/AcDream.UI.ImGui/ImGuiPanelHost.cs create mode 100644 src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs create mode 100644 src/AcDream.UI.ImGui/SilkInputBridge.cs diff --git a/AcDream.slnx b/AcDream.slnx index 5a5029dd..1cf8f243 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -7,6 +7,7 @@ + diff --git a/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj b/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj new file mode 100644 index 00000000..65853fa4 --- /dev/null +++ b/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + latest + true + + + + + + + + + + + + + diff --git a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs new file mode 100644 index 00000000..70ba6891 --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs @@ -0,0 +1,62 @@ +using Hexa.NET.ImGui; +using Hexa.NET.ImGui.Backends.OpenGL3; + +namespace AcDream.UI.ImGui; + +/// +/// One-shot ImGui setup / teardown for the devtools overlay. Called from +/// GameWindow when ACDREAM_DEVTOOLS=1. Hides the cimgui +/// context + OpenGL3 renderer-impl lifecycles behind two static methods +/// so the calling code stays clean. +/// +/// +/// Intentionally not an IDisposable singleton — the host +/// window owns the one call to at application +/// exit. Re-initialisation mid-session is not supported. +/// +/// +public static class ImGuiBootstrapper +{ + private static bool _initialized; + + /// + /// Create an ImGui context, apply the dark style + enable keyboard + /// navigation, and bootstrap the OpenGL3 renderer backend. The GL + /// context owned by Silk.NET must be current on the calling thread. + /// + /// + /// GLSL version directive for the ImGui-internal shader. + /// "#version 330" matches acdream's existing shaders and is + /// the safest default for the OpenGL 4.3 core profile we ship. + /// + public static void Initialize(string glslVersion = "#version 330") + { + if (_initialized) return; + + Hexa.NET.ImGui.ImGui.CreateContext(); + Hexa.NET.ImGui.ImGui.StyleColorsDark(); + + var io = Hexa.NET.ImGui.ImGui.GetIO(); + io.ConfigFlags |= ImGuiConfigFlags.NavEnableKeyboard; + // DO NOT enable NavEnableGamepad — we don't wire a gamepad backend. + // DO NOT enable DockingEnable / ViewportsEnable — out of scope for D.2a. + + ImGuiImplOpenGL3.Init(glslVersion); + + _initialized = true; + } + + /// Tear down the OpenGL3 renderer + destroy the ImGui context. + public static void Shutdown() + { + if (!_initialized) return; + + ImGuiImplOpenGL3.Shutdown(); + Hexa.NET.ImGui.ImGui.DestroyContext(); + + _initialized = false; + } + + /// True after has run successfully. + public static bool IsInitialized => _initialized; +} diff --git a/src/AcDream.UI.ImGui/ImGuiPanelHost.cs b/src/AcDream.UI.ImGui/ImGuiPanelHost.cs new file mode 100644 index 00000000..d9a3a4c1 --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiPanelHost.cs @@ -0,0 +1,46 @@ +using AcDream.UI.Abstractions; + +namespace AcDream.UI.ImGui; + +/// +/// implementation for the ImGui backend. Owns the +/// registered panel set; iterates + draws every frame when the caller is +/// inside an ImGui frame (between ImGui.NewFrame and +/// ImGui.Render). +/// +/// +/// This class does not call ImGui.NewFrame / ImGui.Render +/// itself. Those belong to the caller (GameWindow) so GL-state +/// ownership is explicit and the render-loop integration point is obvious. +/// +/// +public sealed class ImGuiPanelHost : IPanelHost +{ + private readonly Dictionary _panels = new(); + private readonly ImGuiPanelRenderer _renderer = new(); + + /// + public void Register(IPanel panel) + { + ArgumentNullException.ThrowIfNull(panel); + _panels[panel.Id] = panel; // idempotent by Id + } + + /// + public void Unregister(string panelId) => _panels.Remove(panelId); + + /// + public void RenderAll(PanelContext ctx) + { + // Order-independent — ImGui windows stack in the order they're drawn + // for focus purposes but we have <=1 panel in D.2a. + foreach (var panel in _panels.Values) + { + if (!panel.IsVisible) continue; + panel.Render(ctx, _renderer); + } + } + + /// Current registered count (for diagnostics). + public int Count => _panels.Count; +} diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs new file mode 100644 index 00000000..17e463d6 --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -0,0 +1,41 @@ +using System.Numerics; +using AcDream.UI.Abstractions; + +namespace AcDream.UI.ImGui; + +/// +/// implemented as thin wrappers around +/// Hexa.NET.ImGui calls. This is the ONLY place where Hexa.NET.ImGui +/// types appear outside of bootstrap / input-bridge plumbing — panels +/// that need a feature must extend the abstraction here, not by importing +/// ImGui in panel files. +/// +public sealed class ImGuiPanelRenderer : IPanelRenderer +{ + /// + public bool Begin(string title) => Hexa.NET.ImGui.ImGui.Begin(title); + + /// + public void End() => Hexa.NET.ImGui.ImGui.End(); + + /// + public void Text(string text) => Hexa.NET.ImGui.ImGui.TextUnformatted(text); + + /// + public void SameLine() => Hexa.NET.ImGui.ImGui.SameLine(); + + /// + public void Separator() => Hexa.NET.ImGui.ImGui.Separator(); + + /// + public void ProgressBar(float fraction, float width, string? overlay = null) + { + // Clamp defensively; ImGui clamps internally but the abstraction + // contract promises to handle out-of-range values. + if (fraction < 0f) fraction = 0f; + else if (fraction > 1f) fraction = 1f; + + var size = new Vector2(width, 0f); // height 0 → ImGui picks based on font + Hexa.NET.ImGui.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty); + } +} diff --git a/src/AcDream.UI.ImGui/SilkInputBridge.cs b/src/AcDream.UI.ImGui/SilkInputBridge.cs new file mode 100644 index 00000000..6c47f282 --- /dev/null +++ b/src/AcDream.UI.ImGui/SilkInputBridge.cs @@ -0,0 +1,188 @@ +using System.Numerics; +using Hexa.NET.ImGui; +using Silk.NET.Input; + +namespace AcDream.UI.ImGui; + +/// +/// Forwards Silk.NET keyboard / mouse events to ImGui's IO. Replaces what +/// you'd get from the stock GLFW or SDL backends in a non-Silk.NET host. +/// +/// +/// Event-driven (we subscribe to Silk.NET events); does not poll. Each +/// handler writes directly to ImGui.GetIO() via the AddXxx +/// family of calls. Frame-start book-keeping (display size, delta time, +/// active modifier latch) happens in . +/// +/// +/// +/// Call at app shutdown to unsubscribe from Silk.NET +/// events. +/// +/// +public sealed class SilkInputBridge : IDisposable +{ + private readonly IInputContext _input; + private readonly IKeyboard? _keyboard; + private readonly IMouse? _mouse; + + public SilkInputBridge(IInputContext input) + { + _input = input ?? throw new ArgumentNullException(nameof(input)); + + _keyboard = input.Keyboards.Count > 0 ? input.Keyboards[0] : null; + _mouse = input.Mice.Count > 0 ? input.Mice[0] : null; + + if (_keyboard is not null) + { + _keyboard.KeyDown += OnKeyDown; + _keyboard.KeyUp += OnKeyUp; + _keyboard.KeyChar += OnKeyChar; + } + + if (_mouse is not null) + { + _mouse.MouseMove += OnMouseMove; + _mouse.MouseDown += OnMouseDown; + _mouse.MouseUp += OnMouseUp; + _mouse.Scroll += OnScroll; + } + } + + /// + /// Per-frame bookkeeping. Call right before ImGui.NewFrame(). + /// Sets display size (in logical pixels) and delta-time on ImGui's IO. + /// + public void BeginFrame(Vector2 displaySize, float deltaSeconds) + { + var io = Hexa.NET.ImGui.ImGui.GetIO(); + io.DisplaySize = displaySize; + io.DeltaTime = deltaSeconds > 0f ? deltaSeconds : 1f / 60f; + } + + // ─── event handlers ────────────────────────────────────────────── + + private void OnKeyDown(IKeyboard kb, Key key, int scancode) => AddKey(key, down: true); + private void OnKeyUp (IKeyboard kb, Key key, int scancode) => AddKey(key, down: false); + + private void OnKeyChar(IKeyboard kb, char c) + { + // Feeds typed text into any focused ImGui TextField. Safe to call + // even when no TextField has focus — ImGui buffers the character + // and discards it if nothing claims it. + Hexa.NET.ImGui.ImGui.GetIO().AddInputCharacter(c); + } + + private void OnMouseMove(IMouse m, Vector2 pos) + { + Hexa.NET.ImGui.ImGui.GetIO().AddMousePosEvent(pos.X, pos.Y); + } + + private void OnMouseDown(IMouse m, MouseButton button) => AddMouseButton(button, down: true); + private void OnMouseUp (IMouse m, MouseButton button) => AddMouseButton(button, down: false); + + private void OnScroll(IMouse m, ScrollWheel wheel) + { + Hexa.NET.ImGui.ImGui.GetIO().AddMouseWheelEvent(wheel.X, wheel.Y); + } + + // ─── helpers ───────────────────────────────────────────────────── + + private static void AddKey(Key key, bool down) + { + // Update modifier latches first (ImGui reads these when any AddKeyEvent fires). + var io = Hexa.NET.ImGui.ImGui.GetIO(); + if (key is Key.ControlLeft or Key.ControlRight) io.AddKeyEvent(ImGuiKey.ModCtrl, down); + if (key is Key.ShiftLeft or Key.ShiftRight) io.AddKeyEvent(ImGuiKey.ModShift, down); + if (key is Key.AltLeft or Key.AltRight) io.AddKeyEvent(ImGuiKey.ModAlt, down); + if (key is Key.SuperLeft or Key.SuperRight) io.AddKeyEvent(ImGuiKey.ModSuper, down); + + if (KeyMap.TryGetValue(key, out var imguiKey)) + io.AddKeyEvent(imguiKey, down); + // Unmapped keys are silently ignored — fine for D.2a; panels that + // need exotic keys can extend the map. + } + + private static void AddMouseButton(MouseButton button, bool down) + { + int idx = button switch + { + MouseButton.Left => 0, + MouseButton.Right => 1, + MouseButton.Middle => 2, + _ => -1, + }; + if (idx < 0) return; + Hexa.NET.ImGui.ImGui.GetIO().AddMouseButtonEvent(idx, down); + } + + /// + /// Silk.NET → ImGui key map. Covers text-input + navigation keys + + /// WASD + function keys. Unlisted keys fall through to no-op. + /// + private static readonly Dictionary KeyMap = new() + { + // Navigation + control + [Key.Tab] = ImGuiKey.Tab, + [Key.Left] = ImGuiKey.LeftArrow, + [Key.Right] = ImGuiKey.RightArrow, + [Key.Up] = ImGuiKey.UpArrow, + [Key.Down] = ImGuiKey.DownArrow, + [Key.PageUp] = ImGuiKey.PageUp, + [Key.PageDown] = ImGuiKey.PageDown, + [Key.Home] = ImGuiKey.Home, + [Key.End] = ImGuiKey.End, + [Key.Insert] = ImGuiKey.Insert, + [Key.Delete] = ImGuiKey.Delete, + [Key.Backspace] = ImGuiKey.Backspace, + [Key.Space] = ImGuiKey.Space, + [Key.Enter] = ImGuiKey.Enter, + [Key.Escape] = ImGuiKey.Escape, + + // Modifiers (also add via the mod-flag path, but these let ImGui + // see them as named keys too). + [Key.ControlLeft] = ImGuiKey.LeftCtrl, + [Key.ControlRight] = ImGuiKey.RightCtrl, + [Key.ShiftLeft] = ImGuiKey.LeftShift, + [Key.ShiftRight] = ImGuiKey.RightShift, + [Key.AltLeft] = ImGuiKey.LeftAlt, + [Key.AltRight] = ImGuiKey.RightAlt, + + // Letters (Silk.NET.Key.A..Z map 1:1 to ImGuiKey.A..Z). + [Key.A] = ImGuiKey.A, [Key.B] = ImGuiKey.B, [Key.C] = ImGuiKey.C, [Key.D] = ImGuiKey.D, + [Key.E] = ImGuiKey.E, [Key.F] = ImGuiKey.F, [Key.G] = ImGuiKey.G, [Key.H] = ImGuiKey.H, + [Key.I] = ImGuiKey.I, [Key.J] = ImGuiKey.J, [Key.K] = ImGuiKey.K, [Key.L] = ImGuiKey.L, + [Key.M] = ImGuiKey.M, [Key.N] = ImGuiKey.N, [Key.O] = ImGuiKey.O, [Key.P] = ImGuiKey.P, + [Key.Q] = ImGuiKey.Q, [Key.R] = ImGuiKey.R, [Key.S] = ImGuiKey.S, [Key.T] = ImGuiKey.T, + [Key.U] = ImGuiKey.U, [Key.V] = ImGuiKey.V, [Key.W] = ImGuiKey.W, [Key.X] = ImGuiKey.X, + [Key.Y] = ImGuiKey.Y, [Key.Z] = ImGuiKey.Z, + + // Digit row + [Key.Number0] = ImGuiKey.Key0, [Key.Number1] = ImGuiKey.Key1, [Key.Number2] = ImGuiKey.Key2, + [Key.Number3] = ImGuiKey.Key3, [Key.Number4] = ImGuiKey.Key4, [Key.Number5] = ImGuiKey.Key5, + [Key.Number6] = ImGuiKey.Key6, [Key.Number7] = ImGuiKey.Key7, [Key.Number8] = ImGuiKey.Key8, + [Key.Number9] = ImGuiKey.Key9, + + // Function keys + [Key.F1] = ImGuiKey.F1, [Key.F2] = ImGuiKey.F2, [Key.F3] = ImGuiKey.F3, [Key.F4] = ImGuiKey.F4, + [Key.F5] = ImGuiKey.F5, [Key.F6] = ImGuiKey.F6, [Key.F7] = ImGuiKey.F7, [Key.F8] = ImGuiKey.F8, + [Key.F9] = ImGuiKey.F9, [Key.F10] = ImGuiKey.F10, [Key.F11] = ImGuiKey.F11, [Key.F12] = ImGuiKey.F12, + }; + + public void Dispose() + { + if (_keyboard is not null) + { + _keyboard.KeyDown -= OnKeyDown; + _keyboard.KeyUp -= OnKeyUp; + _keyboard.KeyChar -= OnKeyChar; + } + if (_mouse is not null) + { + _mouse.MouseMove -= OnMouseMove; + _mouse.MouseDown -= OnMouseDown; + _mouse.MouseUp -= OnMouseUp; + _mouse.Scroll -= OnScroll; + } + } +} From 55aaca7a145e27e2dc0bf2034b6f87b1afd34921 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 00:43:46 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat(ui):=20Phase=20D.2a=20=E2=80=94=20Vita?= =?UTF-8?q?lsPanel=20wired=20into=20GameWindow=20+=20backend=20pivot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Phase D.2a. Launch with ACDREAM_DEVTOOLS=1 now shows a live ImGui "Vitals" window whose HP bar reads CombatState.GetHealthPercent for the local player. Without the env var the branches are dead code, no ImGui context is created, and behaviour is identical to before. GameWindow hunks: - fields: _imguiBootstrap / _panelHost / _vitalsVm + DevToolsEnabled - init (OnLoad): construct bootstrap + host, register VitalsPanel - GUID push: _vitalsVm?.SetLocalPlayerGuid(chosen.Id) at live-connect - frame begin: _imguiBootstrap.BeginFrame(dt) after GL clear - frame end: _panelHost.RenderAll(ctx) + _imguiBootstrap.Render() after debug overlay - input gating: skip WASD when ImGui.GetIO().WantCaptureKeyboard Backend pivot: Hexa.NET.ImGui → ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui. First-light integration with the Hexa backend crashed 0xC0000005 inside Hexa.NET.ImGui.Backends.OpenGL3.ImGuiImplOpenGL3.InitNative. Root cause: Hexa's native OpenGL3 backend resolves GL function pointers via GLFW or SDL internally; with Silk.NET (which uses neither) the pointers are null and the native code crashes on first use. The mitigation path was already planned — the design doc's Risk section called a pivot to ImGui.NET a "one-morning operation" — and that's exactly what happened. - Packages: Hexa.NET.ImGui 2.2.9 + Hexa.NET.ImGui.Backends 1.0.18 → ImGui.NET 1.91.6.1 + Silk.NET.OpenGL.Extensions.ImGui 2.23.0 - ImGuiBootstrapper: was static Initialize(gl)+Shutdown() wrapping Hexa's OpenGL3 init; now an IDisposable wrapping Silk.NET's ImGuiController instance which handles GL backend init + input subscription in one go. - SilkInputBridge.cs deleted (~190 LOC): ImGuiController subscribes IKeyboard / IMouse events itself, we don't need a bespoke bridge. - ImGuiPanelRenderer: ImGuiNET.ImGui.* calls instead of Hexa.NET.ImGui.ImGui.*. Widget surface unchanged. Boundary discipline is preserved — no panel imports ImGuiNET; only ImGuiPanelRenderer does. The D.2b custom toolkit will implement the same IPanelRenderer contract without touching panel code. Out of scope (tracked for follow-up): - Stam/Mana currently return float? null (VitalsVM). Absolute values need LocalPlayerState + PlayerDescription (0x0013) parsing to be stored rather than discarded — filed as a post-D.2a issue. - Mouse-capture gating (WorldMouseFallThrough-style click-through tests) — not needed until we add clickable inventory items. Roadmap + memory + architecture doc + UI framework plan updated in the same commit per CLAUDE.md roadmap-discipline rules. 753 tests pass (550 Core + 192 Core.Net + 11 new UI.Abstractions), 0 build warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + CLAUDE.md | 15 +- docs/architecture/acdream-architecture.md | 3 +- docs/plans/2026-04-11-roadmap.md | 9 +- docs/plans/2026-04-24-ui-framework.md | 42 +++- .../research/retail-ui/00-master-synthesis.md | 12 +- memory/project_ui_architecture.md | 17 +- src/AcDream.App/AcDream.App.csproj | 2 + src/AcDream.App/Rendering/GameWindow.cs | 65 ++++++ src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj | 19 +- src/AcDream.UI.ImGui/ImGuiBootstrapper.cs | 96 ++++----- src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs | 21 +- src/AcDream.UI.ImGui/SilkInputBridge.cs | 188 ------------------ 13 files changed, 218 insertions(+), 275 deletions(-) delete mode 100644 src/AcDream.UI.ImGui/SilkInputBridge.cs diff --git a/.gitignore b/.gitignore index 1731f2eb..904fdf9c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ references/ # Claude Code session state .claude/ launch.log +launch-*.log + +# ImGui auto-saved window/docking state (per-user, not source) +imgui.ini diff --git a/CLAUDE.md b/CLAUDE.md index 84c1d809..96449ed3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,13 +25,14 @@ The codebase is organized by layer (see architecture doc). Current phase state lives in memory (`memory/project_*.md`), plans in `docs/plans/`, research in `docs/research/`. -**UI strategy:** three-layer split — swappable backend (Hexa.NET.ImGui for -Phase D.2a short-term, custom retail-look toolkit for D.2b later) / -stable `AcDream.UI.Abstractions` layer (ViewModels + Commands + `IPanel` -/ `IPanelRenderer`) / unchanged game state. **All plugin-facing UI -targets `AcDream.UI.Abstractions` — never import a backend namespace -from a panel.** Full design: `docs/plans/2026-04-24-ui-framework.md`. -Memory crib: `memory/project_ui_architecture.md`. +**UI strategy:** three-layer split — swappable backend (ImGui.NET + +`Silk.NET.OpenGL.Extensions.ImGui` for Phase D.2a short-term, custom +retail-look toolkit for D.2b later) / stable `AcDream.UI.Abstractions` +layer (ViewModels + Commands + `IPanel` / `IPanelRenderer`) / unchanged +game state. **All plugin-facing UI targets `AcDream.UI.Abstractions` — +never import a backend namespace from a panel.** Full design: +`docs/plans/2026-04-24-ui-framework.md`. Memory crib: +`memory/project_ui_architecture.md`. ## How to operate diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index e5f7f856..80536a17 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -74,7 +74,8 @@ designed 2026-04-24. Full design: `docs/plans/2026-04-24-ui-framework.md`. ``` ┌─────────────────────────────────────────────────────────────┐ │ UI BACKEND (swappable) │ -│ Hexa.NET.ImGui (Phase D.2a, short-term) │ +│ ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui │ +│ (Phase D.2a, short-term) │ │ or custom retail-look toolkit (Phase D.2b, later) │ ├─────────────────────────────────────────────────────────────┤ │ AcDream.UI.Abstractions (stable contract) │ diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 15595904..aabe5685 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -46,6 +46,7 @@ | G.1+ | Full sky visuals + weather + dynamic-light shader — SkyDescLoader parses Region 0x13000000 dat keyframes with retail fog fields (start/end/mode); WeatherSystem picks Clear/Overcast/Rain/Snow/Storm deterministically per in-game day with 10s fade; SkyRenderer draws far-plane-1e6 celestial meshes with UV scroll; SceneLightingUbo binds at std140 location=1 with 8 Light slots + fog + lightning flash; terrain.vert + mesh.frag + mesh_instanced.frag + sky.frag all consume the shared UBO; LightingHookSink auto-registers Setup.Lights per entity + flips IsLit on SetLightHook; ParticleRenderer renders rain/snow billboards; F7 cycles day time override, F10 cycles weather; WorldSession surfaces server time via ServerTimeUpdated (ConnectRequest + TimeSync flag) | Tests ✓ | | H.1 | Chat wire layer — Talk (0x0015) / Tell (0x005D) / ChatChannel (0x0147) outbound, HearSpeech (0x02BB local + 0x02BC ranged) inbound, ChatLog ring buffer with adapters for every chat source | Tests ✓ | | Glue | GameEventWiring.WireAll — single-call registration mapping parsed GameEvents → Core state classes (ChatLog, CombatState, Spellbook, ItemRepository); GameWindow exposes state classes + wires them to live session | Tests ✓ | +| D.2a | UI scaffold — `AcDream.UI.Abstractions` stable contract (`IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + `VitalsVM` / `VitalsPanel`); `AcDream.UI.ImGui` backend on ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` (pivoted from Hexa.NET.ImGui on 2026-04-25 — Hexa's native OpenGL3 backend resolves GL via GLFW/SDL and crashed 0xC0000005 without them); VitalsPanel wired into GameWindow behind `ACDREAM_DEVTOOLS=1` with `ImGui.WantCaptureKeyboard` WASD suppression. 11 new tests. | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -126,9 +127,11 @@ Plus polish that doesn't get its own phase number: > [`docs/plans/2026-04-24-ui-framework.md`](2026-04-24-ui-framework.md) > for the full design. Short version: > -> 1. **D.2a — Hexa.NET.ImGui as the short-term backend.** Wire up in days, +> 1. **D.2a — ImGui as the short-term backend.** Wire up in days, > iterate game logic (chat-send, inventory actions, vitals HUD reading -> real state) in weeks. Looks like a debugger; that's fine. +> real state) in weeks. Looks like a debugger; that's fine. *(Shipped +> 2026-04-25 on ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` after a +> day-one pivot away from Hexa.NET.ImGui; see shipped table.)* > 2. **Stable `AcDream.UI.Abstractions` layer** — ViewModels + Commands + > `IPanel` / `IPanelRenderer` interfaces. Backend-agnostic. Plugin API > publishes against this layer and never sees ImGui. @@ -142,7 +145,7 @@ Plus polish that doesn't get its own phase number: **Sub-pieces:** - **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`). -- **D.2a — Hexa.NET.ImGui scaffold + `AcDream.UI.Abstractions` layer.** NEW pre-piece introduced 2026-04-24. Wires Hexa.NET.ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModels (`VitalsVM` etc.) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP/stam/mana from `IGameState`. This is what gets game-logic iteration moving; looks like a debugger, acceptable. +- **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. - **D.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`. - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** - **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art. **(D.2b dependency.)** diff --git a/docs/plans/2026-04-24-ui-framework.md b/docs/plans/2026-04-24-ui-framework.md index 85d8a41c..e6fce264 100644 --- a/docs/plans/2026-04-24-ui-framework.md +++ b/docs/plans/2026-04-24-ui-framework.md @@ -1,13 +1,51 @@ # UI framework plan -**Date:** 2026-04-24 -**Status:** design — not yet implemented +**Date:** 2026-04-24 (design), shipped 2026-04-25 +**Status:** **Phase D.2a shipped** — `AcDream.UI.Abstractions` + ImGui backend ++ `VitalsPanel` gated on `ACDREAM_DEVTOOLS=1`. Backend pivoted from +`Hexa.NET.ImGui` to `ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui` during +first-light integration — see the pivot note below. Phase D.2b (custom +retail-look backend) remains design-only. **Owner:** lead engineer (erik) + Claude Captures the UI strategy agreed via discussion on 2026-04-24. Documents the choices AND the alternatives considered so future sessions can re-evaluate with the same context. +## 2026-04-25 pivot: Hexa.NET.ImGui → ImGui.NET + +The original choice (documented below) was `Hexa.NET.ImGui` + +`Hexa.NET.ImGui.Backends.OpenGL3`. It did not survive first-light +integration: + +- First launch with Hexa's backend crashed with `0xC0000005` inside + `Hexa.NET.ImGui.Backends.OpenGL3.ImGuiImplOpenGL3.InitNative`. +- Root cause: Hexa's native OpenGL3 backend does its own GL function + resolution, looking up symbols via GLFW or SDL. Silk.NET uses neither, + so the resolved function pointers were null and the native code + dereferenced them on init. +- Hexa's Silk.NET examples rely on GLFW being co-loaded (its default + on Hexa's own scenes) — not applicable here. + +**Mitigation path** was already written into this doc (§"What we give +up": *"switching to ImGui.NET later is a one-morning operation if Hexa +misbehaves"*) and taken: + +- Packages swapped → `ImGui.NET 1.91.6.1` + `Silk.NET.OpenGL.Extensions.ImGui 2.23.0`. +- `Silk.NET.OpenGL.Extensions.ImGui.ImGuiController` handles the whole + integration (GL backend init against the Silk.NET GL binding, + keyboard + mouse IO event subscription). No hand-written input + bridge needed. +- `ImGuiBootstrapper` is a ~10-line `IDisposable` wrapping the + `ImGuiController` instance. `ImGuiPanelRenderer` wraps `ImGuiNET.ImGui.*`. +- Boundary discipline preserved — panels never import `ImGuiNET` + directly; they only use `IPanelRenderer`. The backend swap is + invisible above the abstraction layer, as designed. + +Sections below from §"Choice: Hexa.NET.ImGui" onward are kept as the +historical design reasoning. They remain useful if we ever re-evaluate +native AOT / upstream-tracking tradeoffs. + ## Goal acdream needs a playable game UI: chat, vitals HUD, inventory, character diff --git a/docs/research/retail-ui/00-master-synthesis.md b/docs/research/retail-ui/00-master-synthesis.md index 7882a9cb..0dbbf771 100644 --- a/docs/research/retail-ui/00-master-synthesis.md +++ b/docs/research/retail-ui/00-master-synthesis.md @@ -1,10 +1,12 @@ # Retail AC Client GUI — Master Synthesis -> **Scope note (2026-04-24):** This document describes retail's Keystone -> UI toolkit — it is the research foundation for **Phase D.2b (custom -> retail-look backend)**, not **Phase D.2a (Hexa.NET.ImGui scaffold)**. -> When reading this for implementation guidance, assume D.2a has shipped -> a working `AcDream.UI.Abstractions` layer (`IPanel`, `IPanelRenderer`, +> **Scope note (2026-04-24, updated 2026-04-25):** This document +> describes retail's Keystone UI toolkit — it is the research foundation +> for **Phase D.2b (custom retail-look backend)**, not Phase D.2a +> (shipped ImGui scaffold, `AcDream.UI.Abstractions` + ImGui.NET + +> `Silk.NET.OpenGL.Extensions.ImGui` + `VitalsPanel`). When reading this +> for implementation guidance, assume D.2a has shipped a working +> `AcDream.UI.Abstractions` layer (`IPanel`, `IPanelRenderer`, > ViewModels, Commands) and you are building the custom retained-mode > toolkit that implements the same contracts using dat-sourced fonts / > sprites / cursors. See `docs/plans/2026-04-24-ui-framework.md` for the diff --git a/memory/project_ui_architecture.md b/memory/project_ui_architecture.md index 2d6240de..bf5e4ad6 100644 --- a/memory/project_ui_architecture.md +++ b/memory/project_ui_architecture.md @@ -15,10 +15,14 @@ └─────────────────────────────────────────┘ ``` -- **UI backend** (bottom swap axis): `Hexa.NET.ImGui` for Phase D.2a +- **UI backend** (bottom swap axis): **`ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui`** for Phase D.2a (short-term, debugger-look, validates game logic fast). Custom retail-look toolkit for Phase D.2b (long-term, uses dat assets). ImGui stays **forever** as the `ACDREAM_DEVTOOLS=1` overlay. + (Pivoted from `Hexa.NET.ImGui` on 2026-04-25 — Hexa's native OpenGL3 + backend resolves GL via GLFW/SDL internally and crashed `0xC0000005` + against Silk.NET; the Silk.NET extension is purpose-built for this + stack.) - **ViewModels + Commands** (the stable contract): per-panel data records (`VitalsVM`, `InventoryVM`, `ChatVM`, …) and action records (`UseItemCmd`, `SendChatCmd`, `CastSpellCmd`, …). Lives in @@ -32,16 +36,19 @@ - `src/AcDream.UI.Abstractions/` — `IPanel`, `IPanelHost`, `IPanelRenderer`, `ICommandBus`, all ViewModels + Commands. Backend- agnostic. -- `src/AcDream.UI.ImGui/` — Hexa.NET.ImGui-based implementation of - `IPanelRenderer` + ImGui bootstrap. Phase D.2a. +- `src/AcDream.UI.ImGui/` — `ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui` + implementation of `IPanelRenderer` + ImGui bootstrap. Phase D.2a. + `ImGuiController` (from the Silk.NET extension) handles GL backend + + input event subscription; `ImGuiBootstrapper` is a thin IDisposable + wrapper; `ImGuiPanelRenderer` wraps the widgets `IPanelRenderer` needs. - `src/AcDream.UI.Retail/` (later) — custom retained-mode toolkit using dat assets, same `IPanelRenderer` contract. Phase D.2b. ## Hard rules 1. **No panel references a backend namespace.** If a panel imports - `Hexa.NET.ImGui` or a custom-toolkit widget class directly, it's a - bug. + `ImGuiNET` / `Silk.NET.OpenGL.Extensions.ImGui` or a custom-toolkit + widget class directly, it's a bug — extend `IPanelRenderer` instead. 2. **Plugin API targets the abstraction layer only.** Plugins define `IPanel` instances; they never see which backend draws them. 3. **Features that only ImGui can express → not in `IPanelRenderer`.** diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 277ae99c..6fb3af04 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -24,6 +24,8 @@ + + diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index cbd4b27f..ab4479f3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -281,6 +281,14 @@ public sealed class GameWindow : IDisposable public readonly AcDream.Core.Spells.Spellbook SpellBook = new(); public readonly AcDream.Core.Items.ItemRepository Items = new(); + // Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1. + // See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy. + private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap; + private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost; + private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; + private static readonly bool DevToolsEnabled = + Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1"; + // Phase G.1-G.2 world lighting/time state. public readonly AcDream.Core.World.WorldTimeService WorldTime = new AcDream.Core.World.WorldTimeService( @@ -870,6 +878,35 @@ public sealed class GameWindow : IDisposable } } + // Phase D.2a — ImGui devtools overlay. Zero cost when the env var + // isn't set: no context creation, no per-frame branches hit. + // See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md. + if (DevToolsEnabled) + { + try + { + _imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!); + _panelHost = new AcDream.UI.ImGui.ImGuiPanelHost(); + + // VitalsVM: GUID=0 at construction; set later at EnterWorld + // (see the _playerServerGuid assignment path). Pre-login the + // HP bar just reads 1.0 (safe default) — harmless. + _vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat); + _panelHost.Register( + new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm)); + + Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel registered)"); + } + catch (Exception ex) + { + Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled"); + _imguiBootstrap?.Dispose(); + _imguiBootstrap = null; + _panelHost = null; + _vitalsVm = null; + } + } + uint centerLandblockId = 0xA9B4FFFFu; Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); @@ -1149,6 +1186,7 @@ public sealed class GameWindow : IDisposable var chosen = _liveSession.Characters.Characters[0]; _playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry + _vitalsVm?.SetLocalPlayerGuid(chosen.Id); // Phase D.2a — devtools HP bar tracks this guid _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _liveSession.EnterWorld(user, characterIndex: 0); @@ -3502,6 +3540,13 @@ public sealed class GameWindow : IDisposable var kb = _input.Keyboards[0]; + // Phase D.2a — suppress game-side WASD / interaction polling when + // ImGui has keyboard focus (e.g. a text field is active). Without + // this, typing "walk" into a chat field would actually walk. + bool suppressGameInput = + DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; + if (suppressGameInput) return; + if (_cameraController.IsFlyMode) { _cameraController.Fly.Update( @@ -3709,6 +3754,12 @@ public sealed class GameWindow : IDisposable _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); + // Phase D.2a — begin ImGui frame. Paired with the Render() call + // after the scene draws (below). ImGuiController.Update() + // consumes buffered Silk.NET input events and calls ImGui.NewFrame. + if (DevToolsEnabled && _imguiBootstrap is not null) + _imguiBootstrap.BeginFrame((float)deltaSeconds); + // Phase 6.4: advance per-entity animation playback before drawing // so the renderer always sees the up-to-date per-part transforms. if (_animatedEntities.Count > 0) @@ -4002,6 +4053,20 @@ public sealed class GameWindow : IDisposable } } + // Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws + // so ImGui composites on top. ImGuiController save/restores the + // GL state it touches (blend, scissor, VAO, shader, texture); any + // state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused + // today) would need manual protection. + if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null) + { + var ctx = new AcDream.UI.Abstractions.PanelContext( + (float)deltaSeconds, + AcDream.UI.Abstractions.NullCommandBus.Instance); + _panelHost.RenderAll(ctx); + _imguiBootstrap.Render(); + } + // Update the window title with performance stats every ~0.5s. _perfAccum += deltaSeconds; _perfFrameCount++; diff --git a/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj b/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj index 65853fa4..66f5bbad 100644 --- a/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj +++ b/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj @@ -7,15 +7,20 @@ true - - - - + + + + diff --git a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs index 70ba6891..bbcc3a81 100644 --- a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs +++ b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs @@ -1,62 +1,64 @@ -using Hexa.NET.ImGui; -using Hexa.NET.ImGui.Backends.OpenGL3; +using Silk.NET.Input; +using Silk.NET.OpenGL; +using Silk.NET.OpenGL.Extensions.ImGui; +using Silk.NET.Windowing; namespace AcDream.UI.ImGui; /// -/// One-shot ImGui setup / teardown for the devtools overlay. Called from -/// GameWindow when ACDREAM_DEVTOOLS=1. Hides the cimgui -/// context + OpenGL3 renderer-impl lifecycles behind two static methods -/// so the calling code stays clean. +/// Owns the ImGuiController from Silk.NET.OpenGL.Extensions.ImGui, +/// which handles the whole Silk.NET ↔ ImGui.NET integration: +/// +/// Creates the ImGui context + OpenGL3 backend using Silk.NET's GL binding +/// (no GLFW / SDL dependency — unlike Hexa.NET.ImGui, which assumed one). +/// Subscribes to Silk.NET's window + input events to drive IO. +/// Per frame: Update(dt) calls ImGui.NewFrame(); Render() +/// calls ImGui.Render() + uploads draw data via its OpenGL3 backend. +/// /// /// -/// Intentionally not an IDisposable singleton — the host -/// window owns the one call to at application -/// exit. Re-initialisation mid-session is not supported. +/// Instance-scoped rather than static so GL-context lifetime is explicit. +/// GameWindow owns the one instance and disposes on shutdown. +/// +/// +/// +/// History: tried Hexa.NET.ImGui + Hexa.NET.ImGui.Backends.OpenGL3 first +/// per the original plan, but its native OpenGL3 backend resolves GL functions +/// via GLFW / SDL internally and crashed (0xC0000005) in InitNative without +/// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25. /// /// -public static class ImGuiBootstrapper +public sealed class ImGuiBootstrapper : IDisposable { - private static bool _initialized; + private readonly ImGuiController _controller; + + public ImGuiBootstrapper(GL gl, IView window, IInputContext input) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(input); + // ImGuiController constructor handles: + // - ImGui.CreateContext() + // - ImGuiOpenGL3 shader + vertex-buffer init (via Silk.NET GL) + // - Keyboard + mouse event subscription (bound to Silk.NET IInputContext) + // - Default style = dark + _controller = new ImGuiController(gl, window, input); + } /// - /// Create an ImGui context, apply the dark style + enable keyboard - /// navigation, and bootstrap the OpenGL3 renderer backend. The GL - /// context owned by Silk.NET must be current on the calling thread. + /// Begin an ImGui frame. Call BEFORE any ImGui.* widget calls. + /// Internally: consumes buffered input events, calls ImGui.NewFrame(). /// - /// - /// GLSL version directive for the ImGui-internal shader. - /// "#version 330" matches acdream's existing shaders and is - /// the safest default for the OpenGL 4.3 core profile we ship. - /// - public static void Initialize(string glslVersion = "#version 330") - { - if (_initialized) return; + public void BeginFrame(float deltaSeconds) => _controller.Update(deltaSeconds); - Hexa.NET.ImGui.ImGui.CreateContext(); - Hexa.NET.ImGui.ImGui.StyleColorsDark(); + /// + /// Finalise the ImGui frame and draw to the framebuffer. Call AFTER all + /// panel draws, within the same frame as . The + /// OpenGL3 backend save/restores the GL state it touches (shader, VAO, + /// texture, blend, scissor); state not in its save-list (e.g. + /// GL_FRAMEBUFFER_SRGB) is caller's responsibility. + /// + public void Render() => _controller.Render(); - var io = Hexa.NET.ImGui.ImGui.GetIO(); - io.ConfigFlags |= ImGuiConfigFlags.NavEnableKeyboard; - // DO NOT enable NavEnableGamepad — we don't wire a gamepad backend. - // DO NOT enable DockingEnable / ViewportsEnable — out of scope for D.2a. - - ImGuiImplOpenGL3.Init(glslVersion); - - _initialized = true; - } - - /// Tear down the OpenGL3 renderer + destroy the ImGui context. - public static void Shutdown() - { - if (!_initialized) return; - - ImGuiImplOpenGL3.Shutdown(); - Hexa.NET.ImGui.ImGui.DestroyContext(); - - _initialized = false; - } - - /// True after has run successfully. - public static bool IsInitialized => _initialized; + public void Dispose() => _controller.Dispose(); } diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs index 17e463d6..d1fde2f9 100644 --- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -1,31 +1,32 @@ using System.Numerics; using AcDream.UI.Abstractions; +using ImGuiNET; namespace AcDream.UI.ImGui; /// /// implemented as thin wrappers around -/// Hexa.NET.ImGui calls. This is the ONLY place where Hexa.NET.ImGui -/// types appear outside of bootstrap / input-bridge plumbing — panels -/// that need a feature must extend the abstraction here, not by importing -/// ImGui in panel files. +/// ImGui.NET calls. This is the ONLY place where ImGuiNET types appear +/// outside of bootstrap plumbing — panels that need a feature must +/// extend the abstraction here, not by importing ImGuiNET in panel +/// files. /// public sealed class ImGuiPanelRenderer : IPanelRenderer { /// - public bool Begin(string title) => Hexa.NET.ImGui.ImGui.Begin(title); + public bool Begin(string title) => ImGuiNET.ImGui.Begin(title); /// - public void End() => Hexa.NET.ImGui.ImGui.End(); + public void End() => ImGuiNET.ImGui.End(); /// - public void Text(string text) => Hexa.NET.ImGui.ImGui.TextUnformatted(text); + public void Text(string text) => ImGuiNET.ImGui.TextUnformatted(text); /// - public void SameLine() => Hexa.NET.ImGui.ImGui.SameLine(); + public void SameLine() => ImGuiNET.ImGui.SameLine(); /// - public void Separator() => Hexa.NET.ImGui.ImGui.Separator(); + public void Separator() => ImGuiNET.ImGui.Separator(); /// public void ProgressBar(float fraction, float width, string? overlay = null) @@ -36,6 +37,6 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer else if (fraction > 1f) fraction = 1f; var size = new Vector2(width, 0f); // height 0 → ImGui picks based on font - Hexa.NET.ImGui.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty); + ImGuiNET.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty); } } diff --git a/src/AcDream.UI.ImGui/SilkInputBridge.cs b/src/AcDream.UI.ImGui/SilkInputBridge.cs deleted file mode 100644 index 6c47f282..00000000 --- a/src/AcDream.UI.ImGui/SilkInputBridge.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Numerics; -using Hexa.NET.ImGui; -using Silk.NET.Input; - -namespace AcDream.UI.ImGui; - -/// -/// Forwards Silk.NET keyboard / mouse events to ImGui's IO. Replaces what -/// you'd get from the stock GLFW or SDL backends in a non-Silk.NET host. -/// -/// -/// Event-driven (we subscribe to Silk.NET events); does not poll. Each -/// handler writes directly to ImGui.GetIO() via the AddXxx -/// family of calls. Frame-start book-keeping (display size, delta time, -/// active modifier latch) happens in . -/// -/// -/// -/// Call at app shutdown to unsubscribe from Silk.NET -/// events. -/// -/// -public sealed class SilkInputBridge : IDisposable -{ - private readonly IInputContext _input; - private readonly IKeyboard? _keyboard; - private readonly IMouse? _mouse; - - public SilkInputBridge(IInputContext input) - { - _input = input ?? throw new ArgumentNullException(nameof(input)); - - _keyboard = input.Keyboards.Count > 0 ? input.Keyboards[0] : null; - _mouse = input.Mice.Count > 0 ? input.Mice[0] : null; - - if (_keyboard is not null) - { - _keyboard.KeyDown += OnKeyDown; - _keyboard.KeyUp += OnKeyUp; - _keyboard.KeyChar += OnKeyChar; - } - - if (_mouse is not null) - { - _mouse.MouseMove += OnMouseMove; - _mouse.MouseDown += OnMouseDown; - _mouse.MouseUp += OnMouseUp; - _mouse.Scroll += OnScroll; - } - } - - /// - /// Per-frame bookkeeping. Call right before ImGui.NewFrame(). - /// Sets display size (in logical pixels) and delta-time on ImGui's IO. - /// - public void BeginFrame(Vector2 displaySize, float deltaSeconds) - { - var io = Hexa.NET.ImGui.ImGui.GetIO(); - io.DisplaySize = displaySize; - io.DeltaTime = deltaSeconds > 0f ? deltaSeconds : 1f / 60f; - } - - // ─── event handlers ────────────────────────────────────────────── - - private void OnKeyDown(IKeyboard kb, Key key, int scancode) => AddKey(key, down: true); - private void OnKeyUp (IKeyboard kb, Key key, int scancode) => AddKey(key, down: false); - - private void OnKeyChar(IKeyboard kb, char c) - { - // Feeds typed text into any focused ImGui TextField. Safe to call - // even when no TextField has focus — ImGui buffers the character - // and discards it if nothing claims it. - Hexa.NET.ImGui.ImGui.GetIO().AddInputCharacter(c); - } - - private void OnMouseMove(IMouse m, Vector2 pos) - { - Hexa.NET.ImGui.ImGui.GetIO().AddMousePosEvent(pos.X, pos.Y); - } - - private void OnMouseDown(IMouse m, MouseButton button) => AddMouseButton(button, down: true); - private void OnMouseUp (IMouse m, MouseButton button) => AddMouseButton(button, down: false); - - private void OnScroll(IMouse m, ScrollWheel wheel) - { - Hexa.NET.ImGui.ImGui.GetIO().AddMouseWheelEvent(wheel.X, wheel.Y); - } - - // ─── helpers ───────────────────────────────────────────────────── - - private static void AddKey(Key key, bool down) - { - // Update modifier latches first (ImGui reads these when any AddKeyEvent fires). - var io = Hexa.NET.ImGui.ImGui.GetIO(); - if (key is Key.ControlLeft or Key.ControlRight) io.AddKeyEvent(ImGuiKey.ModCtrl, down); - if (key is Key.ShiftLeft or Key.ShiftRight) io.AddKeyEvent(ImGuiKey.ModShift, down); - if (key is Key.AltLeft or Key.AltRight) io.AddKeyEvent(ImGuiKey.ModAlt, down); - if (key is Key.SuperLeft or Key.SuperRight) io.AddKeyEvent(ImGuiKey.ModSuper, down); - - if (KeyMap.TryGetValue(key, out var imguiKey)) - io.AddKeyEvent(imguiKey, down); - // Unmapped keys are silently ignored — fine for D.2a; panels that - // need exotic keys can extend the map. - } - - private static void AddMouseButton(MouseButton button, bool down) - { - int idx = button switch - { - MouseButton.Left => 0, - MouseButton.Right => 1, - MouseButton.Middle => 2, - _ => -1, - }; - if (idx < 0) return; - Hexa.NET.ImGui.ImGui.GetIO().AddMouseButtonEvent(idx, down); - } - - /// - /// Silk.NET → ImGui key map. Covers text-input + navigation keys + - /// WASD + function keys. Unlisted keys fall through to no-op. - /// - private static readonly Dictionary KeyMap = new() - { - // Navigation + control - [Key.Tab] = ImGuiKey.Tab, - [Key.Left] = ImGuiKey.LeftArrow, - [Key.Right] = ImGuiKey.RightArrow, - [Key.Up] = ImGuiKey.UpArrow, - [Key.Down] = ImGuiKey.DownArrow, - [Key.PageUp] = ImGuiKey.PageUp, - [Key.PageDown] = ImGuiKey.PageDown, - [Key.Home] = ImGuiKey.Home, - [Key.End] = ImGuiKey.End, - [Key.Insert] = ImGuiKey.Insert, - [Key.Delete] = ImGuiKey.Delete, - [Key.Backspace] = ImGuiKey.Backspace, - [Key.Space] = ImGuiKey.Space, - [Key.Enter] = ImGuiKey.Enter, - [Key.Escape] = ImGuiKey.Escape, - - // Modifiers (also add via the mod-flag path, but these let ImGui - // see them as named keys too). - [Key.ControlLeft] = ImGuiKey.LeftCtrl, - [Key.ControlRight] = ImGuiKey.RightCtrl, - [Key.ShiftLeft] = ImGuiKey.LeftShift, - [Key.ShiftRight] = ImGuiKey.RightShift, - [Key.AltLeft] = ImGuiKey.LeftAlt, - [Key.AltRight] = ImGuiKey.RightAlt, - - // Letters (Silk.NET.Key.A..Z map 1:1 to ImGuiKey.A..Z). - [Key.A] = ImGuiKey.A, [Key.B] = ImGuiKey.B, [Key.C] = ImGuiKey.C, [Key.D] = ImGuiKey.D, - [Key.E] = ImGuiKey.E, [Key.F] = ImGuiKey.F, [Key.G] = ImGuiKey.G, [Key.H] = ImGuiKey.H, - [Key.I] = ImGuiKey.I, [Key.J] = ImGuiKey.J, [Key.K] = ImGuiKey.K, [Key.L] = ImGuiKey.L, - [Key.M] = ImGuiKey.M, [Key.N] = ImGuiKey.N, [Key.O] = ImGuiKey.O, [Key.P] = ImGuiKey.P, - [Key.Q] = ImGuiKey.Q, [Key.R] = ImGuiKey.R, [Key.S] = ImGuiKey.S, [Key.T] = ImGuiKey.T, - [Key.U] = ImGuiKey.U, [Key.V] = ImGuiKey.V, [Key.W] = ImGuiKey.W, [Key.X] = ImGuiKey.X, - [Key.Y] = ImGuiKey.Y, [Key.Z] = ImGuiKey.Z, - - // Digit row - [Key.Number0] = ImGuiKey.Key0, [Key.Number1] = ImGuiKey.Key1, [Key.Number2] = ImGuiKey.Key2, - [Key.Number3] = ImGuiKey.Key3, [Key.Number4] = ImGuiKey.Key4, [Key.Number5] = ImGuiKey.Key5, - [Key.Number6] = ImGuiKey.Key6, [Key.Number7] = ImGuiKey.Key7, [Key.Number8] = ImGuiKey.Key8, - [Key.Number9] = ImGuiKey.Key9, - - // Function keys - [Key.F1] = ImGuiKey.F1, [Key.F2] = ImGuiKey.F2, [Key.F3] = ImGuiKey.F3, [Key.F4] = ImGuiKey.F4, - [Key.F5] = ImGuiKey.F5, [Key.F6] = ImGuiKey.F6, [Key.F7] = ImGuiKey.F7, [Key.F8] = ImGuiKey.F8, - [Key.F9] = ImGuiKey.F9, [Key.F10] = ImGuiKey.F10, [Key.F11] = ImGuiKey.F11, [Key.F12] = ImGuiKey.F12, - }; - - public void Dispose() - { - if (_keyboard is not null) - { - _keyboard.KeyDown -= OnKeyDown; - _keyboard.KeyUp -= OnKeyUp; - _keyboard.KeyChar -= OnKeyChar; - } - if (_mouse is not null) - { - _mouse.MouseMove -= OnMouseMove; - _mouse.MouseDown -= OnMouseDown; - _mouse.MouseUp -= OnMouseUp; - _mouse.Scroll -= OnScroll; - } - } -} From 4d1b8b8aee89ed6c1931edb38effbf570a726f04 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 00:44:17 +0200 Subject: [PATCH 5/5] =?UTF-8?q?docs(issues):=20#5=20=E2=80=94=20VitalsPane?= =?UTF-8?q?l=20stam/mana=20null=20until=20LocalPlayerState=20lands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filed as the one explicit post-D.2a follow-up. VitalsVM returns float? null for Stamina/Mana because absolute values only arrive in PlayerDescription (0x0013) today and we parse-then-discard. A small LocalPlayerState Core class that retains the parsed fields unblocks two more progress bars in the existing Vitals window — no new wire work needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 38d52ec9..5f2c01a9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -135,6 +135,29 @@ Copy this block when adding a new issue: --- +## #5 — VitalsPanel stamina/mana bars always null (absolute values not stored) + +**Status:** OPEN +**Severity:** LOW (cosmetic — HP bar already works; stam/mana would be a nice-to-have) +**Filed:** 2026-04-25 +**Component:** ui / net / player-state + +**Description:** Phase D.2a shipped `VitalsVM` with `StaminaPercent` / `ManaPercent` returning `float?` null. `VitalsPanel` already renders an HP progress bar from `CombatState.GetHealthPercent(localGuid)` because per-entity health is tracked from combat notifications. Stamina and mana are absolute values and only arrive in `PlayerDescription (0x0013)` — which we currently parse then discard. Result: the Vitals window shows HP only. + +**Root cause / status:** We need a `LocalPlayerState` Core class (analogous to `CombatState` but scoped to the local player) that retains parsed `PlayerDescription` fields — at minimum: `CurrentStamina` + `MaxStamina` + `CurrentMana` + `MaxMana`. `AppraiseInfoParser.CreatureProfile` already has the shape for these values; we just don't persist them. + +**Files:** +- `src/AcDream.Core.Net/Parsers/PlayerDescriptionParser.cs` — parses then discards (verify path) +- `src/AcDream.Core.Net/Parsers/AppraiseInfoParser.cs` — has `CreatureProfile` with absolute values +- `src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs` — `StaminaPercent` / `ManaPercent` would divide `LocalPlayerState.Current*` by `Max*` +- `src/AcDream.App/Rendering/GameWindow.cs` — construct `LocalPlayerState`, hand to `VitalsVM`, wire into event dispatch + +**Research:** none needed — wire-level field positions are already decoded in `PlayerDescriptionParser`. + +**Acceptance:** With `ACDREAM_DEVTOOLS=1`, the Vitals window shows three progress bars (HP / Stamina / Mana) that update when the server sends `PlayerDescription` or any delta event (`UpdateHealth`, `UpdateStamina`, `UpdateMana`). + +--- + # Recently closed *(none yet — move DONE items here with closed-date + commit SHA)*