Files
galaxy-game/ui/frontend/src/lib/active-view/battle.svelte
T
Ilia Denisov e2aba856b5 ui/phase-27: viewer layout pass + static cluster + duel layout
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>
2026-05-13 17:38:46 +02:00

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>