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>
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -147,6 +147,17 @@
|
||||
the edition currently in print</a>, and deal seven cards to
|
||||
somebody at a real table.
|
||||
</p>
|
||||
<h3>Behind the curtain</h3>
|
||||
<p>
|
||||
The workshops where this table's pieces are made are open to
|
||||
visitors:
|
||||
the <a href="/?tokens">token workshop</a> shows every token in both
|
||||
arts beside the wall textures and spell sprites;
|
||||
the <a href="/?fx">flourish workshop</a> plays each board effect on
|
||||
demand; and
|
||||
the <a href="/?fpv">first-person workshop</a> walks the maze through
|
||||
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
|
||||
</p>
|
||||
<h3>Send word</h3>
|
||||
<p>
|
||||
Feedback, bug reports, and faint praise all welcome — ravens fly to
|
||||
|
||||
@@ -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<FpFx[]>([]);
|
||||
$effect(() => {
|
||||
const st = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!fp || !st || !prefs.flourishes) return;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
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<MediaRecorder | null>(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 @@
|
||||
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||||
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
||||
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
||||
{#if fp}
|
||||
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
|
||||
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
|
||||
{/if}
|
||||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||
</header>
|
||||
<div class="replay-board">
|
||||
<div class="replay-board" bind:this={stageEl}>
|
||||
{#if fp}
|
||||
<FirstPerson view={step.view} povId={step.view.you}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} />
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} fx={fpFx} />
|
||||
{:else}
|
||||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||||
{/if}
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
<h2>The conjurations — first-person spell sprites</h2>
|
||||
<p class="sub">
|
||||
The moments the raycaster throws through the air: projectiles in
|
||||
flight, bursts on landing, shimmers and shields. Each lives at
|
||||
<code>public/fx3d/<name>.png</code> — square, transparent ground,
|
||||
drawn glowing mid-air at any size; a procedural glow stands in
|
||||
wherever a file is missing.
|
||||
</p>
|
||||
<div class="grid">
|
||||
{#each FX_ART as name (name)}
|
||||
<figure>
|
||||
<img class="masonry conjuration" src={`/fx3d/${name}.png`} alt={`${name} sprite`} />
|
||||
<figcaption>{name}</figcaption>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>Walls & doors — as the maze draws them</h2>
|
||||
<p class="sub">
|
||||
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; }
|
||||
</style>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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/<name>.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<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
|
||||
/** Board-effect kinds that land here as an impact billboard. */
|
||||
const IMPACT_ART: Record<string, string> = {
|
||||
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<string, [string, string]> = {
|
||||
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<string, HTMLCanvasElement>();
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/<name>.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.
|
||||