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
+188 -75
View File
@@ -18,6 +18,7 @@
x,
y,
facing,
pitch = 0,
width = 720,
height = 440,
fx = [],
@@ -35,6 +36,11 @@
y: number;
/** Radians; 0 faces east, matching the board's +x. */
facing: number;
/** Tilt, as the fraction of the pane's height the horizon rises:
* 0 looks level, ~0.36 looks down at your own feet. The raycaster
* shears rather than rotates — verticals stay vertical, and the
* ground at the pane's foot comes within half a cell of the eye. */
pitch?: number;
width?: number;
height?: number;
/** Live spell moments: projectiles, impacts, flashes, shakes. */
@@ -63,7 +69,8 @@
* the pane is an instrument only because the renderer remembers what
* stood under every pixel. */
let hitFrame: {
W: number; H: number; ex: number; ey: number; facing: number;
W: number;
horizon: number; H: number; ex: number; ey: number; facing: number;
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
/** Depth-sorted nearest-first, ready for hover hit-testing. */
@@ -189,7 +196,10 @@
const ctx = canvas?.getContext("2d");
if (!ctx) return;
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
const W = width, H = height, half = H / 2;
// `half` is the horizon row — mid-pane when level, higher when the
// eyes look down. Wall faces center on it; the eye's own height
// above the floor stays H/2 wherever the horizon sits.
const W = width, H = height, half = H / 2 - pitch * H;
// Active shakes wobble the eye a hair off true, fading as they run.
let ex = x, ey = y;
@@ -223,12 +233,72 @@
}
}
// Walls, one ray per column; remember each column's depth for
// sprites, and whether its ray bent through a warp — a sprite's warp
// side must MATCH its column's, or bodies near a far mouth would
// ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W);
const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null);
// Per column: which warp (if any) the ray bent through, and how far
// away that mouth stood. A REAL body paints a column only if it is
// nearer than the mouth (it stands in front of the window); a
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
const warpIdCol = new Int32Array(W).fill(-1);
const warpDistCol = new Float64Array(W).fill(Infinity);
// Each mouth a ray passed through hangs a translucent veil of the
// warp texture at its own depth, drawn with the other overlays.
const veils: { col: number; depth: number; u: number; warp: number }[] = [];
// Known illusions: the ray passes, but a translucent ghost of a wall
// stands at the crossing — drawn after the sprites so bodies show
// through it, shimmering so nobody mistakes it for stone.
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
// Open doorways collect their lintels, hung after the sprites; and
// conjurations still rising collect their growing columns.
const lintels: { col: number; depth: number; u: number }[] = [];
const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = [];
// Cast first, draw after: the floor pass between them needs to know
// which columns bent through a warp, so the ground beyond a mouth can
// wear the far room's decals — the pit past the wormhole included.
const rays: { hit: ReturnType<typeof castRay>; depth: number; wallH: number; top: number }[] = new Array(W);
for (let col = 0; col < W; col++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
const hit = castRay(view, ex, ey, rayAngle, doors, growing);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth;
if (hit.warpId !== undefined) {
warpIdCol[col] = hit.warpId;
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0, warp: hit.warpId });
}
if (hit.ghost) {
ghosts.push({
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
u: hit.ghost.u, worldU: hit.ghost.worldU,
});
}
if (hit.doorway) {
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
}
if (hit.rising) {
risings.push({
col, depth: hit.rising.dist * Math.cos(rayAngle - facing),
u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g,
});
}
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2;
hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH };
rays[col] = { hit, depth, wallH, top };
}
// The ground and the vault overhead, cast per pixel: each screen row
// below (or above) the horizon lies at one fixed depth, so the row is
// walked with a constant world-space step and sampled from the floor
// or ceiling texture — every maze cell wearing one full tile of it.
const flen = (W / 2) / Math.tan(FOV / 2);
const cosF = Math.cos(facing), sinF = Math.sin(facing);
const motions = view.board.warps.map(warpMotion);
if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H);
const buf = frame.data;
const fl = pixelsOf(textures.floor!);
@@ -278,25 +348,35 @@
if (shade <= 0.02) {
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9;
} else {
let u = wx % 1; if (u < 0) u += 1;
let v = wy % 1; if (v < 0) v += 1;
// Ground seen through a warp mouth belongs to the far room:
// carry the point through the warp's rigid motion before
// asking whose flagstone, whose decal, whose light it wears.
let gx = wx, gy = wy;
let hazed = false;
if (below && warpIdCol[col]! >= 0 && d > warpDistCol[col]!) {
const real = motions[warpIdCol[col]!]!.toReal({ x: wx, y: wy });
gx = real.x; gy = real.y;
hazed = true;
}
let u = gx % 1; if (u < 0) u += 1;
let v = gy % 1; if (v < 0) v += 1;
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
let sh = shade;
if (litGrid && below) {
const gx = (wx - u) | 0, gy = (wy - v) | 0;
if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3;
const cx = (gx - u) | 0, cy = (gy - v) | 0;
if (cx < 0 || cy < 0 || cx >= litBw || cy >= litBh || !litGrid[cy * litBw + cx]) sh *= 0.3;
}
if (dreadGrid && below) {
const gx = (wx - u) | 0, gy = (wy - v) | 0;
if (gx >= 0 && gy >= 0 && gx < litBw && gy < litBh && dreadGrid[gy * litBw + gx]) {
const cx = (gx - u) | 0, cy = (gy - v) | 0;
if (cx >= 0 && cy >= 0 && cx < litBw && cy < litBh && dreadGrid[cy * litBw + cx]) {
r = r * (1 - dreadBlend) + 122 * dreadBlend;
g = g * (1 - dreadBlend) + 74 * dreadBlend;
b = b * (1 - dreadBlend) + 138 * dreadBlend;
}
}
if (dec) {
const hx = (wx - u) | 0, hy = (wy - v) | 0;
const hx = (gx - u) | 0, hy = (gy - v) | 0;
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
const di = dec.grid[hy * dec.bw + hx]!;
if (di >= 0) {
@@ -315,6 +395,10 @@
}
}
}
if (hazed) {
// The far room's ground swims in the same violet haze as its walls.
r = r * 0.82 + 120 * 0.18; g = g * 0.82 + 70 * 0.18; b = b * 0.82 + 220 * 0.18;
}
buf[o] = r * sh;
buf[o + 1] = g * sh;
buf[o + 2] = b * sh;
@@ -326,57 +410,9 @@
}
ctx.putImageData(frame, 0, 0);
// Walls, one ray per column; remember each column's depth for
// sprites, and whether its ray bent through a warp — a sprite's warp
// side must MATCH its column's, or bodies near a far mouth would
// ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W);
const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null);
// Per column: which warp (if any) the ray bent through, and how far
// away that mouth stood. A REAL body paints a column only if it is
// nearer than the mouth (it stands in front of the window); a
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
const warpIdCol = new Int32Array(W).fill(-1);
const warpDistCol = new Float64Array(W).fill(Infinity);
// Each mouth a ray passed through hangs a translucent veil of the
// warp texture at its own depth, drawn with the other overlays.
const veils: { col: number; depth: number; u: number; warp: number }[] = [];
// Known illusions: the ray passes, but a translucent ghost of a wall
// stands at the crossing — drawn after the sprites so bodies show
// through it, shimmering so nobody mistakes it for stone.
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
// Open doorways collect their lintels, hung after the sprites; and
// conjurations still rising collect their growing columns.
const lintels: { col: number; depth: number; u: number }[] = [];
const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = [];
// Walls, drawn from the cast: one column each.
for (let col = 0; col < W; col++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
const hit = castRay(view, ex, ey, rayAngle, doors, growing);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth;
if (hit.warpId !== undefined) {
warpIdCol[col] = hit.warpId;
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0, warp: hit.warpId });
}
if (hit.ghost) {
ghosts.push({
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
u: hit.ghost.u, worldU: hit.ghost.worldU,
});
}
if (hit.doorway) {
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
}
if (hit.rising) {
risings.push({
col, depth: hit.rising.dist * Math.cos(rayAngle - facing),
u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g,
});
}
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2;
hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH };
const { hit, depth, wallH, top } = rays[col]!;
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
// Sample by the texture's own size: painted files may be any scale.
// Fire shifts its slice per world cell, so a blaze spanning edges
@@ -466,7 +502,7 @@
}
sprites.sort((a, b) => b.sort - a.sort);
hitFrame = {
W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
W, H, horizon: half, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
};
// The nearest sprite each column carries — depth AND vertical span —
@@ -542,18 +578,22 @@
if (s.depth < spriteZ[col]!) {
spriteZ[col] = s.depth;
sprTop[col] = s.top;
sprBot[col] = s.bottom;
sprBot[col] = s.clipY !== undefined ? Math.min(s.bottom, s.clipY) : s.bottom;
}
const texX = ((col - s.left) / (s.right - s.left));
// Down a pit, the body ends at the ground: only the rows above
// the floor line paint, and only that much of the art.
const shown = s.clipY !== undefined ? Math.min(s.bottom, s.clipY) - s.top : s.bottom - s.top;
if (shown <= 0) continue;
if (img) {
ctx.drawImage(
img,
texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih,
col, s.top, 1, s.bottom - s.top,
texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih * (shown / (s.bottom - s.top)),
col, s.top, 1, shown,
);
} else {
ctx.fillStyle = "rgba(200,190,160,0.6)";
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
ctx.fillRect(col, s.top, 1, shown);
}
if (s.warped) {
// A body seen through a warp swims in the same violet haze.
@@ -709,6 +749,71 @@
ctx.globalAlpha = 1;
}
// Fists. Yours lunges from the lower right into the scene, shrinking
// as it goes, and snaps back; one thrown at you swells from the
// middle of the pane until it fills it. Either way the blow lands
// with a comic starburst.
for (const f of fx) {
if (f.kind !== "fist") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
const ease = (w: number) => w * w * (3 - 2 * w);
const out = f.swing === "out";
// Travel: out 0→0.42 lunge, 0.42→1 retract; in 0→0.45 approach, then hold and fade.
const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : Math.min(1, ease(p / 0.45));
const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
const tx = W * 0.56, ty = H * 0.5;
const fx0 = sx + (tx - sx) * a, fy0 = sy + (ty - sy) * a;
const size = out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a);
const glyph = out ? "\u{1F91C}" : "\u{1F44A}";
ctx.save();
ctx.globalAlpha = !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1;
ctx.translate(fx0, fy0);
ctx.rotate(out ? -0.6 : 0.15);
ctx.font = `${Math.round(size)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(0,0,0,0.55)";
ctx.shadowBlur = 12;
ctx.shadowOffsetY = 6;
ctx.fillText(glyph, 0, 0);
ctx.restore();
// POW: pops at the landing, holds, and shrinks away.
const land = out ? 0.36 : 0.4;
if (p >= land) {
const q = (p - land) / (1 - land);
const pop = q < 0.18 ? ease(q / 0.18) * 1.15 : q < 0.7 ? 1.15 - 0.15 * ease((q - 0.18) / 0.52) : 1 - ease((q - 0.7) / 0.3);
if (pop > 0.01) {
const R = H * 0.19 * pop;
const cx = out ? tx - W * 0.02 : W * 0.5, cy = out ? ty - H * 0.16 : H * 0.34;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-0.14);
ctx.beginPath();
for (let i = 0; i < 24; i++) {
const r = i % 2 === 0 ? R : R * 0.68;
const t = (i / 24) * Math.PI * 2;
ctx.lineTo(Math.cos(t) * r * 1.35, Math.sin(t) * r);
}
ctx.closePath();
ctx.fillStyle = "#f2d24a";
ctx.strokeStyle = "#2b2218";
ctx.lineWidth = Math.max(2, R * 0.08);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#c03020";
ctx.strokeStyle = "#2b2218";
ctx.lineWidth = Math.max(1.5, R * 0.06);
ctx.font = `600 ${Math.round(R * 0.95)}px Oswald, "Archivo Narrow", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.strokeText("POW!", 0, R * 0.04);
ctx.fillText("POW!", 0, R * 0.04);
ctx.restore();
}
}
}
// 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) {
@@ -722,27 +827,28 @@
ctx.clip();
ctx.fillStyle = "#171a20";
ctx.fillRect(x0, 0, x1 - x0, H);
const mid = H / 2;
ctx.fillStyle = "#e0b34a";
ctx.fillRect(x0, half - 1, x1 - x0, 2);
ctx.fillRect(x0, mid - 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.drawImage(face, cx - size / 2, mid - size - 18, size, size);
ctx.strokeStyle = "#43331f";
ctx.lineWidth = 2;
ctx.strokeRect(cx - size / 2, half - size - 18, size, size);
ctx.strokeRect(cx - size / 2, mid - 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.fillText("T H R O U G H T H E E Y E S O F", cx, mid + 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.fillText(f.name.toUpperCase(), cx, mid + Math.round(H * 0.23));
ctx.restore();
}
@@ -835,6 +941,8 @@
glow?: boolean;
alpha?: number;
fallback?: string;
/** Rows at or below this are underground: not painted. */
clipY?: number;
clampL?: number;
clampR?: number;
hit?: { kind: "player" | "creature"; id: string };
@@ -854,14 +962,17 @@
clip?: { x: number; y: number };
hit?: { kind: "player" | "creature"; id: string };
cell?: { x: number; y: number };
webbed?: boolean; gaze?: boolean; dread?: boolean },
webbed?: boolean; gaze?: boolean; dread?: boolean; sink?: boolean },
ex: number, ey: number,
): Projected | null {
const relX = b.x - ex, relY = b.y - ey;
const depth = relX * Math.cos(facing) + relY * Math.sin(facing);
let depth = relX * Math.cos(facing) + relY * Math.sin(facing);
let side = -relX * Math.sin(facing) + relY * Math.cos(facing);
// A being sharing the eyes' own square has no depth to draw at:
// it stands right in front of them, whichever way they look.
if (b.hit && Math.hypot(relX, relY) < 0.35) { depth = 1.0; side = 0; }
if (depth < 0.15) return null;
const side = -relX * Math.sin(facing) + relY * Math.cos(facing);
const W = width, H = height, half = H / 2;
const W = width, H = height, half = H / 2 - pitch * H;
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
const wallH = H / depth;
const size = wallH * b.scale;
@@ -873,6 +984,8 @@
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
: size;
const bottom = half + wallH / 2 - b.rise * wallH;
// A sunken body paints only above the floor line at its depth.
const clipY = b.sink ? half + wallH / 2 : undefined;
// A cell-bound volume is windowed to its own square: the billboard's
// camera-facing plane is intersected with the cell, and only that
// lateral segment may paint, so an obliquely-viewed hedge cannot
@@ -908,7 +1021,7 @@
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
webbed: b.webbed, gaze: b.gaze, dread: b.dread,
hit: b.hit, cell: b.cell,
hit: b.hit, cell: b.cell, clipY,
left: screenX - wide / 2, right: screenX + wide / 2,
top: bottom - size, bottom,
};
@@ -937,7 +1050,7 @@
const f = hitFrame;
if (!f) return null;
const col = Math.max(0, Math.min(f.W - 1, px | 0));
const half = f.H / 2;
const half = f.horizon;
// Sprites first, nearest first, honoring the draw pass's own
// visibility rules for this column.