A returning player's slip now lists what happened while they were away — "Automaton took your treasure.", "You lost 3 life.", "It's your turn." — each fact opening the catch-up reel at its moment, read from the missed steps as the server redacted them for that seat; when nothing touched them it says what each wizard was up to instead. The reel gains a timeline: a tick per move in the mover's color, taller where a turn begins, a blow lands, or gold changes hands, and any tick jumps the reel there. The chronicle takes a filter — All, Game, Table talk — with an unread count on the talk that arrived while the casting panel covered it (a wizard's own words never count), a peek at the latest line above the composer while the panel is up, and the composer itself no longer leaves when a response is owed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
1001 lines
40 KiB
Svelte
1001 lines
40 KiB
Svelte
<script lang="ts">
|
||
import { motion } from "./motion";
|
||
import Board from "./Board.svelte";
|
||
import { wizardColor } from "./colors";
|
||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||
import { untrack } from "svelte";
|
||
import { humanize, spellName } from "./net.svelte";
|
||
import { tokenArt } from "./art";
|
||
import { scheduleFx, type BoardFx } from "./fx";
|
||
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
|
||
import { castRay, edgeMid } from "./fpv/raycast";
|
||
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
|
||
import { cardDef, isPermanentDuration, stackSightTrace, castSightTrace } from "@wizwar/engine";
|
||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||
|
||
let {
|
||
steps,
|
||
onclose,
|
||
moment = false,
|
||
pov = null,
|
||
onshare = null,
|
||
endLabel = null,
|
||
startAt = 0,
|
||
}: {
|
||
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;
|
||
/** The step the reel opens on. */
|
||
startAt?: number;
|
||
} = $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(untrack(() => Math.min(Math.max(0, startAt), Math.max(0, steps.length - 1))));
|
||
/** The timeline: one tick a step, a turn's first step marked, and the
|
||
* steps where blows landed or gold changed hands flagged, so a reel of
|
||
* a hundred moves can be read at a glance and jumped into. */
|
||
const ticks = $derived(steps.map((s, i) => {
|
||
let flag: "turn" | "hit" | "gold" | "end" | null = null;
|
||
for (const e of s.events) {
|
||
if (e.type === "gameWon" || e.type === "playerEliminated") { flag = "end"; break; }
|
||
if (e.type === "treasurePickedUp" || (e.type === "treasureDropped" && e.onHomeOf != null)) flag = "gold";
|
||
else if (!flag && (e.type === "damaged" || e.type === "attackResolved" || e.type === "punched")) flag = "hit";
|
||
else if (!flag && e.type === "turnStarted") flag = "turn";
|
||
}
|
||
return { i, actor: s.actor, flag, color: wizardColor(s.view, s.actor) };
|
||
}));
|
||
let playing = $state(true);
|
||
let speed = $state(1);
|
||
/** Watch the board from above, or relive it through your own eyes. */
|
||
let fp = $state(untrack(() => 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;
|
||
}
|
||
}
|
||
}
|
||
// No boundary yet: these are the FIRST turn's steps, whose opening
|
||
// turnStarted fired in the deal before any command. Its owner is
|
||
// whoever the reel's first turnEnded names.
|
||
for (const st of steps) {
|
||
for (const e of st.events) {
|
||
if (e.type === "turnEnded" && "player" in e) return (e as { player: string }).player;
|
||
if (e.type === "turnStarted" || e.type === "extraTurnStarted") break;
|
||
}
|
||
}
|
||
return pov ?? steps[0]!.view.you;
|
||
});
|
||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||
|
||
/** A step the die decided stops the reel for the roll: the stakes, the
|
||
* tumble, the face, and only then the outcome — a leap over a pit is
|
||
* not a teleport, and a viewer new to the game should see why. */
|
||
const DIE_MS = 2400;
|
||
const dieOf = (st: { events: GameEvent[] } | undefined) =>
|
||
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled");
|
||
/** How long this step's interlude holds the world before it moves. */
|
||
const dieMs = $derived(dieOf(step) && motion.effects ? DIE_MS / speed : 0);
|
||
let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
|
||
$effect(() => {
|
||
const i = Math.min(idx, steps.length - 1);
|
||
const d = dieOf(steps[i]);
|
||
if (!d || !motion.effects) { die = null; return; }
|
||
const t0 = performance.now();
|
||
const dur = DIE_MS / speed;
|
||
const who = d.player ?? "The maze";
|
||
const show = (face: number, landed: boolean) =>
|
||
(die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face, landed });
|
||
show(1, false);
|
||
let raf = 0;
|
||
const tick = (now: number) => {
|
||
const p = Math.max(0, now - t0) / dur;
|
||
if (p >= 1) { die = null; return; }
|
||
if (p < 0.48) {
|
||
// The tumble: faces flick past, slowing as the die settles. A
|
||
// fixed sequence, so the harness films the same roll every time.
|
||
const n = Math.floor(p * 26 - p * p * 18);
|
||
show(((n * 3 + 1) % 4) + 1, false);
|
||
} else if (!die?.landed) {
|
||
show(d.roll, true);
|
||
}
|
||
raf = requestAnimationFrame(tick);
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
return () => { cancelAnimationFrame(raf); die = null; };
|
||
});
|
||
/** While the die is in the air the board shows the world as it was. */
|
||
const dieHeld = $derived(die && die.idx > 0 ? steps[die.idx - 1]!.view : null);
|
||
/** 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 captionLines = (events: GameEvent[], silent: Set<string>) =>
|
||
events.filter((e) => !silent.has(e.type)).map(humanize).filter((l): l is string => l !== null);
|
||
const lines = $derived(captionLines(step.events, CAPTION_SILENT));
|
||
const atEnd = $derived(idx >= steps.length - 1);
|
||
/** What the roll brought about, in the caption's own words — the roll
|
||
* itself is on the die card, so its line is left out. */
|
||
const VERDICT_SILENT = new Set([...CAPTION_SILENT, "dieRolled"]);
|
||
const dieVerdict = $derived(captionLines(step.events, VERDICT_SILENT).slice(0, 2).join(" "));
|
||
|
||
/** The acting wizard's standee, shown beside their words. */
|
||
const actorArt = $derived.by(() => {
|
||
const p = step.view.players.find((x) => x.id === step.actor);
|
||
return p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null;
|
||
});
|
||
|
||
/** The caption's right-hand column — the reel's viewer may never have
|
||
* seen the game, so the acting wizard's state stands beside their
|
||
* deed: strides still to spend this turn, life, and what they carry,
|
||
* display, or suffer. */
|
||
const status = $derived.by(() => {
|
||
const v = step.view;
|
||
const p = v.players.find((x) => x.id === step.actor);
|
||
if (!p) return null;
|
||
const acting = v.activePlayerId === p.id;
|
||
const strides = acting ? Math.max(0, v.turn.movementAllowance - v.turn.movementUsed) : null;
|
||
const chips: string[] = [`♥ ${p.life}`];
|
||
if (p.carriedTreasureId) chips.push("carrying a treasure");
|
||
for (const c of p.displayed) chips.push(cardDef(c.cardId).name.toLowerCase());
|
||
for (const e of v.sustained) {
|
||
if (e.targetId !== p.id) continue;
|
||
chips.push(`✦ ${spellName(e.cardId).toLowerCase()}${isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`}`);
|
||
}
|
||
if (p.lostTurns > 0) chips.push(`loses ${p.lostTurns} turn${p.lostTurns === 1 ? "" : "s"}`);
|
||
if (p.passWallCharges > 0) chips.push(`pass wall × ${p.passWallCharges}`);
|
||
if (acting && v.turn.attackUsed) chips.push("attack spent");
|
||
return { strides, chips };
|
||
});
|
||
|
||
/** A change of eyes wipes a slate across the pane: the cut to another
|
||
* wizard's sight must read as a cut, never as a wizard teleporting. */
|
||
let lastPov: string | null = null;
|
||
let slateSeq = 0;
|
||
$effect(() => {
|
||
const who = povId;
|
||
if (!fp) { lastPov = null; return; }
|
||
if (!motion.effects) { lastPov = who; return; }
|
||
if (lastPov !== null && lastPov !== who) {
|
||
const p = step.view.players.find((x) => x.id === who);
|
||
const fx: FpFx = {
|
||
id: -(++slateSeq), kind: "slate", name: who, // negative: never a stager id
|
||
art: p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null,
|
||
t0: performance.now(), dur: 1400 / speed,
|
||
};
|
||
fpFx = [...untrack(() => fpFx), fx];
|
||
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
|
||
lastPov = who;
|
||
return () => { clearTimeout(t); fpFx = fpFx.filter((f) => f.id !== fx.id); };
|
||
}
|
||
lastPov = who;
|
||
});
|
||
|
||
// 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.
|
||
/** The attack's line, or the line a cast in this step was accepted on —
|
||
* traced after the fact, so the cast's own dust cloud does not blind it. */
|
||
const sightTrace = $derived.by(() => {
|
||
const onStack = stackSightTrace(step.view);
|
||
if (onStack) return onStack;
|
||
for (let i = step.events.length - 1; i >= 0; i--) {
|
||
const e = step.events[i]!;
|
||
if (e.type !== "spellCast") continue;
|
||
const line = castSightTrace(step.view, e, step.events, true);
|
||
if (line) return line;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
/** 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 || !motion.effects) return;
|
||
let cancel = () => {};
|
||
const wait = setTimeout(() => {
|
||
cancel = scheduleFx(
|
||
step.events, step.view,
|
||
(fx) => (boardFx = [...boardFx, fx]),
|
||
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
||
);
|
||
}, dieMs);
|
||
return () => { clearTimeout(wait); cancel(); boardFx = []; };
|
||
});
|
||
|
||
/** The director's slate: the camera effect writes how long this step's
|
||
* outcome stays HIDDEN — the old world held on screen — while the eyes
|
||
* turn to face the recipient. The fx and the view reveal together. */
|
||
let camPlan = $state<{ idx: number; holdMs: number } | null>(null);
|
||
let heldView = $state<GameView | null>(null);
|
||
/** A cutaway frame shows the reel's own wizard too. */
|
||
let cutawayShot = $state(false);
|
||
|
||
/** In first person the same events become projectiles, impacts,
|
||
* flashes, and shakes — scheduled on this step's beat, after the
|
||
* camera's turn has brought the recipient into frame. */
|
||
let fpFx = $state<FpFx[]>([]);
|
||
$effect(() => {
|
||
const i = Math.min(idx, steps.length - 1);
|
||
const st = steps[i];
|
||
if (!fp || !st || !motion.effects) return;
|
||
const plan = camPlan;
|
||
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
|
||
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));
|
||
}, plan.holdMs + delay / speed));
|
||
}
|
||
return () => {
|
||
timers.forEach(clearTimeout);
|
||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||
};
|
||
});
|
||
|
||
$effect(() => {
|
||
if (!playing) return;
|
||
// Each step gets the reel's beat, plus the die's interlude when
|
||
// one decided it. Reading idx here is what re-arms the timer on
|
||
// every advance.
|
||
const from = idx;
|
||
const t = setTimeout(() => {
|
||
if (idx !== from) return;
|
||
if (idx < steps.length - 1) idx += 1;
|
||
else playing = false;
|
||
}, 1500 / speed + dieMs);
|
||
return () => clearTimeout(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 (motion.reduced) { actorPos = {}; return; }
|
||
if (!before || before === v) return;
|
||
const moves = gatherGlides(before, v, povId);
|
||
if (moves.length === 0) { actorPos = {}; return; }
|
||
const t0 = performance.now() + dieMs;
|
||
const dur = 400 / speed;
|
||
let raf = 0;
|
||
const tick = (now: number) => {
|
||
const w = Math.min(1, Math.max(0, (now - t0) / dur));
|
||
const ease = smoothstep(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, pitch: 0 });
|
||
/** How far the eyes drop to watch their own feet: the horizon rises
|
||
* to the pane's top third and the ground underfoot fills the rest. */
|
||
const LOOK_DOWN = 0.36;
|
||
/** Deeds on the ground of your own square, watched by looking down.
|
||
* A punch traded with a square-mate is not one: they stand in front
|
||
* of the eyes wherever those point. */
|
||
const GROUNDWORK = new Set(["spellCast", "squareFilled", "creatureCreated",
|
||
"treasurePickedUp", "treasureDropped", "objectDropped"]);
|
||
let camReady = false;
|
||
$effect(() => {
|
||
const reduced = motion.reduced;
|
||
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 camP = untrack(() => cam.pitch);
|
||
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 = hurledIn(step.events, 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 = aimOfEvents(step.events, v, povId, me.position, step.actor);
|
||
// A leap the legs cannot explain (first frame, teleport, the return
|
||
// from a cutaway) is a CUT, and a cut is not a stride: the direction
|
||
// of travel means nothing at the far end.
|
||
const willCut = !camReady || (dist > 1.6 && !hurled);
|
||
cutawayShot = false;
|
||
/** The broadcast cutaway: stand back down the target cell's deepest
|
||
* corridor, facing it, holding a beat of the world-before so the
|
||
* deed happens ON screen — with the reel's own wizard visible. */
|
||
const takeCutaway = (ax: number, ay: number) => {
|
||
const { facing: a, depth: d } = cutawayStand(v, ax, ay);
|
||
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
|
||
cam.x = ax + Math.cos(a) * back;
|
||
cam.y = ay + Math.sin(a) * back;
|
||
cam.facing = a + Math.PI;
|
||
camReady = true;
|
||
cutawayShot = true;
|
||
const i2 = Math.min(idx, steps.length - 1);
|
||
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
|
||
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
|
||
heldView = pv;
|
||
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
|
||
if (pv) {
|
||
const t = setTimeout(() => (heldView = null), 420 / speed + dieMs);
|
||
return () => clearTimeout(t);
|
||
}
|
||
return;
|
||
};
|
||
let targetFacing = camF;
|
||
let aimed = false;
|
||
// The pit you fell down, or climbed from, is at your feet too; the
|
||
// eyes come back level with the next step.
|
||
const underfoot = step.events.some((e) =>
|
||
(e.type === "fellInPit" || e.type === "climbedFromPit") && e.player === povId);
|
||
let targetPitch = 0;
|
||
const groundwork = aim?.self && step.events.some((e) => GROUNDWORK.has(e.type));
|
||
/** A stride that ends with something to watch — an ambush that
|
||
* fired as the step landed — turns once more after the walk. */
|
||
let afterArc = 0;
|
||
if (camReady && !willCut && dist > 0.05 && !hurled) {
|
||
targetFacing = Math.atan2(dy, dx);
|
||
aimed = true;
|
||
if (aim && !aim.self) {
|
||
const h = sightline(v, tx, ty, aim.x + 0.5, aim.y + 0.5);
|
||
if (h !== null) afterArc = shortestArc(targetFacing, h);
|
||
}
|
||
}
|
||
else if (groundwork || underfoot) {
|
||
targetPitch = LOOK_DOWN;
|
||
aimed = true;
|
||
}
|
||
else if (aim && !aim.self) {
|
||
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||
// The eyes watch their own magic land wherever they can see it —
|
||
// straight down a corridor or through a warp mouth. Only a target
|
||
// out of all sight earns the cutaway.
|
||
const heading = sightline(v, tx, ty, ax, ay);
|
||
if (heading === null) return takeCutaway(ax, ay);
|
||
targetFacing = heading;
|
||
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, straight or through a mouth.
|
||
const heading = sightline(v, tx, ty, actor.position.x + 0.5, actor.position.y + 0.5, 0.2);
|
||
if (heading !== null) {
|
||
targetFacing = heading;
|
||
aimed = true;
|
||
}
|
||
}
|
||
// How good is the view down heading `a` from (px,py)? Corridor depth,
|
||
// and at the reel's end, a bonus for visible subjects.
|
||
const viewScoreAt = (px: number, py: number, a: number): number => {
|
||
let score = Math.min(castRay(v, px, py, a).dist, 8);
|
||
if (!atEnd) return score;
|
||
const bonus = (cell: { x: number; y: number }, worth: number) => {
|
||
const bx = cell.x + 0.5, by = cell.y + 0.5;
|
||
const bearing = Math.atan2(by - py, bx - px);
|
||
if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0;
|
||
const d = Math.hypot(bx - px, by - py);
|
||
if (d < 0.1) return 0;
|
||
const sight = castRay(v, px, py, bearing);
|
||
return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0;
|
||
};
|
||
for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6);
|
||
for (const c of v.creatures) score += bonus(c.position, 5);
|
||
for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3);
|
||
return score;
|
||
};
|
||
if (!aimed) {
|
||
// Nothing aims the eyes. If they'd idle nose-to-brick (the opening
|
||
// cut, a dead-end beat — or the reel's CLOSING shot, which gets a
|
||
// higher standard), find a better view: the deepest corridor, and
|
||
// on the final frame, preferably one with something in it.
|
||
const viewScore = (a: number): number => viewScoreAt(tx, ty, a);
|
||
// A cut lands with whatever stale facing it carried, so a cut with
|
||
// nothing aimed always re-picks its view from the new position.
|
||
const staring = willCut ||
|
||
castRay(v, tx, ty, targetFacing).dist < (atEnd ? 1.2 : 0.8);
|
||
if (staring) {
|
||
const current = viewScore(targetFacing);
|
||
let bestA = targetFacing, bestScore = current;
|
||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||
const sc = viewScore(a);
|
||
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
|
||
}
|
||
// One evaluation, one turn — a wizard walled in on all sides
|
||
// keeps whatever view it has rather than hunting forever.
|
||
targetFacing = bestA;
|
||
}
|
||
}
|
||
// The reel's LAST step may be a stride into a corner: the walk keeps
|
||
// its own facing, then the camera turns once more to a view worth
|
||
// ending on.
|
||
let closingArc = afterArc;
|
||
if (closingArc === 0 && atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) {
|
||
const current = viewScoreAt(tx, ty, targetFacing);
|
||
let bestA = targetFacing, bestScore = current;
|
||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||
const sc = viewScoreAt(tx, ty, a);
|
||
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
|
||
}
|
||
closingArc = shortestArc(targetFacing, bestA);
|
||
}
|
||
const stepIdx = Math.min(idx, steps.length - 1);
|
||
const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null;
|
||
/** Hold the OLD world on screen this long, so the outcome lands only
|
||
* once the eyes face its recipient: a cutaway gets a beat of the
|
||
* "before"; a turn holds until the turn is done. */
|
||
const setSlate = (holdMs: number) => {
|
||
// Locals only: reading heldView back here would register it as a
|
||
// dependency of this very effect, and the timer clearing it would
|
||
// re-trigger us into a ping-pong.
|
||
holdMs += dieMs;
|
||
const hold = holdMs > 60 && prevView ? prevView : null;
|
||
heldView = hold;
|
||
camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 };
|
||
if (hold) {
|
||
const t = setTimeout(() => (heldView = null), holdMs);
|
||
return () => clearTimeout(t);
|
||
}
|
||
return () => {};
|
||
};
|
||
if (reduced) {
|
||
cam.x = tx; cam.y = ty; cam.facing = targetFacing + closingArc; cam.pitch = targetPitch;
|
||
camReady = true;
|
||
heldView = null;
|
||
camPlan = { idx: stepIdx, holdMs: 0 };
|
||
return;
|
||
}
|
||
if (willCut) {
|
||
// First frame, or a leap the legs cannot explain: cut. An aimed
|
||
// cut still holds a beat of the before-world once the reel is
|
||
// rolling; the opening frame reveals at once.
|
||
const wasReady = camReady;
|
||
const cut = () => { cam.x = tx; cam.y = ty; cam.facing = targetFacing; cam.pitch = targetPitch; };
|
||
camReady = true;
|
||
if (dieMs > 0 && wasReady) {
|
||
// The die decides where the eyes land: cut once it has.
|
||
const t = setTimeout(cut, dieMs);
|
||
const clear = setSlate(aim ? 380 / speed : 0);
|
||
return () => { clearTimeout(t); clear(); };
|
||
}
|
||
cut();
|
||
return setSlate(wasReady && aim ? 380 / speed : 0);
|
||
}
|
||
const fromX = camX, fromY = camY, fromF = camF, fromP = camP;
|
||
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 tiltMs = (Math.abs(targetPitch - fromP) > 0.01 ? 340 : 0) / speed;
|
||
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
|
||
// The outcome waits until the eyes face it: after the turn (and,
|
||
// for a stride that turns at its end, after the walk and that turn).
|
||
const clearSlate = setSlate(aimed && (aim || underfoot)
|
||
? Math.max(turnMs, tiltMs) + (afterArc !== 0 ? walkMs + closingMs : 0) + 120 / speed
|
||
: 0);
|
||
const t0 = performance.now() + dieMs;
|
||
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 (tiltMs > 0) {
|
||
const w = Math.min(1, t / tiltMs);
|
||
cam.pitch = fromP + (targetPitch - fromP) * (smoothstep(w));
|
||
}
|
||
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 = smoothstep(w);
|
||
cam.x = fromX + (tx - fromX) * ease;
|
||
cam.y = fromY + (ty - fromY) * ease;
|
||
} else if (closingArc !== 0 && t < turnMs + walkMs + closingMs) {
|
||
cam.x = tx; cam.y = ty;
|
||
const w = (t - turnMs - walkMs) / closingMs;
|
||
cam.facing = fromF + arc + closingArc * smoothstep(w);
|
||
} else if (tiltMs > 0 && t < tiltMs) {
|
||
cam.facing = fromF + arc + closingArc;
|
||
cam.x = tx; cam.y = ty;
|
||
} else {
|
||
cam.facing = fromF + arc + closingArc;
|
||
cam.x = tx; cam.y = ty; cam.pitch = targetPitch;
|
||
return;
|
||
}
|
||
raf = requestAnimationFrame(tick);
|
||
};
|
||
raf = requestAnimationFrame(tick);
|
||
return () => {
|
||
cancelAnimationFrame(raf);
|
||
clearSlate();
|
||
};
|
||
});
|
||
|
||
// --- 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.
|
||
idx = 0;
|
||
const comp = document.createElement("canvas");
|
||
comp.width = 1280;
|
||
comp.height = 720;
|
||
const g = comp.getContext("2d")!;
|
||
const faces = new Map<string, HTMLImageElement>();
|
||
const faceFor = (src: string): HTMLImageElement | null => {
|
||
let img = faces.get(src);
|
||
if (!img) {
|
||
img = new Image();
|
||
img.src = src;
|
||
faces.set(src, img);
|
||
}
|
||
return img.complete && img.naturalWidth > 0 ? img : null;
|
||
};
|
||
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 — their
|
||
// standee at the left.
|
||
g.fillStyle = "rgba(12, 10, 8, 0.78)";
|
||
g.fillRect(0, 720 - 96, 1280, 96);
|
||
const face = actorArt ? faceFor(actorArt) : null;
|
||
const textX = face ? 110 : 28;
|
||
if (face) {
|
||
g.drawImage(face, 24, 720 - 96 + 14, 68, 68);
|
||
g.strokeStyle = "#43331f";
|
||
g.lineWidth = 2;
|
||
g.strokeRect(24, 720 - 96 + 14, 68, 68);
|
||
}
|
||
g.fillStyle = "#e0b34a";
|
||
g.font = "600 22px Oswald, sans-serif";
|
||
g.fillText(step.actor.toUpperCase(), textX, 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, textX, 720 - 32 + i * 26, 760));
|
||
if (status) {
|
||
g.textAlign = "right";
|
||
if (status.strides !== null) {
|
||
g.fillStyle = "#efe8d4";
|
||
g.font = "600 34px Oswald, sans-serif";
|
||
g.fillText(String(status.strides), 1280 - 28, 720 - 52);
|
||
g.fillStyle = "#a49c86";
|
||
g.font = "600 14px Oswald, sans-serif";
|
||
g.fillText(status.strides === 1 ? "STEP LEFT" : "STEPS LEFT", 1280 - 28, 720 - 34);
|
||
}
|
||
g.fillStyle = "#d8d2c0";
|
||
g.font = "16px 'Courier Prime', monospace";
|
||
g.fillText(status.chips.join(" · "), 1280 - 28, 720 - 12, 420);
|
||
g.textAlign = "left";
|
||
}
|
||
// 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 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={heldView ?? step.view} povId={cutawayShot ? "" : povId}
|
||
x={cam.x} y={cam.y} facing={cam.facing} pitch={cam.pitch} width={640} height={360}
|
||
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
||
{:else}
|
||
<Board view={dieHeld ?? step.view} effects={boardFx} {sightTrace} />
|
||
{/if}
|
||
{#if die}
|
||
<div class="die-slate" class:landed={die.landed}>
|
||
<div class="die-card">
|
||
<div class="die-who">{die.player} rolls the die</div>
|
||
<div class="die-why">{die.purpose}</div>
|
||
<div class="die-cube" style:transform={`rotate(${die.landed ? 0 : [-14, 9, -5, 12][die.face - 1]}deg) scale(${die.landed ? 1.12 : 1})`}>
|
||
<span>{die.face}</span>
|
||
</div>
|
||
<div class="die-verdict">{die.landed ? dieVerdict || "…" : "\u00a0"}</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<div class="replay-caption">
|
||
{#if actorArt}<img class="caption-face" src={actorArt} alt={step.actor} />{/if}
|
||
<div class="caption-lines">
|
||
<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>
|
||
{#if status}
|
||
<div class="caption-status">
|
||
{#if status.strides !== null}
|
||
<div class="caption-steps"><b>{status.strides}</b><span>{status.strides === 1 ? "step" : "steps"} left</span></div>
|
||
{/if}
|
||
<div class="caption-chips">
|
||
{#each status.chips as chip, i (i)}<span class="chip">{chip}</span>{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{#if steps.length > 3}
|
||
<div class="timeline" role="group" aria-label="the moves, a tick each">
|
||
{#each ticks as t (t.i)}
|
||
<button class="tick" class:current={t.i === idx} class:turn={t.flag === "turn"} class:hit={t.flag === "hit"} class:gold={t.flag === "gold"} class:end={t.flag === "end"}
|
||
style:--who={t.color} title={`move ${t.i + 1} — ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
|
||
onclick={() => { playing = false; idx = t.i; }} aria-label={`jump to move ${t.i + 1}`}></button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<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;
|
||
position: relative;
|
||
}
|
||
/* The die's interlude: a card over the held world. */
|
||
.die-slate {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
background: rgba(10, 12, 16, 0.55);
|
||
}
|
||
.die-card {
|
||
background: #efe8d4;
|
||
color: #3a2f1f;
|
||
border: 2px solid #43331f;
|
||
border-radius: 6px;
|
||
padding: 0.7rem 1.4rem 0.8rem;
|
||
min-width: 17rem;
|
||
max-width: 80%;
|
||
text-align: center;
|
||
font-family: "Courier Prime", monospace;
|
||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
|
||
}
|
||
.die-who {
|
||
font-family: "Oswald", sans-serif;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
font-size: 0.8rem;
|
||
color: #6b5a3e;
|
||
}
|
||
.die-why { font-size: 0.82rem; margin: 0.15rem 0 0.5rem; }
|
||
.die-cube {
|
||
width: 4.2rem;
|
||
height: 4.2rem;
|
||
margin: 0 auto;
|
||
display: grid;
|
||
place-items: center;
|
||
background: #e0b34a;
|
||
border: 2px solid #43331f;
|
||
border-radius: 0.6rem;
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 2.4rem;
|
||
color: #2b2218;
|
||
transition: none;
|
||
}
|
||
.die-slate.landed .die-cube { background: #f2d27a; box-shadow: 0 0 0 4px rgba(224, 179, 74, 0.35); }
|
||
.die-verdict { margin-top: 0.55rem; font-size: 0.82rem; min-height: 1.3em; }
|
||
.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;
|
||
display: flex;
|
||
gap: 0.6rem;
|
||
align-items: flex-start;
|
||
}
|
||
.caption-face {
|
||
width: 44px;
|
||
height: 44px;
|
||
object-fit: cover;
|
||
border-radius: 3px;
|
||
border: 1.5px solid #43331f;
|
||
flex: none;
|
||
}
|
||
.caption-lines { min-width: 0; flex: 1; }
|
||
.caption-status {
|
||
flex: none;
|
||
max-width: 38%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 0.25rem;
|
||
text-align: right;
|
||
}
|
||
.caption-steps {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.35rem;
|
||
font-family: "Oswald", sans-serif;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
font-size: 0.62rem;
|
||
color: #6b5a3e;
|
||
}
|
||
.caption-steps b { font-size: 1.35rem; line-height: 1; color: #3a2f1f; letter-spacing: 0; }
|
||
.caption-chips { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.25rem; }
|
||
.chip {
|
||
border: 1px solid #b9ad92;
|
||
border-radius: 3px;
|
||
padding: 0 0.35rem;
|
||
font-size: 0.68rem;
|
||
line-height: 1.5;
|
||
color: #4d3f2a;
|
||
background: rgba(255, 255, 255, 0.35);
|
||
white-space: nowrap;
|
||
}
|
||
.replay-controls .speed {
|
||
font-size: 0.75rem;
|
||
opacity: 0.7;
|
||
}
|
||
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
|
||
.timeline {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 1px;
|
||
height: 14px;
|
||
margin: 0.4rem 0.2rem 0.2rem;
|
||
}
|
||
.tick {
|
||
flex: 1 1 0;
|
||
min-width: 2px;
|
||
height: 6px;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 1px;
|
||
background: var(--who, #8d8672);
|
||
opacity: 0.45;
|
||
cursor: pointer;
|
||
}
|
||
.tick.turn { height: 9px; opacity: 0.7; }
|
||
.tick.hit { height: 12px; opacity: 0.9; }
|
||
.tick.gold { height: 14px; opacity: 1; box-shadow: 0 0 0 1px #e0b34a; }
|
||
.tick.end { height: 14px; opacity: 1; background: #e9e1cb; }
|
||
.tick.current { opacity: 1; outline: 1px solid #fff; outline-offset: 1px; }
|
||
.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>
|