969c0480ba
Engine wire change: Report.battle switched from []uuid.UUID to
[]BattleSummary{id, planet, shots} so the map can place battle
markers without N extra fetches. FBS schema + generated Go/TS
regenerated; transcoder + report controller updated; openapi
adds the BattleSummary schema with a freeze test.
Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler
plus engineclient.FetchBattle, contract test stub, openapi spec).
UI:
- BattleViewer (lib/battle-player/) is a logically isolated SVG
radial scene that consumes a BattleReport prop. Planet at the
centre, races on the outer ring at equal angular spacing, race
clusters by (race, className) with <class>:<numLeft> labels;
observer groups (inBattle: false) are not drawn; eliminated
races drop out and survivors re-distribute on the next frame.
- Shot line per frame: red on destroyed, green otherwise; erased
on the next frame. Playback controls: play/pause + step ± +
rewind + 1x/2x/4x speed (400/200/100 ms per frame).
- Page wrapper (lib/active-view/battle.svelte) loads BattleReport
via api/battle-fetch.ts; synthetic-gameId prefix routes to a
fixture loader, otherwise REST through the gateway. Always-
visible <ol> text protocol satisfies the accessibility ask.
- section-battles.svelte links every battle UUID into the viewer.
- map/battle-markers.ts: yellow X cross of 2 LinePrim through the
corners of the planet's circumscribed square (stroke width
clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing
marker is a stroke-only ring (yellow when damaged, red when
wiped). Wired into state-binding.ts; click handler dispatches
battle clicks to the viewer and bombing clicks to the matching
Reports row.
- i18n keys for the viewer in en + ru.
Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru
mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push
event, richer class visuals, animated re-distribution).
Tests: Vitest unit (radial layout + timeline frame builder +
marker stroke formula + marker primitives), Playwright e2e for
the viewer (Reports link → viewer, playback step, not-found),
backend engineclient FetchBattle (200 / 404 / bad input), engine
openapi freezes (BattleReport, BattleReportGroup,
BattleActionReport, BattleSummary, Report.battle items).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
137 lines
5.6 KiB
Markdown
137 lines
5.6 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 is a horizontal
|
||
cluster of small class circles, one per `(race, className)` pair,
|
||
labelled `<className>:<numLeft>` underneath. When a race is wiped
|
||
out, it drops out of the active list and the survivors are
|
||
re-spaced on the next frame.
|
||
|
||
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".
|
||
|
||
## 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.
|