ui/phase-27: skip phantom frames during play + freeze final layout
Two more KNNTS041 viewer fixes: 1. Phantom-frame fast-forward. `buildFrames` now flags every frame whose shot landed on an already-empty defender group as `phantom: true`. During play the BattleViewer effect detects a phantom frame and chains a 0 ms timer to the next non-phantom, so streaks of phantoms (the ~30 frames between shots 224 and 255, and the 401..414 stretch) collapse from "the player just mots the timeline" into a single visual tick. Step controls and the scrubber can still land on a phantom deliberately for protocol inspection. 2. Final-frame layout freeze. `displayFrame` derives from the raw `frames[i]` and, on the very last frame when `activeRaceIds` shrinks vs the penultimate frame (the killing blow eliminates a race), substitutes the penultimate's `remaining` and `activeRaceIds` while keeping the current `shotIndex` and `lastAction`. The result: the surviving cluster no longer reflows onto the planet ring on the very last shot — the user sees the killing line + defender flash rendered against the picture they saw a moment earlier. Tests: `phantom-destroy clamp` case extended with `frame.phantom` flag assertions across the protocol; 644 Vitest cases stay green, 4 Playwright `battle-viewer` cases stay green. Docs: `ui/docs/battle-viewer-ux.md` documents the fast-forward behaviour and the final-frame freeze. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -135,14 +135,31 @@ stay drawn so the user can study the current shot.
|
|||||||
## Phantom destroys
|
## Phantom destroys
|
||||||
|
|
||||||
Legacy emitters (the `dg` engine format that feeds the synthetic-
|
Legacy emitters (the `dg` engine format that feeds the synthetic-
|
||||||
report path) occasionally log more `Destroyed` lines against a
|
report path) occasionally log more `Destroyed` (and `Shields`)
|
||||||
ship-group bucket than the bucket's initial population — the
|
lines against a ship-group bucket than the bucket's initial
|
||||||
emitter keeps recording hits past the moment the group emptied.
|
population — the emitter keeps recording hits past the moment a
|
||||||
`buildFrames` clamps each per-group remaining count at zero and
|
group emptied. `buildFrames` marks every such frame as
|
||||||
only decrements race totals on a real shrink, so a race stays on
|
`phantom: true` and skips the race-total decrement so the race
|
||||||
the scene until its actual ships are gone. The phantom shots still
|
stays on the scene until its actual ships are gone.
|
||||||
draw a line during the frame they belong to; only the running
|
|
||||||
counters are protected.
|
During play the BattleViewer fast-forwards through streaks of
|
||||||
|
phantom frames via a 0 ms timer so the user never sees a silent
|
||||||
|
gap (KNNTS041 had ~30 phantom frames between shots 224 and 255
|
||||||
|
right after the last `Nails:pup` died). Step controls and the
|
||||||
|
scrubber can still land on a phantom frame deliberately — useful
|
||||||
|
when inspecting the protocol entry that the engine emitted into
|
||||||
|
the void.
|
||||||
|
|
||||||
|
## Final-frame freeze
|
||||||
|
|
||||||
|
When the last protocol action eliminates a race, the surviving
|
||||||
|
side would otherwise reflow alone to the planet ring at the very
|
||||||
|
last shot — visually jarring and uninformative. `displayFrame`
|
||||||
|
freezes the layout-determining state (`remaining` and
|
||||||
|
`activeRaceIds`) at the penultimate frame's values while keeping
|
||||||
|
the final frame's `shotIndex` and `lastAction`, so the killing
|
||||||
|
shot still renders as a line + flash against the picture the user
|
||||||
|
saw a moment earlier.
|
||||||
|
|
||||||
## Header + layout
|
## Header + layout
|
||||||
|
|
||||||
|
|||||||
@@ -46,17 +46,62 @@ matching `pkg/model/report/battle.go` and it plays back.
|
|||||||
let shotVisible = $state(true);
|
let shotVisible = $state(true);
|
||||||
let logEl = $state<HTMLOListElement | null>(null);
|
let logEl = $state<HTMLOListElement | null>(null);
|
||||||
|
|
||||||
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
|
const rawFrame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
|
||||||
|
|
||||||
|
// displayFrame freezes the layout at the penultimate frame's
|
||||||
|
// state once the protocol's last action eliminates a race, so
|
||||||
|
// the surviving cluster does not suddenly reflow onto the
|
||||||
|
// planet ring on the very last shot. The frame counter still
|
||||||
|
// advances to the final shot and `lastAction` still drives the
|
||||||
|
// killing line + flash; only `remaining` and `activeRaceIds`
|
||||||
|
// (the layout-determining state) freeze.
|
||||||
|
const displayFrame = $derived.by(() => {
|
||||||
|
const last = frames.length - 1;
|
||||||
|
if (
|
||||||
|
frameIndex === last &&
|
||||||
|
last >= 1 &&
|
||||||
|
frames[last].activeRaceIds.length < frames[last - 1].activeRaceIds.length
|
||||||
|
) {
|
||||||
|
const prev = frames[last - 1];
|
||||||
|
const cur = frames[last];
|
||||||
|
return {
|
||||||
|
shotIndex: cur.shotIndex,
|
||||||
|
lastAction: cur.lastAction,
|
||||||
|
phantom: cur.phantom,
|
||||||
|
remaining: prev.remaining,
|
||||||
|
activeRaceIds: prev.activeRaceIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return rawFrame;
|
||||||
|
});
|
||||||
|
|
||||||
// One tick per frame: blink the shot line off during the last
|
// One tick per frame: blink the shot line off during the last
|
||||||
// 10 % of the frame's interval, then advance. Effect re-arms
|
// 10 % of the frame's interval, then advance. Effect re-arms
|
||||||
// whenever frameIndex / playing / speed changes; previous
|
// whenever frameIndex / playing / speed changes; previous
|
||||||
// timers clean up through the return.
|
// timers clean up through the return.
|
||||||
|
//
|
||||||
|
// A phantom frame (shot against an already-empty defender)
|
||||||
|
// would otherwise hold the scene silent for the full interval.
|
||||||
|
// During play we fast-forward to the next non-phantom frame
|
||||||
|
// through a 0 ms timer, so streaks of phantoms (KNNTS041
|
||||||
|
// frames 225..255, 401..414, …) collapse into a single tick
|
||||||
|
// from the user's POV.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void frameIndex;
|
void frameIndex;
|
||||||
void speed;
|
void speed;
|
||||||
shotVisible = true;
|
shotVisible = true;
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
|
if (rawFrame.phantom && frameIndex < frames.length - 1) {
|
||||||
|
let next = frameIndex + 1;
|
||||||
|
while (next < frames.length - 1 && frames[next].phantom) {
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
const target = next;
|
||||||
|
const skip = setTimeout(() => {
|
||||||
|
frameIndex = target;
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(skip);
|
||||||
|
}
|
||||||
const intervalMs = 400 / speed;
|
const intervalMs = 400 / speed;
|
||||||
const blinkOff = setTimeout(() => {
|
const blinkOff = setTimeout(() => {
|
||||||
shotVisible = false;
|
shotVisible = false;
|
||||||
@@ -77,7 +122,7 @@ matching `pkg/model/report/battle.go` and it plays back.
|
|||||||
// Auto-scroll the visible log row into view so the highlight
|
// Auto-scroll the visible log row into view so the highlight
|
||||||
// keeps up with the timeline on long battles.
|
// keeps up with the timeline on long battles.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void frame.shotIndex;
|
void displayFrame.shotIndex;
|
||||||
if (!logOpen || logEl === null) return;
|
if (!logOpen || logEl === null) return;
|
||||||
const current = logEl.querySelector(
|
const current = logEl.querySelector(
|
||||||
'li[data-current="true"]',
|
'li[data-current="true"]',
|
||||||
@@ -150,12 +195,12 @@ matching `pkg/model/report/battle.go` and it plays back.
|
|||||||
})}
|
})}
|
||||||
</h2>
|
</h2>
|
||||||
<span class="progress" data-testid="battle-frame-index">
|
<span class="progress" data-testid="battle-frame-index">
|
||||||
{frame.shotIndex} / {report.protocol.length}
|
{displayFrame.shotIndex} / {report.protocol.length}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="scene">
|
<div class="scene">
|
||||||
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
|
<BattleScene {report} frame={displayFrame} {shipClassLookup} {shotVisible} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -188,7 +233,7 @@ matching `pkg/model/report/battle.go` and it plays back.
|
|||||||
{#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 === displayFrame.shotIndex ? "true" : "false"}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -19,12 +19,19 @@ import type {
|
|||||||
* `BattleReport.ships`; `activeRaceIds` are the race indices with at
|
* `BattleReport.ships`; `activeRaceIds` are the race indices with at
|
||||||
* least one surviving in-battle group. `lastAction` is the action
|
* least one surviving in-battle group. `lastAction` is the action
|
||||||
* applied to produce this frame, or `null` for the initial frame.
|
* applied to produce this frame, or `null` for the initial frame.
|
||||||
|
* `phantom` is true when the action's defender ship-group was
|
||||||
|
* already at zero before the action ran — legacy emitters keep
|
||||||
|
* logging hits past the moment a group emptied. The viewer fast-
|
||||||
|
* forwards through phantom frames during play so the user never
|
||||||
|
* sees a silent gap; the frame is still in the sequence so step
|
||||||
|
* controls and the scrubber can land on it deliberately.
|
||||||
*/
|
*/
|
||||||
export interface Frame {
|
export interface Frame {
|
||||||
shotIndex: number;
|
shotIndex: number;
|
||||||
remaining: Map<number, number>;
|
remaining: Map<number, number>;
|
||||||
activeRaceIds: number[];
|
activeRaceIds: number[];
|
||||||
lastAction: BattleActionReport | null;
|
lastAction: BattleActionReport | null;
|
||||||
|
phantom: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalisedGroup {
|
export interface NormalisedGroup {
|
||||||
@@ -95,6 +102,7 @@ export function buildFrames(report: BattleReport): Frame[] {
|
|||||||
remaining: new Map(initialRemaining),
|
remaining: new Map(initialRemaining),
|
||||||
activeRaceIds: collectActiveRaces(raceTotals),
|
activeRaceIds: collectActiveRaces(raceTotals),
|
||||||
lastAction: null,
|
lastAction: null,
|
||||||
|
phantom: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupRaceByKey = new Map<number, number>();
|
const groupRaceByKey = new Map<number, number>();
|
||||||
@@ -104,26 +112,20 @@ export function buildFrames(report: BattleReport): Frame[] {
|
|||||||
const runningRaceTotals = new Map(raceTotals);
|
const runningRaceTotals = new Map(raceTotals);
|
||||||
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) {
|
// A shot whose defender group was empty before the action
|
||||||
// Decrement only when the targeted group actually has
|
// ran is a phantom: legacy emitters keep logging hits past
|
||||||
// ships left. Legacy emitters (the `dg` text format used
|
// the moment a group emptied. We keep the frame in the
|
||||||
// by the synthetic-report path) sometimes ship more
|
// sequence (step controls and the scrubber can still land
|
||||||
// `Destroyed` lines than the group's initial population —
|
// on it deliberately) but mark it so the play loop fast-
|
||||||
// looks like the engine keeps logging hits against an
|
// forwards across the silent gap.
|
||||||
// already-empty ship-group bucket. Without this guard
|
const leftBefore = current.get(action.sd) ?? 0;
|
||||||
// `runningRaceTotals` decrements on every phantom and the
|
const phantom = leftBefore === 0;
|
||||||
// race vanishes from `activeRaceIds` long before its
|
if (action.x && !phantom) {
|
||||||
// real groups were all destroyed (KNNTS041 battle on
|
current.set(action.sd, leftBefore - 1);
|
||||||
// planet 7, frame ≈ 406 of 2317). The line still draws
|
const raceId = groupRaceByKey.get(action.sd);
|
||||||
// for that frame so the user sees the shot happen.
|
if (raceId !== undefined) {
|
||||||
const left = current.get(action.sd) ?? 0;
|
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
|
||||||
if (left > 0) {
|
runningRaceTotals.set(raceId, Math.max(0, t));
|
||||||
current.set(action.sd, left - 1);
|
|
||||||
const raceId = groupRaceByKey.get(action.sd);
|
|
||||||
if (raceId !== undefined) {
|
|
||||||
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
|
|
||||||
runningRaceTotals.set(raceId, Math.max(0, t));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
frames.push({
|
frames.push({
|
||||||
@@ -131,6 +133,7 @@ export function buildFrames(report: BattleReport): Frame[] {
|
|||||||
remaining: new Map(current),
|
remaining: new Map(current),
|
||||||
activeRaceIds: collectActiveRaces(runningRaceTotals),
|
activeRaceIds: collectActiveRaces(runningRaceTotals),
|
||||||
lastAction: action,
|
lastAction: action,
|
||||||
|
phantom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,15 @@ describe("buildFrames phantom-destroy clamp", () => {
|
|||||||
// the only active race for the remainder of the protocol.
|
// the only active race for the remainder of the protocol.
|
||||||
expect(frames[5].remaining.get(10)).toBe(0);
|
expect(frames[5].remaining.get(10)).toBe(0);
|
||||||
expect(frames[5].activeRaceIds).toEqual([1]);
|
expect(frames[5].activeRaceIds).toEqual([1]);
|
||||||
|
// Phantom flags: first two destroys land on a non-empty
|
||||||
|
// group → real shots; the remaining three are phantoms.
|
||||||
|
expect(frames[1].phantom).toBe(false);
|
||||||
|
expect(frames[2].phantom).toBe(false);
|
||||||
|
expect(frames[3].phantom).toBe(true);
|
||||||
|
expect(frames[4].phantom).toBe(true);
|
||||||
|
expect(frames[5].phantom).toBe(true);
|
||||||
|
// The initial frame is never a phantom.
|
||||||
|
expect(frames[0].phantom).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a race active while phantom destroys hit one of its empty groups", () => {
|
it("keeps a race active while phantom destroys hit one of its empty groups", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user