ui/phase-27: viewer polish + phantom-destroy clamp

Nine BattleViewer refinements from the latest review pass:

1. Mass radii were uniform in synthetic mode because
   `+layout.svelte` skipped `loadCore()` on the synthetic branch.
   The wasm bridge to `pkg/calc/ship.go` now boots in both modes
   so `computeBattleGroupMass` resolves a real FullMass and
   `radiusForMass` produces a per-battle scale.

2. Phantom-destroy clamp in `buildFrames`. Legacy emitters
   (KNNTS041 planet #7) log many more `Destroyed` lines against a
   group than the group's initial population — at frame 406 of
   2317 the race totals previously hit zero on phantom shots and
   the scene blanked while playback continued silently. We now
   only shrink the per-group remaining count and the race totals
   when the group still has ships. The line still draws on
   phantom frames; only the counters stay sane.

3. Vogel sunflower positions are now reassigned by inward dot
   product before being handed to ranks: the rank-0 bucket — the
   one with the largest initial ship count — always lands at the
   most-inward spiral slot. The previous quarter-step anchor bias
   was too weak; ranks r ≥ 2 routinely overtook rank-0 toward
   the planet. The anchor offset is gone.

4. Bucket order inside a cluster is locked at battle start by
   each bucket's *initial* ship count (`num`), not its live
   `numLeft`. The position of every class circle stays put for
   the whole battle; only the label number changes as ships die.

5. Shot line + defender flash blink on a per-frame timer during
   play. The line stays on for the first 90 % of frame duration,
   off for the last 10 %, so two consecutive shots from the same
   attacker on the same defender look like two distinct pulses.
   On pause the line and flash stay drawn for inspection.

6. The defender's class circle now flashes red (destroyed) or
   green (shielded) in sync with the shot line, so the eye
   catches *who* was hit, not just where the line lands.

7. Battle log rows are buttons. Click / Enter / Space pauses
   playback and seeks to that shot. The list also auto-scrolls
   the current row into view so the highlight does not race off
   the bottom on long battles.

8. Race labels now sit above the cloud's bounding top instead of
   a fixed offset, so a dense cluster does not swallow its own
   race name.

9. Planet glyph + label switch to neutral grey
   (`#2a2f40` / `#4a5066` / `#6d7388`), keeping the planet "in the
   background" rather than competing with the combatants.

Step-back icon switched to `◀︎◀︎` to mirror step-forward.

Tests: two new Vitest cases cover the phantom-destroy clamp
(single-race wipe, mixed-class race survives a class wipe). The
existing 642 Vitest tests stay green; all four `battle-viewer`
Playwright cases pass.

Docs: `ui/docs/battle-viewer-ux.md` rewrites the cluster section
(locked order + Vogel reassignment), adds Playback Details (blink
+ flash semantics), and a Phantom Destroys section explaining the
clamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-13 16:44:46 +02:00
parent 8c260f8715
commit 17a3afd5e9
7 changed files with 384 additions and 70 deletions
@@ -3,8 +3,13 @@ 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 biased toward the planet.
Tech-variant groups of the same (race, className) collapse to one
arranged on a Vogel sunflower spiral. Spiral positions are
reassigned per rank by their inward distance toward the planet so
the rank-0 bucket (heaviest by NumberLeft) always sits at the
most-inward Vogel slot — the cloud visually leans toward the
planet without the cluster anchor needing a manual offset.
Tech-variant groups of the same `(race, className)` collapse to one
visual node — the per-tech detail lives in Reports. Each circle's
radius scales with the per-ship FullMass (sqrt) so heavy ships
visually dominate.
@@ -39,20 +44,20 @@ collapse to a single visual node.
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;
// Vogel sunflower step + half-circle bias toward planet.
const BASE_STEP = 1.8 * MAX_RADIUS;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const ANCHOR_BIAS = 0.25; // fraction of step pushed toward planet
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
@@ -66,6 +71,7 @@ collapse to a single visual node.
race: string;
raceId: number;
groupKeys: number[];
initialNum: number;
numLeft: number;
mass: number;
radius: number;
@@ -79,7 +85,6 @@ collapse to a single visual node.
// those nodes.
const clustersByRace = $derived.by(() => {
const core = coreHandle?.core ?? null;
// First pass: build the bucket list per race.
const out = new Map<number, ClusterEntry[]>();
const bucketIndex = new Map<string, ClusterEntry>();
for (const g of allGroups) {
@@ -97,6 +102,7 @@ collapse to a single visual node.
race: g.group.race,
raceId: g.raceId,
groupKeys: [],
initialNum: 0,
numLeft: 0,
mass,
radius: MAX_RADIUS,
@@ -107,6 +113,7 @@ collapse to a single visual node.
out.set(g.raceId, list);
}
bucket.groupKeys.push(g.key);
bucket.initialNum += g.group.num;
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
}
@@ -120,19 +127,20 @@ collapse to a single visual node.
bucket.radius = radiusForMass(bucket.mass, maxMass);
}
// Sort buckets in each cluster by NumberLeft desc → rank 0 is
// the biggest group (will be placed closest to planet).
// Order is locked by initial ship count, NOT live numLeft:
// the user wants every bucket to keep its visual slot through
// the battle. Rank 0 = the bucket that *started* with the
// most ships, which the legacy game heuristic treats as
// "cover" placed in front of more valuable hulls.
for (const list of out.values()) {
list.sort((a, b) => {
if (b.numLeft !== a.numLeft) return b.numLeft - a.numLeft;
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
return a.className.localeCompare(b.className);
});
}
return out;
});
// bucketByGroupKey lets shot endpoints look up the aggregated
// node by any of its constituent ship-group keys.
const bucketByGroupKey = $derived.by(() => {
const out = new Map<number, ClusterEntry>();
for (const list of clustersByRace.values()) {
@@ -162,11 +170,6 @@ collapse to a single visual node.
step: number;
};
// clusterBasisById carries the local frame for every active
// race's cluster: `u` points from the race anchor toward the
// planet, `v` is `u` rotated 90° clockwise, and `step` is the
// adaptive Vogel-spiral step (scaled down when many class
// buckets share a cluster to stay inside MAX_CLUSTER_RADIUS).
const clusterBasisById = $derived.by(() => {
const out = new Map<number, ClusterBasis>();
for (const anchor of raceLayout) {
@@ -175,27 +178,60 @@ collapse to a single visual node.
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const vx = uy; // (ux, uy) rotated 90° clockwise → (uy, -ux);
const vy = -ux; // use mirror to keep label below cluster.
const vx = uy;
const vy = -ux;
const count = (clustersByRace.get(anchor.raceId) ?? []).length;
const baseStep = BASE_STEP;
const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
const step = Math.min(baseStep, MAX_CLUSTER_RADIUS / denom);
const anchorX = anchor.x + ANCHOR_BIAS * step * ux;
const anchorY = anchor.y + ANCHOR_BIAS * step * uy;
out.set(anchor.raceId, { anchorX, anchorY, ux, uy, vx, vy, step });
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
out.set(anchor.raceId, {
anchorX: anchor.x,
anchorY: anchor.y,
ux,
uy,
vx,
vy,
step,
});
}
return out;
});
function nodePosition(basis: ClusterBasis, rank: number) {
const radius = basis.step * Math.sqrt(rank);
const angle = rank * GOLDEN_ANGLE;
const offsetU = radius * Math.cos(angle);
const offsetV = radius * Math.sin(angle);
// vogelLocalsByRace generates Vogel sunflower positions for each
// cluster and reassigns them by inward dot product (`offsetU`
// descending) so the rank-0 bucket always lands at the most-
// inward spiral point. With the original `r * GOLDEN_ANGLE` angle
// scheme, ranks with r ≥ 2 can land further toward the planet
// than r = 0; the reassignment makes the "biggest group near the
// planet" invariant exact.
const vogelLocalsByRace = $derived.by(() => {
const out = new Map<number, { offsetU: number; offsetV: number }[]>();
for (const [raceId, cluster] of clustersByRace) {
const basis = clusterBasisById.get(raceId);
if (!basis) continue;
const positions = Array.from({ length: cluster.length }, (_, r) => {
const radius = basis.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;
});
out.set(raceId, positions);
}
return out;
});
function worldPosition(
basis: ClusterBasis,
local: { offsetU: number; offsetV: number },
) {
return {
x: basis.anchorX + offsetU * basis.ux + offsetV * basis.vx,
y: basis.anchorY + offsetU * basis.uy + offsetV * basis.vy,
x: basis.anchorX + local.offsetU * basis.ux + local.offsetV * basis.vx,
y: basis.anchorY + local.offsetU * basis.uy + local.offsetV * basis.vy,
};
}
@@ -206,8 +242,9 @@ collapse to a single visual node.
if (!basis) return null;
const cluster = clustersByRace.get(bucket.raceId) ?? [];
const rank = cluster.indexOf(bucket);
if (rank === -1) return null;
return nodePosition(basis, rank);
const locals = vogelLocalsByRace.get(bucket.raceId);
if (rank === -1 || locals === undefined) return null;
return worldPosition(basis, locals[rank]);
}
const shotLine = $derived.by(() => {
@@ -216,7 +253,13 @@ collapse to a single visual node.
const from = findClassCircleCenter(action.sa);
const to = findClassCircleCenter(action.sd);
if (!from || !to) return null;
return { from, to, destroyed: action.x };
return { from, to, destroyed: action.x, defenderKey: action.sd };
});
const flashDefenderBucketKey = $derived.by(() => {
if (!shotLine || !shotVisible) return null;
const bucket = bucketByGroupKey.get(shotLine.defenderKey);
return bucket?.bucketKey ?? null;
});
const raceLabelById = $derived.by(() => {
@@ -224,15 +267,34 @@ collapse to a single visual node.
for (const g of allGroups) {
out.set(g.raceId, g.group.race);
}
// `groupRace` covers protocol-derived races even when no group
// is left to read from `allGroups` (shouldn't happen with
// `inBattle: true` rosters, but keeps the label resolver
// defensive).
for (const [, raceId] of groupRace) {
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
}
return out;
});
// raceLabelYById computes a label position that sits above the
// cluster's bounding top so the race name never hides inside the
// cloud. The label is also clamped to a minimum distance from the
// race anchor so a single-bucket cluster still has its label
// rendered just above the only circle.
const raceLabelYById = $derived.by(() => {
const out = new Map<number, number>();
for (const [raceId, cluster] of clustersByRace) {
const basis = clusterBasisById.get(raceId);
const locals = vogelLocalsByRace.get(raceId);
if (!basis || !locals) continue;
let topY = basis.anchorY;
for (let i = 0; i < cluster.length; i++) {
const world = worldPosition(basis, locals[i]);
const y = world.y - cluster[i].radius;
if (y < topY) topY = y;
}
const fallback = basis.anchorY - MAX_RADIUS - 12;
out.set(raceId, Math.min(topY - 12, fallback));
}
return out;
});
</script>
<svg
@@ -260,6 +322,7 @@ collapse to a single visual node.
{#each raceLayout as anchor (anchor.raceId)}
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
{@const basis = clusterBasisById.get(anchor.raceId)}
{@const locals = vogelLocalsByRace.get(anchor.raceId) ?? []}
<g
class="race-cluster"
data-testid="battle-race-cluster"
@@ -267,18 +330,26 @@ collapse to a single visual node.
>
<text
x={anchor.x}
y={anchor.y - MAX_RADIUS - 12}
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
text-anchor="middle"
class="race-label"
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
{#if basis}
{#each cluster as entry, rank (entry.bucketKey)}
{@const pos = nodePosition(basis, rank)}
{@const local = locals[rank]}
{@const pos = local ? worldPosition(basis, local) : { x: anchor.x, y: anchor.y }}
{@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
@@ -293,7 +364,7 @@ collapse to a single visual node.
</g>
{/each}
{#if shotLine}
{#if shotLine && shotVisible}
<line
x1={shotLine.from.x}
y1={shotLine.from.y}
@@ -315,12 +386,12 @@ collapse to a single visual node.
display: block;
}
.planet {
fill: #2a345f;
stroke: #5b6aa3;
stroke-width: 2;
fill: #2a2f40;
stroke: #4a5066;
stroke-width: 1.5;
}
.planet-label {
fill: #c4caea;
fill: #6d7388;
font-size: 18px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
@@ -334,6 +405,17 @@ collapse to a single visual node.
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;
@@ -6,8 +6,6 @@ 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 type { ShipClassLookup } from "./mass";
@@ -28,35 +26,67 @@ is logically isolated: feed it any `BattleReport` matching
let frameIndex = $state(0);
let playing = $state(false);
let speed = $state<1 | 2 | 4>(1);
// shotVisible blinks off for the last 10% of each frame so two
// consecutive shots from the same attacker on the same defender
// look like two distinct flashes, not one continuous line. On
// pause the line stays drawn so the user can study it.
let shotVisible = $state(true);
let logEl = $state<HTMLOListElement | null>(null);
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;
// Schedule one tick per frame instead of a long-running
// setInterval so the blink and the frame-advance share the same
// timeline. The effect re-runs whenever frameIndex / playing /
// speed changes; on each run we (re)arm the blink + advance
// timers and let the previous run's timers be cleared by the
// cleanup return.
$effect(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
// Track changes via direct reads.
void frameIndex;
void speed;
shotVisible = true;
if (!playing) return;
const intervalMs = 400 / speed;
timer = setInterval(() => {
const blinkOff = setTimeout(() => {
shotVisible = false;
}, intervalMs * 0.9);
const advance = setTimeout(() => {
if (frameIndex >= frames.length - 1) {
playing = false;
return;
}
frameIndex = frameIndex + 1;
}, intervalMs);
return () => {
clearTimeout(blinkOff);
clearTimeout(advance);
};
});
onDestroy(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
// Auto-scroll the log so the current row stays visible as the
// timeline advances. `block: "nearest"` keeps the scroll movement
// gentle — the row lands at the closest edge of the visible
// portion instead of jumping to the centre.
$effect(() => {
void frame.shotIndex;
if (logEl === null) return;
const current = logEl.querySelector(
'li[data-current="true"]',
) as HTMLElement | null;
if (current !== null) {
current.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
function seekToShot(actionIndex: number) {
playing = false;
frameIndex = Math.max(
0,
Math.min(frames.length - 1, actionIndex + 1),
);
}
function describeAction(index: number): string {
const action = report.protocol[index];
const attackerGroup = report.ships[String(action.sa)];
@@ -88,7 +118,7 @@ is logically isolated: feed it any `BattleReport` matching
</header>
<div class="scene">
<BattleScene {report} {frame} {shipClassLookup} />
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
</div>
<PlaybackControls
@@ -103,12 +133,18 @@ is logically isolated: feed it any `BattleReport` matching
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
>
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
<ol data-testid="battle-protocol-log">
<ol bind:this={logEl} 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>
>
<button
type="button"
class="log-row-btn"
onclick={() => seekToShot(i)}
>{describeAction(i)}</button>
</li>
{/each}
</ol>
</section>
@@ -180,11 +216,26 @@ is logically isolated: feed it any `BattleReport` matching
min-height: 0;
}
.log li {
padding: 0.15rem 0;
border-bottom: 1px solid #1c2240;
}
.log li[data-current="true"] {
.log-row-btn {
display: block;
width: 100%;
text-align: left;
padding: 0.15rem 0.4rem;
background: transparent;
border: 0;
color: inherit;
font: inherit;
cursor: pointer;
}
.log-row-btn:hover,
.log-row-btn:focus-visible {
background: #131a36;
}
.log li[data-current="true"] .log-row-btn {
color: #ffe27a;
font-weight: 600;
background: #1a2240;
}
</style>
@@ -57,7 +57,7 @@ already at its end.
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.step_backward")}
data-testid="battle-control-step-back"
>◀︎</button>
>◀︎◀︎</button>
<button
type="button"
onclick={togglePlay}
+18 -6
View File
@@ -105,13 +105,25 @@ export function buildFrames(report: BattleReport): Frame[] {
for (let i = 0; i < report.protocol.length; i++) {
const action = report.protocol[i];
if (action.x) {
// Decrement only when the targeted group actually has
// ships left. Legacy emitters (the `dg` text format used
// by the synthetic-report path) sometimes ship more
// `Destroyed` lines than the group's initial population —
// looks like the engine keeps logging hits against an
// already-empty ship-group bucket. Without this guard
// `runningRaceTotals` decrements on every phantom and the
// race vanishes from `activeRaceIds` long before its
// real groups were all destroyed (KNNTS041 battle on
// planet 7, frame ≈ 406 of 2317). The line still draws
// for that frame so the user sees the shot happen.
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));
if (left > 0) {
current.set(action.sd, left - 1);
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({
@@ -326,7 +326,19 @@ fresh.
return;
}
try {
const { cache } = await loadStore();
// Synthetic mode still needs the wasm `Core` so
// components that bridge to `pkg/calc/ship.go`
// (designer preview, BattleViewer mass radii) can
// resolve their math against the same engine helpers
// the live path uses. The live branch below also
// calls `loadCore()`; without it here the Battle
// Viewer rendered every ship-class circle at
// MAX_RADIUS in synthetic mode.
const [{ cache }, core] = await Promise.all([
loadStore(),
loadCore(),
]);
coreHolder.set(core);
await Promise.all([
gameState.initSynthetic({ cache, gameId, report }),
orderDraft.init({ cache, gameId }),