feat(ui): AcDream.UI.Abstractions layer — IPanel / IPanelRenderer / VitalsVM

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.
This commit is contained in:
Erik 2026-04-25 00:24:11 +02:00
parent b9455259f0
commit 8c64ad2eeb
10 changed files with 333 additions and 0 deletions

View file

@ -0,0 +1,69 @@
namespace AcDream.UI.Abstractions.Panels.Vitals;
/// <summary>
/// First real UI panel — shows the local player's vitals as progress bars.
/// Backend-agnostic; renders exclusively through <see cref="IPanelRenderer"/>
/// so the same file works under Hexa.NET.ImGui (D.2a) and the future custom
/// retail-look toolkit (D.2b).
///
/// <para>
/// D.2a shows only HP (percent). <see cref="VitalsVM.StaminaPercent"/> /
/// <see cref="VitalsVM.ManaPercent"/> return null until a
/// <c>LocalPlayerState</c> is wired (follow-up issue). When they start
/// returning non-null, this panel picks them up automatically.
/// </para>
/// </summary>
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));
}
/// <inheritdoc />
public string Id => "acdream.vitals";
/// <inheritdoc />
public string Title => "Vitals";
/// <inheritdoc />
public bool IsVisible { get; set; } = true;
/// <inheritdoc />
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();
}
}

View file

@ -0,0 +1,75 @@
using AcDream.Core.Combat;
namespace AcDream.UI.Abstractions.Panels.Vitals;
/// <summary>
/// ViewModel for the vitals HUD panel. Reads live health percentage for the
/// local player from <see cref="CombatState"/> (which is fed by the server's
/// <c>UpdateHealth (0x01C0)</c> GameEvent).
///
/// <para>
/// <b>D.2a scope limits:</b>
/// </para>
///
/// <list type="bullet">
/// <item>HP comes from <see cref="CombatState"/> and is <b>percent-only</b>
/// (0..1). Absolute current/max HP is not wired yet.</item>
/// <item>Stamina / Mana are always <c>null</c> — those values live in
/// <c>AppraiseInfoParser.CreatureProfile</c> (parsed from
/// <c>PlayerDescription (0x0013)</c>) but the parsed record is
/// currently discarded. Wiring a <c>LocalPlayerState</c> cache is
/// a separate follow-up; see <c>docs/ISSUES.md</c>.</item>
/// </list>
///
/// <para>
/// <b>GUID timing:</b> the local player's server GUID isn't known at
/// <c>OnLoad</c> (pre-login). Construct with <see cref="SetLocalPlayerGuid"/>
/// left as 0; <c>GameWindow</c> calls the setter when the live session
/// receives its guid at <c>EnterWorld</c>. Before the GUID is set,
/// <see cref="HealthPercent"/> returns 1.0 (via <c>CombatState</c>'s safe
/// default for unknown guids) — the bar reads "full", which is harmless.
/// </para>
/// </summary>
public sealed class VitalsVM
{
private readonly CombatState _combat;
private uint _localPlayerGuid;
/// <summary>
/// Build a VitalsVM bound to a <see cref="CombatState"/> instance. The
/// GUID starts at 0; call <see cref="SetLocalPlayerGuid"/> once the
/// live session assigns it.
/// </summary>
public VitalsVM(CombatState combat)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_localPlayerGuid = 0;
}
/// <summary>
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
/// One-way setter — only <c>GameWindow</c> should call it, exactly once
/// per live session.
/// </summary>
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
/// <summary>
/// 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.
/// </summary>
public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid);
/// <summary>
/// Stamina percent (0..1) or <c>null</c> when absolute values aren't wired.
/// D.2a always returns <c>null</c>; to be populated by a future
/// <c>LocalPlayerState</c> that caches <c>PlayerDescription (0x0013)</c>.
/// </summary>
public float? StaminaPercent => null;
/// <summary>
/// Mana percent (0..1) or <c>null</c> when absolute values aren't wired.
/// Same status as <see cref="StaminaPercent"/>.
/// </summary>
public float? ManaPercent => null;
}