ui/phase-27: mass-based circles + cloud cluster + height fit
Three Phase-27 BattleViewer refinements on top of the radial scene:
1. Height fit. The viewer is pinned to `calc(100dvh − 80px)` so it
never pushes the in-game shell past the viewport. `.active-view`
gains `overflow: hidden` + flex column; `.viewer` becomes a
`flex: 1` child; the always-visible text log shrinks to a 30 dvh
ceiling with its own scroll. A global `body { margin: 0 }`
reset (added to `app.html`) plugs the 16 px the browser's
default body margin used to leak.
2. Mass-based ship-class circles. New `lib/battle-player/mass.ts`
carries the radius formula and the per-battle FullMass compute:
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) * sqrt(mass / max)`,
clamped to `[6, 24] px`. FullMass goes through the existing
wasm bridge (`emptyMass` → `carryingMass` → `fullMass`) — no
new wire fields. The viewer page resolves a
`(race, className) → ShipClassRef` lookup from the parent
GameReport's `localShipClass` + `otherShipClass` tables and
passes it to the viewer via context. Unknown class or
degenerate (weapons/armament) params fall back to MAX_RADIUS
so the bucket stays visible.
3. Cloud cluster layout. Cluster key shifts from per-group
`g.key` to `(raceId, className)` so tech-variants of the same
hull collapse into one visual bucket. The horizontal
classCircleX row is replaced by a Vogel sunflower spiral in
the local `(u, v)` basis — `u` points from the race anchor to
the planet, `v` is `u` rotated 90° clockwise. Buckets are
sorted by NumberLeft desc; the cluster anchor is pushed inward
by a quarter step so rank-0 sits closest to the planet. The
step is adaptive (`min(baseStep, MAX_CLUSTER_RADIUS / sqrt(N))`)
so clusters with many classes do not spill into neighbours.
Tests:
- Vitest: `radiusForMass` covering zero / max / quarter-mass /
out-of-range cases (6 cases).
- Playwright: new `battle-viewer.spec.ts` case asserts
`document.documentElement.scrollHeight - window.innerHeight ≤ 4`
at a 1280×720 desktop viewport. The existing fixture gains
`localShipClass` + `otherShipClass` so the lookup has data to
render proportional circles.
Docs: `ui/docs/battle-viewer-ux.md` rewrites the "Radial scene"
section (cloud layout, mass-based radius, height fit) and adds
a "Height fit" subsection. `docs/FUNCTIONAL.md` §6.5 (+ ru
mirror) get the one-line story about per-mass sizing, cluster
aggregation, and the viewport-locked layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,16 @@
|
||||
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. The viewer itself is a logically isolated
|
||||
component that takes a `BattleReport` prop — this wrapper owns
|
||||
loading and routing concerns.
|
||||
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 viewer remains
|
||||
prop-driven; we just resolve the lookup once here so the lower
|
||||
component does not have to know about `RenderedReportSource`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import {
|
||||
@@ -15,6 +20,15 @@ loading and routing concerns.
|
||||
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";
|
||||
|
||||
@@ -28,6 +42,42 @@ loading and routing concerns.
|
||||
battleId: string;
|
||||
} = $props();
|
||||
|
||||
const rendered = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
// shipClassLookup turns `(race, className)` into the five class
|
||||
// parameters required by `calc.EmptyMass`. Local classes belong
|
||||
// to the report recipient (`report.race`); foreign classes carry
|
||||
// their own `race` field. Lookup is cheap to rebuild whenever the
|
||||
// report changes — the active-view-host re-renders on turn flips
|
||||
// anyway.
|
||||
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 }
|
||||
@@ -86,7 +136,7 @@ loading and routing concerns.
|
||||
{i18n.t("game.battle.loading")}
|
||||
</p>
|
||||
{:else if state.kind === "ready"}
|
||||
<BattleViewer report={state.report} />
|
||||
<BattleViewer report={state.report} shipClassLookup={shipClassLookup} />
|
||||
{:else if state.kind === "not_found"}
|
||||
<p class="status" data-testid="battle-not-found">
|
||||
{i18n.t("game.battle.not_found")}
|
||||
@@ -98,7 +148,26 @@ loading and routing concerns.
|
||||
|
||||
<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 and the always-
|
||||
* visible log without forcing a page-level scroll.
|
||||
*/
|
||||
height: calc(100dvh - 80px);
|
||||
max-height: calc(100dvh - 80px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #d6dcf2;
|
||||
}
|
||||
@@ -107,6 +176,7 @@ loading and routing concerns.
|
||||
gap: 0.5rem;
|
||||
max-width: 880px;
|
||||
margin: 0 auto 1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.back-btn {
|
||||
appearance: none;
|
||||
|
||||
@@ -2,16 +2,33 @@
|
||||
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 cluster of small class circles
|
||||
labelled `<className>:<numLeft>` underneath. The shot line for the
|
||||
current frame's `lastAction` is drawn from attacker group to
|
||||
defender group; red when the shot destroyed the defender, green
|
||||
otherwise. Observer groups (`inBattle === false`) are filtered out
|
||||
by `buildFrames`, so they never appear here.
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
-->
|
||||
<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,
|
||||
@@ -21,50 +38,113 @@ by `buildFrames`, so they never appear here.
|
||||
let {
|
||||
report,
|
||||
frame,
|
||||
shipClassLookup,
|
||||
}: {
|
||||
report: BattleReport;
|
||||
frame: Frame;
|
||||
shipClassLookup?: ShipClassLookup;
|
||||
} = $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 CLASS_CIRCLE_RADIUS = 24;
|
||||
const CLASS_SPACING = 64;
|
||||
// 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);
|
||||
|
||||
const groupRace = $derived(buildGroupRaceMap(report.protocol));
|
||||
const allGroups = $derived(normaliseGroups(report));
|
||||
|
||||
type ClusterEntry = {
|
||||
key: number;
|
||||
bucketKey: string;
|
||||
className: string;
|
||||
race: string;
|
||||
raceId: number;
|
||||
groupKeys: number[];
|
||||
numLeft: number;
|
||||
mass: number;
|
||||
radius: 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(() => {
|
||||
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) {
|
||||
const numLeft = frame.remaining.get(g.key) ?? 0;
|
||||
const list = out.get(g.raceId) ?? [];
|
||||
list.push({
|
||||
key: g.key,
|
||||
className: g.group.className,
|
||||
numLeft,
|
||||
});
|
||||
out.set(g.raceId, list);
|
||||
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: [],
|
||||
numLeft: 0,
|
||||
mass,
|
||||
radius: MAX_RADIUS,
|
||||
};
|
||||
bucketIndex.set(bucketKey, bucket);
|
||||
const list = out.get(g.raceId) ?? [];
|
||||
list.push(bucket);
|
||||
out.set(g.raceId, list);
|
||||
}
|
||||
bucket.groupKeys.push(g.key);
|
||||
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
|
||||
}
|
||||
// Stable cluster order: by classname then key.
|
||||
|
||||
// Per-battle mass normalisation: the heaviest visual node
|
||||
// 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 buckets in each cluster by NumberLeft desc → rank 0 is
|
||||
// the biggest group (will be placed closest to planet).
|
||||
for (const list of out.values()) {
|
||||
list.sort((a, b) => {
|
||||
const byName = a.className.localeCompare(b.className);
|
||||
if (byName !== 0) return byName;
|
||||
return a.key - b.key;
|
||||
if (b.numLeft !== a.numLeft) return b.numLeft - a.numLeft;
|
||||
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()) {
|
||||
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,
|
||||
@@ -72,23 +152,62 @@ by `buildFrames`, so they never appear here.
|
||||
}),
|
||||
);
|
||||
|
||||
function classCircleX(index: number, count: number): number {
|
||||
const span = (count - 1) * CLASS_SPACING;
|
||||
return -span / 2 + index * CLASS_SPACING;
|
||||
type ClusterBasis = {
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
ux: number;
|
||||
uy: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
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) {
|
||||
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; // (ux, uy) rotated 90° clockwise → (uy, -ux);
|
||||
const vy = -ux; // use mirror to keep label below cluster.
|
||||
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 });
|
||||
}
|
||||
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);
|
||||
return {
|
||||
x: basis.anchorX + offsetU * basis.ux + offsetV * basis.vx,
|
||||
y: basis.anchorY + offsetU * basis.uy + offsetV * basis.vy,
|
||||
};
|
||||
}
|
||||
|
||||
function findClassCircleCenter(groupKey: number) {
|
||||
const raceId = groupRace.get(groupKey);
|
||||
if (raceId === undefined) return null;
|
||||
const anchor = raceLayout.find((a) => a.raceId === raceId);
|
||||
if (!anchor) return null;
|
||||
const cluster = clustersByRace.get(raceId) ?? [];
|
||||
const idx = cluster.findIndex((c) => c.key === groupKey);
|
||||
if (idx === -1) return null;
|
||||
return {
|
||||
x: anchor.x + classCircleX(idx, cluster.length),
|
||||
y: anchor.y,
|
||||
};
|
||||
const bucket = bucketByGroupKey.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);
|
||||
if (rank === -1) return null;
|
||||
return nodePosition(basis, rank);
|
||||
}
|
||||
|
||||
const shotLine = $derived.by(() => {
|
||||
@@ -105,6 +224,13 @@ by `buildFrames`, so they never appear here.
|
||||
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;
|
||||
});
|
||||
</script>
|
||||
@@ -112,6 +238,7 @@ by `buildFrames`, so they never appear here.
|
||||
<svg
|
||||
class="battle-scene"
|
||||
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="img"
|
||||
aria-label="battle scene"
|
||||
data-testid="battle-scene"
|
||||
@@ -132,6 +259,7 @@ by `buildFrames`, so they never appear here.
|
||||
|
||||
{#each raceLayout as anchor (anchor.raceId)}
|
||||
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
||||
{@const basis = clusterBasisById.get(anchor.raceId)}
|
||||
<g
|
||||
class="race-cluster"
|
||||
data-testid="battle-race-cluster"
|
||||
@@ -139,30 +267,29 @@ by `buildFrames`, so they never appear here.
|
||||
>
|
||||
<text
|
||||
x={anchor.x}
|
||||
y={anchor.y - CLASS_CIRCLE_RADIUS - 12}
|
||||
y={anchor.y - MAX_RADIUS - 12}
|
||||
text-anchor="middle"
|
||||
class="race-label"
|
||||
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
||||
{#each cluster as entry, i (entry.key)}
|
||||
{@const cx = anchor.x + classCircleX(i, cluster.length)}
|
||||
<g
|
||||
class="class-marker"
|
||||
data-testid="battle-class-marker"
|
||||
data-group-key={entry.key}
|
||||
>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={anchor.y}
|
||||
r={CLASS_CIRCLE_RADIUS}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={anchor.y + CLASS_CIRCLE_RADIUS + 16}
|
||||
text-anchor="middle"
|
||||
class="class-label"
|
||||
>{entry.className}:{entry.numLeft}</text>
|
||||
</g>
|
||||
{/each}
|
||||
{#if basis}
|
||||
{#each cluster as entry, rank (entry.bucketKey)}
|
||||
{@const pos = nodePosition(basis, rank)}
|
||||
<g
|
||||
class="class-marker"
|
||||
data-testid="battle-class-marker"
|
||||
data-bucket-key={entry.bucketKey}
|
||||
data-class-name={entry.className}
|
||||
>
|
||||
<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}
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
@@ -183,7 +310,7 @@ by `buildFrames`, so they never appear here.
|
||||
<style>
|
||||
.battle-scene {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
background: #0a0d1a;
|
||||
display: block;
|
||||
}
|
||||
@@ -210,7 +337,7 @@ by `buildFrames`, so they never appear here.
|
||||
}
|
||||
.class-label {
|
||||
fill: #b8c0e6;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
.shot {
|
||||
|
||||
@@ -10,12 +10,19 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import type { BattleReport } from "../../api/battle-fetch";
|
||||
import type { ShipClassLookup } from "./mass";
|
||||
|
||||
import BattleScene from "./battle-scene.svelte";
|
||||
import PlaybackControls from "./playback-controls.svelte";
|
||||
import { buildFrames } from "./timeline";
|
||||
|
||||
let { report }: { report: BattleReport } = $props();
|
||||
let {
|
||||
report,
|
||||
shipClassLookup,
|
||||
}: {
|
||||
report: BattleReport;
|
||||
shipClassLookup?: ShipClassLookup;
|
||||
} = $props();
|
||||
|
||||
const frames = $derived(buildFrames(report));
|
||||
let frameIndex = $state(0);
|
||||
@@ -81,7 +88,7 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
</header>
|
||||
|
||||
<div class="scene">
|
||||
<BattleScene {report} {frame} />
|
||||
<BattleScene {report} {frame} {shipClassLookup} />
|
||||
</div>
|
||||
|
||||
<PlaybackControls
|
||||
@@ -113,6 +120,9 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 880px;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
color: #d6dcf2;
|
||||
@@ -122,6 +132,7 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
@@ -139,6 +150,16 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
border: 1px solid #1e264a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.log {
|
||||
flex: 0 1 auto;
|
||||
min-height: 4rem;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.log h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
@@ -146,15 +167,17 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.log ol {
|
||||
list-style: decimal inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
color: #c6cdf0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.log li {
|
||||
padding: 0.15rem 0;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Mass helpers for the Battle Viewer.
|
||||
//
|
||||
// Phase 27 refinement: ship-class circles are sized by per-ship
|
||||
// FullMass (Empty + carrying), with a 6..24 px range. The viewer
|
||||
// resolves a `(race, className) → ShipClassRef` lookup from the
|
||||
// surrounding GameReport's `localShipClass` / `otherShipClass`
|
||||
// tables and feeds it through the wasm bridge to `pkg/calc/ship.go`.
|
||||
//
|
||||
// Pure utilities live here; the Svelte components consume them.
|
||||
|
||||
import type { Core } from "../../platform/core/index";
|
||||
import type { BattleReportGroup } from "../../api/battle-fetch";
|
||||
|
||||
/** Smallest visible ship circle. Picked so the `<class>:<n>` label
|
||||
* stays legible on every viewport. */
|
||||
export const MIN_RADIUS = 6;
|
||||
|
||||
/** Largest ship circle. Matches the Phase-27 baseline so heavy
|
||||
* ships keep their previous visual prominence. */
|
||||
export const MAX_RADIUS = 24;
|
||||
|
||||
/**
|
||||
* ShipClassRef is the minimum slice of a ship class needed to
|
||||
* compute its mass. Mirrors the relevant fields of
|
||||
* `ShipClassSummary` (own classes) and `ReportOtherShipClass`
|
||||
* (foreign classes) without coupling the viewer to either type.
|
||||
*/
|
||||
export interface ShipClassRef {
|
||||
drive: number;
|
||||
weapons: number;
|
||||
armament: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ShipClassLookup resolves `(race, className)` to a ship-class
|
||||
* descriptor. Returns `null` when the class is not in the parent
|
||||
* report — happens with legacy-mode foreign races that lack a
|
||||
* `<Race> Ship Types` block.
|
||||
*/
|
||||
export interface ShipClassLookup {
|
||||
get(race: string, className: string): ShipClassRef | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* computeBattleGroupMass returns the per-ship FullMass for a given
|
||||
* battle group. Mass=0 means "unknown" — either the wasm bridge
|
||||
* rejected the ship-class params (degenerate weapons/armament pair)
|
||||
* or the class did not resolve in the lookup. Either way the
|
||||
* caller's downstream `radiusForMass` falls back to MAX_RADIUS so
|
||||
* the node stays visible.
|
||||
*
|
||||
* Cargo never changes during a battle, so this can be cached per
|
||||
* `(race, className)` bucket for the lifetime of the viewer
|
||||
* session.
|
||||
*/
|
||||
export function computeBattleGroupMass(
|
||||
group: BattleReportGroup,
|
||||
classDef: ShipClassRef | null,
|
||||
core: Core,
|
||||
): number {
|
||||
if (classDef === null) return 0;
|
||||
const empty = core.emptyMass({
|
||||
drive: classDef.drive,
|
||||
weapons: classDef.weapons,
|
||||
armament: classDef.armament,
|
||||
shields: classDef.shields,
|
||||
cargo: classDef.cargo,
|
||||
});
|
||||
if (empty === null) return 0;
|
||||
const cargoTech = classDef.cargo * (group.tech.CARGO ?? 0);
|
||||
const carrying = core.carryingMass({
|
||||
load: group.loadQuantity,
|
||||
cargoTech,
|
||||
});
|
||||
return core.fullMass({ emptyMass: empty, carryingMass: carrying });
|
||||
}
|
||||
|
||||
/**
|
||||
* radiusForMass maps an absolute ship mass to a circle radius via
|
||||
* a per-battle normalisation: the heaviest visual node always
|
||||
* renders at MAX_RADIUS, lighter ones scale by sqrt(mass /
|
||||
* maxMassInBattle) so the smallest ships don't disappear and the
|
||||
* heaviest ones don't dominate the scene at >MAX_RADIUS. mass<=0
|
||||
* falls back to MAX_RADIUS so unresolved/invalid classes stay
|
||||
* visible.
|
||||
*/
|
||||
export function radiusForMass(mass: number, maxMassInBattle: number): number {
|
||||
if (maxMassInBattle <= 0 || mass <= 0) return MAX_RADIUS;
|
||||
const scaled = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(mass / maxMassInBattle);
|
||||
if (scaled < MIN_RADIUS) return MIN_RADIUS;
|
||||
if (scaled > MAX_RADIUS) return MAX_RADIUS;
|
||||
return scaled;
|
||||
}
|
||||
|
||||
/**
|
||||
* MapShipClassLookup is a `Map<string, ShipClassRef>`-backed
|
||||
* implementation of `ShipClassLookup`. Key encoding mirrors the
|
||||
* one battle.svelte uses when populating the lookup from the
|
||||
* parent GameReport.
|
||||
*/
|
||||
export class MapShipClassLookup implements ShipClassLookup {
|
||||
constructor(private readonly map: Map<string, ShipClassRef>) {}
|
||||
|
||||
get(race: string, className: string): ShipClassRef | null {
|
||||
return this.map.get(`${race}::${className}`) ?? null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user