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; +}