Files
galaxy-game/ui/frontend/src/map/state-binding.ts
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

145 lines
4.5 KiB
TypeScript
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.
// State binding between the typed game report and the renderer's
// World. Phase 11 emitted primitives only for planets; Phase 19
// extends the binding with ship-group primitives (own / foreign / in-
// hyperspace / incoming / unidentified) plus a `hitLookup` map so the
// click handler can dispatch a renderer-side hit back to the right
// selection variant. Later phases extend with ship-class reach
// circles (Phase 17 / 18 in `ui/core/calc/`), reach / visibility
// zones, and battle / bombing markers (Phase 27).
//
// The four planet kinds in the report each map to a distinct style so
// the user can tell own / other-race / uninhabited / unidentified
// planets apart at a glance. The exact colours are Phase 11 defaults
// chosen against the dark theme; Phase 35 polish picks final
// colours and adds theme switching.
import type { GameReport, ReportPlanet } from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte";
import { buildBattleAndBombingMarkers } from "./battle-markers";
import { shipGroupsToPrimitives } from "./ship-groups";
import { World, type Primitive, type PrimitiveID, type Style } from "./world";
const STYLE_LOCAL: Style = {
fillColor: 0x6dd2ff,
fillAlpha: 1,
pointRadiusPx: 6,
};
const STYLE_OTHER: Style = {
fillColor: 0xff8a65,
fillAlpha: 1,
pointRadiusPx: 5,
};
const STYLE_UNINHABITED: Style = {
fillColor: 0xb0bec5,
fillAlpha: 0.85,
pointRadiusPx: 4,
};
const STYLE_UNIDENTIFIED: Style = {
fillColor: 0x546e7a,
fillAlpha: 0.7,
pointRadiusPx: 3,
};
// PlanetIDs occupy the [0, 4_000_000_000) range — well below
// JavaScript's `Number.MAX_SAFE_INTEGER` — so the engine `number`
// (uint64) fits in a primitive id (number) without truncation. The
// binding uses the engine number directly as the primitive id so the
// click handler can recover a planet by hit-test result without an
// extra lookup.
function styleFor(kind: ReportPlanet["kind"]): Style {
switch (kind) {
case "local":
return STYLE_LOCAL;
case "other":
return STYLE_OTHER;
case "uninhabited":
return STYLE_UNINHABITED;
case "unidentified":
return STYLE_UNIDENTIFIED;
}
}
function priorityFor(kind: ReportPlanet["kind"]): number {
switch (kind) {
case "local":
return 4;
case "other":
return 3;
case "uninhabited":
return 2;
case "unidentified":
return 1;
}
}
/**
* HitTarget describes which game entity a renderer-side hit-test
* resolves to. The click handler in `lib/active-view/map.svelte`
* looks the hit primitive's id up in the binding's hitLookup map
* and dispatches `selection.selectPlanet` or
* `selection.selectShipGroup` accordingly.
*/
export type HitTarget =
| { kind: "planet"; number: number }
| { kind: "shipGroup"; ref: ShipGroupRef }
| { kind: "battle"; battleId: string; planet: number }
| { kind: "bombing"; planet: number };
export interface ReportToWorldResult {
world: World;
hitLookup: Map<PrimitiveID, HitTarget>;
}
/**
* reportToWorld translates a GameReport into a renderer-ready World
* containing one Point primitive per planet (all four planet kinds)
* plus the Phase 19 ship-group surface — own / foreign groups
* (on-planet or in-hyperspace), incoming groups (dashed trajectory
* line + clickable point), and unidentified-group blips. The world
* rectangle matches `report.mapWidth` × `report.mapHeight`.
*
* If the report carries zero planets (turn-zero edge cases or seeded
* tests), the World is still well-formed: the renderer mounts on an
* empty primitive list without errors.
*/
export function reportToWorld(report: GameReport): ReportToWorldResult {
const primitives: Primitive[] = [];
const hitLookup = new Map<PrimitiveID, HitTarget>();
for (const planet of report.planets) {
primitives.push({
kind: "point",
id: planet.number,
priority: priorityFor(planet.kind),
style: styleFor(planet.kind),
hitSlopPx: 0,
x: planet.x,
y: planet.y,
});
hitLookup.set(planet.number, { kind: "planet", number: planet.number });
}
const groups = shipGroupsToPrimitives(report);
for (const prim of groups.primitives) {
primitives.push(prim);
}
for (const [primId, ref] of groups.lookup) {
hitLookup.set(primId, { kind: "shipGroup", ref });
}
const markers = buildBattleAndBombingMarkers(report);
for (const prim of markers.primitives) {
primitives.push(prim);
}
for (const [primId, target] of markers.lookup) {
hitLookup.set(primId, target);
}
const width = report.mapWidth > 0 ? report.mapWidth : 1;
const height = report.mapHeight > 0 ? report.mapHeight : 1;
return { world: new World(width, height, primitives), hitLookup };
}