Replay steps carry the same events the live table saw, so the spells flare on catch-up reels and whole-game replays alike — retroactively: stored games re-derive their events on replay, so every fireball ever thrown bursts again. Step changes clear pending effects so scrubbing backward does not smear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
179 lines
5.4 KiB
Svelte
179 lines
5.4 KiB
Svelte
<script lang="ts">
|
||
import Board from "./Board.svelte";
|
||
import { humanize } from "./net.svelte";
|
||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||
|
||
let {
|
||
steps,
|
||
onclose,
|
||
}: {
|
||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||
onclose: () => void;
|
||
} = $props();
|
||
|
||
let idx = $state(0);
|
||
let playing = $state(true);
|
||
let speed = $state(1);
|
||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||
const lines = $derived(
|
||
step.events.map(humanize).filter((l): l is string => l !== null),
|
||
);
|
||
const atEnd = $derived(idx >= steps.length - 1);
|
||
|
||
/** Each step's spells flare on the reel exactly as they did at the table. */
|
||
let boardFx = $state<BoardFx[]>([]);
|
||
$effect(() => {
|
||
const step = steps[Math.min(idx, steps.length - 1)];
|
||
if (!step) return;
|
||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
|
||
timers.push(setTimeout(() => {
|
||
boardFx = [...boardFx, fx];
|
||
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
|
||
}, delay));
|
||
}
|
||
return () => { timers.forEach(clearTimeout); boardFx = []; };
|
||
});
|
||
|
||
$effect(() => {
|
||
if (!playing) return;
|
||
const t = setInterval(() => {
|
||
if (idx < steps.length - 1) idx += 1;
|
||
else playing = false;
|
||
}, 1500 / speed);
|
||
return () => clearInterval(t);
|
||
});
|
||
|
||
function onkeydown(e: KeyboardEvent) {
|
||
if (e.key === "Escape") onclose();
|
||
if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); }
|
||
if (e.key === "ArrowLeft") { playing = false; idx = Math.max(idx - 1, 0); }
|
||
if (e.key === " ") { e.preventDefault(); playing = !playing; }
|
||
}
|
||
</script>
|
||
|
||
<svelte:window {onkeydown} />
|
||
|
||
<div class="replay-scrim">
|
||
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
||
<header class="replay-head">
|
||
<span class="replay-title">{steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||
</header>
|
||
<div class="replay-board">
|
||
<Board view={step.view} effects={boardFx} />
|
||
</div>
|
||
<div class="replay-caption">
|
||
<strong>{step.actor}</strong>
|
||
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
||
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
|
||
{#each step.chat ?? [] as c, i (i)}
|
||
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
||
{/each}
|
||
</div>
|
||
<div class="replay-controls">
|
||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
|
||
{playing ? "❚❚" : "▶"}
|
||
</button>
|
||
<button onclick={() => { playing = false; idx = Math.min(steps.length - 1, idx + 1); }} aria-label="next move">▶</button>
|
||
{#each [1, 2, 4] as x (x)}
|
||
<button class="speed" class:current={speed === x} onclick={() => (speed = x)}>{x}×</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.replay-scrim {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(10, 12, 16, 0.88);
|
||
display: grid;
|
||
place-items: center;
|
||
z-index: 50;
|
||
padding: 1rem;
|
||
}
|
||
.replay {
|
||
background: #171a20;
|
||
border: 1px solid rgba(233, 225, 203, 0.25);
|
||
border-radius: 8px;
|
||
width: min(46rem, 100%);
|
||
max-height: calc(100dvh - 2rem);
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 0.8rem 1rem 1rem;
|
||
color: #d8d2c0;
|
||
font-family: "Archivo Narrow", sans-serif;
|
||
}
|
||
.replay-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.8rem;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
.replay-title {
|
||
font-family: "Oswald", sans-serif;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
font-size: 0.85rem;
|
||
color: #e9e1cb;
|
||
}
|
||
.replay-count { font-size: 0.8rem; color: #8d8672; }
|
||
.replay-skip {
|
||
margin-left: auto;
|
||
background: none;
|
||
border: none;
|
||
color: #a49c86;
|
||
text-decoration: underline;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
.replay-board {
|
||
min-height: 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
.replay-board :global(svg.board) {
|
||
max-height: calc(100dvh - 15rem);
|
||
width: auto;
|
||
max-width: 100%;
|
||
}
|
||
.reel-talk { font-style: italic; opacity: 0.85; }
|
||
.replay-caption {
|
||
background: #efe8d4;
|
||
color: #3a2f1f;
|
||
border-radius: 3px;
|
||
padding: 0.5rem 0.75rem;
|
||
margin-top: 0.7rem;
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.78rem;
|
||
line-height: 1.45;
|
||
min-height: 3.4rem;
|
||
}
|
||
.replay-controls .speed {
|
||
font-size: 0.75rem;
|
||
opacity: 0.7;
|
||
}
|
||
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
|
||
.replay-controls {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 0.6rem;
|
||
margin-top: 0.6rem;
|
||
}
|
||
.replay-controls button {
|
||
background: #e9e1cb;
|
||
color: #43331f;
|
||
border: 1.5px solid #43331f;
|
||
border-radius: 3px;
|
||
min-width: 2.6rem;
|
||
padding: 0.35rem 0.6rem;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
.playpause { min-width: 3.4rem; }
|
||
</style>
|