Stage three: spells fly through your own eyes

The reel's first-person mode now shows the fight, not just the rooms.
The board flourishes' event map is recast into world space (fx3d):
fireballs, bolts, and thrown things streak caster to target as glowing
billboards; landings swell and fade; blows that land on YOU wash the
screen red and jolt the camera; warp-steps and teleports flash violet;
a hurled body glides fast and straight with its eyes held steady. Nine
paintable conjuration sprites ship at public/fx3d (spec for the artist
in research/), shown in the token workshop beside the masonry.

Three rough edges sharpened along the way: sprites now project through
warp mouths under the same rigid motion the rays use, clipped by the
depth buffer to the opening; fire and warp surfaces pull a different
texture slice per world cell so a long blaze reads as one; and the
replay camera only turns toward an actor its eyes could actually see.

Two more gifts: the reel can record its first-person canvas straight
to a downloadable WebM (⏺ save video), and the About tab now shows
visitors the way into the three workshops.

One buried mine defused: rAF hands frame-start times that can precede
the tween's own clock, and a zero-length turn divided by it — one NaN
facing poisoned every ray black and re-triggered the camera effect
forever. The tween clamps and guards its phases now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 14:51:04 -04:00
co-authored by Claude Fable 5
parent fcafdb06de
commit e8c6b93c71
16 changed files with 412 additions and 37 deletions
+83 -17
View File
@@ -3,8 +3,9 @@
// GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote.
import { castRay, billboards, type Billboard } from "./raycast";
import { castRay, billboards } from "./raycast";
import { materialTextures } from "./textures";
import { fxFallback, type FpFx } from "./fx3d";
import { tokenArt } from "../art";
import type { GameView } from "@wizwar/engine";
@@ -16,6 +17,7 @@
facing,
width = 720,
height = 440,
fx = [],
}: {
view: GameView;
povId: string;
@@ -26,6 +28,8 @@
facing: number;
width?: number;
height?: number;
/** Live spell moments: projectiles, impacts, flashes, shakes. */
fx?: FpFx[];
} = $props();
const FOV = Math.PI / 2.9;
@@ -83,11 +87,21 @@
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
const W = width, H = height, half = H / 2;
// Active shakes wobble the eye a hair off true, fading as they run.
let ex = x, ey = y;
for (const f of fx) {
if (f.kind !== "shake") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
ex += f.mag * (1 - p) * Math.sin(time * 0.11);
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
}
// 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 fx = (W / 2) / Math.tan(FOV / 2);
const flen = (W / 2) / Math.tan(FOV / 2);
const cosF = Math.cos(facing), sinF = Math.sin(facing);
if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H);
const buf = frame.data;
@@ -111,10 +125,10 @@
const tp = tex.data;
const shade = Math.max(0, Math.min(1, 1.25 / (1 + d * 0.45)) * (1 - d / FOG));
// World position at column 0 and its per-column step, both at depth d.
const sideStep = d / fx;
const sideStep = d / flen;
const side0 = -(W / 2) * sideStep;
let wx = x + d * cosF - side0 * sinF;
let wy = y + d * sinF + side0 * cosF;
let wx = ex + d * cosF - side0 * sinF;
let wy = ey + d * sinF + side0 * cosF;
const stepX = -sideStep * sinF;
const stepY = sideStep * cosF;
for (let col = 0; col < W; col++) {
@@ -140,26 +154,31 @@
const zbuf = new Float64Array(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, x, y, rayAngle);
const hit = castRay(view, ex, ey, rayAngle);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth;
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2;
const tex = textures[hit.kind] ?? textures.wall!;
// Sample by the texture's own size: painted files may be any scale.
ctx.drawImage(tex, Math.min(tex.width - 1, hit.u * tex.width), 0,
// Fire and warps shift their slice per world cell, so a blaze
// spanning edges reads as one long fire, not a repeated flame.
const texU = hit.kind === "firewall" || hit.kind === "warp"
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
: hit.u;
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
// Distance and orientation carve the light; overlays animate it.
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
if (hit.kind === "firewall") {
const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.u * 17 + col * 0.15);
const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.worldU * 17 + col * 0.15);
ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`;
ctx.fillRect(col, top, 1, wallH);
dark *= 0.5; // the fire lights itself
}
if (hit.kind === "warp") {
const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.u * 9);
const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.worldU * 9);
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(col, top, 1, wallH);
}
@@ -177,24 +196,53 @@
// Sprites, far to near, sliced against the depth buffer.
const sprites = billboards(view, povId, tokenArt)
.map((b) => project(b))
.filter((s): s is Projected => s !== null)
.sort((a, b) => b.depth - a.depth);
.map((b) => project(b, ex, ey))
.filter((s): s is Projected => s !== null);
for (const f of fx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
const at = f.kind === "projectile"
? { x: f.from.x + (f.to.x - f.from.x) * p, y: f.from.y + (f.to.y - f.from.y) * p }
: f.at;
const s = project({
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
rise: 0.3,
}, ex, ey);
if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
}
sprites.sort((a, b) => b.depth - a.depth);
for (const s of sprites) {
const img = imageFor(s.src);
const img = imageFor(s.src) ?? (s.fallback ? fxFallback(s.fallback) : null);
const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0);
const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0);
if (s.glow) {
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = s.alpha ?? 1;
}
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue;
const texX = ((col - s.left) / (s.right - s.left));
if (img) {
ctx.drawImage(
img,
texX * img.naturalWidth, 0, Math.max(1, img.naturalWidth / (s.right - s.left)), img.naturalHeight,
texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih,
col, s.top, 1, s.bottom - s.top,
);
} else {
ctx.fillStyle = "rgba(200,190,160,0.6)";
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
}
if (s.warped) {
// A body seen through a warp swims in the same violet haze.
ctx.fillStyle = "rgba(120,70,220,0.2)";
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
}
}
if (s.glow) {
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
}
}
@@ -204,6 +252,17 @@
vig.addColorStop(1, "rgba(0,0,0,0.45)");
ctx.fillStyle = vig;
ctx.fillRect(0, 0, W, H);
// Being hit is felt: a whole-screen wash that fades as it goes.
for (const f of fx) {
if (f.kind !== "flash") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
ctx.globalAlpha = f.peak * (1 - p);
ctx.fillStyle = f.color;
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
}
interface Projected {
@@ -213,9 +272,16 @@
right: number;
top: number;
bottom: number;
warped?: boolean;
glow?: boolean;
alpha?: number;
fallback?: string;
}
function project(b: Billboard): Projected | null {
const relX = b.x - x, relY = b.y - y;
function project(
b: { x: number; y: number; src: string; scale: number; rise: number; warped?: 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);
if (depth < 0.15) return null;
const side = -relX * Math.sin(facing) + relY * Math.cos(facing);
@@ -225,7 +291,7 @@
const size = wallH * b.scale;
const bottom = half + wallH / 2 - b.rise * wallH;
return {
src: b.src, depth,
src: b.src, depth, warped: b.warped,
left: screenX - size / 2, right: screenX + size / 2,
top: bottom - size, bottom,
};