17a3afd5e9
Nine BattleViewer refinements from the latest review pass: 1. Mass radii were uniform in synthetic mode because `+layout.svelte` skipped `loadCore()` on the synthetic branch. The wasm bridge to `pkg/calc/ship.go` now boots in both modes so `computeBattleGroupMass` resolves a real FullMass and `radiusForMass` produces a per-battle scale. 2. Phantom-destroy clamp in `buildFrames`. Legacy emitters (KNNTS041 planet #7) log many more `Destroyed` lines against a group than the group's initial population — at frame 406 of 2317 the race totals previously hit zero on phantom shots and the scene blanked while playback continued silently. We now only shrink the per-group remaining count and the race totals when the group still has ships. The line still draws on phantom frames; only the counters stay sane. 3. Vogel sunflower positions are now reassigned by inward dot product before being handed to ranks: the rank-0 bucket — the one with the largest initial ship count — always lands at the most-inward spiral slot. The previous quarter-step anchor bias was too weak; ranks r ≥ 2 routinely overtook rank-0 toward the planet. The anchor offset is gone. 4. Bucket order inside a cluster is locked at battle start by each bucket's *initial* ship count (`num`), not its live `numLeft`. The position of every class circle stays put for the whole battle; only the label number changes as ships die. 5. Shot line + defender flash blink on a per-frame timer during play. The line stays on for the first 90 % of frame duration, off for the last 10 %, so two consecutive shots from the same attacker on the same defender look like two distinct pulses. On pause the line and flash stay drawn for inspection. 6. The defender's class circle now flashes red (destroyed) or green (shielded) in sync with the shot line, so the eye catches *who* was hit, not just where the line lands. 7. Battle log rows are buttons. Click / Enter / Space pauses playback and seeks to that shot. The list also auto-scrolls the current row into view so the highlight does not race off the bottom on long battles. 8. Race labels now sit above the cloud's bounding top instead of a fixed offset, so a dense cluster does not swallow its own race name. 9. Planet glyph + label switch to neutral grey (`#2a2f40` / `#4a5066` / `#6d7388`), keeping the planet "in the background" rather than competing with the combatants. Step-back icon switched to `◀︎◀︎` to mirror step-forward. Tests: two new Vitest cases cover the phantom-destroy clamp (single-race wipe, mixed-class race survives a class wipe). The existing 642 Vitest tests stay green; all four `battle-viewer` Playwright cases pass. Docs: `ui/docs/battle-viewer-ux.md` rewrites the cluster section (locked order + Vogel reassignment), adds Playback Details (blink + flash semantics), and a Phantom Destroys section explaining the clamp. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
8.9 KiB
Markdown
204 lines
8.9 KiB
Markdown
# Battle Viewer UX
|
||
|
||
Phase 27 ships a dedicated viewer for battles (`/games/<id>/battle/<battleId>`).
|
||
Bombings stay where they were in Phase 23 — a static table in the
|
||
Reports view (`section-bombings.svelte`). The two domains are
|
||
deliberately not mixed in any visual surface or click target.
|
||
|
||
## Data shape
|
||
|
||
The `BattleViewer` component (`lib/battle-player/battle-viewer.svelte`)
|
||
is logically isolated. It accepts a `BattleReport` matching
|
||
`pkg/model/report/battle.go`. The fields it uses:
|
||
|
||
- `id`, `planet`, `planetName` — header + the central-planet glyph.
|
||
- `races: { [raceId]: raceUUID }` — race index space used by the
|
||
protocol's `a` / `d` fields.
|
||
- `ships: { [groupKey]: BattleReportGroup }` — ship-group rosters
|
||
with `race` name, `className`, initial `num`, end-state `numLeft`,
|
||
and the `inBattle` flag. Observer groups (`inBattle: false`) are
|
||
never drawn.
|
||
- `protocol: BattleActionReport[]` — flat list of shots. Each carries
|
||
attacker `(a, sa)`, defender `(d, sd)`, and `x` (destroyed?).
|
||
|
||
The component asks `timeline.ts.buildFrames(report)` to expand the
|
||
protocol into `protocol.length + 1` frames; frame 0 is the initial
|
||
state and frame `N` reflects state after action `protocol[N-1]`. The
|
||
race index per ship group is derived from the protocol itself —
|
||
every in-battle group appears at least once as attacker or defender,
|
||
and the engine never crosses these wires.
|
||
|
||
## Radial scene
|
||
|
||
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
|
||
planet at the centre and arrays the still-active races on an outer
|
||
ring at equal angular spacing. Each race anchor hosts a *cloud* of
|
||
class circles arranged on a Vogel sunflower spiral biased toward the
|
||
planet (the cluster anchor is pushed inward by a quarter step so the
|
||
rank-0 node — the heaviest group by NumberLeft — sits closest to the
|
||
planet, and the spiral fans the rest behind it). When a race is
|
||
wiped out, it drops out of the active list and the survivors are
|
||
re-spaced on the next frame.
|
||
|
||
Each class circle is one *bucket* keyed by `(race, className)`:
|
||
tech-variants of the same class collapse into one node so the scene
|
||
stays readable when a race fields a dozen tech levels of the same
|
||
hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
|
||
across the underlying groups; per-tech detail is available in the
|
||
Reports view (Foreign Ship Classes / My Ship Types).
|
||
|
||
Bucket order inside a cluster is **locked at battle start** by the
|
||
initial ship count (`num` summed across tech variants, descending).
|
||
As ships die during playback only the label number changes — every
|
||
bucket keeps its slot in the Vogel spiral, so the user does not see
|
||
the cluster reshuffle when a class empties. Vogel positions are
|
||
then reassigned per rank by their inward distance toward the
|
||
planet, so the rank-0 bucket (the largest at battle start) always
|
||
sits at the most-inward spiral slot.
|
||
|
||
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
||
the per-ship `LoadQuantity`). The viewer resolves a
|
||
`(race, className) → ShipClassRef` lookup from the surrounding
|
||
`GameReport.localShipClass` + `otherShipClass` tables and runs it
|
||
through the existing wasm bridge to `pkg/calc/ship.go`
|
||
(`emptyMass` + `carryingMass` + `fullMass`). The radius is then
|
||
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) × sqrt(mass / maxMassInBattle)`
|
||
clamped to `[6, 24]` pixels — per-battle normalisation, so the
|
||
heaviest ship in any given battle renders at the cap. Unknown class
|
||
or invalid params fall back to MAX_RADIUS so the bucket stays
|
||
visible.
|
||
|
||
The current frame's shot is drawn as a thin line from the attacker's
|
||
class circle to the defender's class circle. Colour:
|
||
|
||
- red (`#ee3344`) when the action's `x === true` (the defender
|
||
ship was destroyed),
|
||
- green (`#44dd66`) otherwise.
|
||
|
||
Each frame redraws the line in isolation, so continuous playback
|
||
produces the "shot-shot-shot" pulse the user wanted.
|
||
|
||
## Playback controls
|
||
|
||
`lib/battle-player/playback-controls.svelte` ships the full set:
|
||
|
||
| Control | Effect |
|
||
| ------------- | ------------------------------------------ |
|
||
| ⏮ rewind | Stop, jump to frame 0 |
|
||
| ◀︎ step back | Stop, frame ← frame − 1 |
|
||
| ▶︎ / ⏸ play | Toggle continuous playback |
|
||
| ▶︎▶︎ step fwd | Stop, frame ← frame + 1 |
|
||
| 1x / 2x / 4x | Speed switch: 400 / 200 / 100 ms per frame |
|
||
|
||
When the timeline is at its end and the user hits play, the frame
|
||
counter wraps to 0 and continues. Step buttons disable themselves at
|
||
their boundary.
|
||
|
||
## Accessibility
|
||
|
||
Below the scene the viewer renders a static `<ol>` text protocol —
|
||
one line per action, formatted from `BattleReportGroup.race` and
|
||
`BattleReportGroup.className`. The line for the current frame is
|
||
highlighted so a non-visual reader can follow along by scrolling
|
||
the log instead of watching the SVG. The list is always present
|
||
and never hidden, satisfying the original Phase 27 acceptance "the
|
||
same data is accessible as a static text log".
|
||
|
||
Each log row is also a `<button>`: a click or Enter/Space jumps
|
||
playback to that shot (pauses and seeks). The list auto-scrolls
|
||
the current row into view as the timeline advances, so the user
|
||
does not have to chase the highlight on long battles.
|
||
|
||
## Playback details
|
||
|
||
On play, the shot line + the defender circle's colour flash gate
|
||
on a per-frame timer that blinks them off during the last 10 % of
|
||
the frame's duration. Two consecutive shots from the same attacker
|
||
on the same defender therefore look like two distinct pulses
|
||
rather than one continuous line. On pause the line and flash
|
||
stay drawn so the user can study the current shot.
|
||
|
||
## Phantom destroys
|
||
|
||
Legacy emitters (the `dg` engine format that feeds the synthetic-
|
||
report path) occasionally log more `Destroyed` lines against a
|
||
ship-group bucket than the bucket's initial population — the
|
||
emitter keeps recording hits past the moment the group emptied.
|
||
`buildFrames` clamps each per-group remaining count at zero and
|
||
only decrements race totals on a real shrink, so a race stays on
|
||
the scene until its actual ships are gone. The phantom shots still
|
||
draw a line during the frame they belong to; only the running
|
||
counters are protected.
|
||
|
||
## Height fit
|
||
|
||
The viewer is pinned to the viewport: `.active-view` uses
|
||
`calc(100dvh − 80px)` so the in-game-shell header + optional
|
||
HistoryBanner do not push the scene below the fold. Inside the
|
||
viewer, the scene grows (`flex: 1`) and the log shrinks to a
|
||
30 dvh ceiling with its own internal scroll, so the page itself
|
||
never scrolls vertically. The 80 px allowance maps to the current
|
||
Header + HistoryBanner total on desktop; mobile breakpoints reuse
|
||
the same calc because dvh tracks the dynamic viewport.
|
||
|
||
## Map markers
|
||
|
||
`map/battle-markers.ts` emits two marker kinds per
|
||
current-turn report. Both are wired into the binding's
|
||
`hitLookup` so a click goes through the existing hit-test plumbing.
|
||
|
||
### Battle marker — yellow cross
|
||
|
||
For every `report.battles[i]` whose `planet` resolves to a visible
|
||
planet, the marker emits two `LinePrim` lines through the opposite
|
||
corners of the square circumscribed around the planet circle. The
|
||
result is an X-shaped cross overlaid on the planet glyph.
|
||
|
||
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
|
||
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
|
||
(`width = 1 + (shots − 1) × 4 / 99`, clamped). A click on either
|
||
line navigates to `/games/<id>/battle/<battleId>?turn=<turn>`.
|
||
|
||
### Bombing marker — colored ring
|
||
|
||
For every `report.bombings[i]`, the marker emits a single
|
||
stroke-only `CirclePrim` slightly larger than the planet circle.
|
||
Colour:
|
||
|
||
- yellow (`#FFD400`) when `wiped: false`,
|
||
- red (`#FF3030`) when `wiped: true`.
|
||
|
||
A click on the ring navigates to `/games/<id>/report#report-bombings`
|
||
and scrolls the matching `report-bombing-row` (by `data-planet`)
|
||
into view. Bombing markers never open the Battle Viewer — the two
|
||
domains stay separate.
|
||
|
||
## Data source
|
||
|
||
The Battle Viewer page (`lib/active-view/battle.svelte`) calls
|
||
`api/battle-fetch.ts.fetchBattle(gameId, turn, battleId)`. The
|
||
loader has two modes:
|
||
|
||
- **Synthetic** — when `gameId` carries the
|
||
`synthetic-` prefix, the lookup is served from
|
||
`api/synthetic-battle.ts`. Vitest unit tests and Playwright e2e
|
||
tests register fixture battles via `registerSyntheticBattle`
|
||
before mounting the route.
|
||
- **Production** — otherwise the loader issues
|
||
`GET /api/v1/user/games/{gameId}/battles/{turn}/{battleId}`
|
||
against the backend gateway route added in
|
||
`backend/internal/server/handlers_user_games.go.Battle`. The
|
||
gateway forwards verbatim to the engine's
|
||
`GET /api/v1/battle/:turn/:uuid`.
|
||
|
||
## TODOs
|
||
|
||
- Push event `game.battle.new` + toast → viewer link (deferred —
|
||
needs an engine emit-side change).
|
||
- Richer ship-class visuals derived from the class's mass,
|
||
armament, shields. Current MVP uses a small circle plus
|
||
`<class>:<numLeft>` label.
|
||
- Animated transitions when a race drops out and the survivors
|
||
re-distribute. Current implementation hard-jumps on the next
|
||
frame.
|