The eyes look down, the die stops the reel, and a punch is a fist

A day of Eric watching the clips as a newcomer would.

The camera can pitch: FirstPerson takes a `pitch` that shears the
horizon up the pane (walls center on it, the eye stays half a wall
high), so the ground at the pane's foot comes within half a cell. The
director uses it for deeds at your own feet — a conjuration on your
square, a treasure taken up, the pit you fell down — instead of the
cutaway that stepped outside your body.

Everything shows through a warp. The floor pass now runs between the
cast and the wall draw, so a column that bent through a mouth maps its
ground through the warp's rigid motion and wears the far room's decals
— the pit past the wormhole included — under the same violet haze as
its walls.

A wizard down a pit is public (inPit on the player view), drawn sunk to
the floor line in first person and dimmed on the board. A body sharing
your square, which had no depth to draw at, stands right in front of
you. A pit leap glides like a shove instead of cutting. And the
director's blocked-sight test no longer counts a warp BEYOND the
target as a block, nor stands a cutaway camera inside another wizard's
square — the pair that put Wanderer's face across the whole pane.

The reel stops for a die: a card over the held world names who rolls
and for what, tumbles the faces on the reel's own clock, lands the
roll, and only then plays the outcome; the beat, the fx, the glides
and the camera all wait it out. A punch by you drives a fist into the
scene; one at you comes at the camera; both land with a comic POW.

film-clips rolls until the reel's last move rather than a fixed count,
and the gate settles longer on a step whose caption rolls a die. All
thirty goldens re-blessed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-02 19:20:03 -04:00
co-authored by Claude Fable 5.1
parent 7223f8eb0d
commit 7c0a761c59
27 changed files with 410 additions and 112 deletions
+168 -25
View File
@@ -82,6 +82,43 @@
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) && prefs.flourishes ? 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 || !prefs.flourishes) { die = null; return; }
const t0 = performance.now();
const dur = DIE_MS / speed;
const who = d.player ?? "The maze";
die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: 1, landed: 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);
die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: ((n * 3 + 1) % 4) + 1, landed: false };
} else if (!die?.landed) {
die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: d.roll, landed: 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"]);
@@ -92,6 +129,8 @@
.filter((l): l is string => l !== null),
);
const atEnd = $derived(idx >= steps.length - 1);
/** What the roll brought about, in the caption's own words. */
const dieVerdict = $derived(lines.filter((l) => !l.startsWith("\u{1F3B2}")).slice(0, 2).join(" "));
/** The acting wizard's standee, shown beside their words. */
const actorArt = $derived.by(() => {
@@ -171,12 +210,15 @@
$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 = []; };
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
@@ -214,11 +256,16 @@
$effect(() => {
if (!playing) return;
const t = setInterval(() => {
// Each step gets the reel's beat, plus the die's interlude when
// one decided it. Read the step here so every advance re-arms the
// timer — a derived that stays 0 across steps would not.
const from = idx;
const t = setTimeout(() => {
if (idx !== from) return;
if (idx < steps.length - 1) idx += 1;
else playing = false;
}, 1500 / speed);
return () => clearInterval(t);
}, 1500 / speed + dieMs);
return () => clearTimeout(t);
});
/** Other bodies glide between steps instead of blinking cell to cell:
@@ -235,7 +282,7 @@
if (!before || before === v) return;
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
const t0 = performance.now();
const t0 = performance.now() + dieMs;
const dur = 400 / speed;
let raf = 0;
const tick = (now: number) => {
@@ -257,7 +304,10 @@
// 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 });
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;
let camReady = false;
$effect(() => {
if (!fp) { camReady = false; return; }
@@ -273,6 +323,7 @@
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);
@@ -304,26 +355,44 @@
const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed : 0 };
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
if (pv) {
const t = setTimeout(() => (heldView = null), 420 / speed);
const t = setTimeout(() => (heldView = null), 420 / speed + dieMs);
return () => clearTimeout(t);
}
return;
};
let targetFacing = camF;
let aimed = false;
// Something at your own feet — a conjuration on your square, a
// treasure taken up, the pit you just fell down — is watched by
// looking down; the eyes come back level with the next step.
const underfoot = step.events.some((e) =>
(e.type === "fellInPit" || e.type === "climbedFromPit") && "player" in e && (e as { player: string }).player === povId);
let targetPitch = 0;
// Only GROUND deeds earn the look-down: a conjuration on this
// square, a treasure taken up or set down. A punch traded with
// someone sharing the square aims at nobody's feet — they stand
// right in front of the eyes, wherever those point.
const GROUNDWORK = new Set(["spellCast", "squareFilled", "creatureCreated",
"treasurePickedUp", "treasureDropped", "objectDropped"]);
const groundwork = aim?.self && step.events.some((e) => GROUNDWORK.has(e.type));
if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
else if (groundwork || underfoot) {
targetPitch = LOOK_DOWN;
aimed = true;
}
else if (aim?.self) {
// Summoned at your own feet: no turn can show it — step outside
// yourself and watch it grow beside you.
return takeCutaway(me.position.x + 0.5, me.position.y + 0.5);
// Nothing to turn toward: keep the view, unless it is a wall.
}
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) {
// Blocked: a wall before the target, or a warp mouth before it —
// a warp BEYOND the target bends nothing the eyes need.
const warpBefore = sight.warped && (sight.warpDist ?? Infinity) < toAim - 0.4;
if (warpBefore || sight.dist < toAim - 0.4) {
return takeCutaway(ax, ay);
}
targetFacing = Math.atan2(ay - ty, ax - tx);
@@ -335,7 +404,8 @@
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) {
const warpBefore = ray.warped && (ray.warpDist ?? Infinity) < toActor - 0.2;
if (!warpBefore && ray.dist > toActor - 0.2) {
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
}
@@ -403,6 +473,7 @@
// 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 };
@@ -417,23 +488,35 @@
// cut still holds a beat of the before-world once the reel is
// rolling; the opening frame reveals at once.
const wasReady = camReady;
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
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;
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 clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
const tiltMs = (Math.abs(targetPitch - fromP) > 0.01 ? 340 : 0) / speed;
const clearSlate = setSlate(aimed && (aim || underfoot) ? Math.max(turnMs, tiltMs) + 120 / speed : 0);
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
const t0 = performance.now();
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) * (w * w * (3 - 2 * w));
}
if (turnMs > 0 && t < turnMs) {
cam.facing = fromF + arc * (t / turnMs);
} else if (walkMs > 0 && t < turnMs + walkMs) {
@@ -446,9 +529,12 @@
cam.x = tx; cam.y = ty;
const w = (t - turnMs - walkMs) / closingMs;
cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w);
} else {
} 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);
@@ -596,10 +682,22 @@
<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} width={640} height={360}
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={step.view} effects={boardFx} {sightTrace} />
<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">
@@ -695,7 +793,52 @@
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;