diff --git a/deploy/scene-goldens/come-here-s10-board.png b/deploy/scene-goldens/come-here-s10-board.png index 0f8b715..22efeac 100644 Binary files a/deploy/scene-goldens/come-here-s10-board.png and b/deploy/scene-goldens/come-here-s10-board.png differ diff --git a/deploy/scene-goldens/come-here-s10-fpv.png b/deploy/scene-goldens/come-here-s10-fpv.png index ff8888d..e151107 100644 Binary files a/deploy/scene-goldens/come-here-s10-fpv.png and b/deploy/scene-goldens/come-here-s10-fpv.png differ diff --git a/deploy/scene-goldens/come-here-s13-fpv.png b/deploy/scene-goldens/come-here-s13-fpv.png index 1d7a916..1417d8f 100644 Binary files a/deploy/scene-goldens/come-here-s13-fpv.png and b/deploy/scene-goldens/come-here-s13-fpv.png differ diff --git a/deploy/scene-goldens/fire-in-the-webs-s11-board.png b/deploy/scene-goldens/fire-in-the-webs-s11-board.png index fb6bb32..1a59590 100644 Binary files a/deploy/scene-goldens/fire-in-the-webs-s11-board.png and b/deploy/scene-goldens/fire-in-the-webs-s11-board.png differ diff --git a/deploy/scene-goldens/fire-in-the-webs-s11-fpv.png b/deploy/scene-goldens/fire-in-the-webs-s11-fpv.png index 8bad7ae..ba4ba2e 100644 Binary files a/deploy/scene-goldens/fire-in-the-webs-s11-fpv.png and b/deploy/scene-goldens/fire-in-the-webs-s11-fpv.png differ diff --git a/deploy/scene-goldens/fire-in-the-webs-s14-fpv.png b/deploy/scene-goldens/fire-in-the-webs-s14-fpv.png index f395e74..c6bee7d 100644 Binary files a/deploy/scene-goldens/fire-in-the-webs-s14-fpv.png and b/deploy/scene-goldens/fire-in-the-webs-s14-fpv.png differ diff --git a/deploy/scene-goldens/shield-holds-s11-board.png b/deploy/scene-goldens/shield-holds-s11-board.png index e38265d..439c31c 100644 Binary files a/deploy/scene-goldens/shield-holds-s11-board.png and b/deploy/scene-goldens/shield-holds-s11-board.png differ diff --git a/deploy/scene-goldens/shield-holds-s11-fpv.png b/deploy/scene-goldens/shield-holds-s11-fpv.png index c4d908a..2631917 100644 Binary files a/deploy/scene-goldens/shield-holds-s11-fpv.png and b/deploy/scene-goldens/shield-holds-s11-fpv.png differ diff --git a/deploy/scene-goldens/shield-holds-s12-fpv.png b/deploy/scene-goldens/shield-holds-s12-fpv.png index 1920826..924a62d 100644 Binary files a/deploy/scene-goldens/shield-holds-s12-fpv.png and b/deploy/scene-goldens/shield-holds-s12-fpv.png differ diff --git a/deploy/scene-goldens/springing-the-trap-s5-board.png b/deploy/scene-goldens/springing-the-trap-s5-board.png index f2a6711..ea0c948 100644 Binary files a/deploy/scene-goldens/springing-the-trap-s5-board.png and b/deploy/scene-goldens/springing-the-trap-s5-board.png differ diff --git a/deploy/scene-goldens/springing-the-trap-s5-fpv.png b/deploy/scene-goldens/springing-the-trap-s5-fpv.png index d11af9e..e0ba288 100644 Binary files a/deploy/scene-goldens/springing-the-trap-s5-fpv.png and b/deploy/scene-goldens/springing-the-trap-s5-fpv.png differ diff --git a/deploy/scene-goldens/springing-the-trap-s6-fpv.png b/deploy/scene-goldens/springing-the-trap-s6-fpv.png index baf5c56..fbbd61a 100644 Binary files a/deploy/scene-goldens/springing-the-trap-s6-fpv.png and b/deploy/scene-goldens/springing-the-trap-s6-fpv.png differ diff --git a/deploy/scene-goldens/stone-gaze-s10-board.png b/deploy/scene-goldens/stone-gaze-s10-board.png index 833972f..ced6d73 100644 Binary files a/deploy/scene-goldens/stone-gaze-s10-board.png and b/deploy/scene-goldens/stone-gaze-s10-board.png differ diff --git a/deploy/scene-goldens/stone-gaze-s10-fpv.png b/deploy/scene-goldens/stone-gaze-s10-fpv.png index 9057ba5..3c0f7dc 100644 Binary files a/deploy/scene-goldens/stone-gaze-s10-fpv.png and b/deploy/scene-goldens/stone-gaze-s10-fpv.png differ diff --git a/deploy/scene-goldens/stone-gaze-s14-fpv.png b/deploy/scene-goldens/stone-gaze-s14-fpv.png index 5ae0580..ec07f3b 100644 Binary files a/deploy/scene-goldens/stone-gaze-s14-fpv.png and b/deploy/scene-goldens/stone-gaze-s14-fpv.png differ diff --git a/deploy/scene-goldens/the-leap-s8-board.png b/deploy/scene-goldens/the-leap-s8-board.png index f46af2f..a7dfdc8 100644 Binary files a/deploy/scene-goldens/the-leap-s8-board.png and b/deploy/scene-goldens/the-leap-s8-board.png differ diff --git a/deploy/scene-goldens/the-leap-s8-fpv.png b/deploy/scene-goldens/the-leap-s8-fpv.png index 01de0af..ce36aeb 100644 Binary files a/deploy/scene-goldens/the-leap-s8-fpv.png and b/deploy/scene-goldens/the-leap-s8-fpv.png differ diff --git a/deploy/scene-goldens/the-leap-s9-fpv.png b/deploy/scene-goldens/the-leap-s9-fpv.png index b81815f..decf9de 100644 Binary files a/deploy/scene-goldens/the-leap-s9-fpv.png and b/deploy/scene-goldens/the-leap-s9-fpv.png differ diff --git a/deploy/scene-goldens/the-rout-s11-board.png b/deploy/scene-goldens/the-rout-s11-board.png index 53fc7ce..37c7f77 100644 Binary files a/deploy/scene-goldens/the-rout-s11-board.png and b/deploy/scene-goldens/the-rout-s11-board.png differ diff --git a/deploy/scene-goldens/the-rout-s11-fpv.png b/deploy/scene-goldens/the-rout-s11-fpv.png index 7200cff..aa52f83 100644 Binary files a/deploy/scene-goldens/the-rout-s11-fpv.png and b/deploy/scene-goldens/the-rout-s11-fpv.png differ diff --git a/deploy/scene-goldens/the-rout-s4-fpv.png b/deploy/scene-goldens/the-rout-s4-fpv.png index d243a33..d14392c 100644 Binary files a/deploy/scene-goldens/the-rout-s4-fpv.png and b/deploy/scene-goldens/the-rout-s4-fpv.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s6-board.png b/deploy/scene-goldens/wall-comes-down-s6-board.png index 393a8f8..0f390e5 100644 Binary files a/deploy/scene-goldens/wall-comes-down-s6-board.png and b/deploy/scene-goldens/wall-comes-down-s6-board.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s6-fpv.png b/deploy/scene-goldens/wall-comes-down-s6-fpv.png index a9997bb..e78fffc 100644 Binary files a/deploy/scene-goldens/wall-comes-down-s6-fpv.png and b/deploy/scene-goldens/wall-comes-down-s6-fpv.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s7-fpv.png b/deploy/scene-goldens/wall-comes-down-s7-fpv.png index dfe7b80..d687cce 100644 Binary files a/deploy/scene-goldens/wall-comes-down-s7-fpv.png and b/deploy/scene-goldens/wall-comes-down-s7-fpv.png differ diff --git a/deploy/scene-goldens/warp-sniper-s16-board.png b/deploy/scene-goldens/warp-sniper-s16-board.png index 13fa995..6f22f93 100644 Binary files a/deploy/scene-goldens/warp-sniper-s16-board.png and b/deploy/scene-goldens/warp-sniper-s16-board.png differ diff --git a/deploy/scene-goldens/warp-sniper-s16-fpv.png b/deploy/scene-goldens/warp-sniper-s16-fpv.png index 5e262f1..9697cc2 100644 Binary files a/deploy/scene-goldens/warp-sniper-s16-fpv.png and b/deploy/scene-goldens/warp-sniper-s16-fpv.png differ diff --git a/deploy/scene-goldens/warp-sniper-s6-fpv.png b/deploy/scene-goldens/warp-sniper-s6-fpv.png index 0ed564d..41e4054 100644 Binary files a/deploy/scene-goldens/warp-sniper-s6-fpv.png and b/deploy/scene-goldens/warp-sniper-s6-fpv.png differ diff --git a/deploy/scene-goldens/wormhole-heist-s10-fpv.png b/deploy/scene-goldens/wormhole-heist-s10-fpv.png index ffa6153..ed893d8 100644 Binary files a/deploy/scene-goldens/wormhole-heist-s10-fpv.png and b/deploy/scene-goldens/wormhole-heist-s10-fpv.png differ diff --git a/deploy/scene-goldens/wormhole-heist-s5-board.png b/deploy/scene-goldens/wormhole-heist-s5-board.png index 7d1bdc1..02a031d 100644 Binary files a/deploy/scene-goldens/wormhole-heist-s5-board.png and b/deploy/scene-goldens/wormhole-heist-s5-board.png differ diff --git a/deploy/scene-goldens/wormhole-heist-s5-fpv.png b/deploy/scene-goldens/wormhole-heist-s5-fpv.png index 0f924f2..2c537bd 100644 Binary files a/deploy/scene-goldens/wormhole-heist-s5-fpv.png and b/deploy/scene-goldens/wormhole-heist-s5-fpv.png differ diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 581d482..954b533 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -2,14 +2,14 @@ import Board from "./Board.svelte"; import FirstPerson from "./fpv/FirstPerson.svelte"; import { untrack } from "svelte"; - import { humanize } from "./net.svelte"; + import { humanize, spellName } from "./net.svelte"; import { tokenArt } from "./art"; import { scheduleFx, type BoardFx } from "./fx"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; import { castRay, edgeMid } from "./fpv/raycast"; import { cutawayStand, deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { prefs } from "./prefs.svelte"; - import { stackSightTrace } from "@wizwar/engine"; + import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine"; let { @@ -99,6 +99,51 @@ 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 steps = 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 { steps, 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 (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); + } + 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. const sightTrace = $derived(stackSightTrace(step.view)); @@ -464,7 +509,22 @@ 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, 1224 - (textX - 28))); + said.forEach((line, i) => g.fillText(line, textX, 720 - 32 + i * 26, 760)); + if (status) { + g.textAlign = "right"; + if (status.steps !== null) { + g.fillStyle = "#efe8d4"; + g.font = "600 34px Oswald, sans-serif"; + g.fillText(String(status.steps), 1280 - 28, 720 - 52); + g.fillStyle = "#a49c86"; + g.font = "600 14px Oswald, sans-serif"; + g.fillText(status.steps === 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"; @@ -552,6 +612,16 @@
💬 {c.player}: {c.text}
{/each} + {#if status} +
+ {#if status.steps !== null} +
{status.steps}{status.steps === 1 ? "step" : "steps"} left
+ {/if} +
+ {#each status.chips as chip, i (i)}{chip}{/each} +
+
+ {/if}
@@ -654,7 +724,38 @@ border: 1.5px solid #43331f; flex: none; } - .caption-lines { min-width: 0; } + .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; diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 892ac0b..db816a6 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -5,7 +5,7 @@ // by the same depth buffer the walls wrote. import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast"; import { materialTextures } from "./textures"; - import { doorOpenness, fxFallback, growProgress, surgeIntensity, type FpFx } from "./fx3d"; + import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { tokenArt } from "../art"; import { PLAYER_COLORS } from "../colors"; @@ -709,6 +709,43 @@ ctx.globalAlpha = 1; } + // A change of eyes: the slate wipes across, names the wizard whose + // sight this now is, and wipes away to reveal it. + for (const f of fx) { + if (f.kind !== "slate") continue; + const [l, r] = slateBand((time - f.t0) / f.dur); + if (r <= l) continue; + const x0 = Math.round(l * W), x1 = Math.round(r * W); + ctx.save(); + ctx.beginPath(); + ctx.rect(x0, 0, x1 - x0, H); + ctx.clip(); + ctx.fillStyle = "#171a20"; + ctx.fillRect(x0, 0, x1 - x0, H); + ctx.fillStyle = "#e0b34a"; + ctx.fillRect(x0, half - 1, x1 - x0, 2); + // The card's face: standee, then the name — composed on the full + // pane so it lands where it will sit once the band is complete. + const face = f.art ? imageFor(f.art) : null; + const size = Math.round(H * 0.24); + const cx = W / 2; + if (face) { + ctx.drawImage(face, cx - size / 2, half - size - 18, size, size); + ctx.strokeStyle = "#43331f"; + ctx.lineWidth = 2; + ctx.strokeRect(cx - size / 2, half - size - 18, size, size); + } + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + ctx.fillStyle = "#a49c86"; + ctx.font = `600 ${Math.round(H * 0.045)}px Oswald, "Archivo Narrow", sans-serif`; + ctx.fillText("T H R O U G H T H E E Y E S O F", cx, half + Math.round(H * 0.09)); + ctx.fillStyle = "#e9e1cb"; + ctx.font = `600 ${Math.round(H * 0.13)}px Oswald, "Archivo Narrow", sans-serif`; + ctx.fillText(f.name.toUpperCase(), cx, half + Math.round(H * 0.23)); + ctx.restore(); + } + // The crosshair's answer, before the click commits: what the pane // would target here, outlined in the table's gold with its name. if (ontarget && mouse) drawHover(ctx, W, H, half); diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts index 3e87511..4fdd7ab 100644 --- a/packages/web/src/fpv/fx3d.ts +++ b/packages/web/src/fpv/fx3d.ts @@ -25,6 +25,10 @@ export type FpFx = * veil hung in that opening surges — the warp's own face animates, * no billboard over whoever stands in it. */ | { id: number; kind: "surge"; warp: number; t0: number; dur: number } + /** The reel changes eyes: a title card wipes across the pane naming + * whose turn — and whose sight — this now is, so a cut between + * wizards never reads as one wizard teleporting. */ + | { id: number; kind: "slate"; name: string; art: string | null; t0: number; dur: number } | { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number }; /** How hard a surging veil glows at progress p: a snap to full as the @@ -36,6 +40,17 @@ export function surgeIntensity(p: number): number { return (1 - q) * (1 - q); } +/** The slate's wipe: how much of the pane its band covers at progress + * p, as [left, right] fractions — sweeping in from the left, holding, + * then sweeping off to the right. */ +export function slateBand(p: number): [number, number] { + const ease = (w: number) => w * w * (3 - 2 * w); + if (p < 0 || p >= 1) return [0, 0]; + if (p < 0.28) return [0, ease(p / 0.28)]; + if (p < 0.68) return [0, 1]; + return [ease((p - 0.68) / 0.32), 1]; +} + /** Eased growth 0..1 for a conjuration's rise. */ export function growProgress(p: number): number { if (p <= 0) return 0.01;