Files
galaxy-game/ui/frontend/src/lib/battle-player/battle-scene.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

439 lines
12 KiB
Svelte

<!--
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 *cloud* of class circles
arranged on a Vogel sunflower spiral. Spiral positions are
reassigned per rank by their inward distance toward the planet so
the rank-0 bucket (the bucket with the largest initial ship count)
always sits at the most-inward Vogel slot.
Tech-variant groups of the same `(race, className)` collapse to one
visual node — per-tech detail lives in Reports. Each circle's
radius scales with the per-ship FullMass (sqrt) so heavy ships
visually dominate. Order, position, radius and mass are locked at
battle start; only NumberLeft (the label number) and per-bucket
visibility change per frame. Empty buckets are hidden so the
remaining ones keep their original spots without reshuffling.
Observer groups (`inBattle === false`) are filtered out by
`buildFrames`. Same-race opponents are forbidden by the engine's
combat filter, so a shot never collapses to a single visual node.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { BattleReport } from "../../api/battle-fetch";
import {
CORE_CONTEXT_KEY,
type CoreHandle,
} from "$lib/core-context.svelte";
import { layoutRaces } from "./radial-layout";
import {
computeBattleGroupMass,
radiusForMass,
MAX_RADIUS,
type ShipClassLookup,
} from "./mass";
import {
buildGroupRaceMap,
normaliseGroups,
type Frame,
} from "./timeline";
let {
report,
frame,
shipClassLookup,
shotVisible = true,
}: {
report: BattleReport;
frame: Frame;
shipClassLookup?: ShipClassLookup;
shotVisible?: boolean;
} = $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 BASE_STEP = 1.8 * MAX_RADIUS;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
const LABEL_MIN_Y = 24;
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
const groupRace = $derived(buildGroupRaceMap(report.protocol));
const allGroups = $derived(normaliseGroups(report));
type StaticBucket = {
bucketKey: string;
className: string;
race: string;
raceId: number;
groupKeys: number[];
initialNum: number;
mass: number;
radius: number;
// Local offsets in the cluster's (u, v) basis. `u` always
// points from the race anchor toward the planet, so a
// constant local-frame layout produces the same "inward" feel
// regardless of which slot on the outer ring the race
// currently occupies (races rotate when peers die).
offsetU: number;
offsetV: number;
};
// staticBucketsByRace locks the bucket roster, ordering, masses,
// radii and local positions for the lifetime of this viewer. The
// derivation only re-runs when `report` or the wasm `core` flip
// (initial mount and core boot completion). Per-frame NumberLeft
// changes do not touch this map — they live in `renderedByRace`.
const staticBucketsByRace = $derived.by(() => {
const core = coreHandle?.core ?? null;
const out = new Map<number, StaticBucket[]>();
const bucketIndex = new Map<string, StaticBucket>();
for (const g of allGroups) {
const bucketKey = `${g.raceId}::${g.group.className}`;
let bucket = bucketIndex.get(bucketKey);
if (bucket === undefined) {
const classDef =
shipClassLookup?.get(g.group.race, g.group.className) ?? null;
const mass = core
? computeBattleGroupMass(g.group, classDef, core)
: 0;
bucket = {
bucketKey,
className: g.group.className,
race: g.group.race,
raceId: g.raceId,
groupKeys: [],
initialNum: 0,
mass,
radius: MAX_RADIUS,
offsetU: 0,
offsetV: 0,
};
bucketIndex.set(bucketKey, bucket);
const list = out.get(g.raceId) ?? [];
list.push(bucket);
out.set(g.raceId, list);
}
bucket.groupKeys.push(g.key);
bucket.initialNum += g.group.num;
}
// Per-battle mass normalisation: the heaviest bucket renders
// at MAX_RADIUS; lighter ones scale by sqrt(m/max).
let maxMass = 0;
for (const bucket of bucketIndex.values()) {
if (bucket.mass > maxMass) maxMass = bucket.mass;
}
for (const bucket of bucketIndex.values()) {
bucket.radius = radiusForMass(bucket.mass, maxMass);
}
// Sort each race's buckets by initial count (descending) +
// className as a stable tie-break, then assign Vogel positions
// reordered by inward dot product (offsetU desc) so the
// largest-by-num bucket lands at the most-inward Vogel slot.
for (const list of out.values()) {
list.sort((a, b) => {
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
return a.className.localeCompare(b.className);
});
const N = list.length;
const denom = Math.max(1, Math.sqrt(Math.max(N, 1)));
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
const positions = Array.from({ length: N }, (_, r) => {
const radius = step * Math.sqrt(r);
const angle = r * GOLDEN_ANGLE;
return {
offsetU: radius * Math.cos(angle),
offsetV: radius * Math.sin(angle),
};
});
positions.sort((a, b) => {
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
return a.offsetV - b.offsetV;
});
for (let r = 0; r < N; r++) {
list[r].offsetU = positions[r].offsetU;
list[r].offsetV = positions[r].offsetV;
}
}
return out;
});
type RenderedBucket = StaticBucket & { numLeft: number };
// renderedByRace overlays the per-frame `remaining` map onto the
// static cluster: only buckets with `numLeft > 0` survive into
// the render list, so an emptied class disappears from the cloud
// while its neighbours keep their slots.
const renderedByRace = $derived.by(() => {
const out = new Map<number, RenderedBucket[]>();
for (const [raceId, list] of staticBucketsByRace) {
const filtered: RenderedBucket[] = [];
for (const bucket of list) {
let numLeft = 0;
for (const key of bucket.groupKeys) {
numLeft += frame.remaining.get(key) ?? 0;
}
if (numLeft > 0) filtered.push({ ...bucket, numLeft });
}
if (filtered.length > 0) out.set(raceId, filtered);
}
return out;
});
// visibleBucketByGroupKey lets shot endpoints resolve to a node
// only when the bucket is currently rendered. A phantom shot
// against an already-empty bucket therefore returns `null` and
// no line is drawn.
const visibleBucketByGroupKey = $derived.by(() => {
const out = new Map<number, RenderedBucket>();
for (const list of renderedByRace.values()) {
for (const bucket of list) {
for (const key of bucket.groupKeys) {
out.set(key, bucket);
}
}
}
return out;
});
const raceLayout = $derived(
layoutRaces(frame.activeRaceIds, {
center: CENTER,
radius: RACE_RING_RADIUS,
}),
);
type ClusterBasis = {
anchorX: number;
anchorY: number;
ux: number;
uy: number;
vx: number;
vy: number;
};
const clusterBasisById = $derived.by(() => {
const out = new Map<number, ClusterBasis>();
for (const anchor of raceLayout) {
const dx = CENTER.x - anchor.x;
const dy = CENTER.y - anchor.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const vx = uy;
const vy = -ux;
out.set(anchor.raceId, {
anchorX: anchor.x,
anchorY: anchor.y,
ux,
uy,
vx,
vy,
});
}
return out;
});
function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
return {
x: basis.anchorX + bucket.offsetU * basis.ux + bucket.offsetV * basis.vx,
y: basis.anchorY + bucket.offsetU * basis.uy + bucket.offsetV * basis.vy,
};
}
function findClassCircleCenter(groupKey: number) {
const bucket = visibleBucketByGroupKey.get(groupKey);
if (!bucket) return null;
const basis = clusterBasisById.get(bucket.raceId);
if (!basis) return null;
return worldPosition(basis, bucket);
}
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, defenderKey: action.sd };
});
const flashDefenderBucketKey = $derived.by(() => {
if (!shotLine || !shotVisible) return null;
const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
return bucket?.bucketKey ?? null;
});
const raceLabelById = $derived.by(() => {
const out = new Map<number, string>();
for (const g of allGroups) {
out.set(g.raceId, g.group.race);
}
for (const [, raceId] of groupRace) {
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
}
return out;
});
// raceLabelYById finds a y just above the visible cluster's top
// edge and clamps it to the SVG viewport so the north race
// (anchor near the top) never has its label clipped off-canvas.
const raceLabelYById = $derived.by(() => {
const out = new Map<number, number>();
for (const [raceId, list] of renderedByRace) {
const basis = clusterBasisById.get(raceId);
if (!basis || list.length === 0) continue;
let topY = basis.anchorY;
for (const bucket of list) {
const world = worldPosition(basis, bucket);
const top = world.y - bucket.radius;
if (top < topY) topY = top;
}
const fallback = basis.anchorY - MAX_RADIUS - 12;
const target = Math.min(topY - 12, fallback);
out.set(raceId, Math.max(target, LABEL_MIN_Y));
}
return out;
});
</script>
<svg
class="battle-scene"
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
preserveAspectRatio="xMidYMid meet"
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 = renderedByRace.get(anchor.raceId) ?? []}
{@const basis = clusterBasisById.get(anchor.raceId)}
{#if basis && cluster.length > 0}
<g
class="race-cluster"
data-testid="battle-race-cluster"
data-race-id={anchor.raceId}
>
<text
x={anchor.x}
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
text-anchor="middle"
class="race-label"
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
{#each cluster as entry (entry.bucketKey)}
{@const pos = worldPosition(basis, entry)}
{@const flash =
entry.bucketKey === flashDefenderBucketKey
? shotLine?.destroyed
? "destroyed"
: "shielded"
: null}
<g
class="class-marker"
data-testid="battle-class-marker"
data-bucket-key={entry.bucketKey}
data-class-name={entry.className}
data-flash={flash}
>
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
<text
x={pos.x}
y={pos.y + entry.radius + 12}
text-anchor="middle"
class="class-label"
>{entry.className}:{entry.numLeft}</text>
</g>
{/each}
</g>
{/if}
{/each}
{#if shotLine && shotVisible}
<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: 100%;
background: #0a0d1a;
display: block;
}
.planet {
fill: #2a2f40;
stroke: #4a5066;
stroke-width: 1.5;
}
.planet-label {
fill: #6d7388;
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;
transition:
fill 80ms ease-in,
stroke 80ms ease-in;
}
.class-marker[data-flash="destroyed"] circle {
fill: #ee3344;
stroke: #ee3344;
}
.class-marker[data-flash="shielded"] circle {
fill: #44dd66;
stroke: #44dd66;
}
.class-label {
fill: #b8c0e6;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.shot {
stroke: #44dd66;
stroke-width: 2;
}
.shot.destroyed {
stroke: #ee3344;
}
</style>