Files
galaxy-game/ui/docs/battle-viewer-ux.md
T
Ilia Denisov e2aba856b5 ui/phase-27: viewer layout pass + static cluster + duel layout
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>
2026-05-13 17:38:46 +02:00

230 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.