diff --git a/ui/docs/battle-viewer-ux.md b/ui/docs/battle-viewer-ux.md index 4a4d7e2..93f8773 100644 --- a/ui/docs/battle-viewer-ux.md +++ b/ui/docs/battle-viewer-ux.md @@ -135,14 +135,31 @@ 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. +report path) occasionally log more `Destroyed` (and `Shields`) +lines against a ship-group bucket than the bucket's initial +population — the emitter keeps recording hits past the moment a +group emptied. `buildFrames` marks every such frame as +`phantom: true` and skips the race-total decrement so the race +stays on the scene until its actual ships are gone. + +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 diff --git a/ui/frontend/src/lib/battle-player/battle-viewer.svelte b/ui/frontend/src/lib/battle-player/battle-viewer.svelte index f2395a8..e1704d6 100644 --- a/ui/frontend/src/lib/battle-player/battle-viewer.svelte +++ b/ui/frontend/src/lib/battle-player/battle-viewer.svelte @@ -46,17 +46,62 @@ matching `pkg/model/report/battle.go` and it plays back. let shotVisible = $state(true); let logEl = $state(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 // 10 % of the frame's interval, then advance. Effect re-arms // whenever frameIndex / playing / speed changes; previous // 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(() => { void frameIndex; void speed; shotVisible = true; 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 blinkOff = setTimeout(() => { 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 // keeps up with the timeline on long battles. $effect(() => { - void frame.shotIndex; + void displayFrame.shotIndex; if (!logOpen || logEl === null) return; const current = logEl.querySelector( 'li[data-current="true"]', @@ -150,12 +195,12 @@ matching `pkg/model/report/battle.go` and it plays back. })} - {frame.shotIndex} / {report.protocol.length} + {displayFrame.shotIndex} / {report.protocol.length}
- +