2e7478f5ea
Two more KNNTS041 viewer fixes: 1. Phantom-frame fast-forward. `buildFrames` now flags every frame whose shot landed on an already-empty defender group as `phantom: true`. During play the BattleViewer effect detects a phantom frame and chains a 0 ms timer to the next non-phantom, so streaks of phantoms (the ~30 frames between shots 224 and 255, and the 401..414 stretch) collapse from "the player just mots the timeline" into a single visual tick. Step controls and the scrubber can still land on a phantom deliberately for protocol inspection. 2. Final-frame layout freeze. `displayFrame` derives from the raw `frames[i]` and, on the very last frame when `activeRaceIds` shrinks vs the penultimate frame (the killing blow eliminates a race), substitutes the penultimate's `remaining` and `activeRaceIds` while keeping the current `shotIndex` and `lastAction`. The result: the surviving cluster no longer reflows onto the planet ring on the very last shot — the user sees the killing line + defender flash rendered against the picture they saw a moment earlier. Tests: `phantom-destroy clamp` case extended with `frame.phantom` flag assertions across the protocol; 644 Vitest cases stay green, 4 Playwright `battle-viewer` cases stay green. Docs: `ui/docs/battle-viewer-ux.md` documents the fast-forward behaviour and the final-frame freeze. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
247 lines
11 KiB
Markdown
247 lines
11 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),
|
||
together with mass, radius and local position. The static layout
|
||
lives in `staticBucketsByRace`; the per-frame derivation
|
||
`renderedByRace` overlays the live `NumberLeft` and drops buckets
|
||
once they hit zero. The remaining buckets keep their slots in the
|
||
cloud, so the cluster does not reshuffle when a class empties — the
|
||
empty bucket simply disappears.
|
||
|
||
Vogel positions are reassigned per rank by their inward distance
|
||
toward the planet, so the rank-0 bucket (the largest by initial
|
||
ship count) always sits at the most-inward spiral slot.
|
||
|
||
When two races remain in battle the radial layout switches to the
|
||
horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This
|
||
keeps both race labels clear of the SVG top edge and reads as the
|
||
two sides facing off naturally.
|
||
|
||
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:
|
||
|
||
| Control | Effect |
|
||
| ------------------ | ------------------------------------------------------- |
|
||
| ⏮ rewind | Stop, jump to frame 0 |
|
||
| ◀︎◀︎ step back | Stop, frame ← frame − 1 |
|
||
| ▶︎ / ⏸ play | Toggle continuous playback |
|
||
| ▶︎▶︎ step forward | Stop, frame ← frame + 1 |
|
||
| `Nx` cycle speed | Single button, cycles 1x → 2x → 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) |
|
||
| `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height |
|
||
|
||
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.
|
||
|
||
A drag-seek slider sits between the scene and the controls. Dragging
|
||
pauses playback and lands `frameIndex` on the chosen shot — handy
|
||
for jumping to the moment a particular race started losing ground.
|
||
|
||
## 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` (and `Shields`)
|
||
lines against a ship-group bucket than the bucket's initial
|
||
population — the emitter keeps recording hits past the moment a
|
||
group emptied. `buildFrames` marks every such frame as
|
||
`phantom: true` and skips the race-total decrement so the race
|
||
stays on the scene until its actual ships are gone.
|
||
|
||
During play the BattleViewer fast-forwards through streaks of
|
||
phantom frames via a 0 ms timer so the user never sees a silent
|
||
gap (KNNTS041 had ~30 phantom frames between shots 224 and 255
|
||
right after the last `Nails:pup` died). Step controls and the
|
||
scrubber can still land on a phantom frame deliberately — useful
|
||
when inspecting the protocol entry that the engine emitted into
|
||
the void.
|
||
|
||
## Final-frame freeze
|
||
|
||
When the last protocol action eliminates a race, the surviving
|
||
side would otherwise reflow alone to the planet ring at the very
|
||
last shot — visually jarring and uninformative. `displayFrame`
|
||
freezes the layout-determining state (`remaining` and
|
||
`activeRaceIds`) at the penultimate frame's values while keeping
|
||
the final frame's `shotIndex` and `lastAction`, so the killing
|
||
shot still renders as a line + flash against the picture the user
|
||
saw a moment earlier.
|
||
|
||
## Header + layout
|
||
|
||
The viewer header carries three rows of chrome in a single line:
|
||
the back-navigation buttons (`back to map` / `back to report`) on
|
||
the left, a centred title — `Battle on planet <name> (<#number>)`,
|
||
i18n key `game.battle.header_title` — and the frame counter on the
|
||
right. Pulling navigation into the header frees the entire viewer
|
||
area for the scene; the `.viewer` container has no `max-width` cap,
|
||
so on wide monitors the scene scales up while the log keeps its
|
||
internal 30 dvh scroll.
|
||
|
||
## 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`), the scrubber + controls hold
|
||
their natural height, and the log (when expanded) 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.
|