Compare commits
5 commits
b9455259f0
...
4d1b8b8aee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d1b8b8aee | ||
|
|
55aaca7a14 | ||
|
|
a7dbce3474 | ||
|
|
fc03fa377b | ||
|
|
8c64ad2eeb |
28 changed files with 835 additions and 23 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -23,3 +23,7 @@ references/
|
||||||
# Claude Code session state
|
# Claude Code session state
|
||||||
.claude/
|
.claude/
|
||||||
launch.log
|
launch.log
|
||||||
|
launch-*.log
|
||||||
|
|
||||||
|
# ImGui auto-saved window/docking state (per-user, not source)
|
||||||
|
imgui.ini
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
||||||
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.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.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
|
||||||
|
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
|
||||||
|
<Project Path="src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tools/">
|
<Folder Name="/tools/">
|
||||||
<Project Path="tools/RetailTimeProbe/RetailTimeProbe.csproj" />
|
<Project Path="tools/RetailTimeProbe/RetailTimeProbe.csproj" />
|
||||||
|
|
@ -14,5 +16,6 @@
|
||||||
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||||
|
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
|
||||||
15
CLAUDE.md
15
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/`,
|
state lives in memory (`memory/project_*.md`), plans in `docs/plans/`,
|
||||||
research in `docs/research/`.
|
research in `docs/research/`.
|
||||||
|
|
||||||
**UI strategy:** three-layer split — swappable backend (Hexa.NET.ImGui for
|
**UI strategy:** three-layer split — swappable backend (ImGui.NET +
|
||||||
Phase D.2a short-term, custom retail-look toolkit for D.2b later) /
|
`Silk.NET.OpenGL.Extensions.ImGui` for Phase D.2a short-term, custom
|
||||||
stable `AcDream.UI.Abstractions` layer (ViewModels + Commands + `IPanel`
|
retail-look toolkit for D.2b later) / stable `AcDream.UI.Abstractions`
|
||||||
/ `IPanelRenderer`) / unchanged game state. **All plugin-facing UI
|
layer (ViewModels + Commands + `IPanel` / `IPanelRenderer`) / unchanged
|
||||||
targets `AcDream.UI.Abstractions` — never import a backend namespace
|
game state. **All plugin-facing UI targets `AcDream.UI.Abstractions` —
|
||||||
from a panel.** Full design: `docs/plans/2026-04-24-ui-framework.md`.
|
never import a backend namespace from a panel.** Full design:
|
||||||
Memory crib: `memory/project_ui_architecture.md`.
|
`docs/plans/2026-04-24-ui-framework.md`. Memory crib:
|
||||||
|
`memory/project_ui_architecture.md`.
|
||||||
|
|
||||||
## How to operate
|
## How to operate
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Recently closed
|
||||||
|
|
||||||
*(none yet — move DONE items here with closed-date + commit SHA)*
|
*(none yet — move DONE items here with closed-date + commit SHA)*
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,8 @@ designed 2026-04-24. Full design: `docs/plans/2026-04-24-ui-framework.md`.
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ UI BACKEND (swappable) │
|
│ 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) │
|
│ or custom retail-look toolkit (Phase D.2b, later) │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ AcDream.UI.Abstractions (stable contract) │
|
│ AcDream.UI.Abstractions (stable contract) │
|
||||||
|
|
|
||||||
|
|
@ -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 ✓ |
|
| 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 ✓ |
|
| 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 ✓ |
|
| 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:
|
Plus polish that doesn't get its own phase number:
|
||||||
- FlyCamera default speed lowered + Shift-to-boost
|
- 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)
|
> [`docs/plans/2026-04-24-ui-framework.md`](2026-04-24-ui-framework.md)
|
||||||
> for the full design. Short version:
|
> 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
|
> 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 +
|
> 2. **Stable `AcDream.UI.Abstractions` layer** — ViewModels + Commands +
|
||||||
> `IPanel` / `IPanelRenderer` interfaces. Backend-agnostic. Plugin API
|
> `IPanel` / `IPanelRenderer` interfaces. Backend-agnostic. Plugin API
|
||||||
> publishes against this layer and never sees ImGui.
|
> publishes against this layer and never sees ImGui.
|
||||||
|
|
@ -142,7 +145,7 @@ Plus polish that doesn't get its own phase number:
|
||||||
|
|
||||||
**Sub-pieces:**
|
**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.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.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.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.)**
|
- **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.)**
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,51 @@
|
||||||
# UI framework plan
|
# UI framework plan
|
||||||
|
|
||||||
**Date:** 2026-04-24
|
**Date:** 2026-04-24 (design), shipped 2026-04-25
|
||||||
**Status:** design — not yet implemented
|
**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
|
**Owner:** lead engineer (erik) + Claude
|
||||||
|
|
||||||
Captures the UI strategy agreed via discussion on 2026-04-24. Documents
|
Captures the UI strategy agreed via discussion on 2026-04-24. Documents
|
||||||
the choices AND the alternatives considered so future sessions can
|
the choices AND the alternatives considered so future sessions can
|
||||||
re-evaluate with the same context.
|
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
|
## Goal
|
||||||
|
|
||||||
acdream needs a playable game UI: chat, vitals HUD, inventory, character
|
acdream needs a playable game UI: chat, vitals HUD, inventory, character
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
# Retail AC Client GUI — Master Synthesis
|
# Retail AC Client GUI — Master Synthesis
|
||||||
|
|
||||||
> **Scope note (2026-04-24):** This document describes retail's Keystone
|
> **Scope note (2026-04-24, updated 2026-04-25):** This document
|
||||||
> UI toolkit — it is the research foundation for **Phase D.2b (custom
|
> describes retail's Keystone UI toolkit — it is the research foundation
|
||||||
> retail-look backend)**, not **Phase D.2a (Hexa.NET.ImGui scaffold)**.
|
> for **Phase D.2b (custom retail-look backend)**, not Phase D.2a
|
||||||
> When reading this for implementation guidance, assume D.2a has shipped
|
> (shipped ImGui scaffold, `AcDream.UI.Abstractions` + ImGui.NET +
|
||||||
> a working `AcDream.UI.Abstractions` layer (`IPanel`, `IPanelRenderer`,
|
> `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
|
> ViewModels, Commands) and you are building the custom retained-mode
|
||||||
> toolkit that implements the same contracts using dat-sourced fonts /
|
> toolkit that implements the same contracts using dat-sourced fonts /
|
||||||
> sprites / cursors. See `docs/plans/2026-04-24-ui-framework.md` for the
|
> sprites / cursors. See `docs/plans/2026-04-24-ui-framework.md` for the
|
||||||
|
|
|
||||||
|
|
@ -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
|
(short-term, debugger-look, validates game logic fast). Custom
|
||||||
retail-look toolkit for Phase D.2b (long-term, uses dat assets).
|
retail-look toolkit for Phase D.2b (long-term, uses dat assets).
|
||||||
ImGui stays **forever** as the `ACDREAM_DEVTOOLS=1` overlay.
|
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
|
- **ViewModels + Commands** (the stable contract): per-panel data
|
||||||
records (`VitalsVM`, `InventoryVM`, `ChatVM`, …) and action records
|
records (`VitalsVM`, `InventoryVM`, `ChatVM`, …) and action records
|
||||||
(`UseItemCmd`, `SendChatCmd`, `CastSpellCmd`, …). Lives in
|
(`UseItemCmd`, `SendChatCmd`, `CastSpellCmd`, …). Lives in
|
||||||
|
|
@ -32,16 +36,19 @@
|
||||||
- `src/AcDream.UI.Abstractions/` — `IPanel`, `IPanelHost`,
|
- `src/AcDream.UI.Abstractions/` — `IPanel`, `IPanelHost`,
|
||||||
`IPanelRenderer`, `ICommandBus`, all ViewModels + Commands. Backend-
|
`IPanelRenderer`, `ICommandBus`, all ViewModels + Commands. Backend-
|
||||||
agnostic.
|
agnostic.
|
||||||
- `src/AcDream.UI.ImGui/` — Hexa.NET.ImGui-based implementation of
|
- `src/AcDream.UI.ImGui/` — `ImGui.NET` + `Silk.NET.OpenGL.Extensions.ImGui`
|
||||||
`IPanelRenderer` + ImGui bootstrap. Phase D.2a.
|
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
|
- `src/AcDream.UI.Retail/` (later) — custom retained-mode toolkit using
|
||||||
dat assets, same `IPanelRenderer` contract. Phase D.2b.
|
dat assets, same `IPanelRenderer` contract. Phase D.2b.
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
1. **No panel references a backend namespace.** If a panel imports
|
1. **No panel references a backend namespace.** If a panel imports
|
||||||
`Hexa.NET.ImGui` or a custom-toolkit widget class directly, it's a
|
`ImGuiNET` / `Silk.NET.OpenGL.Extensions.ImGui` or a custom-toolkit
|
||||||
bug.
|
widget class directly, it's a bug — extend `IPanelRenderer` instead.
|
||||||
2. **Plugin API targets the abstraction layer only.** Plugins define
|
2. **Plugin API targets the abstraction layer only.** Plugins define
|
||||||
`IPanel` instances; they never see which backend draws them.
|
`IPanel` instances; they never see which backend draws them.
|
||||||
3. **Features that only ImGui can express → not in `IPanelRenderer`.**
|
3. **Features that only ImGui can express → not in `IPanelRenderer`.**
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||||
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
|
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
|
||||||
|
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="Rendering\Shaders\*.*">
|
<None Update="Rendering\Shaders\*.*">
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,14 @@ public sealed class GameWindow : IDisposable
|
||||||
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
|
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
|
||||||
public readonly AcDream.Core.Items.ItemRepository Items = 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.
|
// Phase G.1-G.2 world lighting/time state.
|
||||||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||||||
new AcDream.Core.World.WorldTimeService(
|
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;
|
uint centerLandblockId = 0xA9B4FFFFu;
|
||||||
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
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];
|
var chosen = _liveSession.Characters.Characters[0];
|
||||||
_playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry
|
_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
|
_worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
|
||||||
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||||
_liveSession.EnterWorld(user, characterIndex: 0);
|
_liveSession.EnterWorld(user, characterIndex: 0);
|
||||||
|
|
@ -3502,6 +3540,13 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
var kb = _input.Keyboards[0];
|
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)
|
if (_cameraController.IsFlyMode)
|
||||||
{
|
{
|
||||||
_cameraController.Fly.Update(
|
_cameraController.Fly.Update(
|
||||||
|
|
@ -3709,6 +3754,12 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
_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
|
// Phase 6.4: advance per-entity animation playback before drawing
|
||||||
// so the renderer always sees the up-to-date per-part transforms.
|
// so the renderer always sees the up-to-date per-part transforms.
|
||||||
if (_animatedEntities.Count > 0)
|
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.
|
// Update the window title with performance stats every ~0.5s.
|
||||||
_perfAccum += deltaSeconds;
|
_perfAccum += deltaSeconds;
|
||||||
_perfFrameCount++;
|
_perfFrameCount++;
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
28
src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj
Normal file
28
src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Pivoted from Hexa.NET.ImGui to ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui
|
||||||
|
on 2026-04-25 because Hexa's native OpenGL3 backend does its own GL
|
||||||
|
function resolution via GLFW / SDL internally; with Silk.NET (no GLFW/SDL)
|
||||||
|
it crashed in InitNative with 0xC0000005. The Silk.NET extension is
|
||||||
|
purpose-built for this scenario — it wraps ImGui.NET's backend using
|
||||||
|
Silk.NET's own GL binding. See docs/plans/2026-04-24-ui-framework.md
|
||||||
|
"Risk: Hexa.NET.ImGui stops being maintained → mitigation: switch to
|
||||||
|
ImGui.NET is a one-morning operation." This WAS the one-morning
|
||||||
|
operation, triggered by a different Hexa issue (GL-loader absence). -->
|
||||||
|
<PackageReference Include="ImGui.NET" Version="1.91.6.1" />
|
||||||
|
<PackageReference Include="Silk.NET.OpenGL.Extensions.ImGui" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
64
src/AcDream.UI.ImGui/ImGuiBootstrapper.cs
Normal file
64
src/AcDream.UI.ImGui/ImGuiBootstrapper.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
using Silk.NET.Input;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Silk.NET.OpenGL.Extensions.ImGui;
|
||||||
|
using Silk.NET.Windowing;
|
||||||
|
|
||||||
|
namespace AcDream.UI.ImGui;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the <c>ImGuiController</c> from <c>Silk.NET.OpenGL.Extensions.ImGui</c>,
|
||||||
|
/// which handles the whole Silk.NET ↔ ImGui.NET integration:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Creates the ImGui context + OpenGL3 backend using Silk.NET's GL binding
|
||||||
|
/// (no GLFW / SDL dependency — unlike Hexa.NET.ImGui, which assumed one).</item>
|
||||||
|
/// <item>Subscribes to Silk.NET's window + input events to drive IO.</item>
|
||||||
|
/// <item>Per frame: <c>Update(dt)</c> calls <c>ImGui.NewFrame()</c>; <c>Render()</c>
|
||||||
|
/// calls <c>ImGui.Render()</c> + uploads draw data via its OpenGL3 backend.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Instance-scoped rather than static so GL-context lifetime is explicit.
|
||||||
|
/// <c>GameWindow</c> owns the one instance and disposes on shutdown.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// History: tried <c>Hexa.NET.ImGui</c> + <c>Hexa.NET.ImGui.Backends.OpenGL3</c> first
|
||||||
|
/// per the original plan, but its native OpenGL3 backend resolves GL functions
|
||||||
|
/// via GLFW / SDL internally and crashed (0xC0000005) in <c>InitNative</c> without
|
||||||
|
/// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImGuiBootstrapper : IDisposable
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Begin an ImGui frame. Call BEFORE any <c>ImGui.*</c> widget calls.
|
||||||
|
/// Internally: consumes buffered input events, calls <c>ImGui.NewFrame()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public void BeginFrame(float deltaSeconds) => _controller.Update(deltaSeconds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finalise the ImGui frame and draw to the framebuffer. Call AFTER all
|
||||||
|
/// panel draws, within the same frame as <see cref="BeginFrame"/>. The
|
||||||
|
/// OpenGL3 backend save/restores the GL state it touches (shader, VAO,
|
||||||
|
/// texture, blend, scissor); state not in its save-list (e.g.
|
||||||
|
/// <c>GL_FRAMEBUFFER_SRGB</c>) is caller's responsibility.
|
||||||
|
/// </summary>
|
||||||
|
public void Render() => _controller.Render();
|
||||||
|
|
||||||
|
public void Dispose() => _controller.Dispose();
|
||||||
|
}
|
||||||
46
src/AcDream.UI.ImGui/ImGuiPanelHost.cs
Normal file
46
src/AcDream.UI.ImGui/ImGuiPanelHost.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using AcDream.UI.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.UI.ImGui;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IPanelHost"/> implementation for the ImGui backend. Owns the
|
||||||
|
/// registered panel set; iterates + draws every frame when the caller is
|
||||||
|
/// inside an ImGui frame (between <c>ImGui.NewFrame</c> and
|
||||||
|
/// <c>ImGui.Render</c>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>This class does not call <c>ImGui.NewFrame</c> / <c>ImGui.Render</c>
|
||||||
|
/// itself.</b> Those belong to the caller (GameWindow) so GL-state
|
||||||
|
/// ownership is explicit and the render-loop integration point is obvious.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImGuiPanelHost : IPanelHost
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, IPanel> _panels = new();
|
||||||
|
private readonly ImGuiPanelRenderer _renderer = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Register(IPanel panel)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(panel);
|
||||||
|
_panels[panel.Id] = panel; // idempotent by Id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Unregister(string panelId) => _panels.Remove(panelId);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current registered count (for diagnostics).</summary>
|
||||||
|
public int Count => _panels.Count;
|
||||||
|
}
|
||||||
42
src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
Normal file
42
src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.UI.Abstractions;
|
||||||
|
using ImGuiNET;
|
||||||
|
|
||||||
|
namespace AcDream.UI.ImGui;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IPanelRenderer"/> implemented as thin wrappers around
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImGuiPanelRenderer : IPanelRenderer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool Begin(string title) => ImGuiNET.ImGui.Begin(title);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void End() => ImGuiNET.ImGui.End();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Text(string text) => ImGuiNET.ImGui.TextUnformatted(text);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SameLine() => ImGuiNET.ImGui.SameLine();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Separator() => ImGuiNET.ImGui.Separator();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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
|
||||||
|
ImGuiNET.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
22
tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs
Normal file
22
tests/AcDream.UI.Abstractions.Tests/NullCommandBusTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs
Normal file
24
tests/AcDream.UI.Abstractions.Tests/PanelContextTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs
Normal file
80
tests/AcDream.UI.Abstractions.Tests/VitalsVMTests.cs
Normal file
|
|
@ -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<ArgumentNullException>(() => new VitalsVM(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue