The board's furniture and the reel's fists, painted as cels

The last of the inked look. Walls carry stone joints and highlights,
doors their grain, hinges, and a brass lock plate whose keyhole turns
to a cross when jammed; battle scars fracture and branch with the
damage; illusion walls drift as tapered wisps with breathing glints;
the wall of fire burns in flat three-tone flames on a stepped flicker;
warp mouths and destinations are painted curls. Through a wizard's
eyes the punch is a three-tone hand with an ink contour and a
hand-cut POW, the same on every platform where the emoji glyph was
not, and the incoming fist no longer reverses late in its travel. The
inked stand-ins for missing conjuration files match. Goldens
re-blessed: every board frame, and the web's third ring.

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-15 12:42:02 -04:00
co-authored by Claude Fable 5.1
parent 8dfd0e0c29
commit 34b621b5cb
22 changed files with 305 additions and 189 deletions
+16 -94
View File
@@ -4,8 +4,9 @@
// billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote.
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx";
import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, slateBand, smoothstep, 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";
@@ -589,40 +590,10 @@
if (s.glow) ctx.globalCompositeOperation = "source-over";
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
// STICKY WAND's webs cling over the caught body: spokes from a hub
// and two sagging rings, each strand shadowed so the silk reads
// and three sagging rings, each strand shadowed so the silk reads
// against busy token art. Only when the walls show the sprite.
if (s.webbed && s.hit && seen) {
const w = s.right - s.left, h = s.bottom - s.top;
const hubX = s.left + w * 0.5, hubY = s.top + h * 0.45;
const rimU: [number, number][] = [
[0.02, 0.06], [0.5, 0.0], [0.98, 0.08], [1.0, 0.5],
[0.96, 0.94], [0.5, 1.0], [0.04, 0.92], [0.0, 0.5],
];
const rim: [number, number][] = rimU.map(([u, v]) => [s.left + u * w, s.top + v * h]);
const web = () => {
ctx.beginPath();
for (const [rx, ry] of rim) {
ctx.moveTo(hubX, hubY);
ctx.lineTo(rx, ry);
}
for (const t of [0.45, 0.75]) {
for (let i = 0; i <= rim.length; i++) {
const [rx, ry] = rim[i % rim.length]!;
const px = hubX + (rx - hubX) * t, py = hubY + (ry - hubY) * t + h * 0.02;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
}
ctx.stroke();
};
ctx.save();
ctx.strokeStyle = "rgba(25,25,30,0.45)";
ctx.lineWidth = 2.5;
web();
ctx.strokeStyle = "rgba(245,245,240,0.8)";
ctx.lineWidth = 1;
web();
ctx.restore();
paintWeb(ctx, s.left, s.top, s.right - s.left, s.bottom - s.top);
}
}
@@ -742,60 +713,19 @@
if (f.kind !== "fist") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
const ease = smoothstep;
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}";
const pose = punchPose(p, f.swing === "out", W, H);
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.globalAlpha = pose.alpha;
ctx.translate(pose.x, pose.y);
ctx.rotate(pose.rotation);
paintFist(ctx, pose.size, f.swing);
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();
}
if (pose.radius > H * 0.19 * 0.01) {
ctx.save();
ctx.translate(pose.impactX, pose.impactY);
ctx.rotate(-0.14);
paintPow(ctx, pose.radius);
ctx.restore();
}
}
@@ -846,15 +776,7 @@
* the stroke at `alpha`, the fill fainter. */
function floorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number,
rgb: string, alpha: number, fill: number) {
ctx.save();
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
ctx.fillStyle = `rgba(${rgb},${fill})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
paintFloorRing(ctx, cx, cy, rx, ry, rgb, alpha, fill);
}
/** Paint the hover cue for whatever stands under the crosshair. */