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:
parent
b9455259f0
commit
8c64ad2eeb
10 changed files with 333 additions and 0 deletions
|
|
@ -6,6 +6,7 @@
|
|||
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
||||
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
||||
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
|
||||
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tools/">
|
||||
<Project Path="tools/RetailTimeProbe/RetailTimeProbe.csproj" />
|
||||
|
|
|
|||
12
src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
Normal file
12
src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
24
src/AcDream.UI.Abstractions/ICommandBus.cs
Normal file
24
src/AcDream.UI.Abstractions/ICommandBus.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes user-intent commands from panels to the systems that handle
|
||||
/// them (WorldSession, ChatService, Inventory, ...). Panels never touch
|
||||
/// those systems directly — they <see cref="Publish{T}(T)"/> a record
|
||||
/// and the bus dispatches.
|
||||
///
|
||||
/// <para>
|
||||
/// D.2a scaffolding: <see cref="NullCommandBus"/> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ICommandBus
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
void Publish<T>(T command) where T : notnull;
|
||||
}
|
||||
37
src/AcDream.UI.Abstractions/IPanel.cs
Normal file
37
src/AcDream.UI.Abstractions/IPanel.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// A UI panel — chat window, inventory, vitals HUD, character sheet, etc.
|
||||
/// Panels are backend-agnostic: they only call into <see cref="IPanelRenderer"/>
|
||||
/// 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).
|
||||
///
|
||||
/// <para>
|
||||
/// Hard rule: <b>no <c>using Hexa.NET.ImGui</c> inside a panel file</b>. If a
|
||||
/// widget needs a feature the abstraction doesn't expose, extend
|
||||
/// <see cref="IPanelRenderer"/>; do not import the backend. See
|
||||
/// <c>docs/plans/2026-04-24-ui-framework.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IPanel
|
||||
{
|
||||
/// <summary>Stable, globally-unique identifier. Convention: <c>acdream.{name}</c>.</summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>Human-readable window title shown in the chrome of the panel.</summary>
|
||||
string Title { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the panel is currently visible. Backends read this per frame;
|
||||
/// panels may mutate it in response to their own close-button handling.
|
||||
/// </summary>
|
||||
bool IsVisible { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Draw the panel for one frame. Called by <see cref="IPanelHost.RenderAll"/>
|
||||
/// on the render thread once ImGui's (or the future custom backend's)
|
||||
/// frame has begun. Panels issue drawing calls through <paramref name="renderer"/>
|
||||
/// and publish user-intent actions through <paramref name="ctx"/>.<see cref="PanelContext.Commands"/>.
|
||||
/// </summary>
|
||||
void Render(PanelContext ctx, IPanelRenderer renderer);
|
||||
}
|
||||
36
src/AcDream.UI.Abstractions/IPanelHost.cs
Normal file
36
src/AcDream.UI.Abstractions/IPanelHost.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the set of live <see cref="IPanel"/>s and drives per-frame draw
|
||||
/// dispatch. The backend (Hexa.NET.ImGui in D.2a, custom in D.2b) implements
|
||||
/// this; <c>GameWindow</c> creates one at startup and registers panels.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Does not</b> call <c>ImGui.NewFrame</c> / <c>ImGui.Render</c> — those
|
||||
/// belong to the caller so GL-state ownership is unambiguous. Caller pattern:
|
||||
/// </para>
|
||||
///
|
||||
/// <code>
|
||||
/// // per frame, render thread
|
||||
/// inputBridge.BeginFrame(size, dt);
|
||||
/// ImGui.NewFrame();
|
||||
/// panelHost.RenderAll(ctx);
|
||||
/// ImGui.Render();
|
||||
/// ImGuiImplOpenGL3.RenderDrawData(ImGui.GetDrawData());
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public interface IPanelHost
|
||||
{
|
||||
/// <summary>Register a panel for per-frame rendering. Idempotent by <see cref="IPanel.Id"/>.</summary>
|
||||
void Register(IPanel panel);
|
||||
|
||||
/// <summary>Remove the panel with the matching id. No-op if not present.</summary>
|
||||
void Unregister(string panelId);
|
||||
|
||||
/// <summary>
|
||||
/// Iterate every visible panel and call <see cref="IPanel.Render"/>. Call
|
||||
/// order within a frame is the registration order; panels with
|
||||
/// <see cref="IPanel.IsVisible"/> set to <c>false</c> are skipped entirely.
|
||||
/// </summary>
|
||||
void RenderAll(PanelContext ctx);
|
||||
}
|
||||
43
src/AcDream.UI.Abstractions/IPanelRenderer.cs
Normal file
43
src/AcDream.UI.Abstractions/IPanelRenderer.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Drawing primitives exposed to panels. The <b>only</b> 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.
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IPanelRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Begin a top-level window. Matches retail's root <c>UiPanel</c> +
|
||||
/// ImGui's <c>Begin</c>. Returns <c>false</c> if the window is collapsed
|
||||
/// — the caller must still call <see cref="End"/> to balance.
|
||||
/// </summary>
|
||||
bool Begin(string title);
|
||||
|
||||
/// <summary>Close the most recent <see cref="Begin"/>.</summary>
|
||||
void End();
|
||||
|
||||
/// <summary>Draw a single line of text. No formatting / markdown.</summary>
|
||||
void Text(string text);
|
||||
|
||||
/// <summary>Keep the next widget on the same line as the previous one.</summary>
|
||||
void SameLine();
|
||||
|
||||
/// <summary>Horizontal rule separator.</summary>
|
||||
void Separator();
|
||||
|
||||
/// <summary>
|
||||
/// A filled progress bar.
|
||||
/// <paramref name="fraction"/> is clamped by the backend to [0, 1].
|
||||
/// <paramref name="width"/> is the pixel width of the full bar.
|
||||
/// <paramref name="overlay"/> is optional text (e.g. <c>"54%"</c>) rendered on top.
|
||||
/// </summary>
|
||||
void ProgressBar(float fraction, float width, string? overlay = null);
|
||||
}
|
||||
21
src/AcDream.UI.Abstractions/NullCommandBus.cs
Normal file
21
src/AcDream.UI.Abstractions/NullCommandBus.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// No-op <see cref="ICommandBus"/>. Accepts any published command and
|
||||
/// discards it. Used as the default in D.2a until chat / inventory panels
|
||||
/// need real command routing.
|
||||
/// </summary>
|
||||
public sealed class NullCommandBus : ICommandBus
|
||||
{
|
||||
/// <summary>Shared singleton — the bus is stateless.</summary>
|
||||
public static readonly NullCommandBus Instance = new();
|
||||
|
||||
private NullCommandBus() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
// Intentionally empty. Panel-emitted commands in D.2a are
|
||||
// read-only diagnostics; nothing routes server-ward yet.
|
||||
}
|
||||
}
|
||||
15
src/AcDream.UI.Abstractions/PanelContext.cs
Normal file
15
src/AcDream.UI.Abstractions/PanelContext.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame context passed to each <see cref="IPanel.Render"/> call.
|
||||
/// Struct + record for zero-allocation per frame. Add fields here as new
|
||||
/// capabilities become panel-facing — e.g. a future <c>IGameState</c>
|
||||
/// handle once we need richer data than individual ViewModels can carry.
|
||||
///
|
||||
/// <para>
|
||||
/// Carried by value; cheap. Passed per-render; do not cache across frames.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct PanelContext(
|
||||
float DeltaSeconds,
|
||||
ICommandBus Commands);
|
||||
69
src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs
Normal file
69
src/AcDream.UI.Abstractions/Panels/Vitals/VitalsPanel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
75
src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs
Normal file
75
src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue