Files
wizwar6e/packages/web/src/Replay.svelte
T
Eric WagonerandClaude Fable 5 70d7b4babb Flourishes play on the reel too
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>
2026-08-16 23:55:01 -04:00

179 lines
5.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>