ui/phase-27: viewer layout pass + static cluster + duel layout
Layout reshuffle so the scene captures the maximum viewer area: - Header collapses three rows into one: `back to map` / `back to report` on the left, the centred title `Battle on planet <name> (#<number>)` (new i18n key `game.battle.header_title`), and the frame counter on the right. The wrapper `.active-view` no longer renders its own back-row; routes flow through props. - Viewer drops the `max-width: 880px` cap so on a wide monitor the scene scales up across the full active-view-host. - A drag-seek `<input type="range">` sits between the scene and the controls; dragging pauses playback and lands `frameIndex` on the chosen shot. - Speed control is one cycling button: `1x → 2x → 4x → 6x → 1x`. The label shows the current speed; the new 6x adds a 67 ms frame interval for skimming a long timeline. - The text protocol log is now collapsible behind a `Log ▲▼` toggle in the controls bar. The toggle is its own button; the default state stays expanded. Collapsing the log hands the remaining height to the scene. - Numerical list markers (`1. 2. 3.`) are dropped from the log; `list-style: none` keeps each row visually clean. Static cluster + visibility filter: - `staticBucketsByRace` now locks bucket order, mass, radius and local Vogel-spiral positions for the lifetime of the viewer; it only re-derives when `report` or the wasm `core` change. - `renderedByRace` overlays the per-frame `remaining` map and drops buckets whose `numLeft` hits zero. The surviving buckets keep their slots, so a class emptying never reshuffles the cluster — the empty bucket simply disappears. - A shot whose attacker or defender bucket is no longer visible draws no line (phantom shots into already-empty buckets are silently skipped, matching the user expectation that pup at 0 should stop attracting fire visually). - Race label clamps to a minimum y inside the SVG viewport so three-or-more-race layouts with a north anchor never clip the top race name off-canvas. Duel layout (user suggestion): - `layoutRaces` rotates the radial start angle by 90° when only two participants remain, so race 0 lands at 9 o'clock and race 1 at 3 o'clock. The pair faces off horizontally; neither label pushes against the SVG top edge. The existing test for two-race positions is updated accordingly. Tests: the existing `layoutRaces` two-race case is rewritten for the horizontal duel; the `game-shell-stubs` battle case checks the loading placeholder (back buttons now live in the loaded viewer, not the wrapper). 644 Vitest cases stay green; 4 Playwright battle-viewer cases stay green. Docs: `ui/docs/battle-viewer-ux.md` documents the static cluster / visibility filter, the duel layout, the scrubber, the cycling speed button and the collapsible log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,30 @@
|
||||
<!--
|
||||
PlaybackControls — rewind / step-back / play-pause / step-forward
|
||||
plus a 1x/2x/4x speed switch. Owns no playback state; bind `playing`,
|
||||
`frameIndex`, and `speed` from the orchestrator. Disables step/rewind
|
||||
when there's nowhere to go and disables forward when the timeline is
|
||||
already at its end.
|
||||
plus a single cycling speed button (1x → 2x → 4x → 6x → 1x) and a
|
||||
"log" toggle that the orchestrator uses to collapse the always-on
|
||||
text protocol when the user wants more space for the scene. Owns no
|
||||
state of its own; binds `playing`, `frameIndex`, `speed`, and
|
||||
`logOpen` from the orchestrator. Disables step/rewind when there's
|
||||
nowhere to go and step-forward when the timeline is at its end.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
|
||||
export type PlaybackSpeed = 1 | 2 | 4 | 6;
|
||||
|
||||
const SPEED_CYCLE: PlaybackSpeed[] = [1, 2, 4, 6];
|
||||
|
||||
let {
|
||||
playing = $bindable(),
|
||||
frameIndex = $bindable(),
|
||||
speed = $bindable(),
|
||||
logOpen = $bindable(),
|
||||
frameCount,
|
||||
}: {
|
||||
playing: boolean;
|
||||
frameIndex: number;
|
||||
speed: 1 | 2 | 4;
|
||||
speed: PlaybackSpeed;
|
||||
logOpen: boolean;
|
||||
frameCount: number;
|
||||
} = $props();
|
||||
|
||||
@@ -38,9 +46,16 @@ already at its end.
|
||||
playing = false;
|
||||
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
|
||||
}
|
||||
function setSpeed(value: 1 | 2 | 4) {
|
||||
speed = value;
|
||||
function cycleSpeed() {
|
||||
const idx = SPEED_CYCLE.indexOf(speed);
|
||||
const next = SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length];
|
||||
speed = next;
|
||||
}
|
||||
function toggleLog() {
|
||||
logOpen = !logOpen;
|
||||
}
|
||||
|
||||
const speedLabel = $derived(`${speed}x`);
|
||||
</script>
|
||||
|
||||
<div class="controls" data-testid="battle-controls">
|
||||
@@ -77,25 +92,25 @@ already at its end.
|
||||
|
||||
<div class="spacer" aria-hidden="true"></div>
|
||||
|
||||
<span class="speed-label">{i18n.t("game.battle.controls.speed_label")}</span>
|
||||
<button
|
||||
type="button"
|
||||
class:active={speed === 1}
|
||||
onclick={() => setSpeed(1)}
|
||||
data-testid="battle-control-speed-1x"
|
||||
>{i18n.t("game.battle.controls.speed_1x")}</button>
|
||||
class="speed-btn"
|
||||
onclick={cycleSpeed}
|
||||
title={i18n.t("game.battle.controls.speed_label")}
|
||||
aria-label={i18n.t("game.battle.controls.speed_label")}
|
||||
data-testid="battle-control-speed"
|
||||
data-speed={speed}
|
||||
>{speedLabel}</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class:active={speed === 2}
|
||||
onclick={() => setSpeed(2)}
|
||||
data-testid="battle-control-speed-2x"
|
||||
>{i18n.t("game.battle.controls.speed_2x")}</button>
|
||||
<button
|
||||
type="button"
|
||||
class:active={speed === 4}
|
||||
onclick={() => setSpeed(4)}
|
||||
data-testid="battle-control-speed-4x"
|
||||
>{i18n.t("game.battle.controls.speed_4x")}</button>
|
||||
class="log-toggle"
|
||||
class:active={logOpen}
|
||||
onclick={toggleLog}
|
||||
aria-pressed={logOpen}
|
||||
aria-label={i18n.t("game.battle.controls.log_toggle")}
|
||||
data-testid="battle-control-log-toggle"
|
||||
>{i18n.t("game.battle.controls.log_toggle")} {logOpen ? "▲" : "▼"}</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -130,16 +145,11 @@ already at its end.
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.active {
|
||||
background: #3a4585;
|
||||
border-color: #5d6cb8;
|
||||
color: #ffffff;
|
||||
.speed-btn {
|
||||
min-width: 3rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.speed-label {
|
||||
color: #93a0d0;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-right: 0.2rem;
|
||||
.log-toggle.active {
|
||||
background: #2a3463;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user