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:
co-authored by
Claude Fable 5.1
parent
7223f8eb0d
commit
7c0a761c59
+168
-25
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user