Files
galaxy-game/ui/docs/battle-viewer-ux.md
T
Ilia Denisov 969c0480ba ui/phase-27: battle viewer (radial scene, playback, map markers)
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>
2026-05-13 12:24:20 +02:00

5.6 KiB
Raw Blame History

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.