diff --git a/packages/web/public/fx3d/bolt.png b/packages/web/public/fx3d/bolt.png new file mode 100644 index 0000000..3995c3f Binary files /dev/null and b/packages/web/public/fx3d/bolt.png differ diff --git a/packages/web/public/fx3d/burst.png b/packages/web/public/fx3d/burst.png new file mode 100644 index 0000000..54c7105 Binary files /dev/null and b/packages/web/public/fx3d/burst.png differ diff --git a/packages/web/public/fx3d/fireball.png b/packages/web/public/fx3d/fireball.png new file mode 100644 index 0000000..2ea7baa Binary files /dev/null and b/packages/web/public/fx3d/fireball.png differ diff --git a/packages/web/public/fx3d/hit.png b/packages/web/public/fx3d/hit.png new file mode 100644 index 0000000..dc2da16 Binary files /dev/null and b/packages/web/public/fx3d/hit.png differ diff --git a/packages/web/public/fx3d/shield.png b/packages/web/public/fx3d/shield.png new file mode 100644 index 0000000..df6f7d9 Binary files /dev/null and b/packages/web/public/fx3d/shield.png differ diff --git a/packages/web/public/fx3d/shimmer.png b/packages/web/public/fx3d/shimmer.png new file mode 100644 index 0000000..0006988 Binary files /dev/null and b/packages/web/public/fx3d/shimmer.png differ diff --git a/packages/web/public/fx3d/spark.png b/packages/web/public/fx3d/spark.png new file mode 100644 index 0000000..674aa06 Binary files /dev/null and b/packages/web/public/fx3d/spark.png differ diff --git a/packages/web/public/fx3d/splash.png b/packages/web/public/fx3d/splash.png new file mode 100644 index 0000000..3197127 Binary files /dev/null and b/packages/web/public/fx3d/splash.png differ diff --git a/packages/web/public/fx3d/waterbolt.png b/packages/web/public/fx3d/waterbolt.png new file mode 100644 index 0000000..c3b8627 Binary files /dev/null and b/packages/web/public/fx3d/waterbolt.png differ diff --git a/packages/web/src/Help.svelte b/packages/web/src/Help.svelte index 9f08319..f28bd0a 100644 --- a/packages/web/src/Help.svelte +++ b/packages/web/src/Help.svelte @@ -147,6 +147,17 @@ the edition currently in print, and deal seven cards to somebody at a real table.

+

Behind the curtain

+

+ The workshops where this table's pieces are made are open to + visitors: + the token workshop shows every token in both + arts beside the wall textures and spell sprites; + the flourish workshop plays each board effect on + demand; and + the first-person workshop walks the maze through + a wizard's own eyes (add &demo=1 to watch a reel). +

Send word

