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:
+11
-3
@@ -712,10 +712,18 @@ which forwards verbatim to the engine's
|
||||
|
||||
Visual model is radial: the planet sits at the centre, races are
|
||||
placed at equal angular spacing on an outer ring, and each race is
|
||||
rendered as a horizontal cluster of small ship-class circles
|
||||
labelled `<className>:<numLeft>`. Observer groups (`inBattle:
|
||||
rendered as a cloud of ship-class circles arranged on a Vogel
|
||||
sunflower spiral biased toward the planet (the largest group by
|
||||
NumberLeft sits closest to the planet, lighter buckets fan behind).
|
||||
Tech-variants of the same `(race, className)` collapse into one
|
||||
visual bucket labelled `<className>:<numLeft>`; per-class detail
|
||||
stays available in the Reports view. Circle radius scales with
|
||||
per-ship FullMass (range `[6, 24] px`, per-battle normalisation)
|
||||
so heavy ships visually dominate. Observer groups (`inBattle:
|
||||
false`) are not drawn. Eliminated races drop out and the survivors
|
||||
re-spread on the next frame.
|
||||
re-spread on the next frame. The viewer is pinned to the viewport
|
||||
(scene grows, log scrolls internally) so no page-level scroll
|
||||
appears.
|
||||
|
||||
Each frame is one protocol entry; the shot is drawn as a thin line
|
||||
from attacker to defender, red on `destroyed`, green otherwise.
|
||||
|
||||
+13
-5
@@ -729,11 +729,19 @@ Battle Viewer — отдельное представление, заменяю
|
||||
`GET /api/v1/battle/:turn/:uuid`.
|
||||
|
||||
Визуальная модель — радиальная: планета в центре, расы по внешней
|
||||
окружности на равных угловых интервалах, внутри расы — горизонтальный
|
||||
кластер маленьких кружков по классам кораблей с подписями
|
||||
`<className>:<numLeft>` под каждым. Наблюдатели (`inBattle: false`)
|
||||
не рисуются. Выбывшие расы убираются из сцены, оставшиеся
|
||||
перераспределяются на следующем кадре.
|
||||
окружности на равных угловых интервалах, внутри расы — облако
|
||||
кружков по классам кораблей, выложенное Vogel-спиралью с биасом к
|
||||
планете (самая многочисленная группа по NumberLeft — ближе к
|
||||
планете, остальные раскручиваются спиралью позади). Tech-варианты
|
||||
одного `(race, className)` схлопываются в один визуальный нод
|
||||
`<className>:<numLeft>`; детали по тех-уровням остаются в Reports.
|
||||
Радиус кружка масштабируется по FullMass корабля (диапазон
|
||||
`[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что
|
||||
тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle:
|
||||
false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся
|
||||
перераспределяются на следующем кадре. Viewer закреплён по высоте
|
||||
viewport-а: сцена растягивается, лог скроллит внутри — никаких
|
||||
скроллов на уровне страницы.
|
||||
|
||||
Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией
|
||||
от атакующего к защитнику, красной при `destroyed`, зелёной иначе.
|
||||
|
||||
@@ -32,12 +32,33 @@ and the engine never crosses these wires.
|
||||
|
||||
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
|
||||
planet at the centre and arrays the still-active races on an outer
|
||||
ring at equal angular spacing. Each race anchor is a horizontal
|
||||
cluster of small class circles, one per `(race, className)` pair,
|
||||
labelled `<className>:<numLeft>` underneath. When a race is wiped
|
||||
out, it drops out of the active list and the survivors are
|
||||
ring at equal angular spacing. Each race anchor hosts a *cloud* of
|
||||
class circles arranged on a Vogel sunflower spiral biased toward the
|
||||
planet (the cluster anchor is pushed inward by a quarter step so the
|
||||
rank-0 node — the heaviest group by NumberLeft — sits closest to the
|
||||
planet, and the spiral fans the rest behind it). When a race is
|
||||
wiped out, it drops out of the active list and the survivors are
|
||||
re-spaced on the next frame.
|
||||
|
||||
Each class circle is one *bucket* keyed by `(race, className)`:
|
||||
tech-variants of the same class collapse into one node so the scene
|
||||
stays readable when a race fields a dozen tech levels of the same
|
||||
hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
|
||||
across the underlying groups; per-tech detail is available in the
|
||||
Reports view (Foreign Ship Classes / My Ship Types).
|
||||
|
||||
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
||||
the per-ship `LoadQuantity`). The viewer resolves a
|
||||
`(race, className) → ShipClassRef` lookup from the surrounding
|
||||
`GameReport.localShipClass` + `otherShipClass` tables and runs it
|
||||
through the existing wasm bridge to `pkg/calc/ship.go`
|
||||
(`emptyMass` + `carryingMass` + `fullMass`). The radius is then
|
||||
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) × sqrt(mass / maxMassInBattle)`
|
||||
clamped to `[6, 24]` pixels — per-battle normalisation, so the
|
||||
heaviest ship in any given battle renders at the cap. Unknown class
|
||||
or invalid params fall back to MAX_RADIUS so the bucket stays
|
||||
visible.
|
||||
|
||||
The current frame's shot is drawn as a thin line from the attacker's
|
||||
class circle to the defender's class circle. Colour:
|
||||
|
||||
@@ -74,6 +95,17 @@ the log instead of watching the SVG. The list is always present
|
||||
and never hidden, satisfying the original Phase 27 acceptance "the
|
||||
same data is accessible as a static text log".
|
||||
|
||||
## Height fit
|
||||
|
||||
The viewer is pinned to the viewport: `.active-view` uses
|
||||
`calc(100dvh − 80px)` so the in-game-shell header + optional
|
||||
HistoryBanner do not push the scene below the fold. Inside the
|
||||
viewer, the scene grows (`flex: 1`) and the log shrinks to a
|
||||
30 dvh ceiling with its own internal scroll, so the page itself
|
||||
never scrolls vertically. The 80 px allowance maps to the current
|
||||
Header + HistoryBanner total on desktop; mobile breakpoints reuse
|
||||
the same calc because dvh tracks the dynamic viewport.
|
||||
|
||||
## Map markers
|
||||
|
||||
`map/battle-markers.ts` emits two marker kinds per
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Galaxy</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BattleReport } from "../src/api/battle-fetch";
|
||||
import { layoutRaces } from "../src/lib/battle-player/radial-layout";
|
||||
import {
|
||||
MAX_RADIUS,
|
||||
MIN_RADIUS,
|
||||
radiusForMass,
|
||||
} from "../src/lib/battle-player/mass";
|
||||
import {
|
||||
buildFrames,
|
||||
buildGroupRaceMap,
|
||||
@@ -144,3 +149,29 @@ describe("buildFrames", () => {
|
||||
expect(frames[4].activeRaceIds).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("radiusForMass", () => {
|
||||
it("returns MAX_RADIUS when mass is zero", () => {
|
||||
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
|
||||
});
|
||||
|
||||
it("returns MAX_RADIUS when maxMassInBattle is zero", () => {
|
||||
expect(radiusForMass(50, 0)).toBe(MAX_RADIUS);
|
||||
});
|
||||
|
||||
it("returns MAX_RADIUS at the per-battle ceiling", () => {
|
||||
expect(radiusForMass(100, 100)).toBeCloseTo(MAX_RADIUS, 5);
|
||||
});
|
||||
|
||||
it("scales by sqrt(mass / maxMass): one quarter of max mass = halfway", () => {
|
||||
const r = radiusForMass(25, 100);
|
||||
const expected = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * 0.5;
|
||||
expect(r).toBeCloseTo(expected, 5);
|
||||
});
|
||||
|
||||
it("never returns a radius below MIN_RADIUS or above MAX_RADIUS", () => {
|
||||
expect(radiusForMass(1, Number.MAX_SAFE_INTEGER)).toBeGreaterThanOrEqual(MIN_RADIUS);
|
||||
expect(radiusForMass(Number.MAX_SAFE_INTEGER, 1)).toBeLessThanOrEqual(MAX_RADIUS);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,28 @@ async function mockGatewayAndBattle(page: Page): Promise<void> {
|
||||
},
|
||||
],
|
||||
battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }],
|
||||
localShipClass: [
|
||||
{
|
||||
name: "Cruiser",
|
||||
drive: 10,
|
||||
armament: 2,
|
||||
weapons: 5,
|
||||
shields: 5,
|
||||
cargo: 2,
|
||||
},
|
||||
],
|
||||
otherShipClass: [
|
||||
{
|
||||
race: "Bajori",
|
||||
name: "Hawk",
|
||||
drive: 12,
|
||||
armament: 1,
|
||||
weapons: 4,
|
||||
shields: 2,
|
||||
cargo: 0,
|
||||
mass: 75,
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -249,4 +271,35 @@ test.describe("Phase 27 battle viewer", () => {
|
||||
|
||||
await expect(page.getByTestId("battle-not-found")).toBeVisible();
|
||||
});
|
||||
|
||||
test("viewer fits the desktop viewport without a vertical scroll", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"desktop-only height-fit check",
|
||||
);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await mockGatewayAndBattle(page);
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
|
||||
|
||||
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
||||
await expect(page.getByTestId("battle-scene")).toBeVisible();
|
||||
|
||||
// Phase 27 refinement: viewer + log fit the viewport; the
|
||||
// internal log scrolls inside its own pane rather than
|
||||
// growing the page. Allow a small tolerance for fractional
|
||||
// pixel rounding around flex math, but reject any
|
||||
// scrollable overflow beyond a couple of pixels.
|
||||
// Phase 27 refinement: viewer + log fit the viewport; the
|
||||
// internal log scrolls inside its own pane rather than
|
||||
// growing the page. Allow a small tolerance for fractional
|
||||
// pixel rounding around flex math.
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollHeight - window.innerHeight,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user