From 8c64ad2eeb6ddb6549452d9e5f190e8c3670485e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 00:24:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20AcDream.UI.Abstractions=20layer=20?= =?UTF-8?q?=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 e7fd39a..760229e 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 0000000..0fc070c --- /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 0000000..7174296 --- /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 0000000..5a4a37f --- /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 0000000..bbd685f --- /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 0000000..93e2584 --- /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 0000000..2c111ea --- /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 0000000..37caba1 --- /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 0000000..5040c7e --- /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 0000000..6d512f2 --- /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; +}