ui: plan 01-27 done #1
@@ -47,6 +47,15 @@ hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
|
|||||||
across the underlying groups; per-tech detail is available in the
|
across the underlying groups; per-tech detail is available in the
|
||||||
Reports view (Foreign Ship Classes / My Ship Types).
|
Reports view (Foreign Ship Classes / My Ship Types).
|
||||||
|
|
||||||
|
Bucket order inside a cluster is **locked at battle start** by the
|
||||||
|
initial ship count (`num` summed across tech variants, descending).
|
||||||
|
As ships die during playback only the label number changes — every
|
||||||
|
bucket keeps its slot in the Vogel spiral, so the user does not see
|
||||||
|
the cluster reshuffle when a class empties. Vogel positions are
|
||||||
|
then reassigned per rank by their inward distance toward the
|
||||||
|
planet, so the rank-0 bucket (the largest at battle start) always
|
||||||
|
sits at the most-inward spiral slot.
|
||||||
|
|
||||||
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
||||||
the per-ship `LoadQuantity`). The viewer resolves a
|
the per-ship `LoadQuantity`). The viewer resolves a
|
||||||
`(race, className) → ShipClassRef` lookup from the surrounding
|
`(race, className) → ShipClassRef` lookup from the surrounding
|
||||||
@@ -95,6 +104,32 @@ the log instead of watching the SVG. The list is always present
|
|||||||
and never hidden, satisfying the original Phase 27 acceptance "the
|
and never hidden, satisfying the original Phase 27 acceptance "the
|
||||||
same data is accessible as a static text log".
|
same data is accessible as a static text log".
|
||||||
|
|
||||||
|
Each log row is also a `<button>`: a click or Enter/Space jumps
|
||||||
|
playback to that shot (pauses and seeks). The list auto-scrolls
|
||||||
|
the current row into view as the timeline advances, so the user
|
||||||
|
does not have to chase the highlight on long battles.
|
||||||
|
|
||||||
|
## Playback details
|
||||||
|
|
||||||
|
On play, the shot line + the defender circle's colour flash gate
|
||||||
|
on a per-frame timer that blinks them off during the last 10 % of
|
||||||
|
the frame's duration. Two consecutive shots from the same attacker
|
||||||
|
on the same defender therefore look like two distinct pulses
|
||||||
|
rather than one continuous line. On pause the line and flash
|
||||||
|
stay drawn so the user can study the current shot.
|
||||||
|
|
||||||
|
## Phantom destroys
|
||||||
|
|
||||||
|
Legacy emitters (the `dg` engine format that feeds the synthetic-
|
||||||
|
report path) occasionally log more `Destroyed` lines against a
|
||||||
|
ship-group bucket than the bucket's initial population — the
|
||||||
|
emitter keeps recording hits past the moment the group emptied.
|
||||||
|
`buildFrames` clamps each per-group remaining count at zero and
|
||||||
|
only decrements race totals on a real shrink, so a race stays on
|
||||||
|
the scene until its actual ships are gone. The phantom shots still
|
||||||
|
draw a line during the frame they belong to; only the running
|
||||||
|
counters are protected.
|
||||||
|
|
||||||
## Height fit
|
## Height fit
|
||||||
|
|
||||||
The viewer is pinned to the viewport: `.active-view` uses
|
The viewer is pinned to the viewport: `.active-view` uses
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ BattleScene — radial SVG visualisation of one battle frame.
|
|||||||
|
|
||||||
Layout: planet at the centre, race anchors equally spaced on an
|
Layout: planet at the centre, race anchors equally spaced on an
|
||||||
outer ring, each race rendered as a *cloud* of class circles
|
outer ring, each race rendered as a *cloud* of class circles
|
||||||
arranged on a Vogel sunflower spiral biased toward the planet.
|
arranged on a Vogel sunflower spiral. Spiral positions are
|
||||||
Tech-variant groups of the same (race, className) collapse to one
|
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
|
visual node — the per-tech detail lives in Reports. Each circle's
|
||||||
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
||||||
visually dominate.
|
visually dominate.
|
||||||
@@ -39,20 +44,20 @@ collapse to a single visual node.
|
|||||||
report,
|
report,
|
||||||
frame,
|
frame,
|
||||||
shipClassLookup,
|
shipClassLookup,
|
||||||
|
shotVisible = true,
|
||||||
}: {
|
}: {
|
||||||
report: BattleReport;
|
report: BattleReport;
|
||||||
frame: Frame;
|
frame: Frame;
|
||||||
shipClassLookup?: ShipClassLookup;
|
shipClassLookup?: ShipClassLookup;
|
||||||
|
shotVisible?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const VIEW_BOX = 800;
|
const VIEW_BOX = 800;
|
||||||
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
|
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
|
||||||
const PLANET_RADIUS = 60;
|
const PLANET_RADIUS = 60;
|
||||||
const RACE_RING_RADIUS = 280;
|
const RACE_RING_RADIUS = 280;
|
||||||
// Vogel sunflower step + half-circle bias toward planet.
|
|
||||||
const BASE_STEP = 1.8 * MAX_RADIUS;
|
const BASE_STEP = 1.8 * MAX_RADIUS;
|
||||||
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
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 MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
|
||||||
|
|
||||||
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
||||||
@@ -66,6 +71,7 @@ collapse to a single visual node.
|
|||||||
race: string;
|
race: string;
|
||||||
raceId: number;
|
raceId: number;
|
||||||
groupKeys: number[];
|
groupKeys: number[];
|
||||||
|
initialNum: number;
|
||||||
numLeft: number;
|
numLeft: number;
|
||||||
mass: number;
|
mass: number;
|
||||||
radius: number;
|
radius: number;
|
||||||
@@ -79,7 +85,6 @@ collapse to a single visual node.
|
|||||||
// those nodes.
|
// those nodes.
|
||||||
const clustersByRace = $derived.by(() => {
|
const clustersByRace = $derived.by(() => {
|
||||||
const core = coreHandle?.core ?? null;
|
const core = coreHandle?.core ?? null;
|
||||||
// First pass: build the bucket list per race.
|
|
||||||
const out = new Map<number, ClusterEntry[]>();
|
const out = new Map<number, ClusterEntry[]>();
|
||||||
const bucketIndex = new Map<string, ClusterEntry>();
|
const bucketIndex = new Map<string, ClusterEntry>();
|
||||||
for (const g of allGroups) {
|
for (const g of allGroups) {
|
||||||
@@ -97,6 +102,7 @@ collapse to a single visual node.
|
|||||||
race: g.group.race,
|
race: g.group.race,
|
||||||
raceId: g.raceId,
|
raceId: g.raceId,
|
||||||
groupKeys: [],
|
groupKeys: [],
|
||||||
|
initialNum: 0,
|
||||||
numLeft: 0,
|
numLeft: 0,
|
||||||
mass,
|
mass,
|
||||||
radius: MAX_RADIUS,
|
radius: MAX_RADIUS,
|
||||||
@@ -107,6 +113,7 @@ collapse to a single visual node.
|
|||||||
out.set(g.raceId, list);
|
out.set(g.raceId, list);
|
||||||
}
|
}
|
||||||
bucket.groupKeys.push(g.key);
|
bucket.groupKeys.push(g.key);
|
||||||
|
bucket.initialNum += g.group.num;
|
||||||
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
|
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,19 +127,20 @@ collapse to a single visual node.
|
|||||||
bucket.radius = radiusForMass(bucket.mass, maxMass);
|
bucket.radius = radiusForMass(bucket.mass, maxMass);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort buckets in each cluster by NumberLeft desc → rank 0 is
|
// Order is locked by initial ship count, NOT live numLeft:
|
||||||
// the biggest group (will be placed closest to planet).
|
// 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()) {
|
for (const list of out.values()) {
|
||||||
list.sort((a, b) => {
|
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 a.className.localeCompare(b.className);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
// bucketByGroupKey lets shot endpoints look up the aggregated
|
|
||||||
// node by any of its constituent ship-group keys.
|
|
||||||
const bucketByGroupKey = $derived.by(() => {
|
const bucketByGroupKey = $derived.by(() => {
|
||||||
const out = new Map<number, ClusterEntry>();
|
const out = new Map<number, ClusterEntry>();
|
||||||
for (const list of clustersByRace.values()) {
|
for (const list of clustersByRace.values()) {
|
||||||
@@ -162,11 +170,6 @@ collapse to a single visual node.
|
|||||||
step: 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 clusterBasisById = $derived.by(() => {
|
||||||
const out = new Map<number, ClusterBasis>();
|
const out = new Map<number, ClusterBasis>();
|
||||||
for (const anchor of raceLayout) {
|
for (const anchor of raceLayout) {
|
||||||
@@ -175,27 +178,60 @@ collapse to a single visual node.
|
|||||||
const len = Math.hypot(dx, dy) || 1;
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
const ux = dx / len;
|
const ux = dx / len;
|
||||||
const uy = dy / len;
|
const uy = dy / len;
|
||||||
const vx = uy; // (ux, uy) rotated 90° clockwise → (uy, -ux);
|
const vx = uy;
|
||||||
const vy = -ux; // use mirror to keep label below cluster.
|
const vy = -ux;
|
||||||
const count = (clustersByRace.get(anchor.raceId) ?? []).length;
|
const count = (clustersByRace.get(anchor.raceId) ?? []).length;
|
||||||
const baseStep = BASE_STEP;
|
|
||||||
const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
|
const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
|
||||||
const step = Math.min(baseStep, MAX_CLUSTER_RADIUS / denom);
|
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
|
||||||
const anchorX = anchor.x + ANCHOR_BIAS * step * ux;
|
out.set(anchor.raceId, {
|
||||||
const anchorY = anchor.y + ANCHOR_BIAS * step * uy;
|
anchorX: anchor.x,
|
||||||
out.set(anchor.raceId, { anchorX, anchorY, ux, uy, vx, vy, step });
|
anchorY: anchor.y,
|
||||||
|
ux,
|
||||||
|
uy,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
step,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
function nodePosition(basis: ClusterBasis, rank: number) {
|
// vogelLocalsByRace generates Vogel sunflower positions for each
|
||||||
const radius = basis.step * Math.sqrt(rank);
|
// cluster and reassigns them by inward dot product (`offsetU`
|
||||||
const angle = rank * GOLDEN_ANGLE;
|
// descending) so the rank-0 bucket always lands at the most-
|
||||||
const offsetU = radius * Math.cos(angle);
|
// inward spiral point. With the original `r * GOLDEN_ANGLE` angle
|
||||||
const offsetV = radius * Math.sin(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 {
|
return {
|
||||||
x: basis.anchorX + offsetU * basis.ux + offsetV * basis.vx,
|
x: basis.anchorX + local.offsetU * basis.ux + local.offsetV * basis.vx,
|
||||||
y: basis.anchorY + offsetU * basis.uy + offsetV * basis.vy,
|
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;
|
if (!basis) return null;
|
||||||
const cluster = clustersByRace.get(bucket.raceId) ?? [];
|
const cluster = clustersByRace.get(bucket.raceId) ?? [];
|
||||||
const rank = cluster.indexOf(bucket);
|
const rank = cluster.indexOf(bucket);
|
||||||
if (rank === -1) return null;
|
const locals = vogelLocalsByRace.get(bucket.raceId);
|
||||||
return nodePosition(basis, rank);
|
if (rank === -1 || locals === undefined) return null;
|
||||||
|
return worldPosition(basis, locals[rank]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shotLine = $derived.by(() => {
|
const shotLine = $derived.by(() => {
|
||||||
@@ -216,7 +253,13 @@ collapse to a single visual node.
|
|||||||
const from = findClassCircleCenter(action.sa);
|
const from = findClassCircleCenter(action.sa);
|
||||||
const to = findClassCircleCenter(action.sd);
|
const to = findClassCircleCenter(action.sd);
|
||||||
if (!from || !to) return null;
|
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(() => {
|
const raceLabelById = $derived.by(() => {
|
||||||
@@ -224,15 +267,34 @@ collapse to a single visual node.
|
|||||||
for (const g of allGroups) {
|
for (const g of allGroups) {
|
||||||
out.set(g.raceId, g.group.race);
|
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) {
|
for (const [, raceId] of groupRace) {
|
||||||
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
|
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
|
||||||
}
|
}
|
||||||
return out;
|
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>
|
</script>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
@@ -260,6 +322,7 @@ collapse to a single visual node.
|
|||||||
{#each raceLayout as anchor (anchor.raceId)}
|
{#each raceLayout as anchor (anchor.raceId)}
|
||||||
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
||||||
{@const basis = clusterBasisById.get(anchor.raceId)}
|
{@const basis = clusterBasisById.get(anchor.raceId)}
|
||||||
|
{@const locals = vogelLocalsByRace.get(anchor.raceId) ?? []}
|
||||||
<g
|
<g
|
||||||
class="race-cluster"
|
class="race-cluster"
|
||||||
data-testid="battle-race-cluster"
|
data-testid="battle-race-cluster"
|
||||||
@@ -267,18 +330,26 @@ collapse to a single visual node.
|
|||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
x={anchor.x}
|
x={anchor.x}
|
||||||
y={anchor.y - MAX_RADIUS - 12}
|
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="race-label"
|
class="race-label"
|
||||||
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
||||||
{#if basis}
|
{#if basis}
|
||||||
{#each cluster as entry, rank (entry.bucketKey)}
|
{#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
|
<g
|
||||||
class="class-marker"
|
class="class-marker"
|
||||||
data-testid="battle-class-marker"
|
data-testid="battle-class-marker"
|
||||||
data-bucket-key={entry.bucketKey}
|
data-bucket-key={entry.bucketKey}
|
||||||
data-class-name={entry.className}
|
data-class-name={entry.className}
|
||||||
|
data-flash={flash}
|
||||||
>
|
>
|
||||||
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
|
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
|
||||||
<text
|
<text
|
||||||
@@ -293,7 +364,7 @@ collapse to a single visual node.
|
|||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if shotLine}
|
{#if shotLine && shotVisible}
|
||||||
<line
|
<line
|
||||||
x1={shotLine.from.x}
|
x1={shotLine.from.x}
|
||||||
y1={shotLine.from.y}
|
y1={shotLine.from.y}
|
||||||
@@ -315,12 +386,12 @@ collapse to a single visual node.
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.planet {
|
.planet {
|
||||||
fill: #2a345f;
|
fill: #2a2f40;
|
||||||
stroke: #5b6aa3;
|
stroke: #4a5066;
|
||||||
stroke-width: 2;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
.planet-label {
|
.planet-label {
|
||||||
fill: #c4caea;
|
fill: #6d7388;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
@@ -334,6 +405,17 @@ collapse to a single visual node.
|
|||||||
fill: #1a2042;
|
fill: #1a2042;
|
||||||
stroke: #6d7bb5;
|
stroke: #6d7bb5;
|
||||||
stroke-width: 1.5;
|
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 {
|
.class-label {
|
||||||
fill: #b8c0e6;
|
fill: #b8c0e6;
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
`pkg/model/report/battle.go` and it plays back.
|
`pkg/model/report/battle.go` and it plays back.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import type { BattleReport } from "../../api/battle-fetch";
|
import type { BattleReport } from "../../api/battle-fetch";
|
||||||
import type { ShipClassLookup } from "./mass";
|
import type { ShipClassLookup } from "./mass";
|
||||||
@@ -28,35 +26,67 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
let frameIndex = $state(0);
|
let frameIndex = $state(0);
|
||||||
let playing = $state(false);
|
let playing = $state(false);
|
||||||
let speed = $state<1 | 2 | 4>(1);
|
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)]);
|
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
|
||||||
|
|
||||||
// 1x = 400 ms per frame, 2x = 200 ms, 4x = 100 ms. The timer is
|
// Schedule one tick per frame instead of a long-running
|
||||||
// rescheduled whenever `speed` or `playing` flips.
|
// setInterval so the blink and the frame-advance share the same
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
// 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(() => {
|
$effect(() => {
|
||||||
if (timer !== null) {
|
// Track changes via direct reads.
|
||||||
clearInterval(timer);
|
void frameIndex;
|
||||||
timer = null;
|
void speed;
|
||||||
}
|
shotVisible = true;
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
const intervalMs = 400 / speed;
|
const intervalMs = 400 / speed;
|
||||||
timer = setInterval(() => {
|
const blinkOff = setTimeout(() => {
|
||||||
|
shotVisible = false;
|
||||||
|
}, intervalMs * 0.9);
|
||||||
|
const advance = setTimeout(() => {
|
||||||
if (frameIndex >= frames.length - 1) {
|
if (frameIndex >= frames.length - 1) {
|
||||||
playing = false;
|
playing = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
frameIndex = frameIndex + 1;
|
frameIndex = frameIndex + 1;
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(blinkOff);
|
||||||
|
clearTimeout(advance);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
// Auto-scroll the log so the current row stays visible as the
|
||||||
if (timer !== null) {
|
// timeline advances. `block: "nearest"` keeps the scroll movement
|
||||||
clearInterval(timer);
|
// gentle — the row lands at the closest edge of the visible
|
||||||
timer = null;
|
// 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 {
|
function describeAction(index: number): string {
|
||||||
const action = report.protocol[index];
|
const action = report.protocol[index];
|
||||||
const attackerGroup = report.ships[String(action.sa)];
|
const attackerGroup = report.ships[String(action.sa)];
|
||||||
@@ -88,7 +118,7 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="scene">
|
<div class="scene">
|
||||||
<BattleScene {report} {frame} {shipClassLookup} />
|
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlaybackControls
|
<PlaybackControls
|
||||||
@@ -103,12 +133,18 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
|
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
|
||||||
>
|
>
|
||||||
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
|
<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)}
|
{#each report.protocol as _action, i (i)}
|
||||||
<li
|
<li
|
||||||
data-testid="battle-protocol-log-item"
|
data-testid="battle-protocol-log-item"
|
||||||
data-current={i + 1 === frame.shotIndex ? "true" : "false"}
|
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}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
@@ -180,11 +216,26 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.log li {
|
.log li {
|
||||||
padding: 0.15rem 0;
|
|
||||||
border-bottom: 1px solid #1c2240;
|
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;
|
color: #ffe27a;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
background: #1a2240;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ already at its end.
|
|||||||
disabled={frameIndex === 0}
|
disabled={frameIndex === 0}
|
||||||
aria-label={i18n.t("game.battle.controls.step_backward")}
|
aria-label={i18n.t("game.battle.controls.step_backward")}
|
||||||
data-testid="battle-control-step-back"
|
data-testid="battle-control-step-back"
|
||||||
>◀︎</button>
|
>◀︎◀︎</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={togglePlay}
|
onclick={togglePlay}
|
||||||
|
|||||||
@@ -105,13 +105,25 @@ export function buildFrames(report: BattleReport): Frame[] {
|
|||||||
for (let i = 0; i < report.protocol.length; i++) {
|
for (let i = 0; i < report.protocol.length; i++) {
|
||||||
const action = report.protocol[i];
|
const action = report.protocol[i];
|
||||||
if (action.x) {
|
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 left = current.get(action.sd) ?? 0;
|
||||||
const next = Math.max(0, left - 1);
|
if (left > 0) {
|
||||||
current.set(action.sd, next);
|
current.set(action.sd, left - 1);
|
||||||
const raceId = groupRaceByKey.get(action.sd);
|
const raceId = groupRaceByKey.get(action.sd);
|
||||||
if (raceId !== undefined) {
|
if (raceId !== undefined) {
|
||||||
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
|
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
|
||||||
runningRaceTotals.set(raceId, Math.max(0, t));
|
runningRaceTotals.set(raceId, Math.max(0, t));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
frames.push({
|
frames.push({
|
||||||
|
|||||||
@@ -326,7 +326,19 @@ fresh.
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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([
|
await Promise.all([
|
||||||
gameState.initSynthetic({ cache, gameId, report }),
|
gameState.initSynthetic({ cache, gameId, report }),
|
||||||
orderDraft.init({ cache, gameId }),
|
orderDraft.init({ cache, gameId }),
|
||||||
|
|||||||
@@ -150,6 +150,128 @@ describe("buildFrames", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildFrames phantom-destroy clamp", () => {
|
||||||
|
it("does not drop a race when destroyed shots exceed initial counts", () => {
|
||||||
|
// Race "Phantom" has a single group with 2 ships; the engine
|
||||||
|
// emits five Destroyed shots against it (legacy emitter quirk
|
||||||
|
// reproduced in KNNTS041 planet #7). The group goes to 0
|
||||||
|
// after two real destroys; the remaining three are phantoms
|
||||||
|
// and must not push raceTotals into negatives or drop the
|
||||||
|
// race from activeRaceIds prematurely. Race "Survivor" keeps
|
||||||
|
// its single ship throughout so it stays active alongside
|
||||||
|
// Phantom until Phantom legitimately empties.
|
||||||
|
const report: BattleReport = {
|
||||||
|
id: "phantom-battle",
|
||||||
|
planet: 1,
|
||||||
|
planetName: "P",
|
||||||
|
races: { "0": "phantom-uuid", "1": "survivor-uuid" },
|
||||||
|
ships: {
|
||||||
|
"10": {
|
||||||
|
race: "Phantom",
|
||||||
|
className: "Drone",
|
||||||
|
tech: {},
|
||||||
|
num: 2,
|
||||||
|
numLeft: 0,
|
||||||
|
loadType: "",
|
||||||
|
loadQuantity: 0,
|
||||||
|
inBattle: true,
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
race: "Survivor",
|
||||||
|
className: "Hawk",
|
||||||
|
tech: {},
|
||||||
|
num: 1,
|
||||||
|
numLeft: 1,
|
||||||
|
loadType: "",
|
||||||
|
loadQuantity: 0,
|
||||||
|
inBattle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
protocol: [
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #1
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #2 → group=0
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #1
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #2
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #3
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const frames = buildFrames(report);
|
||||||
|
expect(frames[2].remaining.get(10)).toBe(0);
|
||||||
|
// After the 2nd real destroy Phantom has 0 ships in its only
|
||||||
|
// group and must drop out of activeRaceIds.
|
||||||
|
expect(frames[2].activeRaceIds).toEqual([1]);
|
||||||
|
// Phantoms past frame 2 must NOT keep decrementing — group
|
||||||
|
// stays at 0, totals don't go negative, and Survivor remains
|
||||||
|
// the only active race for the remainder of the protocol.
|
||||||
|
expect(frames[5].remaining.get(10)).toBe(0);
|
||||||
|
expect(frames[5].activeRaceIds).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a race active while phantom destroys hit one of its empty groups", () => {
|
||||||
|
// One race ("Doublet"), two groups of different class. Class
|
||||||
|
// A gets all five Destroyed shots; class B never gets hit.
|
||||||
|
// Class A only has 2 ships → 3 phantoms. The race must stay
|
||||||
|
// active because class B's single ship is intact.
|
||||||
|
const report: BattleReport = {
|
||||||
|
id: "doublet-battle",
|
||||||
|
planet: 2,
|
||||||
|
planetName: "P2",
|
||||||
|
races: { "0": "doublet-uuid", "1": "attacker-uuid" },
|
||||||
|
ships: {
|
||||||
|
"10": {
|
||||||
|
race: "Doublet",
|
||||||
|
className: "A",
|
||||||
|
tech: {},
|
||||||
|
num: 2,
|
||||||
|
numLeft: 0,
|
||||||
|
loadType: "",
|
||||||
|
loadQuantity: 0,
|
||||||
|
inBattle: true,
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
race: "Doublet",
|
||||||
|
className: "B",
|
||||||
|
tech: {},
|
||||||
|
num: 1,
|
||||||
|
numLeft: 1,
|
||||||
|
loadType: "",
|
||||||
|
loadQuantity: 0,
|
||||||
|
inBattle: true,
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
race: "Attacker",
|
||||||
|
className: "Gun",
|
||||||
|
tech: {},
|
||||||
|
num: 1,
|
||||||
|
numLeft: 1,
|
||||||
|
loadType: "",
|
||||||
|
loadQuantity: 0,
|
||||||
|
inBattle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
protocol: [
|
||||||
|
// Open the protocol with a shot that names class B so
|
||||||
|
// normaliseGroups picks it up (groups never referenced
|
||||||
|
// in the protocol are filtered out of the visual
|
||||||
|
// roster); the shot misses so class B stays intact.
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 11, x: false },
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||||
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const frames = buildFrames(report);
|
||||||
|
// After all 6 actions: Doublet:A is at 0 (group capped at 2
|
||||||
|
// real destroys + 3 phantoms), Doublet:B unchanged at 1, so
|
||||||
|
// race totals = 1 → race stays active.
|
||||||
|
expect(frames[6].remaining.get(10)).toBe(0);
|
||||||
|
expect(frames[6].remaining.get(11)).toBe(1);
|
||||||
|
expect(frames[6].activeRaceIds.sort()).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("radiusForMass", () => {
|
describe("radiusForMass", () => {
|
||||||
it("returns MAX_RADIUS when mass is zero", () => {
|
it("returns MAX_RADIUS when mass is zero", () => {
|
||||||
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
|
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
|
||||||
|
|||||||
Reference in New Issue
Block a user