8c260f8715
Three Phase-27 BattleViewer refinements on top of the radial scene:
1. Height fit. The viewer is pinned to `calc(100dvh − 80px)` so it
never pushes the in-game shell past the viewport. `.active-view`
gains `overflow: hidden` + flex column; `.viewer` becomes a
`flex: 1` child; the always-visible text log shrinks to a 30 dvh
ceiling with its own scroll. A global `body { margin: 0 }`
reset (added to `app.html`) plugs the 16 px the browser's
default body margin used to leak.
2. Mass-based ship-class circles. New `lib/battle-player/mass.ts`
carries the radius formula and the per-battle FullMass compute:
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) * sqrt(mass / max)`,
clamped to `[6, 24] px`. FullMass goes through the existing
wasm bridge (`emptyMass` → `carryingMass` → `fullMass`) — no
new wire fields. The viewer page resolves a
`(race, className) → ShipClassRef` lookup from the parent
GameReport's `localShipClass` + `otherShipClass` tables and
passes it to the viewer via context. Unknown class or
degenerate (weapons/armament) params fall back to MAX_RADIUS
so the bucket stays visible.
3. Cloud cluster layout. Cluster key shifts from per-group
`g.key` to `(raceId, className)` so tech-variants of the same
hull collapse into one visual bucket. The horizontal
classCircleX row is replaced by a Vogel sunflower spiral in
the local `(u, v)` basis — `u` points from the race anchor to
the planet, `v` is `u` rotated 90° clockwise. Buckets are
sorted by NumberLeft desc; the cluster anchor is pushed inward
by a quarter step so rank-0 sits closest to the planet. The
step is adaptive (`min(baseStep, MAX_CLUSTER_RADIUS / sqrt(N))`)
so clusters with many classes do not spill into neighbours.
Tests:
- Vitest: `radiusForMass` covering zero / max / quarter-mass /
out-of-range cases (6 cases).
- Playwright: new `battle-viewer.spec.ts` case asserts
`document.documentElement.scrollHeight - window.innerHeight ≤ 4`
at a 1280×720 desktop viewport. The existing fixture gains
`localShipClass` + `otherShipClass` so the lookup has data to
render proportional circles.
Docs: `ui/docs/battle-viewer-ux.md` rewrites the "Radial scene"
section (cloud layout, mass-based radius, height fit) and adds
a "Height fit" subsection. `docs/FUNCTIONAL.md` §6.5 (+ ru
mirror) get the one-line story about per-mass sizing, cluster
aggregation, and the viewport-locked layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
7.2 KiB
Markdown
169 lines
7.2 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).
|
||
|
||
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".
|
||
|
||
## 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.
|