Feedback, bug reports, and faint praise all welcome — ravens fly to diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index bf197ef..19b7ea0 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -3,6 +3,8 @@ import FirstPerson from "./fpv/FirstPerson.svelte"; import { humanize } from "./net.svelte"; import { scheduleFx, type BoardFx } from "./fx"; + import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; + import { castRay } from "./fpv/raycast"; import { prefs } from "./prefs.svelte"; import { stackSightTrace } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine"; @@ -43,6 +45,27 @@ return () => { cancel(); boardFx = []; }; }); + /** In first person the same events become projectiles, impacts, + * flashes, and shakes — scheduled on this step's beat. */ + let fpFx = $state([]); + $effect(() => { + const st = steps[Math.min(idx, steps.length - 1)]; + if (!fp || !st || !prefs.flourishes) return; + const timers: ReturnType[] = []; + const started: number[] = []; + for (const { fx, delay } of fpFxForEvents(st.events, st.view, st.view.you)) { + timers.push(setTimeout(() => { + started.push(fx.id); + fpFx = [...fpFx, { ...fx, t0: performance.now() }]; + timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80)); + }, delay / speed)); + } + return () => { + timers.forEach(clearTimeout); + started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id))); + }; + }); + $effect(() => { if (!playing) return; const t = setInterval(() => { @@ -76,14 +99,24 @@ const dist = Math.hypot(dx, dy); // Where should the eye end up pointing? Along its own stride; at the // actor, when someone else moved the world; wherever it was, otherwise. + // A blow that hurls the body glides fast and straight, however far, + // eyes still where they were; only unexplained leaps (teleports) cut. + const hurled = step.events.some((e) => + (e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || + e.type === "retreatedInHorror") && e.player === v.you); const actor = v.players.find((p) => p.id === step.actor); let targetFacing = cam.facing; - if (dist > 0.05) targetFacing = Math.atan2(dy, dx); + if (dist > 0.05 && !hurled) targetFacing = Math.atan2(dy, dx); else if (actor && actor.id !== v.you && (actor.position.x !== me.position.x || actor.position.y !== me.position.y)) { - targetFacing = Math.atan2(actor.position.y + 0.5 - ty, actor.position.x + 0.5 - tx); + // Turn toward the actor — but only if these eyes could actually see + // them: an unbroken straight sight line, no warps bending it. + 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) targetFacing = Math.atan2(ay - ty, ax - tx); } - if (!camReady || dist > 1.6) { + if (!camReady || (dist > 1.6 && !hurled)) { // First frame, or a leap the legs cannot explain: cut. cam.x = tx; cam.y = ty; cam.facing = targetFacing; camReady = true; @@ -91,15 +124,18 @@ } const fromX = cam.x, fromY = cam.y, fromF = cam.facing; const arc = shortestArc(fromF, targetFacing); - const turnMs = Math.min(260, Math.abs(arc) * 180) / speed; - const walkMs = (dist > 0.05 ? 420 : 0) / speed; + const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed; + const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed; const t0 = performance.now(); let raf = 0; const tick = (now: number) => { - const t = now - t0; - if (t < turnMs) { + // 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 (turnMs > 0 && t < turnMs) { cam.facing = fromF + arc * (t / turnMs); - } else if (t < turnMs + walkMs) { + } else if (walkMs > 0 && t < turnMs + walkMs) { cam.facing = fromF + arc; const w = (t - turnMs) / walkMs; const ease = w * w * (3 - 2 * w); @@ -116,6 +152,36 @@ return () => cancelAnimationFrame(raf); }); + // --- Save a video: record the first-person canvas as it plays. -------- + let stageEl: HTMLDivElement | undefined = $state(); + let recorder = $state(null); + function toggleRecord() { + if (recorder) { recorder.stop(); return; } + const cv = stageEl?.querySelector("canvas"); + if (!cv) return; + const mime = MediaRecorder.isTypeSupported("video/webm;codecs=vp9") + ? "video/webm;codecs=vp9" : "video/webm"; + const rec = new MediaRecorder(cv.captureStream(60), { mimeType: mime }); + const chunks: Blob[] = []; + rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); }; + rec.onstop = () => { + const url = URL.createObjectURL(new Blob(chunks, { type: "video/webm" })); + const a = document.createElement("a"); + a.href = url; + a.download = `wizwar-moves-${steps[0]?.seq ?? 0}-${steps[steps.length - 1]?.seq ?? 0}.webm`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 2000); + recorder = null; + }; + rec.start(); + recorder = rec; + playing = true; + } + // The reel running out ends the recording and hands over the file. + $effect(() => { + if (recorder && !playing && atEnd) recorder.stop(); + }); + function onkeydown(e: KeyboardEvent) { if (e.key === "Escape") onclose(); if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); } @@ -133,12 +199,16 @@ move {idx + 1} of {steps.length} + {#if fp} + + {/if} -

