feat(client): self-only vitals HUD — STR/DEX/INT plates + HP/STAM/MANA bars (#181) #205

Merged
marco merged 2 commits from feat/vitals-hud into main 2026-07-25 17:55:53 +02:00
Collaborator

Summary

Adds the self player's Vitals HUD (#181): three primary-stat plates (STR / DEX / INT) and three pool bars (HP / STAM / MANA) showing current/max, anchored bottom-left and laid out to the agreed gothic mockup. This is the procedural first pass — the decision was "1 poi 2": the layout + live wiring land now, and the carved gothic frame skin is a deliberate follow-up once the art exists. STAM is wired end-to-end but reads full until stamina drain lands (#185).

The vitals are server-authoritative and self-only. Each tick computes every player's vitals and emits a VitalsState only when they change — detected by comparing a fresh snapshot against the last one sent, held on the entity (PlayerMobile.LastSentVitals), so there's no dirty-flag plumbing threaded through the mutators. The change is routed to that player's own connection (SendToAsync by id), never broadcast and never sent to observers — mirroring how InventoryState and SkillGains are already scoped. It's a new wire message, so ProtocolVersion.Current is bumped to 14 in the same change.

Client side: the dispatcher stores the latest VitalsState on ClientWorld; the new VitalsHud view reads it and paints the plates + bars (VitalsBar.Fill gives the clamped fill fraction, NaN-safe at zero max). The HUD draws nothing until the first VitalsState arrives. VitalsHud is a thin view owned by WorldRenderer — no game state, consistent with the Screen thin-coordinator gate. Engine-agnostic fill math lives in Client.Core (VitalsBar), unit-tested there.

Screenshots / recording

Captured by piloting the client via the debug harness (docs/debug-harness.md) from a fresh database:

login test
screenshot vitals_full.png          # STR/DEX/INT 50 · HP/STAM/MANA 50/50
# focus chat, /set hits 18, target self (click viewport centre = self's tile)
screenshot vitals_hurt.png          # HP bar drained to 18/50, others unchanged

Full vitals (matches the mockup layout):

full vitals — STR/DEX/INT 50, all pools 50/50

After /set hits 18 on self — the HP bar reacts live (server VitalsState → HUD), STAM/MANA untouched:

after /set hits 18 — HP bar drained to 18/50

How it was tested

  • Unit (Client.Core) — VitalsBar.Fill clamps to [0,1] across under/over/normal inputs and returns 0 (not NaN) at zero max.
  • Unit (World) — the tick emits a player's vitals on its first tick, then only on change; a SetHits(10) re-emits carrying the new value; the payload carries the stats + both pools.
  • Integration (WebSocket) — the trust boundary: Alice (admin) /set str 99 on herself receives her own VitalsState (MaxHits re-clamped to 99); Bob, standing nearby, says "ping" and reads his stream to his own echo without ever receiving a VitalsState { Str: 99 }. The AoI "hears nothing" helper now ignores a player's own self-only VitalsState (it is not an area-of-interest leak).
  • Full gate, run locally — dotnet csharpier check clean, whole-solution dotnet build 0 warnings (analyzers as errors), dotnet test green across every project (GameServer 242).

Checklist

  • just lint passes (CSharpier + analyzers, zero warnings)
  • just test is green
  • The whole solution builds (client and tools included)
  • Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS)
  • Tests added/updated for this change
  • Linked the related issue (#181) and its Definition of Done is met
## Summary Adds the self player's **Vitals HUD** (#181): three primary-stat plates (STR / DEX / INT) and three pool bars (HP / STAM / MANA) showing current/max, anchored bottom-left and laid out to the agreed gothic mockup. This is the procedural first pass — the decision was *"1 poi 2"*: the layout + live wiring land now, and the carved gothic frame skin is a deliberate follow-up once the art exists. STAM is wired end-to-end but reads full until stamina drain lands (#185). The vitals are **server-authoritative and self-only**. Each tick computes every player's vitals and emits a `VitalsState` **only when they change** — detected by comparing a fresh snapshot against the last one sent, held on the entity (`PlayerMobile.LastSentVitals`), so there's no dirty-flag plumbing threaded through the mutators. The change is routed to that player's **own** connection (`SendToAsync` by id), never broadcast and never sent to observers — mirroring how `InventoryState` and `SkillGains` are already scoped. It's a new wire message, so `ProtocolVersion.Current` is bumped to **14** in the same change. Client side: the dispatcher stores the latest `VitalsState` on `ClientWorld`; the new `VitalsHud` view reads it and paints the plates + bars (`VitalsBar.Fill` gives the clamped fill fraction, NaN-safe at zero max). The HUD draws nothing until the first `VitalsState` arrives. `VitalsHud` is a thin view owned by `WorldRenderer` — no game state, consistent with the `Screen` thin-coordinator gate. Engine-agnostic fill math lives in `Client.Core` (`VitalsBar`), unit-tested there. ## Screenshots / recording Captured by piloting the client via the debug harness (`docs/debug-harness.md`) from a **fresh database**: ``` login test screenshot vitals_full.png # STR/DEX/INT 50 · HP/STAM/MANA 50/50 # focus chat, /set hits 18, target self (click viewport centre = self's tile) screenshot vitals_hurt.png # HP bar drained to 18/50, others unchanged ``` **Full vitals** (matches the mockup layout): ![full vitals — STR/DEX/INT 50, all pools 50/50](https://git.homelab.devncode.it/attachments/09eaa28c-59ea-42c5-8bc8-27b0f7c84e14) **After `/set hits 18` on self** — the HP bar reacts live (server `VitalsState` → HUD), STAM/MANA untouched: ![after /set hits 18 — HP bar drained to 18/50](https://git.homelab.devncode.it/attachments/2cabb461-3b42-44f9-bb4d-b8d28a5f56b4) ## How it was tested - **Unit (`Client.Core`)** — `VitalsBar.Fill` clamps to `[0,1]` across under/over/normal inputs and returns `0` (not `NaN`) at zero max. - **Unit (`World`)** — the tick emits a player's vitals on its first tick, then **only on change**; a `SetHits(10)` re-emits carrying the new value; the payload carries the stats + both pools. - **Integration (WebSocket)** — the trust boundary: Alice (admin) `/set str 99` on herself receives her own `VitalsState` (MaxHits re-clamped to 99); Bob, standing nearby, says "ping" and reads his stream to his own echo **without ever** receiving a `VitalsState { Str: 99 }`. The AoI "hears nothing" helper now ignores a player's own self-only `VitalsState` (it is not an area-of-interest leak). - **Full gate, run locally** — `dotnet csharpier check` clean, whole-solution `dotnet build` **0 warnings** (analyzers as errors), `dotnet test` green across every project (GameServer 242). ## Checklist - [x] `just lint` passes (CSharpier + analyzers, zero warnings) - [x] `just test` is green - [x] The whole solution builds (client and tools included) - [x] Multi-platform preserved (server on Win/macOS/Linux, client on Win/macOS) - [x] Tests added/updated for this change - [x] Linked the related issue (#181) and its Definition of Done is met
feat(client): self-only vitals HUD — STR/DEX/INT plates + HP/STAM/MANA bars (#181)
All checks were successful
ci / Lua content lint (pull_request) Successful in 17s
ci / Lint & Test (pull_request) Successful in 3m29s
e864bd4c99
Merge remote-tracking branch 'origin/main' into _pr205
All checks were successful
ci / Lua content lint (pull_request) Successful in 14s
ci / Lint & Test (pull_request) Successful in 5m1s
088453fe31
# Conflicts:
#	src/IsoMmo.GameServer/Gameplay/World.cs
marco approved these changes 2026-07-25 17:55:52 +02:00
marco left a comment

Reviewed: scope + body + code all consistent. VitalsHud correctly follows the passive-HUD-overlay pattern (like InventoryPanel/DamageNumbers/Journal — standalone, LineRenderer + fonts, drawn by WorldRenderer), not the Gump framework (which is for interactive draggable windows). Server side is self-only + AoI-safe (trust-boundary integration-tested). Merge conflict with #203 (both added a TickResult field) resolved by keeping both; build + 543 tests green.

Reviewed: scope + body + code all consistent. VitalsHud correctly follows the passive-HUD-overlay pattern (like InventoryPanel/DamageNumbers/Journal — standalone, LineRenderer + fonts, drawn by WorldRenderer), not the Gump framework (which is for interactive draggable windows). Server side is self-only + AoI-safe (trust-boundary integration-tested). Merge conflict with #203 (both added a TickResult field) resolved by keeping both; build + 543 tests green.
marco merged commit 33fa39546a into main 2026-07-25 17:55:53 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo!205
No description provided.