The reel slates a change of eyes and captions the wizard's state
Two things for a viewer who has never seen the game. When the reel's
pov passes to another wizard, the cut used to be instant — one wizard
apparently teleporting. Now a slate wipes across the pane: a dark band
sweeps in over the old sight, holds with the new wizard's standee and
THROUGH THE EYES OF <name>, and sweeps off to reveal theirs. It is an
FpFx ("slate") drawn by the renderer on the reel's own clock, so the
harness and the film see the same frames.
The caption grows a right-hand column: strides left to spend this turn
as a countdown, ♥ life, and chips for what the wizard carries,
displays, or suffers — a treasure, displayed cards, spells on them
with turns remaining, lost turns, banked Pass Wall, attack spent. The
save-video composite carries the same column.
All thirty scene goldens re-blessed: the caption is inside every
frame. The 3D panes are pixel-identical to the previous goldens.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6a933f030c
commit
7223f8eb0d
@@ -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 @@
|
||||
<div class="reel-talk">💬 {c.player}: {c.text}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if status}
|
||||
<div class="caption-status">
|
||||
{#if status.steps !== null}
|
||||
<div class="caption-steps"><b>{status.steps}</b><span>{status.steps === 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>
|
||||
<div class="replay-controls">
|
||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user