"While you were away": returning to a game with unseen moves shows a
banner — "You missed N moves. Watch what happened" — that opens a
replay reel. The server rebuilds the game and captures a redacted
per-move view for the viewer (own hand only, capped at the last 200
moves); the client plays the reel on a full board with the actor and
humanized events captioned per step, auto-advancing with pause,
step-back/forward, arrow-key control, and skip-to-now. Seen progress
is tracked per room in the browser (every state broadcast now carries
the log sequence), so the banner only appears when there is genuinely
something to watch.
Attention between turns is now named, not just signaled: game
summaries carry WHY a game waits on you — your turn, counteract
(you're being attacked mid-someone-else's-turn), forced discard, or a
pending interruption — the ledger prints it ("UNDER ATTACK —
respond!"), and browser notifications say "you are under attack in
GNSK!" rather than a generic your-turn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
4.1 KiB
Svelte
150 lines
4.1 KiB
Svelte
<script lang="ts">
|
|
import Board from "./Board.svelte";
|
|
import { humanize } from "./net.svelte";
|
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
|
|
|
let {
|
|
steps,
|
|
onclose,
|
|
}: {
|
|
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
|
|
onclose: () => void;
|
|
} = $props();
|
|
|
|
let idx = $state(0);
|
|
let playing = $state(true);
|
|
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);
|
|
|
|
$effect(() => {
|
|
if (!playing) return;
|
|
const t = setInterval(() => {
|
|
if (idx < steps.length - 1) idx += 1;
|
|
else playing = false;
|
|
}, 1500);
|
|
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">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} />
|
|
</div>
|
|
<div class="replay-caption">
|
|
<strong>{step.actor}</strong>
|
|
{#each lines as line, i (i)}<div>{line}</div>{/each}
|
|
{#if lines.length === 0}<div>…considers the maze.</div>{/if}
|
|
</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>
|
|
</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%;
|
|
}
|
|
.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 {
|
|
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>
|