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:
co-authored by
Claude Fable 5
parent
fcafdb06de
commit
e8c6b93c71
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user