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>
This commit is contained in:
@@ -5,19 +5,20 @@ 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 (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.
|
||||
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 — the per-tech detail lives in Reports. Each circle's
|
||||
visual node — per-tech detail lives in Reports. Each circle's
|
||||
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
||||
visually dominate.
|
||||
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`, so they never appear here. Same-race opponents are
|
||||
forbidden by the engine's combat filter, so a shot can never
|
||||
collapse to a single visual node.
|
||||
`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";
|
||||
@@ -59,34 +60,40 @@ collapse to a single visual node.
|
||||
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 ClusterEntry = {
|
||||
type StaticBucket = {
|
||||
bucketKey: string;
|
||||
className: string;
|
||||
race: string;
|
||||
raceId: number;
|
||||
groupKeys: number[];
|
||||
initialNum: number;
|
||||
numLeft: 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;
|
||||
};
|
||||
|
||||
// Aggregate every `(raceId, className)` into a single bucket and
|
||||
// compute per-bucket NumberLeft (sum across tech-variants) and
|
||||
// per-ship FullMass via the wasm bridge. mass=0 when the class
|
||||
// either doesn't resolve in the lookup or the calc rejects its
|
||||
// params; downstream `radiusForMass` falls back to MAX_RADIUS for
|
||||
// those nodes.
|
||||
const clustersByRace = $derived.by(() => {
|
||||
// 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, ClusterEntry[]>();
|
||||
const bucketIndex = new Map<string, ClusterEntry>();
|
||||
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);
|
||||
@@ -103,9 +110,10 @@ collapse to a single visual node.
|
||||
raceId: g.raceId,
|
||||
groupKeys: [],
|
||||
initialNum: 0,
|
||||
numLeft: 0,
|
||||
mass,
|
||||
radius: MAX_RADIUS,
|
||||
offsetU: 0,
|
||||
offsetV: 0,
|
||||
};
|
||||
bucketIndex.set(bucketKey, bucket);
|
||||
const list = out.get(g.raceId) ?? [];
|
||||
@@ -114,11 +122,10 @@ collapse to a single visual node.
|
||||
}
|
||||
bucket.groupKeys.push(g.key);
|
||||
bucket.initialNum += g.group.num;
|
||||
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
|
||||
}
|
||||
|
||||
// Per-battle mass normalisation: the heaviest visual node
|
||||
// renders at MAX_RADIUS; lighter ones scale by sqrt(m/max).
|
||||
// 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;
|
||||
@@ -127,23 +134,67 @@ collapse to a single visual node.
|
||||
bucket.radius = radiusForMass(bucket.mass, maxMass);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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;
|
||||
});
|
||||
|
||||
const bucketByGroupKey = $derived.by(() => {
|
||||
const out = new Map<number, ClusterEntry>();
|
||||
for (const list of clustersByRace.values()) {
|
||||
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);
|
||||
@@ -167,7 +218,6 @@ collapse to a single visual node.
|
||||
uy: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
const clusterBasisById = $derived.by(() => {
|
||||
@@ -180,9 +230,6 @@ collapse to a single visual node.
|
||||
const uy = dy / len;
|
||||
const vx = uy;
|
||||
const vy = -ux;
|
||||
const count = (clustersByRace.get(anchor.raceId) ?? []).length;
|
||||
const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
|
||||
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
|
||||
out.set(anchor.raceId, {
|
||||
anchorX: anchor.x,
|
||||
anchorY: anchor.y,
|
||||
@@ -190,61 +237,24 @@ collapse to a single visual node.
|
||||
uy,
|
||||
vx,
|
||||
vy,
|
||||
step,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// 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 },
|
||||
) {
|
||||
function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
|
||||
return {
|
||||
x: basis.anchorX + local.offsetU * basis.ux + local.offsetV * basis.vx,
|
||||
y: basis.anchorY + local.offsetU * basis.uy + local.offsetV * basis.vy,
|
||||
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 = bucketByGroupKey.get(groupKey);
|
||||
const bucket = visibleBucketByGroupKey.get(groupKey);
|
||||
if (!bucket) return null;
|
||||
const basis = clusterBasisById.get(bucket.raceId);
|
||||
if (!basis) return null;
|
||||
const cluster = clustersByRace.get(bucket.raceId) ?? [];
|
||||
const rank = cluster.indexOf(bucket);
|
||||
const locals = vogelLocalsByRace.get(bucket.raceId);
|
||||
if (rank === -1 || locals === undefined) return null;
|
||||
return worldPosition(basis, locals[rank]);
|
||||
return worldPosition(basis, bucket);
|
||||
}
|
||||
|
||||
const shotLine = $derived.by(() => {
|
||||
@@ -258,7 +268,7 @@ collapse to a single visual node.
|
||||
|
||||
const flashDefenderBucketKey = $derived.by(() => {
|
||||
if (!shotLine || !shotVisible) return null;
|
||||
const bucket = bucketByGroupKey.get(shotLine.defenderKey);
|
||||
const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
|
||||
return bucket?.bucketKey ?? null;
|
||||
});
|
||||
|
||||
@@ -273,25 +283,23 @@ collapse to a single visual node.
|
||||
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.
|
||||
// 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, cluster] of clustersByRace) {
|
||||
for (const [raceId, list] of renderedByRace) {
|
||||
const basis = clusterBasisById.get(raceId);
|
||||
const locals = vogelLocalsByRace.get(raceId);
|
||||
if (!basis || !locals) continue;
|
||||
if (!basis || list.length === 0) 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;
|
||||
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;
|
||||
out.set(raceId, Math.min(topY - 12, fallback));
|
||||
const target = Math.min(topY - 12, fallback);
|
||||
out.set(raceId, Math.max(target, LABEL_MIN_Y));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
@@ -320,24 +328,22 @@ collapse to a single visual node.
|
||||
>{report.planetName} (#{report.planet})</text>
|
||||
|
||||
{#each raceLayout as anchor (anchor.raceId)}
|
||||
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
||||
{@const cluster = renderedByRace.get(anchor.raceId) ?? []}
|
||||
{@const basis = clusterBasisById.get(anchor.raceId)}
|
||||
{@const locals = vogelLocalsByRace.get(anchor.raceId) ?? []}
|
||||
<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>
|
||||
{#if basis}
|
||||
{#each cluster as entry, rank (entry.bucketKey)}
|
||||
{@const local = locals[rank]}
|
||||
{@const pos = local ? worldPosition(basis, local) : { x: anchor.x, y: anchor.y }}
|
||||
{#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
|
||||
@@ -360,8 +366,8 @@ collapse to a single visual node.
|
||||
>{entry.className}:{entry.numLeft}</text>
|
||||
</g>
|
||||
{/each}
|
||||
{/if}
|
||||
</g>
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if shotLine && shotVisible}
|
||||
|
||||
Reference in New Issue
Block a user