Files
galaxy-game/ui/docs/battle-viewer-ux.md
T
Ilia Denisov 2e7478f5ea ui/phase-27: skip phantom frames during play + freeze final layout
Two more KNNTS041 viewer fixes:

1. Phantom-frame fast-forward. `buildFrames` now flags every frame
   whose shot landed on an already-empty defender group as
   `phantom: true`. During play the BattleViewer effect detects a
   phantom frame and chains a 0 ms timer to the next non-phantom,
   so streaks of phantoms (the ~30 frames between shots 224 and
   255, and the 401..414 stretch) collapse from "the player just
   mots the timeline" into a single visual tick. Step controls and
   the scrubber can still land on a phantom deliberately for
   protocol inspection.

2. Final-frame layout freeze. `displayFrame` derives from the raw
   `frames[i]` and, on the very last frame when `activeRaceIds`
   shrinks vs the penultimate frame (the killing blow eliminates a
   race), substitutes the penultimate's `remaining` and
   `activeRaceIds` while keeping the current `shotIndex` and
   `lastAction`. The result: the surviving cluster no longer
   reflows onto the planet ring on the very last shot — the user
   sees the killing line + defender flash rendered against the
   picture they saw a moment earlier.

Tests: `phantom-destroy clamp` case extended with `frame.phantom`
flag assertions across the protocol; 644 Vitest cases stay green,
4 Playwright `battle-viewer` cases stay green.

Docs: `ui/docs/battle-viewer-ux.md` documents the fast-forward
behaviour and the final-frame freeze.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:16:11 +02:00

11 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 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 (and Shields) lines against a ship-group bucket than the bucket's initial population — the emitter keeps recording hits past the moment a group emptied. buildFrames marks every such frame as phantom: true and skips the race-total decrement so the race stays on the scene until its actual ships are gone.

During play the BattleViewer fast-forwards through streaks of phantom frames via a 0 ms timer so the user never sees a silent gap (KNNTS041 had ~30 phantom frames between shots 224 and 255 right after the last Nails:pup died). Step controls and the scrubber can still land on a phantom frame deliberately — useful when inspecting the protocol entry that the engine emitted into the void.

Final-frame freeze

When the last protocol action eliminates a race, the surviving side would otherwise reflow alone to the planet ring at the very last shot — visually jarring and uninformative. displayFrame freezes the layout-determining state (remaining and activeRaceIds) at the penultimate frame's values while keeping the final frame's shotIndex and lastAction, so the killing shot still renders as a line + flash against the picture the user saw a moment earlier.

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.