+
{#if fp} + x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} fx={fpFx} /> {:else} {/if} diff --git a/packages/web/src/TokenGallery.svelte b/packages/web/src/TokenGallery.svelte index e2d36c7..5d7f45c 100644 --- a/packages/web/src/TokenGallery.svelte +++ b/packages/web/src/TokenGallery.svelte @@ -7,6 +7,7 @@ import EdgeGlyph, { type LockState } from "./EdgeGlyph.svelte"; import FirewallEdge from "./FirewallEdge.svelte"; import IllusionShimmer from "./IllusionShimmer.svelte"; + import { FX_ART } from "./fpv/fx3d"; const GROUPS: { title: string; files: string[] }[] = [ { title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] }, @@ -102,6 +103,23 @@ {/each}
+

The conjurations — first-person spell sprites

+

+ The moments the raycaster throws through the air: projectiles in + flight, bursts on landing, shimmers and shields. Each lives at + public/fx3d/<name>.png — square, transparent ground, + drawn glowing mid-air at any size; a procedural glow stands in + wherever a file is missing. +

+
+ {#each FX_ART as name (name)} +
+ {`${name} +
{name}
+
+ {/each} +
+

Walls & doors — as the maze draws them

Wall-segment effects have never been tokens: locks picked, jammed, and @@ -195,4 +213,6 @@ border: 1px solid #3a3428; background: #101318; } + /* Sprites fly against darkness; show them on it. */ + .conjuration { background: #14101c; } diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 6f425d7..c24454e 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -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, }; diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts new file mode 100644 index 0000000..48a0dfe --- /dev/null +++ b/packages/web/src/fpv/fx3d.ts @@ -0,0 +1,120 @@ +// First-person spell moments. The board flourishes (fx.ts) already know +// which event hurls what across which cells; this recasts their output +// into world-space projectiles, impact bursts, whole-screen flashes, and +// camera shakes for the raycaster to draw. What happens ACROSS the room +// is watched; what happens to YOU is felt. + +import { fxForEvents } from "../fx"; +import { CELL } from "../fx-sprites/geom"; +import type { GameEvent, GameView } from "@wizwar/engine"; + +export type FpFx = + | { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number } + | { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number } + | { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number } + | { id: number; kind: "shake"; mag: number; t0: number; dur: number }; + +/** The paintable conjuration sprites: public/fx3d/.png, square with + * transparent ground, drawn as a billboard mid-air. A procedural glow + * stands in wherever a file is missing. */ +export const FX_ART = [ + "fireball", "bolt", "waterbolt", "spark", + "burst", "splash", "hit", "shimmer", "shield", +] as const; + +let nextId = 1; + +const PROJECTILE_DUR: Record = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 }; +/** Board-effect kinds that land here as an impact billboard. */ +const IMPACT_ART: Record = { + burst: "burst", splash: "splash", fireworks: "burst", + hit: "hit", pow: "hit", claw: "hit", + shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer", + soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer", + shield: "shield", absorb: "shield", +}; + +/** Translate one step's events into first-person moments with start delays. */ +export function fpFxForEvents( + events: GameEvent[], view: GameView, povId: string, +): { fx: FpFx; delay: number }[] { + const out: { fx: FpFx; delay: number }[] = []; + for (const { fx, delay } of fxForEvents(events, view)) { + if (fx.kind === "fireball" || fx.kind === "bolt" || fx.kind === "waterbolt" || fx.kind === "streak") { + const art = fx.kind === "streak" ? "spark" : fx.kind; + out.push({ + fx: { + id: nextId++, kind: "projectile", art, + from: { x: fx.a.x / CELL, y: fx.a.y / CELL }, + to: { x: fx.b.x / CELL, y: fx.b.y / CELL }, + t0: 0, dur: PROJECTILE_DUR[art]!, + }, + delay, + }); + } else if (fx.kind === "portal") { + out.push({ + fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 }, + delay, + }); + } else if ("at" in fx && IMPACT_ART[fx.kind]) { + out.push({ + fx: { id: nextId++, kind: "impact", art: IMPACT_ART[fx.kind]!, at: { x: fx.at.x + 0.5, y: fx.at.y + 0.5 }, t0: 0, dur: 550 }, + delay, + }); + } + } + // Blows that land on the point of view: give impacts a beat to arrive + // when something visibly flew first. + const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0; + for (const e of events) { + if (e.type === "damaged" && e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#c03020", peak: 0.4, t0: 0, dur: 450 }, delay: povDelay }); + out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay }); + } else if (e.type === "lifeGained" && e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay }); + } else if (e.type === "teleported" && e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 }); + } else if (e.type === "moved" && e.via === "warp" && e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.28, t0: 0, dur: 350 }, delay: 0 }); + } else if ( + (e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || e.type === "retreatedInHorror") && + e.player === povId + ) { + out.push({ fx: { id: nextId++, kind: "shake", mag: 0.05, t0: 0, dur: 350 }, delay: povDelay }); + } + } + return out; +} + +/** Procedural stand-ins for the conjuration sprites: a soft glow in each + * art's palette, replaced on screen the moment a painted file loads. */ +const FX_COLORS: Record = { + fireball: ["#ffe27a", "#e0431a"], + bolt: ["#ffffff", "#7ab0ff"], + waterbolt: ["#cfefff", "#1c6ed0"], + spark: ["#fff6d8", "#c0a040"], + burst: ["#fff0a0", "#d05010"], + splash: ["#e8f8ff", "#2080c0"], + hit: ["#ffd0c0", "#c02020"], + shimmer: ["#f0e0ff", "#7040c0"], + shield: ["#ffffff", "#4060d0"], +}; +const baked = new Map(); +export function fxFallback(name: string): HTMLCanvasElement { + let t = baked.get(name); + if (!t) { + t = document.createElement("canvas"); + t.width = 64; + t.height = 64; + const c = t.getContext("2d")!; + const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!; + const g = c.createRadialGradient(32, 32, 2, 32, 32, 30); + g.addColorStop(0, core); + g.addColorStop(0.55, rim); + g.addColorStop(1, "rgba(0,0,0,0)"); + c.fillStyle = g; + c.fillRect(0, 0, 64, 64); + baked.set(name, t); + } + return t; +} diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 6db2a25..81b8701 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -18,6 +18,9 @@ export interface Hit { u: number; /** Vertical faces get a different shade than horizontal ones. */ axis: "x" | "y"; + /** The un-wrapped along-face coordinate: u plus the world cell it lies + * in, so multi-cell surfaces (a long wall of fire) can read as one. */ + worldU: number; /** The eye reached this through a warp: haze it other-worldly. */ warped?: boolean; } @@ -104,7 +107,7 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"] }; const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx; const u = along - Math.floor(along); - return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, warped }; + return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, worldU: along, warped }; } // No slab in this cell: advance through the open boundary. @@ -114,7 +117,8 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): if (crossingX) { nx = cx + stepX; sideDistX += dDistX; } else { ny = cy + stepY; sideDistY += dDistY; } const axis: Hit["axis"] = crossingX ? "x" : "y"; - const u0 = crossingX ? (oy + dist * dy) % 1 : (ox + dist * dx) % 1; + const along = crossingX ? oy + dist * dy : ox + dist * dx; + const u0 = along % 1; const texU = u0 < 0 ? u0 + 1 : u0; const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })]; @@ -123,31 +127,31 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): const warp = view.board.warps.find( (w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side, ); - if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, warped }; + if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped }; // Step through: re-enter at the paired mouth, heading inward, the // offset along the edge preserved (a wraparound keeps its lane). const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]]; angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]); const c = warp.to.cell; - const along = texU; - if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + along; } - else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + along; } - else if (warp.to.side === "S") { ox = c.x + along; oy = c.y + 1 - 1e-4; } - else { ox = c.x + along; oy = c.y + 1e-4; } + const lane = texU; + if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; } + else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; } + else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; } + else { ox = c.x + lane; oy = c.y + 1e-4; } baseDist += dist; warped = true; i = 64; // restart the DDA from the far mouth break; } if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { - return { dist: baseDist + dist, kind: "stone", u: texU, axis, warped }; + return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped }; } cx = nx; cy = ny; } if (!warped || traversal === 2) break; } - return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", warped }; + return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped }; } /** Can a wizard's body (not just their eye) cross this edge? Workshop @@ -178,6 +182,18 @@ export interface Billboard { /** Lifted off the floor (0 = feet on the ground). */ rise: number; label: string; + /** A reflection seen through a warp mouth, not the thing itself. */ + warped?: boolean; +} + +/** The along=0 corner of a warp mouth — the anchor castRay's lane + * preservation measures from. */ +function mouthAnchor(m: { cell: Cell; side: Side }): { x: number; y: number } { + const c = m.cell; + if (m.side === "E") return { x: c.x + 1, y: c.y }; + if (m.side === "W") return { x: c.x, y: c.y }; + if (m.side === "S") return { x: c.x, y: c.y + 1 }; + return { x: c.x, y: c.y }; } /** Everything standing in the maze that the reel should draw as a sprite. */ @@ -228,5 +244,28 @@ export function billboards( const [tx, ty] = k.split(",").map(Number) as [number, number]; out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "objects"), scale: 0.25, rise: 0, label: cards[0]!.cardId }); } + // Rays see through warp mouths; the standing world should follow. Each + // sprite near a far mouth also appears in the near mouth's frame, under + // the same rigid motion the rays use — the zbuffer clips it to the + // opening, since everything around the mouth is nearer wall. + const real = out.slice(); + for (const w of view.board.warps) { + const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side]; + const a1 = mouthAnchor(w.from); + const a2 = mouthAnchor(w.to); + const cosD = Math.cos(-delta), sinD = Math.sin(-delta); + const inward = SIDE_ANGLE[OPPOSITE[w.to.side]]; + const inX = Math.cos(inward), inY = Math.sin(inward); + for (const b of real) { + const rx = b.x - a2.x, ry = b.y - a2.y; + if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue; + out.push({ + ...b, + x: a1.x + rx * cosD - ry * sinD, + y: a1.y + rx * sinD + ry * cosD, + warped: true, + }); + } + } return out; } diff --git a/research/fx3d-sprite-spec.md b/research/fx3d-sprite-spec.md new file mode 100644 index 0000000..89247f4 --- /dev/null +++ b/research/fx3d-sprite-spec.md @@ -0,0 +1,49 @@ +# The conjuration sprites — a spec for the artist + +The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws +spell moments through the air as **billboard sprites**: flat images that +always face the camera, drawn glowing in mid-air, occluded by walls, and +scaled by distance. Nine sprites cover every spell moment in the game. +Each lives at `packages/web/public/textures/../fx3d/.png` — replace +the file, refresh, done. The current files are placeholder radial glows; +all nine are shown side by side at `/?tokens` under "The conjurations." + +## Technical requirements + +- **Format:** PNG with a transparent background. The transparent area is + essential — whatever is opaque is what flies. +- **Size:** square, any resolution; 128×128 or 256×256 recommended. The + renderer scales freely and pixelates on magnification (the whole view + is deliberately chunky, like the wall textures). +- **Drawn additively:** sprites are composited in "lighter" (additive) + mode over a dark dungeon, so dark pixels vanish and bright pixels glow. + Paint light-on-transparent; pure black will be invisible. Midtones read + as translucent light. +- **No animation frames.** Each sprite is a single still. Motion comes + from the renderer: projectiles streak across the room, impacts swell + from small to large while fading out. Radially symmetric (or nearly so) + designs work best since the image does not rotate. +- **Fills most of the canvas.** Leave only a small transparent margin; + the renderer sizes the sprite by its canvas, not its painted extent. + +## The nine sprites and where they appear + +| file | flies as | moment | +| --- | --- | --- | +| `fireball.png` | projectile | Fireball, Sudden Death, Blaster Wand — a ball of fire streaking caster → target | +| `bolt.png` | projectile | Lightning Blast, Power Drain — a crackling bolt | +| `waterbolt.png` | projectile | Waterbolt — a hurled gout of water | +| `spark.png` | projectile | anything else that travels: thrown objects, bodies knocked back, dragged things — a neutral streak of force | +| `burst.png` | impact | a fireball landing; victory fireworks — swells and fades | +| `splash.png` | impact | waterbolt landing, stone turned to water | +| `hit.png` | impact | damage landing on someone you can see; punches, claws | +| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition | +| `shield.png` | impact | an attack fully stopped; absorbed spells | + +Projectiles hold their size in flight (about a third of a wizard's +height). Impacts start small and swell to roughly double while fading — +so an impact painted as a ring or burst reads especially well. + +What the sprites do NOT need to carry: screen-wide flashes when YOU are +hit (red), healed (green), or teleported (violet), and the camera shake — +those are renderer effects, not images.