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>
7.2 KiB
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'sa/dfields.ships: { [groupKey]: BattleReportGroup }— ship-group rosters withracename,className, initialnum, end-statenumLeft, and theinBattleflag. Observer groups (inBattle: false) are never drawn.protocol: BattleActionReport[]— flat list of shots. Each carries attacker(a, sa), defender(d, sd), andx(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'sx === 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) whenwiped: false, - red (
#FF3030) whenwiped: 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
gameIdcarries thesynthetic-prefix, the lookup is served fromapi/synthetic-battle.ts. Vitest unit tests and Playwright e2e tests register fixture battles viaregisterSyntheticBattlebefore mounting the route. - Production — otherwise the loader issues
GET /api/v1/user/games/{gameId}/battles/{turn}/{battleId}against the backend gateway route added inbackend/internal/server/handlers_user_games.go.Battle. The gateway forwards verbatim to the engine'sGET /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.