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}
-
+ 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}
+
+ {/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.