"You draw Waterbolt" belongs in the chronicle, not on a replay whose canvas gets recorded and passed around — the reel's captions now skip the private-draw events (drawn, dealt, stolen) and say only how many. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
574 lines
22 KiB
Svelte
574 lines
22 KiB
Svelte
<script lang="ts">
|
||
import Board from "./Board.svelte";
|
||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||
import { untrack } from "svelte";
|
||
import { humanize } from "./net.svelte";
|
||
import { scheduleFx, type BoardFx } from "./fx";
|
||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||
import { castRay, edgeMid } from "./fpv/raycast";
|
||
import { prefs } from "./prefs.svelte";
|
||
import { stackSightTrace } from "@wizwar/engine";
|
||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||
|
||
let {
|
||
steps,
|
||
onclose,
|
||
moment = false,
|
||
pov = null,
|
||
onshare = null,
|
||
endLabel = null,
|
||
}: {
|
||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||
onclose: () => void;
|
||
/** An instant replay of one turn: open straight into first person,
|
||
* through the eyes of the wizard whose turn it is. */
|
||
moment?: boolean;
|
||
/** The turn-owner, when the server already knows it (moment reels):
|
||
* the fallback when no boundary has scrolled past yet. */
|
||
pov?: string | null;
|
||
/** Mint a public link to this turn; resolves to the URL. */
|
||
onshare?: (() => Promise<string>) | null;
|
||
/** What the leave button says once the reel has run out. */
|
||
endLabel?: string | null;
|
||
} = $props();
|
||
|
||
/** The share button's little life: offer, mint, report. */
|
||
let shareState = $state<"idle" | "minting" | "copied" | "failed">("idle");
|
||
async function doShare() {
|
||
if (!onshare || shareState === "minting") return;
|
||
shareState = "minting";
|
||
try {
|
||
const url = await onshare();
|
||
await navigator.clipboard.writeText(url);
|
||
shareState = "copied";
|
||
} catch {
|
||
shareState = "failed";
|
||
}
|
||
setTimeout(() => (shareState = "idle"), 4000);
|
||
}
|
||
|
||
let idx = $state(0);
|
||
let playing = $state(true);
|
||
let speed = $state(1);
|
||
/** Watch the board from above, or relive it through your own eyes. */
|
||
let fp = $state(moment);
|
||
|
||
/** Whose eyes the first-person camera wears: the wizard whose turn it
|
||
* is, ALWAYS — their position seen with the viewer's knowledge of the
|
||
* maze, so nothing private leaks. The owner is whoever the last turn
|
||
* boundary at or before the current step named; before any boundary
|
||
* scrolls past, the server-known owner (moment reels) or the viewer. */
|
||
const povId = $derived.by(() => {
|
||
for (let i = Math.min(idx, steps.length - 1); i >= 0; i--) {
|
||
const evs = steps[i]!.events;
|
||
for (let j = evs.length - 1; j >= 0; j--) {
|
||
const e = evs[j]!;
|
||
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
|
||
return e.player as string;
|
||
}
|
||
}
|
||
}
|
||
return pov ?? steps[0]!.view.you;
|
||
});
|
||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||
/** What you drew belongs in the chronicle, not on a reel that may be
|
||
* recorded and passed around — captions keep the count, not the cards. */
|
||
const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]);
|
||
const lines = $derived(
|
||
step.events
|
||
.filter((e) => !CAPTION_SILENT.has(e.type))
|
||
.map(humanize)
|
||
.filter((l): l is string => l !== null),
|
||
);
|
||
const atEnd = $derived(idx >= steps.length - 1);
|
||
|
||
// The reel draws the same sight line the live table shows for an LOS
|
||
// attack in progress, so a replay-watcher can see how a spell reached them.
|
||
const sightTrace = $derived(stackSightTrace(step.view));
|
||
|
||
/** Every wall destroyed so far in the reel leaves a mound of rubble on
|
||
* the first-person floor for the rest of it. */
|
||
const rubbleSpots = $derived.by(() => {
|
||
const spots: { x: number; y: number }[] = [];
|
||
const seen = new Set<string>();
|
||
for (let i = 0; i <= Math.min(idx, steps.length - 1); i++) {
|
||
for (const e of steps[i]!.events) {
|
||
if (e.type !== "wallDestroyed" || !("edge" in e)) continue;
|
||
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
|
||
const key = `${edge.cell.x},${edge.cell.y}:${edge.side}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
spots.push(edgeMid(edge.cell, edge.side));
|
||
}
|
||
}
|
||
return spots;
|
||
});
|
||
|
||
/** 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 || !prefs.flourishes) return;
|
||
const cancel = scheduleFx(
|
||
step.events, step.view,
|
||
(fx) => (boardFx = [...boardFx, fx]),
|
||
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
||
);
|
||
return () => { cancel(); boardFx = []; };
|
||
});
|
||
|
||
/** In first person the same events become projectiles, impacts,
|
||
* flashes, and shakes — scheduled on this step's beat. */
|
||
let fpFx = $state<FpFx[]>([]);
|
||
$effect(() => {
|
||
const st = steps[Math.min(idx, steps.length - 1)];
|
||
if (!fp || !st || !prefs.flourishes) return;
|
||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||
const started: number[] = [];
|
||
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
|
||
timers.push(setTimeout(() => {
|
||
started.push(fx.id);
|
||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||
}, delay / speed));
|
||
}
|
||
return () => {
|
||
timers.forEach(clearTimeout);
|
||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||
};
|
||
});
|
||
|
||
$effect(() => {
|
||
if (!playing) return;
|
||
const t = setInterval(() => {
|
||
if (idx < steps.length - 1) idx += 1;
|
||
else playing = false;
|
||
}, 1500 / speed);
|
||
return () => clearInterval(t);
|
||
});
|
||
|
||
/** Other bodies glide between steps instead of blinking cell to cell:
|
||
* each step, anyone whose position changed a walkable distance tweens
|
||
* from where the previous step's view had them. */
|
||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||
let prevView: GameView | null = null;
|
||
$effect(() => {
|
||
const st = steps[Math.min(idx, steps.length - 1)];
|
||
if (!fp || !st) { prevView = null; actorPos = {}; return; }
|
||
const v = st.view;
|
||
const before = prevView;
|
||
prevView = v;
|
||
if (!before || before === v) return;
|
||
const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = [];
|
||
const gather = (
|
||
id: string,
|
||
now: { x: number; y: number },
|
||
was: { x: number; y: number } | undefined,
|
||
) => {
|
||
if (!was) return;
|
||
const d = Math.hypot(now.x - was.x, now.y - was.y);
|
||
// A single stride or shove glides; a leap across the maze is a
|
||
// teleport and should simply be there.
|
||
if (d > 0.05 && d <= 3.5) {
|
||
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
|
||
}
|
||
};
|
||
for (const p of v.players) {
|
||
if (!p.alive || p.id === povId) continue;
|
||
const was = before.players.find((q) => q.id === p.id && q.alive);
|
||
gather(p.id, p.position, was?.position);
|
||
}
|
||
for (const c of v.creatures) {
|
||
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
|
||
}
|
||
if (moves.length === 0) { actorPos = {}; return; }
|
||
const t0 = performance.now();
|
||
const dur = 400 / speed;
|
||
let raf = 0;
|
||
const tick = (now: number) => {
|
||
const w = Math.min(1, Math.max(0, (now - t0) / dur));
|
||
const ease = w * w * (3 - 2 * w);
|
||
const next: Record<string, { x: number; y: number }> = {};
|
||
for (const m of moves) {
|
||
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
|
||
}
|
||
actorPos = next;
|
||
if (w < 1) raf = requestAnimationFrame(tick);
|
||
else actorPos = {};
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
return () => cancelAnimationFrame(raf);
|
||
});
|
||
|
||
// --- The first-person camera: your wizard's walk, relived. -------------
|
||
// Each step the camera settles on your position. A step away tweens —
|
||
// turn first, then stride; a leap (teleport, warp) cuts. When you stood
|
||
// still, the eye turns toward whoever acted.
|
||
const cam = $state({ x: 0, y: 0, facing: 0 });
|
||
let camReady = false;
|
||
function shortestArc(from: number, to: number): number {
|
||
let d = (to - from) % (2 * Math.PI);
|
||
if (d > Math.PI) d -= 2 * Math.PI;
|
||
if (d < -Math.PI) d += 2 * Math.PI;
|
||
return d;
|
||
}
|
||
$effect(() => {
|
||
if (!fp) { camReady = false; return; }
|
||
const v = step.view;
|
||
const me = v.players.find((p) => p.id === povId);
|
||
if (!me) return;
|
||
const tx = me.position.x + 0.5;
|
||
const ty = me.position.y + 0.5;
|
||
// Read the camera WITHOUT tracking it: this effect's own tween writes
|
||
// cam every frame, and a tracked read would re-trigger the effect per
|
||
// frame — each restart resetting the ease to zero, so the walk decays
|
||
// into a slow drift. Untracked, one step runs one tween.
|
||
const camX = untrack(() => cam.x);
|
||
const camY = untrack(() => cam.y);
|
||
const camF = untrack(() => cam.facing);
|
||
const dx = tx - camX;
|
||
const dy = ty - camY;
|
||
const dist = Math.hypot(dx, dy);
|
||
// Where should the eye end up pointing? Along its own stride; at the
|
||
// actor, when someone else moved the world; wherever it was, otherwise.
|
||
// A blow that hurls the body glides fast and straight, however far,
|
||
// eyes still where they were; only unexplained leaps (teleports) cut.
|
||
const hurled = step.events.some((e) =>
|
||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||
e.type === "retreatedInHorror") && e.player === povId);
|
||
const actor = v.players.find((p) => p.id === step.actor);
|
||
// Where the eyes' own magic went this step: a wizard looks where
|
||
// they aim, so the camera turns to watch its own spells land.
|
||
const aim = (() => {
|
||
for (const e of step.events) {
|
||
let cell: { x: number; y: number } | null = null;
|
||
if (e.type === "spellCast" && e.caster === povId) {
|
||
cell = e.targetCell ?? v.players.find((p) => p.id === e.target)?.position ?? null;
|
||
} else if (e.type === "attackResolved" && e.attacker === povId) {
|
||
cell = v.players.find((p) => p.id === e.defender)?.position ?? null;
|
||
} else if (e.type === "objectThrown" && e.attacker === povId) {
|
||
cell = e.landedAt;
|
||
}
|
||
if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell;
|
||
}
|
||
return null;
|
||
})();
|
||
let targetFacing = camF;
|
||
let aimed = false;
|
||
// A stride turns the eyes along it — but only a REAL stride: on the
|
||
// reel's first run the camera sits at its uninitialized origin, and
|
||
// that phantom distance must not pick the opening shot's direction.
|
||
if (camReady && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
||
else if (aim) {
|
||
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||
const toAim = Math.hypot(ax - tx, ay - ty);
|
||
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||
if (sight.warped || sight.dist < toAim - 0.4) {
|
||
// The deed lands beyond these eyes — a summon across the maze, a
|
||
// spell through walls. Cut away to the spot like a broadcast
|
||
// camera: stand back down its deepest corridor, facing it, for
|
||
// this one beat; the next step cuts home to the wizard.
|
||
let best = { d: -1, a: 0 };
|
||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||
const h = castRay(v, ax, ay, a);
|
||
if (h.dist > best.d) best = { d: h.dist, a };
|
||
}
|
||
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
|
||
cam.x = ax + Math.cos(best.a) * back;
|
||
cam.y = ay + Math.sin(best.a) * back;
|
||
cam.facing = best.a + Math.PI;
|
||
camReady = true;
|
||
return;
|
||
}
|
||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||
aimed = true;
|
||
} else if (actor && actor.id !== povId &&
|
||
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
||
// Turn toward the actor — but only if these eyes could actually see
|
||
// them: an unbroken straight sight line, no warps bending it.
|
||
const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5;
|
||
const toActor = Math.hypot(ax - tx, ay - ty);
|
||
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||
if (!ray.warped && ray.dist > toActor - 0.2) {
|
||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||
aimed = true;
|
||
}
|
||
}
|
||
if (!aimed && castRay(v, tx, ty, targetFacing).dist < 0.8) {
|
||
// Nothing aims the eyes and they'd idle nose-to-brick (the opening
|
||
// cut, or a beat after pacing into a dead end): face the deepest
|
||
// corridor from where the wizard stands instead.
|
||
let deepest = -1;
|
||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||
const h = castRay(v, tx, ty, a);
|
||
if (h.dist > deepest) { deepest = h.dist; targetFacing = a; }
|
||
}
|
||
}
|
||
if (!camReady || (dist > 1.6 && !hurled)) {
|
||
// First frame, or a leap the legs cannot explain: cut.
|
||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||
camReady = true;
|
||
return;
|
||
}
|
||
const fromX = camX, fromY = camY, fromF = camF;
|
||
const arc = shortestArc(fromF, targetFacing);
|
||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||
const t0 = performance.now();
|
||
let raf = 0;
|
||
const tick = (now: number) => {
|
||
// rAF hands frame-start time, which can precede t0; and a
|
||
// zero-length phase must never divide. Either poisons the facing
|
||
// with NaN, which no later frame can wash out.
|
||
const t = Math.max(0, now - t0);
|
||
if (turnMs > 0 && t < turnMs) {
|
||
cam.facing = fromF + arc * (t / turnMs);
|
||
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
||
cam.facing = fromF + arc;
|
||
const w = (t - turnMs) / walkMs;
|
||
const ease = w * w * (3 - 2 * w);
|
||
cam.x = fromX + (tx - fromX) * ease;
|
||
cam.y = fromY + (ty - fromY) * ease;
|
||
} else {
|
||
cam.facing = fromF + arc;
|
||
cam.x = tx; cam.y = ty;
|
||
return;
|
||
}
|
||
raf = requestAnimationFrame(tick);
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
return () => cancelAnimationFrame(raf);
|
||
});
|
||
|
||
// --- Save a video: the first-person view composited into a shareable
|
||
// 1280x720 frame — the step's caption burned in at the bottom, the
|
||
// game's name in the corner — so a downloaded clip explains itself.
|
||
let stageEl: HTMLDivElement | undefined = $state();
|
||
let recorder = $state<MediaRecorder | null>(null);
|
||
function toggleRecord() {
|
||
if (recorder) { recorder.stop(); return; }
|
||
const cv = stageEl?.querySelector("canvas");
|
||
if (!cv) return;
|
||
// Recording means capturing the WHOLE reel: rewind to the top and
|
||
// roll. (Hitting record at the end used to auto-stop a beat later —
|
||
// a one-second clip of the final frame.)
|
||
idx = 0;
|
||
const comp = document.createElement("canvas");
|
||
comp.width = 1280;
|
||
comp.height = 720;
|
||
const g = comp.getContext("2d")!;
|
||
let compRaf = 0;
|
||
const drawComp = () => {
|
||
g.imageSmoothingEnabled = false;
|
||
g.drawImage(cv, 0, 0, 1280, 720);
|
||
g.imageSmoothingEnabled = true;
|
||
// The caption bar: who did what, in the reel's own words.
|
||
g.fillStyle = "rgba(12, 10, 8, 0.78)";
|
||
g.fillRect(0, 720 - 96, 1280, 96);
|
||
g.fillStyle = "#e0b34a";
|
||
g.font = "600 22px Oswald, sans-serif";
|
||
g.fillText(step.actor.toUpperCase(), 28, 720 - 60, 400);
|
||
g.fillStyle = "#efe8d4";
|
||
g.font = "21px 'Courier Prime', monospace";
|
||
const said = lines.slice(0, 2);
|
||
if (said.length === 0) said.push("…considers the maze.");
|
||
said.forEach((line, i) => g.fillText(line, 28, 720 - 32 + i * 26, 1224));
|
||
// The colophon corner.
|
||
g.fillStyle = "rgba(224, 179, 74, 0.6)";
|
||
g.font = "600 20px Oswald, sans-serif";
|
||
g.textAlign = "right";
|
||
g.fillText("W I Z - W A R", 1280 - 24, 40);
|
||
g.textAlign = "left";
|
||
compRaf = requestAnimationFrame(drawComp);
|
||
};
|
||
compRaf = requestAnimationFrame(drawComp);
|
||
// MP4 travels everywhere (iMessage, QuickTime, every platform);
|
||
// WebM is the fallback where the browser can't mux it.
|
||
const mime = [
|
||
"video/mp4;codecs=avc1.42E01E", "video/mp4",
|
||
"video/webm;codecs=vp9", "video/webm",
|
||
].find((m) => MediaRecorder.isTypeSupported(m)) ?? "video/webm";
|
||
const container = mime.startsWith("video/mp4") ? "video/mp4" : "video/webm";
|
||
const ext = container === "video/mp4" ? "mp4" : "webm";
|
||
const rec = new MediaRecorder(comp.captureStream(60), { mimeType: mime });
|
||
const chunks: Blob[] = [];
|
||
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
||
rec.onstop = () => {
|
||
cancelAnimationFrame(compRaf);
|
||
const url = URL.createObjectURL(new Blob(chunks, { type: container }));
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `wizwar-moves-${steps[0]?.seq ?? 0}-${steps[steps.length - 1]?.seq ?? 0}.${ext}`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||
recorder = null;
|
||
};
|
||
rec.start();
|
||
recorder = rec;
|
||
playing = true;
|
||
}
|
||
// The reel running out ends the recording and hands over the file.
|
||
$effect(() => {
|
||
if (recorder && !playing && atEnd) recorder.stop();
|
||
});
|
||
|
||
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">{moment ? `Instant replay — ${povId}'s turn` : 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-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
||
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
||
{#if fp}
|
||
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
|
||
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
|
||
{/if}
|
||
{#if moment && onshare}
|
||
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
|
||
{shareState === "idle" ? "🔗 share link"
|
||
: shareState === "minting" ? "…"
|
||
: shareState === "copied" ? "✓ link copied" : "share failed"}</button>
|
||
{/if}
|
||
<button class="replay-skip" onclick={onclose}>{atEnd ? (endLabel ?? "back to the game") : "skip to now"}</button>
|
||
</header>
|
||
<div class="replay-board" bind:this={stageEl}>
|
||
{#if fp}
|
||
<FirstPerson view={step.view} {povId}
|
||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
||
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
||
{:else}
|
||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||
{/if}
|
||
</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-eyes {
|
||
margin-left: auto;
|
||
background: none;
|
||
border: 1px solid #5a5342;
|
||
border-radius: 3px;
|
||
color: #a49c86;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
padding: 0.15rem 0.5rem;
|
||
}
|
||
.replay-eyes.lit { color: #e9e1cb; border-color: #a49c86; }
|
||
.replay-skip {
|
||
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>
|