e2aba856b5
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>
173 lines
4.3 KiB
Svelte
173 lines
4.3 KiB
Svelte
<!--
|
|
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.
|
|
|
|
This wrapper also bridges the surrounding GameReport's ship-class
|
|
tables into a `(race, className) → ShipClassRef` lookup the viewer
|
|
needs to size class circles by ship mass. The back-navigation
|
|
buttons (`back to map` / `back to report`) live INSIDE the viewer
|
|
header now — we just hand the routes down as callbacks so the
|
|
viewer keeps its prop-driven contract.
|
|
-->
|
|
<script lang="ts">
|
|
import { getContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
|
|
import {
|
|
BattleFetchError,
|
|
fetchBattle,
|
|
type BattleReport,
|
|
} from "../../api/battle-fetch";
|
|
import { i18n } from "$lib/i18n/index.svelte";
|
|
import {
|
|
RENDERED_REPORT_CONTEXT_KEY,
|
|
type RenderedReportSource,
|
|
} from "$lib/rendered-report.svelte";
|
|
import {
|
|
MapShipClassLookup,
|
|
type ShipClassLookup,
|
|
type ShipClassRef,
|
|
} from "../battle-player/mass";
|
|
|
|
import BattleViewer from "../battle-player/battle-viewer.svelte";
|
|
|
|
let {
|
|
gameId,
|
|
turn,
|
|
battleId,
|
|
}: {
|
|
gameId: string;
|
|
turn: number;
|
|
battleId: string;
|
|
} = $props();
|
|
|
|
const rendered = getContext<RenderedReportSource | undefined>(
|
|
RENDERED_REPORT_CONTEXT_KEY,
|
|
);
|
|
|
|
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
|
|
const map = new Map<string, ShipClassRef>();
|
|
const report = rendered?.report;
|
|
if (report) {
|
|
for (const cls of report.localShipClass) {
|
|
map.set(`${report.race}::${cls.name}`, {
|
|
drive: cls.drive,
|
|
weapons: cls.weapons,
|
|
armament: cls.armament,
|
|
shields: cls.shields,
|
|
cargo: cls.cargo,
|
|
});
|
|
}
|
|
for (const cls of report.otherShipClass) {
|
|
map.set(`${cls.race}::${cls.name}`, {
|
|
drive: cls.drive,
|
|
weapons: cls.weapons,
|
|
armament: cls.armament,
|
|
shields: cls.shields,
|
|
cargo: cls.cargo,
|
|
});
|
|
}
|
|
}
|
|
return new MapShipClassLookup(map);
|
|
});
|
|
|
|
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}
|
|
>
|
|
{#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}
|
|
{shipClassLookup}
|
|
onBackToMap={backToMap}
|
|
onBackToReport={backToReport}
|
|
/>
|
|
{: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 {
|
|
display: flex;
|
|
flex-direction: column;
|
|
/*
|
|
* The in-game shell renders this active view inside an
|
|
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
|
|
* the surrounding `.game-shell` uses `min-height: 100vh`, so
|
|
* without a hard upper bound the viewer pushes the whole
|
|
* shell past the viewport. We pin the active view to `100dvh`
|
|
* minus a small allowance for the header chrome (in-game
|
|
* Header + optional HistoryBanner ≈ 66 px on desktop) so the
|
|
* internal flex chain can split the remaining height between
|
|
* the scene, scrubber, controls and log without forcing a
|
|
* page-level scroll.
|
|
*/
|
|
height: calc(100dvh - 80px);
|
|
max-height: calc(100dvh - 80px);
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
box-sizing: border-box;
|
|
font-family: system-ui, sans-serif;
|
|
color: #d6dcf2;
|
|
}
|
|
.status {
|
|
margin: 2rem auto;
|
|
max-width: 880px;
|
|
color: #93a0d0;
|
|
font-size: 0.95rem;
|
|
text-align: center;
|
|
}
|
|
.status.error {
|
|
color: #e08585;
|
|
}
|
|
</style>
|