e2aba856b5
Layout reshuffle so the scene captures the maximum viewer area: - Header collapses three rows into one: `back to map` / `back to report` on the left, the centred title `Battle on planet <name> (#<number>)` (new i18n key `game.battle.header_title`), and the frame counter on the right. The wrapper `.active-view` no longer renders its own back-row; routes flow through props. - Viewer drops the `max-width: 880px` cap so on a wide monitor the scene scales up across the full active-view-host. - A drag-seek `<input type="range">` sits between the scene and the controls; dragging pauses playback and lands `frameIndex` on the chosen shot. - Speed control is one cycling button: `1x → 2x → 4x → 6x → 1x`. The label shows the current speed; the new 6x adds a 67 ms frame interval for skimming a long timeline. - The text protocol log is now collapsible behind a `Log ▲▼` toggle in the controls bar. The toggle is its own button; the default state stays expanded. Collapsing the log hands the remaining height to the scene. - Numerical list markers (`1. 2. 3.`) are dropped from the log; `list-style: none` keeps each row visually clean. Static cluster + visibility filter: - `staticBucketsByRace` now locks bucket order, mass, radius and local Vogel-spiral positions for the lifetime of the viewer; it only re-derives when `report` or the wasm `core` change. - `renderedByRace` overlays the per-frame `remaining` map and drops buckets whose `numLeft` hits zero. The surviving buckets keep their slots, so a class emptying never reshuffles the cluster — the empty bucket simply disappears. - A shot whose attacker or defender bucket is no longer visible draws no line (phantom shots into already-empty buckets are silently skipped, matching the user expectation that pup at 0 should stop attracting fire visually). - Race label clamps to a minimum y inside the SVG viewport so three-or-more-race layouts with a north anchor never clip the top race name off-canvas. Duel layout (user suggestion): - `layoutRaces` rotates the radial start angle by 90° when only two participants remain, so race 0 lands at 9 o'clock and race 1 at 3 o'clock. The pair faces off horizontally; neither label pushes against the SVG top edge. The existing test for two-race positions is updated accordingly. Tests: the existing `layoutRaces` two-race case is rewritten for the horizontal duel; the `game-shell-stubs` battle case checks the loading placeholder (back buttons now live in the loaded viewer, not the wrapper). 644 Vitest cases stay green; 4 Playwright battle-viewer cases stay green. Docs: `ui/docs/battle-viewer-ux.md` documents the static cluster / visibility filter, the duel layout, the scrubber, the cycling speed button and the collapsible log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
10 KiB
Markdown
230 lines
10 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` 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.
|
||
|
||
## 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.
|