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>
This commit is contained in:
@@ -1,30 +1,134 @@
|
||||
<!--
|
||||
Phase 10 stub for the battle-log active view. Phase 27 wires the real
|
||||
battle viewer.
|
||||
Phase 27 — active-view wrapper around the BattleViewer. Loads the
|
||||
BattleReport for the supplied `gameId`/`turn`/`battleId` and either
|
||||
shows the radial playback (BattleViewer), a loading skeleton, or a
|
||||
not-found state. The viewer itself is a logically isolated
|
||||
component that takes a `BattleReport` prop — this wrapper owns
|
||||
loading and routing concerns.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import {
|
||||
BattleFetchError,
|
||||
fetchBattle,
|
||||
type BattleReport,
|
||||
} from "../../api/battle-fetch";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
|
||||
type Props = { battleId: string };
|
||||
let { battleId }: Props = $props();
|
||||
import BattleViewer from "../battle-player/battle-viewer.svelte";
|
||||
|
||||
let {
|
||||
gameId,
|
||||
turn,
|
||||
battleId,
|
||||
}: {
|
||||
gameId: string;
|
||||
turn: number;
|
||||
battleId: string;
|
||||
} = $props();
|
||||
|
||||
let state = $state<
|
||||
| { kind: "loading" }
|
||||
| { kind: "ready"; report: BattleReport }
|
||||
| { kind: "not_found" }
|
||||
| { kind: "error"; message: string }
|
||||
>({ kind: "loading" });
|
||||
|
||||
$effect(() => {
|
||||
if (!battleId) {
|
||||
state = { kind: "not_found" };
|
||||
return;
|
||||
}
|
||||
state = { kind: "loading" };
|
||||
fetchBattle(gameId, turn, battleId)
|
||||
.then((report) => {
|
||||
state = { kind: "ready", report };
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof BattleFetchError && err.status === 404) {
|
||||
state = { kind: "not_found" };
|
||||
} else {
|
||||
state = {
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function backToReport() {
|
||||
goto(`/games/${gameId}/report`);
|
||||
}
|
||||
function backToMap() {
|
||||
goto(`/games/${gameId}/map`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}>
|
||||
<h2>{i18n.t("game.view.battle")}</h2>
|
||||
<p>{i18n.t("game.shell.coming_soon")}</p>
|
||||
<nav class="back-row">
|
||||
<button
|
||||
type="button"
|
||||
class="back-btn"
|
||||
onclick={backToMap}
|
||||
data-testid="battle-back-to-map"
|
||||
>{i18n.t("game.battle.back_to_map")}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="back-btn"
|
||||
onclick={backToReport}
|
||||
data-testid="battle-back-to-report"
|
||||
>{i18n.t("game.battle.back_to_report")}</button>
|
||||
</nav>
|
||||
|
||||
{#if state.kind === "loading"}
|
||||
<p class="status" data-testid="battle-loading">
|
||||
{i18n.t("game.battle.loading")}
|
||||
</p>
|
||||
{:else if state.kind === "ready"}
|
||||
<BattleViewer report={state.report} />
|
||||
{:else if state.kind === "not_found"}
|
||||
<p class="status" data-testid="battle-not-found">
|
||||
{i18n.t("game.battle.not_found")}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="status error" data-testid="battle-error">{state.message}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.active-view {
|
||||
padding: 1.5rem;
|
||||
padding: 1rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #d6dcf2;
|
||||
}
|
||||
.active-view h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
.back-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
max-width: 880px;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
.active-view p {
|
||||
margin: 0;
|
||||
color: #555;
|
||||
.back-btn {
|
||||
appearance: none;
|
||||
background: #1f2748;
|
||||
color: #d6dcf2;
|
||||
border: 1px solid #2c3568;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.back-btn:hover {
|
||||
background: #2a3463;
|
||||
}
|
||||
.status {
|
||||
margin: 2rem auto;
|
||||
max-width: 880px;
|
||||
color: #93a0d0;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
.status.error {
|
||||
color: #e08585;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,8 @@ preference the store already manages.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
createRenderer,
|
||||
@@ -402,13 +404,40 @@ preference the store already manages.
|
||||
if (selection === undefined) return;
|
||||
const hit = handle.hitAt(cursorPx);
|
||||
if (hit === null) return;
|
||||
if (hit.primitive.kind !== "point") return;
|
||||
const target = hitLookup.get(hit.primitive.id);
|
||||
if (target === undefined) return;
|
||||
if (target.kind === "planet") {
|
||||
selection.selectPlanet(target.number);
|
||||
} else {
|
||||
selection.selectShipGroup(target.ref);
|
||||
switch (target.kind) {
|
||||
case "planet":
|
||||
if (hit.primitive.kind !== "point") return;
|
||||
selection.selectPlanet(target.number);
|
||||
break;
|
||||
case "shipGroup":
|
||||
if (hit.primitive.kind !== "point") return;
|
||||
selection.selectShipGroup(target.ref);
|
||||
break;
|
||||
case "battle": {
|
||||
const gameId = page.params.id ?? "";
|
||||
const turn = store?.report?.turn ?? 0;
|
||||
void goto(
|
||||
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "bombing": {
|
||||
const gameId = page.params.id ?? "";
|
||||
void goto(
|
||||
`/games/${gameId}/report#report-bombings`,
|
||||
).then(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const row = document.querySelector(
|
||||
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
|
||||
);
|
||||
if (row && row.scrollIntoView) {
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<!--
|
||||
Phase 23 Report View — battles section. The wire only carries
|
||||
battle UUIDs (the full battle report is fetched lazily by Phase 27),
|
||||
so each row is a monospace, non-interactive `<span>` of the battle
|
||||
identifier. Phase 27 will turn each row into a link to
|
||||
`/games/<id>/battle/<uuid>`; until then dead links are worse than
|
||||
plain text.
|
||||
Phase 27 Report View — battles section. Each row is a link into the
|
||||
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
|
||||
`turn` follows the current report's turn so history-mode views land
|
||||
on the right battle. Phase 23 rendered the same rows as inactive
|
||||
monospace `<span>`; the rewire here is the one-liner the Phase 23
|
||||
decision log called out.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
@@ -19,7 +20,9 @@ plain text.
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
const report = $derived(rendered?.report ?? null);
|
||||
const ids = $derived(report?.battleIds ?? []);
|
||||
const battles = $derived(report?.battles ?? []);
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const turn = $derived(report?.turn ?? 0);
|
||||
</script>
|
||||
|
||||
<section
|
||||
@@ -31,22 +34,23 @@ plain text.
|
||||
|
||||
{#if report === null}
|
||||
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||
{:else if ids.length === 0}
|
||||
{:else if battles.length === 0}
|
||||
<p class="status" data-testid="battles-empty">
|
||||
{i18n.t("game.report.section.battles.empty")}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="ids" data-testid="battles-list">
|
||||
{#each ids as id (id)}
|
||||
{#each battles as b (b.id)}
|
||||
<li>
|
||||
<span class="label">
|
||||
{i18n.t("game.report.section.battles.id_label")}
|
||||
</span>
|
||||
<span
|
||||
<a
|
||||
class="uuid"
|
||||
href={`/games/${gameId}/battle/${b.id}?turn=${turn}`}
|
||||
data-testid="report-battle-row"
|
||||
data-id={id}
|
||||
>{id}</span>
|
||||
data-id={b.id}
|
||||
>{b.id}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -87,5 +91,10 @@ plain text.
|
||||
.uuid {
|
||||
color: #cfd7ff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.uuid:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<!--
|
||||
BattleScene — radial SVG visualisation of one battle frame.
|
||||
|
||||
Layout: planet at the centre, race anchors equally spaced on an
|
||||
outer ring, each race rendered as a cluster of small class circles
|
||||
labelled `<className>:<numLeft>` underneath. The shot line for the
|
||||
current frame's `lastAction` is drawn from attacker group to
|
||||
defender group; red when the shot destroyed the defender, green
|
||||
otherwise. Observer groups (`inBattle === false`) are filtered out
|
||||
by `buildFrames`, so they never appear here.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { BattleReport } from "../../api/battle-fetch";
|
||||
import { layoutRaces } from "./radial-layout";
|
||||
import {
|
||||
buildGroupRaceMap,
|
||||
normaliseGroups,
|
||||
type Frame,
|
||||
} from "./timeline";
|
||||
|
||||
let {
|
||||
report,
|
||||
frame,
|
||||
}: {
|
||||
report: BattleReport;
|
||||
frame: Frame;
|
||||
} = $props();
|
||||
|
||||
const VIEW_BOX = 800;
|
||||
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
|
||||
const PLANET_RADIUS = 60;
|
||||
const RACE_RING_RADIUS = 280;
|
||||
const CLASS_CIRCLE_RADIUS = 24;
|
||||
const CLASS_SPACING = 64;
|
||||
|
||||
const groupRace = $derived(buildGroupRaceMap(report.protocol));
|
||||
const allGroups = $derived(normaliseGroups(report));
|
||||
|
||||
type ClusterEntry = {
|
||||
key: number;
|
||||
className: string;
|
||||
numLeft: number;
|
||||
};
|
||||
|
||||
const clustersByRace = $derived.by(() => {
|
||||
const out = new Map<number, ClusterEntry[]>();
|
||||
for (const g of allGroups) {
|
||||
const numLeft = frame.remaining.get(g.key) ?? 0;
|
||||
const list = out.get(g.raceId) ?? [];
|
||||
list.push({
|
||||
key: g.key,
|
||||
className: g.group.className,
|
||||
numLeft,
|
||||
});
|
||||
out.set(g.raceId, list);
|
||||
}
|
||||
// Stable cluster order: by classname then key.
|
||||
for (const list of out.values()) {
|
||||
list.sort((a, b) => {
|
||||
const byName = a.className.localeCompare(b.className);
|
||||
if (byName !== 0) return byName;
|
||||
return a.key - b.key;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const raceLayout = $derived(
|
||||
layoutRaces(frame.activeRaceIds, {
|
||||
center: CENTER,
|
||||
radius: RACE_RING_RADIUS,
|
||||
}),
|
||||
);
|
||||
|
||||
function classCircleX(index: number, count: number): number {
|
||||
const span = (count - 1) * CLASS_SPACING;
|
||||
return -span / 2 + index * CLASS_SPACING;
|
||||
}
|
||||
|
||||
function findClassCircleCenter(groupKey: number) {
|
||||
const raceId = groupRace.get(groupKey);
|
||||
if (raceId === undefined) return null;
|
||||
const anchor = raceLayout.find((a) => a.raceId === raceId);
|
||||
if (!anchor) return null;
|
||||
const cluster = clustersByRace.get(raceId) ?? [];
|
||||
const idx = cluster.findIndex((c) => c.key === groupKey);
|
||||
if (idx === -1) return null;
|
||||
return {
|
||||
x: anchor.x + classCircleX(idx, cluster.length),
|
||||
y: anchor.y,
|
||||
};
|
||||
}
|
||||
|
||||
const shotLine = $derived.by(() => {
|
||||
const action = frame.lastAction;
|
||||
if (!action) return null;
|
||||
const from = findClassCircleCenter(action.sa);
|
||||
const to = findClassCircleCenter(action.sd);
|
||||
if (!from || !to) return null;
|
||||
return { from, to, destroyed: action.x };
|
||||
});
|
||||
|
||||
const raceLabelById = $derived.by(() => {
|
||||
const out = new Map<number, string>();
|
||||
for (const g of allGroups) {
|
||||
out.set(g.raceId, g.group.race);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="battle-scene"
|
||||
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
|
||||
role="img"
|
||||
aria-label="battle scene"
|
||||
data-testid="battle-scene"
|
||||
>
|
||||
<circle
|
||||
cx={CENTER.x}
|
||||
cy={CENTER.y}
|
||||
r={PLANET_RADIUS}
|
||||
class="planet"
|
||||
data-testid="battle-scene-planet"
|
||||
/>
|
||||
<text
|
||||
x={CENTER.x}
|
||||
y={CENTER.y + PLANET_RADIUS + 24}
|
||||
text-anchor="middle"
|
||||
class="planet-label"
|
||||
>{report.planetName} (#{report.planet})</text>
|
||||
|
||||
{#each raceLayout as anchor (anchor.raceId)}
|
||||
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
||||
<g
|
||||
class="race-cluster"
|
||||
data-testid="battle-race-cluster"
|
||||
data-race-id={anchor.raceId}
|
||||
>
|
||||
<text
|
||||
x={anchor.x}
|
||||
y={anchor.y - CLASS_CIRCLE_RADIUS - 12}
|
||||
text-anchor="middle"
|
||||
class="race-label"
|
||||
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
||||
{#each cluster as entry, i (entry.key)}
|
||||
{@const cx = anchor.x + classCircleX(i, cluster.length)}
|
||||
<g
|
||||
class="class-marker"
|
||||
data-testid="battle-class-marker"
|
||||
data-group-key={entry.key}
|
||||
>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={anchor.y}
|
||||
r={CLASS_CIRCLE_RADIUS}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={anchor.y + CLASS_CIRCLE_RADIUS + 16}
|
||||
text-anchor="middle"
|
||||
class="class-label"
|
||||
>{entry.className}:{entry.numLeft}</text>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
{#if shotLine}
|
||||
<line
|
||||
x1={shotLine.from.x}
|
||||
y1={shotLine.from.y}
|
||||
x2={shotLine.to.x}
|
||||
y2={shotLine.to.y}
|
||||
class="shot"
|
||||
class:destroyed={shotLine.destroyed}
|
||||
data-testid="battle-shot"
|
||||
data-destroyed={shotLine.destroyed ? "true" : "false"}
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.battle-scene {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: #0a0d1a;
|
||||
display: block;
|
||||
}
|
||||
.planet {
|
||||
fill: #2a345f;
|
||||
stroke: #5b6aa3;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.planet-label {
|
||||
fill: #c4caea;
|
||||
font-size: 18px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.race-label {
|
||||
fill: #e2e6ff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.class-marker circle {
|
||||
fill: #1a2042;
|
||||
stroke: #6d7bb5;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.class-label {
|
||||
fill: #b8c0e6;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
.shot {
|
||||
stroke: #44dd66;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.shot.destroyed {
|
||||
stroke: #ee3344;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<!--
|
||||
BattleViewer — orchestrates the radial battle scene, the playback
|
||||
controls, and the accessibility text log for one BattleReport. Owns
|
||||
the playback state (`frameIndex`, `playing`, `speed`). The component
|
||||
is logically isolated: feed it any `BattleReport` matching
|
||||
`pkg/model/report/battle.go` and it plays back.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import type { BattleReport } from "../../api/battle-fetch";
|
||||
|
||||
import BattleScene from "./battle-scene.svelte";
|
||||
import PlaybackControls from "./playback-controls.svelte";
|
||||
import { buildFrames } from "./timeline";
|
||||
|
||||
let { report }: { report: BattleReport } = $props();
|
||||
|
||||
const frames = $derived(buildFrames(report));
|
||||
let frameIndex = $state(0);
|
||||
let playing = $state(false);
|
||||
let speed = $state<1 | 2 | 4>(1);
|
||||
|
||||
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
|
||||
|
||||
// 1x = 400 ms per frame, 2x = 200 ms, 4x = 100 ms. The timer is
|
||||
// rescheduled whenever `speed` or `playing` flips.
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
$effect(() => {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (!playing) return;
|
||||
const intervalMs = 400 / speed;
|
||||
timer = setInterval(() => {
|
||||
if (frameIndex >= frames.length - 1) {
|
||||
playing = false;
|
||||
return;
|
||||
}
|
||||
frameIndex = frameIndex + 1;
|
||||
}, intervalMs);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
});
|
||||
|
||||
function describeAction(index: number): string {
|
||||
const action = report.protocol[index];
|
||||
const attackerGroup = report.ships[String(action.sa)];
|
||||
const defenderGroup = report.ships[String(action.sd)];
|
||||
const attackerRace = attackerGroup?.race ?? `race ${action.a}`;
|
||||
const attackerClass = attackerGroup?.className ?? `class ${action.sa}`;
|
||||
const defenderRace = defenderGroup?.race ?? `race ${action.d}`;
|
||||
const defenderClass = defenderGroup?.className ?? `class ${action.sd}`;
|
||||
const key = action.x
|
||||
? "game.battle.log.destroyed"
|
||||
: "game.battle.log.shielded";
|
||||
return i18n.t(key, {
|
||||
attacker_race: attackerRace,
|
||||
attacker_class: attackerClass,
|
||||
defender_race: defenderRace,
|
||||
defender_class: defenderClass,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="viewer" data-testid="battle-viewer">
|
||||
<header class="header">
|
||||
<h2 data-testid="battle-viewer-title">
|
||||
{i18n.t("game.battle.title")}
|
||||
</h2>
|
||||
<span class="progress" data-testid="battle-frame-index">
|
||||
{frame.shotIndex} / {report.protocol.length}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="scene">
|
||||
<BattleScene {report} {frame} />
|
||||
</div>
|
||||
|
||||
<PlaybackControls
|
||||
bind:playing
|
||||
bind:frameIndex
|
||||
bind:speed
|
||||
frameCount={frames.length}
|
||||
/>
|
||||
|
||||
<section
|
||||
class="log"
|
||||
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
|
||||
>
|
||||
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
|
||||
<ol data-testid="battle-protocol-log">
|
||||
{#each report.protocol as _action, i (i)}
|
||||
<li
|
||||
data-testid="battle-protocol-log-item"
|
||||
data-current={i + 1 === frame.shotIndex ? "true" : "false"}
|
||||
>{describeAction(i)}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
color: #d6dcf2;
|
||||
font-family: inherit;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.progress {
|
||||
color: #93a0d0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.scene {
|
||||
background: #0a0d1a;
|
||||
border: 1px solid #1e264a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
color: #93a0d0;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.log ol {
|
||||
list-style: decimal inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
color: #c6cdf0;
|
||||
}
|
||||
.log li {
|
||||
padding: 0.15rem 0;
|
||||
border-bottom: 1px solid #1c2240;
|
||||
}
|
||||
.log li[data-current="true"] {
|
||||
color: #ffe27a;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!--
|
||||
PlaybackControls — rewind / step-back / play-pause / step-forward
|
||||
plus a 1x/2x/4x speed switch. Owns no playback state; bind `playing`,
|
||||
`frameIndex`, and `speed` from the orchestrator. Disables step/rewind
|
||||
when there's nowhere to go and disables forward when the timeline is
|
||||
already at its end.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
|
||||
let {
|
||||
playing = $bindable(),
|
||||
frameIndex = $bindable(),
|
||||
speed = $bindable(),
|
||||
frameCount,
|
||||
}: {
|
||||
playing: boolean;
|
||||
frameIndex: number;
|
||||
speed: 1 | 2 | 4;
|
||||
frameCount: number;
|
||||
} = $props();
|
||||
|
||||
function rewind() {
|
||||
playing = false;
|
||||
frameIndex = 0;
|
||||
}
|
||||
function stepBack() {
|
||||
playing = false;
|
||||
if (frameIndex > 0) frameIndex = frameIndex - 1;
|
||||
}
|
||||
function togglePlay() {
|
||||
if (frameIndex >= frameCount - 1) {
|
||||
frameIndex = 0;
|
||||
}
|
||||
playing = !playing;
|
||||
}
|
||||
function stepForward() {
|
||||
playing = false;
|
||||
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
|
||||
}
|
||||
function setSpeed(value: 1 | 2 | 4) {
|
||||
speed = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="controls" data-testid="battle-controls">
|
||||
<button
|
||||
type="button"
|
||||
onclick={rewind}
|
||||
disabled={frameIndex === 0}
|
||||
aria-label={i18n.t("game.battle.controls.rewind")}
|
||||
data-testid="battle-control-rewind"
|
||||
>⏮</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={stepBack}
|
||||
disabled={frameIndex === 0}
|
||||
aria-label={i18n.t("game.battle.controls.step_backward")}
|
||||
data-testid="battle-control-step-back"
|
||||
>◀︎</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePlay}
|
||||
aria-label={playing
|
||||
? i18n.t("game.battle.controls.pause")
|
||||
: i18n.t("game.battle.controls.play")}
|
||||
data-testid="battle-control-play"
|
||||
data-playing={playing ? "true" : "false"}
|
||||
>{playing ? "⏸" : "▶︎"}</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={stepForward}
|
||||
disabled={frameIndex >= frameCount - 1}
|
||||
aria-label={i18n.t("game.battle.controls.step_forward")}
|
||||
data-testid="battle-control-step-forward"
|
||||
>▶︎▶︎</button>
|
||||
|
||||
<div class="spacer" aria-hidden="true"></div>
|
||||
|
||||
<span class="speed-label">{i18n.t("game.battle.controls.speed_label")}</span>
|
||||
<button
|
||||
type="button"
|
||||
class:active={speed === 1}
|
||||
onclick={() => setSpeed(1)}
|
||||
data-testid="battle-control-speed-1x"
|
||||
>{i18n.t("game.battle.controls.speed_1x")}</button>
|
||||
<button
|
||||
type="button"
|
||||
class:active={speed === 2}
|
||||
onclick={() => setSpeed(2)}
|
||||
data-testid="battle-control-speed-2x"
|
||||
>{i18n.t("game.battle.controls.speed_2x")}</button>
|
||||
<button
|
||||
type="button"
|
||||
class:active={speed === 4}
|
||||
onclick={() => setSpeed(4)}
|
||||
data-testid="battle-control-speed-4x"
|
||||
>{i18n.t("game.battle.controls.speed_4x")}</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #131934;
|
||||
border: 1px solid #1e264a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
button {
|
||||
appearance: none;
|
||||
background: #1f2748;
|
||||
color: #d6dcf2;
|
||||
border: 1px solid #2c3568;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
background: #2a3463;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.active {
|
||||
background: #3a4585;
|
||||
border-color: #5d6cb8;
|
||||
color: #ffffff;
|
||||
}
|
||||
.speed-label {
|
||||
color: #93a0d0;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
// Radial layout for the BattleViewer.
|
||||
//
|
||||
// Places race anchors on a circle of radius `radius` around `center`
|
||||
// at equal angular spacing. The first anchor sits at the top (12
|
||||
// o'clock); subsequent anchors march clockwise. When a race is
|
||||
// eliminated mid-battle, the caller filters it out of `activeRaceIds`
|
||||
// and the survivors are re-spaced on the next frame. The same helper
|
||||
// drives both the initial layout and that re-distribution.
|
||||
|
||||
export interface RaceAnchor {
|
||||
raceId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Angle in radians measured from the positive Y axis clockwise. */
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export interface RadialLayoutOptions {
|
||||
center: { x: number; y: number };
|
||||
radius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* layoutRaces returns anchor positions for each `activeRaceIds`
|
||||
* entry, placed at equal angular spacing on a circle. The input
|
||||
* order is preserved so consumers get a stable mapping across
|
||||
* frames; eliminated entries should simply be filtered out before
|
||||
* the call.
|
||||
*/
|
||||
export function layoutRaces(
|
||||
activeRaceIds: number[],
|
||||
options: RadialLayoutOptions,
|
||||
): RaceAnchor[] {
|
||||
const count = activeRaceIds.length;
|
||||
if (count === 0) return [];
|
||||
const { center, radius } = options;
|
||||
const out: RaceAnchor[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 12 o'clock = -PI/2 in math convention; clockwise → +i*step.
|
||||
const step = (2 * Math.PI) / count;
|
||||
const angle = -Math.PI / 2 + i * step;
|
||||
out.push({
|
||||
raceId: activeRaceIds[i],
|
||||
x: center.x + radius * Math.cos(angle),
|
||||
y: center.y + radius * Math.sin(angle),
|
||||
angle,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Timeline builder for the BattleViewer.
|
||||
//
|
||||
// Given a `BattleReport`, expands the flat `protocol` into a
|
||||
// sequence of frames. Frame 0 carries the initial state; frame N
|
||||
// (1 ≤ N ≤ protocol.length) reflects the state right after the
|
||||
// (N-1)-th action has been applied. Each frame is self-contained so
|
||||
// stepping forward and backward is a constant-time index lookup, no
|
||||
// rewind logic needed.
|
||||
|
||||
import type {
|
||||
BattleActionReport,
|
||||
BattleReport,
|
||||
BattleReportGroup,
|
||||
} from "../../api/battle-fetch";
|
||||
|
||||
/**
|
||||
* Frame is one tick of the battle playback. `remaining` carries the
|
||||
* surviving ship count for each ship-group key from
|
||||
* `BattleReport.ships`; `activeRaceIds` are the race indices with at
|
||||
* least one surviving in-battle group. `lastAction` is the action
|
||||
* applied to produce this frame, or `null` for the initial frame.
|
||||
*/
|
||||
export interface Frame {
|
||||
shotIndex: number;
|
||||
remaining: Map<number, number>;
|
||||
activeRaceIds: number[];
|
||||
lastAction: BattleActionReport | null;
|
||||
}
|
||||
|
||||
export interface NormalisedGroup {
|
||||
key: number;
|
||||
group: BattleReportGroup;
|
||||
raceId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* normaliseGroups returns the in-battle ship groups from a
|
||||
* BattleReport indexed by their integer key. Observer groups
|
||||
* (`inBattle === false`) are skipped because they are neither
|
||||
* targeted nor drawn. The race index per group is derived from the
|
||||
* protocol — every in-battle group appears at least once as
|
||||
* attacker or defender, and the engine's pairing (a, sa) / (d, sd)
|
||||
* defines the relationship.
|
||||
*/
|
||||
export function normaliseGroups(report: BattleReport): NormalisedGroup[] {
|
||||
const raceByKey = buildGroupRaceMap(report.protocol);
|
||||
const out: NormalisedGroup[] = [];
|
||||
for (const [keyRaw, group] of Object.entries(report.ships)) {
|
||||
if (!group.inBattle) continue;
|
||||
const key = Number(keyRaw);
|
||||
if (!Number.isFinite(key)) continue;
|
||||
const raceId = raceByKey.get(key);
|
||||
if (raceId === undefined) continue;
|
||||
out.push({ key, group, raceId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* buildGroupRaceMap extracts the `ship-group key → race index`
|
||||
* mapping from a battle protocol. Same key appearing twice always
|
||||
* carries the same race index — protocol entries are emitted by the
|
||||
* engine, which never crosses these wires.
|
||||
*/
|
||||
export function buildGroupRaceMap(
|
||||
protocol: BattleActionReport[],
|
||||
): Map<number, number> {
|
||||
const out = new Map<number, number>();
|
||||
for (const action of protocol) {
|
||||
if (!out.has(action.sa)) out.set(action.sa, action.a);
|
||||
if (!out.has(action.sd)) out.set(action.sd, action.d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* buildFrames walks the protocol once and emits a frame after each
|
||||
* applied action plus the initial frame. The remaining-ships map is
|
||||
* cloned per frame so callers can step backward without manual
|
||||
* bookkeeping. Eliminated races drop out of `activeRaceIds` as soon
|
||||
* as their last in-battle group hits zero.
|
||||
*/
|
||||
export function buildFrames(report: BattleReport): Frame[] {
|
||||
const groups = normaliseGroups(report);
|
||||
const initialRemaining = new Map<number, number>();
|
||||
const raceTotals = new Map<number, number>();
|
||||
for (const g of groups) {
|
||||
initialRemaining.set(g.key, g.group.num);
|
||||
raceTotals.set(g.raceId, (raceTotals.get(g.raceId) ?? 0) + g.group.num);
|
||||
}
|
||||
|
||||
const frames: Frame[] = [];
|
||||
frames.push({
|
||||
shotIndex: 0,
|
||||
remaining: new Map(initialRemaining),
|
||||
activeRaceIds: collectActiveRaces(raceTotals),
|
||||
lastAction: null,
|
||||
});
|
||||
|
||||
const groupRaceByKey = new Map<number, number>();
|
||||
for (const g of groups) groupRaceByKey.set(g.key, g.raceId);
|
||||
|
||||
const current = new Map(initialRemaining);
|
||||
const runningRaceTotals = new Map(raceTotals);
|
||||
for (let i = 0; i < report.protocol.length; i++) {
|
||||
const action = report.protocol[i];
|
||||
if (action.x) {
|
||||
const left = current.get(action.sd) ?? 0;
|
||||
const next = Math.max(0, left - 1);
|
||||
current.set(action.sd, next);
|
||||
const raceId = groupRaceByKey.get(action.sd);
|
||||
if (raceId !== undefined) {
|
||||
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
|
||||
runningRaceTotals.set(raceId, Math.max(0, t));
|
||||
}
|
||||
}
|
||||
frames.push({
|
||||
shotIndex: i + 1,
|
||||
remaining: new Map(current),
|
||||
activeRaceIds: collectActiveRaces(runningRaceTotals),
|
||||
lastAction: action,
|
||||
});
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
function collectActiveRaces(totals: Map<number, number>): number[] {
|
||||
const out: number[] = [];
|
||||
for (const [raceId, total] of totals.entries()) {
|
||||
if (total > 0) out.push(raceId);
|
||||
}
|
||||
return out.sort((a, b) => a - b);
|
||||
}
|
||||
@@ -483,6 +483,23 @@ const en = {
|
||||
"game.report.section.battles.title": "battles",
|
||||
"game.report.section.battles.empty": "no battles last turn",
|
||||
"game.report.section.battles.id_label": "battle",
|
||||
"game.battle.title": "battle",
|
||||
"game.battle.loading": "loading battle…",
|
||||
"game.battle.not_found": "battle not found",
|
||||
"game.battle.back_to_report": "back to report",
|
||||
"game.battle.back_to_map": "back to map",
|
||||
"game.battle.controls.play": "play",
|
||||
"game.battle.controls.pause": "pause",
|
||||
"game.battle.controls.step_forward": "step forward",
|
||||
"game.battle.controls.step_backward": "step back",
|
||||
"game.battle.controls.rewind": "rewind to start",
|
||||
"game.battle.controls.speed_label": "speed",
|
||||
"game.battle.controls.speed_1x": "1x",
|
||||
"game.battle.controls.speed_2x": "2x",
|
||||
"game.battle.controls.speed_4x": "4x",
|
||||
"game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}",
|
||||
"game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held",
|
||||
"game.battle.accessibility.protocol_heading": "battle log",
|
||||
"game.report.section.bombings.title": "bombings",
|
||||
"game.report.section.bombings.empty": "no bombings last turn",
|
||||
"game.report.section.bombings.column.planet": "planet",
|
||||
|
||||
@@ -484,6 +484,23 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.report.section.battles.title": "сражения",
|
||||
"game.report.section.battles.empty": "сражений в этом ходу не было",
|
||||
"game.report.section.battles.id_label": "сражение",
|
||||
"game.battle.title": "сражение",
|
||||
"game.battle.loading": "загрузка сражения…",
|
||||
"game.battle.not_found": "сражение не найдено",
|
||||
"game.battle.back_to_report": "к отчёту",
|
||||
"game.battle.back_to_map": "к карте",
|
||||
"game.battle.controls.play": "запустить",
|
||||
"game.battle.controls.pause": "пауза",
|
||||
"game.battle.controls.step_forward": "шаг вперёд",
|
||||
"game.battle.controls.step_backward": "шаг назад",
|
||||
"game.battle.controls.rewind": "к началу",
|
||||
"game.battle.controls.speed_label": "скорость",
|
||||
"game.battle.controls.speed_1x": "1x",
|
||||
"game.battle.controls.speed_2x": "2x",
|
||||
"game.battle.controls.speed_4x": "4x",
|
||||
"game.battle.log.destroyed": "{attacker_class} расы {attacker_race} уничтожает {defender_class} расы {defender_race}",
|
||||
"game.battle.log.shielded": "{attacker_class} расы {attacker_race} попадает в {defender_class} расы {defender_race}, щиты выдержали",
|
||||
"game.battle.accessibility.protocol_heading": "протокол сражения",
|
||||
"game.report.section.bombings.title": "бомбардировки",
|
||||
"game.report.section.bombings.empty": "бомбардировок в этом ходу не было",
|
||||
"game.report.section.bombings.column.planet": "планета",
|
||||
|
||||
Reference in New Issue
Block a user