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>
439 lines
12 KiB
Svelte
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>
|