From cdfae39398d0250e8d173dc88d40b6efe2a1cfec Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 16:30:35 -0400 Subject: [PATCH] The maze performs: doors swing, walls die loudly, homes wear their colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entertainment polish for the instant replay, all through one wizard's eyes. Doors now slide open along their own edge — Wolf3D style — when anyone steps through in a reel, hold for the crossing, and swing shut; the raycaster takes a per-edge openness map and doors carry their texture with them, leaving a sliver of jamb at the far post. A wall that dies bursts into stone and shakes the camera, then leaves a mound of rubble at the fallen edge for the rest of the reel (a tenth paintable sprite, drawn opaque — debris, not light). Jammed locks seethe: a rusty pulse across the wood, keyed off the view's own door states. Walking THROUGH a wall shimmers on both faces and washes the walker's own screen stone-gray. And every home base wears its owner's emblem on the flagstones — a near-white paintable overlay tinted by wizard color per cell in the floor cast. Testing surfaced a real ghost: a body standing near a far warp mouth also rendered at its virtual position, which can overlap real corridors — the depth buffer alone cannot clip it. Every column now remembers whether its ray bent, and a sprite draws only where its warp side matches the column's; projectile legs carry the same flag. Co-Authored-By: Claude Fable 5 --- packages/web/public/fx3d/rubble.png | Bin 0 -> 1556 bytes packages/web/public/textures/home.png | Bin 0 -> 1055 bytes packages/web/src/Replay.svelte | 22 +++++- packages/web/src/TokenGallery.svelte | 2 +- packages/web/src/fpv/FirstPerson.svelte | 89 ++++++++++++++++++++++-- packages/web/src/fpv/fx3d.ts | 78 +++++++++++++++++---- packages/web/src/fpv/raycast.ts | 47 +++++++++---- packages/web/src/fpv/textures.ts | 22 +++++- research/fx3d-sprite-spec.md | 15 +++- 9 files changed, 238 insertions(+), 37 deletions(-) create mode 100644 packages/web/public/fx3d/rubble.png create mode 100644 packages/web/public/textures/home.png diff --git a/packages/web/public/fx3d/rubble.png b/packages/web/public/fx3d/rubble.png new file mode 100644 index 0000000000000000000000000000000000000000..fd435e16cec3b78bb8516c5dee29724b158c91c6 GIT binary patch literal 1556 zcmV+v2J88WP)oNoc!Z2@(~ zI|Qg1v77ExfZbMHov%{?YDVm)I~8EJ6<6o$RDhZhyXj5^*loqt`8pM#X2fo~Qvv_? z6*tQ7zN>)e&Ul}Hw%2=kpw}Bf;SmXtjeJH4CF;L#2%zLJ*#PJ81p_#ZiIky4TN&BN zM;O9J9jV?;5iro>eH|zEp=?FON48WakH!!5bbmz2p}sR|RE4dKuy0;?ac=D5P;hKy zh$|BAUl|+yjveq2I$o#r?rIeFM zK04^ln}5RGTp+5(-#L5h>ye?U??yhWi+99pQUV6B>=CM$Ji3+E9Kk9|A{Q*>cM`#i zu(Pwji_D4*%e7?#wtmnfK}rCYc)v1=nF|A5ED=Q81!emT>Gh7{&}Q~L!sKmjZB1SU zN*FI$hs|N7E4auB0B8d9e?lys516$03;{T6et3Q4!opv=0ICGpnV?62?x1Fzk@LaK zHd2Pm$jE@g!o5&mUkB~&k3vJk0m#nIQha)sm6f}C?ywKf1ktyH9s$?Gk*2O1ER{V* znob_E5DJB0aq$k!%-n!$*M5O}_x^?>M;?Q!stR&Tg|>mzxx*3S>zb`9CxFAn9kKdd z5!T>b&>t}Dy>)9ICMJG_f`U9l62QzK=Ur-;lz`#O;~yY+8pWthN3d#~@LW)X%;-~7 ze%Q4uTe=<6($c`^`_*_T4w{OT5-`YK#ACH-(C>H zi;Ih3-@Xc{tJ@EL|Ev%rGwKo$4WbgKWKmoZX(3zGFT9J+1tBmsV^&rcR8~H$4C3)T z2&JV(u)MsY=$Z=$4TGi#&XKZ3y3HK}`fHU@ZcnW~5 ztW3zs*%eil1MASC1JK!d6g*hA<>h5?_;4E#8Tt8pVmQ4HCEwK4pa;?TGMHQQ)ovL9 zjDzy&Xl1**pM>MbJD|7sgd&u9O-;4t0}>e>9Y+nT!R=0mM3cuy?)SWBU$~>5)G`8a zqK9ZTX=SmWP@@=%l?AP@Z^&FiJAQ5XPMv%CZIRPT0-&+6UNomETPm^f6R@Bg(gxyf z+nQwrl$I9XrIOyBDzP5L()JNWWoAAgGK*{*>EP;oy6>eMA}84_BcQUX{2!Vi6)7t# z5r*gH<_hfsW4d1Qgtv?UPeDN@O^}LsBwuHRg`QM~jf=I60C$EvFK(tJlWTgY*yM1DK13=qKOc;R(|bar zS7R}7xHH+)_lm&_0szYhINf*t8~hxTC`q-YrDX^P!*6?O=Of$z0000Aab2Y^uwWBhnBeNV7vkF(H*tNo(_z#v93jPb1)*6eXc&UQbQG3EK(4-Nn$7{V9^_lYNmTxzJ@m+yKW z6aWS=f+38x6-zEP)Y7AEolRaa00_qbMld85M-8>~pqI41ZGI2{2u3&tFw#~`HIG_) z(2Jhcb06=$13)N(5sm>@k@TP!J?ZV*uUhXF00I$;V1!qP*}kV2J?YI1+dg%5-T)vB zfe1x#-7uFudeWO2%yQ|a*LnefAcP?hq56R8y12M_u>5oW`f@RU^LKInj<_t>>~U7j z*Wc5d8O&Oize(z6wrghq2tg3S5ZEMS_50`Zb$0bOO|n;+N=Hg%WqMYAO-Jl;MozhD z{A32RlJYl6W*ZY*9;7YvYSR#a5CkEtHtA*)V@axJs%VYyxn3JGZPOG ztN}>ObG4H+FU{Y2Xk3s>O;eID2vz_j<}tBKuU@FeCr_3hnYymg_wKHJKoA3vnn!k# zBu{yjMB5ry2qFMB<}sbAqWpo!s(namnjq|(C<3sr*?l}00k9>Vt7I*OBdM{ynP?IL zpuh$~QWU?al|VZ1r|u2fEG%gEJrO>?@ddAeHxpilxKIXU@PY_c=z z+61}OH0Am*Pk(Cw6iXnCpFI0fY*Y!M=_cfg9CGPz3SZ{w?*xDbQV65`=+maizvgOF z{$K<~UVd|I*6eW>(>(f}0nkJOfvbJ|`1H%8$@KHX$>~ReHG7=V&lEnN{C0*H05p?8 zAYU3G%%i~@0Gfq@5XwB7y#k=2ZwMjHqt`nCn)-qe+B{l=0HCoegc#=084Li;Z6U-m zkH(+?7$AiZ(>(fu17Jv72tLd+PZCi8AZQ { + const spots: { x: number; y: number }[] = []; + const seen = new Set(); + for (let i = 0; i <= Math.min(idx, steps.length - 1); i++) { + for (const e of steps[i]!.events) { + if (e.type !== "wallDestroyed" || !("edge" in e)) continue; + const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge; + const key = `${edge.cell.x},${edge.cell.y}:${edge.side}`; + if (seen.has(key)) continue; + seen.add(key); + spots.push(edgeMid(edge.cell, edge.side)); + } + } + return spots; + }); + /** Each step's spells flare on the reel exactly as they did at the table. */ let boardFx = $state([]); $effect(() => { @@ -320,7 +338,7 @@ {#if fp} + fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} /> {:else} {/if} diff --git a/packages/web/src/TokenGallery.svelte b/packages/web/src/TokenGallery.svelte index 5d7f45c..8b42fc5 100644 --- a/packages/web/src/TokenGallery.svelte +++ b/packages/web/src/TokenGallery.svelte @@ -95,7 +95,7 @@ wherever a file is missing.

- {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as name (name)} + {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as name (name)}
{`${name}
{name}
diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 933ef36..e3a1fb3 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -5,8 +5,9 @@ // by the same depth buffer the walls wrote. import { castRay, billboards } from "./raycast"; import { materialTextures } from "./textures"; - import { fxFallback, type FpFx } from "./fx3d"; + import { doorOpenness, fxFallback, type FpFx } from "./fx3d"; import { tokenArt } from "../art"; + import { PLAYER_COLORS } from "../colors"; import type { GameView } from "@wizwar/engine"; let { @@ -19,6 +20,7 @@ height = 440, fx = [], posOverride, + rubble = [], }: { view: GameView; povId: string; @@ -33,6 +35,8 @@ fx?: FpFx[]; /** Bodies mid-glide: world positions that override the view's cells. */ posOverride?: Record; + /** Where walls have died: a mound of stone marks each fallen edge. */ + rubble?: { x: number; y: number }[]; } = $props(); const FOV = Math.PI / 2.9; @@ -84,6 +88,33 @@ } let frame: ImageData | null = null; + // Home bases wear their owner's emblem on the floor: a per-view grid of + // owner tints, indexed by integer cell for the per-pixel pass. + const homeCache = new WeakMap(); + function homesOf(v: GameView) { + let h = homeCache.get(v); + if (!h) { + const bw = v.board.width, bh = v.board.height; + const grid = new Int16Array(bw * bh).fill(-1); + const tints: [number, number, number][] = []; + for (const p of v.players) { + if (!p.home) continue; + const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888"; + tints.push([ + parseInt(hex.slice(1, 3), 16) / 255, + parseInt(hex.slice(3, 5), 16) / 255, + parseInt(hex.slice(5, 7), 16) / 255, + ]); + if (p.home.x >= 0 && p.home.y >= 0 && p.home.x < bw && p.home.y < bh) { + grid[p.home.y * bw + p.home.x] = tints.length - 1; + } + } + h = { grid, bw, bh, tints }; + homeCache.set(v, h); + } + return h; + } + function draw(time: number) { const ctx = canvas?.getContext("2d"); if (!ctx) return; @@ -100,6 +131,14 @@ ey += f.mag * (1 - p) * Math.cos(time * 0.087); } + // Doors mid-swing this frame, from the animated fx. + let doors: Record | undefined; + for (const f of fx) { + if (f.kind !== "door") continue; + const a = doorOpenness((time - f.t0) / f.dur); + if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a); + } + // 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 @@ -123,6 +162,8 @@ continue; } const d = (H / 2) / dz; + const homes = below ? homesOf(view) : null; + const hp = below ? pixelsOf(textures.home!) : null; const tex = below ? fl : ce; const tw = tex.width, th = tex.height; const tp = tex.data; @@ -142,9 +183,28 @@ let u = wx % 1; if (u < 0) u += 1; let v = wy % 1; if (v < 0) v += 1; const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4; - buf[o] = tp[ti]! * shade; - buf[o + 1] = tp[ti + 1]! * shade; - buf[o + 2] = tp[ti + 2]! * shade; + let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!; + if (homes) { + const hx = (wx - u) | 0, hy = (wy - v) | 0; + if (hx >= 0 && hy >= 0 && hx < homes.bw && hy < homes.bh) { + const owner = homes.grid[hy * homes.bw + hx]!; + if (owner >= 0 && hp) { + // The emblem sits over the flagstones, wearing its + // owner's color. + const hi = (((v * hp.height) | 0) * hp.width + ((u * hp.width) | 0)) * 4; + const ha = hp.data[hi + 3]! / 255; + if (ha > 0) { + const [tr, tg, tb] = homes.tints[owner]!; + r = r * (1 - ha) + hp.data[hi]! * tr * ha; + g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha; + b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha; + } + } + } + } + buf[o] = r * shade; + buf[o + 1] = g * shade; + buf[o + 2] = b * shade; } buf[o + 3] = 255; wx += stepX; @@ -153,13 +213,18 @@ } ctx.putImageData(frame, 0, 0); - // Walls, one ray per column; remember each column's depth for sprites. + // Walls, one ray per column; remember each column's depth for + // sprites, and whether its ray bent through a warp — a sprite's warp + // side must MATCH its column's, or bodies near a far mouth would + // ghost into real corridors the unrolled space happens to overlap. const zbuf = new Float64Array(W); + const warpedCol = new Uint8Array(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, ex, ey, rayAngle); + const hit = castRay(view, ex, ey, rayAngle, doors); const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye zbuf[col] = depth; + warpedCol[col] = hit.warped ? 1 : 0; 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!; @@ -185,6 +250,12 @@ ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; ctx.fillRect(col, top, 1, wallH); } + if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") { + // A jammed lock seethes: a rusty seal pulsing across the wood. + const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22); + ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`; + ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3); + } if (dark > 0.02) { ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`; ctx.fillRect(col, top, 1, wallH); @@ -201,6 +272,10 @@ const sprites = billboards(view, povId, tokenArt, posOverride) .map((b) => project(b, ex, ey)) .filter((s): s is Projected => s !== null); + for (const spot of rubble) { + const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.4, rise: 0 }, ex, ey); + if (s) sprites.push({ ...s, fallback: "rubble" }); + } for (const f of fx) { if (f.kind !== "projectile" && f.kind !== "impact") continue; const p = (time - f.t0) / f.dur; @@ -212,6 +287,7 @@ 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, + warped: f.kind === "projectile" ? f.warped : undefined, }, ex, ey); if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); } @@ -225,6 +301,7 @@ if (s.glow) 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; + if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue; const texX = ((col - s.left) / (s.right - s.left)); if (img) { ctx.drawImage( diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts index 04d7201..c536226 100644 --- a/packages/web/src/fpv/fx3d.ts +++ b/packages/web/src/fpv/fx3d.ts @@ -6,21 +6,35 @@ import { fxForEvents } from "../fx"; import { CELL } from "../fx-sprites/geom"; -import { castRay, warpMotion } from "./raycast"; +import { castRay, edgeMid, warpMotion } from "./raycast"; +import { edgeKey } from "@wizwar/engine"; 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: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number; + /** This leg flies in a warp mouth's virtual space: visible only + * through the opening, on columns whose rays bent. */ + warped?: boolean } | { 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 }; + | { id: number; kind: "shake"; mag: number; t0: number; dur: number } + | { id: number; kind: "door"; edge: string; t0: number; dur: number }; + +/** How far open an animated door stands at progress p: swings open, + * holds for the crossing, swings shut. */ +export function doorOpenness(p: number): number { + if (p < 0 || p >= 1) return 0; + if (p < 0.3) return p / 0.3; + if (p < 0.7) return 1; + return (1 - p) / 0.3; +} /** 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", + "burst", "splash", "hit", "shimmer", "shield", "rubble", ] as const; let nextId = 1; @@ -42,7 +56,7 @@ const IMPACT_ART: Record = { * clipped by the depth buffer to the side the camera stands on. */ function projectileLegs( view: GameView, a: { x: number; y: number }, b: { x: number; y: number }, -): { from: { x: number; y: number }; to: { x: number; y: number } }[] { +): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean }[] { const len = Math.hypot(b.x - a.x, b.y - a.y); if (len < 0.05) return [{ from: a, to: b }]; const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x)); @@ -54,7 +68,7 @@ function projectileLegs( const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x)); if (hit.warped && hit.dist >= vlen - 0.4) { return [ - { from: a, to: vt }, + { from: a, to: vt, warped: true }, { from: motion.toReal(a), to: b }, ]; } @@ -76,7 +90,7 @@ export function fpFxForEvents( out.push({ fx: { id: nextId++, kind: "projectile", art, - from: leg.from, to: leg.to, + from: leg.from, to: leg.to, warped: leg.warped, t0: 0, dur: PROJECTILE_DUR[art]!, }, delay, @@ -94,6 +108,30 @@ export function fpFxForEvents( }); } } + // The maze itself performs: doors swing for whoever steps through, + // walls die into rubble, and walking THROUGH stone shimmers. + for (const e of events) { + if ((e.type === "moved" || e.type === "creatureMoved") && "direction" in e) { + const via = e.type === "moved" ? e.via : "step"; + const key = edgeKey(e.from, e.direction); + if (via === "step" && view.board.edges[key] === "door") { + out.push({ fx: { id: nextId++, kind: "door", edge: key, t0: 0, dur: 1100 }, delay: 0 }); + } + if (e.type === "moved" && e.via === "passWall") { + out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.from.x + 0.5, y: e.from.y + 0.5 }, t0: 0, dur: 500 }, delay: 0 }); + out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.to.x + 0.5, y: e.to.y + 0.5 }, t0: 0, dur: 500 }, delay: 200 }); + if (e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#9a8fb0", peak: 0.35, t0: 0, dur: 450 }, delay: 0 }); + } + } + } + if (e.type === "wallDestroyed" && "edge" in e) { + const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell, + (e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side); + out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 700 }, delay: 0 }); + out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 }); + } + } // 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; @@ -129,6 +167,7 @@ const FX_COLORS: Record = { hit: ["#ffd0c0", "#c02020"], shimmer: ["#f0e0ff", "#7040c0"], shield: ["#ffffff", "#4060d0"], + rubble: ["#b0a898", "#5c544a"], }; const baked = new Map(); export function fxFallback(name: string): HTMLCanvasElement { @@ -139,12 +178,25 @@ export function fxFallback(name: string): HTMLCanvasElement { 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); + if (name === "rubble") { + // A mound of broken stone, opaque — this one is debris, not light. + for (let i = 0; i < 9; i++) { + const jx = 12 + ((i * 37) % 40); + const jy = 40 + ((i * 23) % 18); + const r = 6 + ((i * 13) % 8); + c.fillStyle = i % 2 ? core : rim; + c.beginPath(); + c.arc(jx, jy, r, 0, Math.PI * 2); + c.fill(); + } + } else { + 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 6164371..09c9048 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -21,6 +21,8 @@ export interface Hit { /** 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 edge struck, for state the renderer dresses (jammed locks). */ + edge?: string; /** The eye reached this through a warp: haze it other-worldly. */ warped?: boolean; } @@ -28,15 +30,13 @@ export interface Hit { export const SIDE_ANGLE: Record = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 }; export const OPPOSITE: Record = { E: "W", W: "E", N: "S", S: "N" }; -/** Is this edge passable to the EYE (rays), and if not, what is it? */ +/** Is this edge passable to the EYE (rays), and if not, what is it? + * Doors always report as doors; how far each stands open — fully for an + * open or held door, mid-swing during an animation — is castRay's call. */ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null { const e = view.board.edges[key] ?? "open"; if (e === "open") return null; - if (e === "door") { - // An open or held door is a doorway; the eye passes through the gap. - if (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key)) return null; - return "door"; - } + if (e === "door") return "door"; if (e === "firewall") return "firewall"; return "wall"; } @@ -69,7 +69,13 @@ function slabHit( return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" }; } -export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit { +export function castRay( + view: GameView, ox: number, oy: number, angle: number, + /** Transient door openness (0 shut - 1 wide), keyed by edge; doors the + * view holds open stand at 1 without an entry. Wolf3D-style: an open + * door has slid along its own edge, the gap growing from u=0. */ + doors?: Record, +): Hit { // Sight runs THROUGH warps as the rules say it does: a ray that reaches // a mouth re-enters at the paired mouth and marches on, its hits hazed. let baseDist = 0; @@ -90,12 +96,25 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): // Every blocked edge of the current cell stands as a slab; the // nearest strike inside this cell's span wins. const exit = Math.min(sideDistX, sideDistY); - let best: { t: number; axis: "x" | "y"; kind: Hit["kind"] } | null = null; + let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string } | null = null; const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => { - const kind = edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)); + const key = edgeKey({ x: cx, y: cy }, side); + const kind = edgeObstacle(view, key); if (!kind) return; const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY); - if (h && h.t <= exit + 0.15 && (!best || h.t < best.t)) best = { ...h, kind }; + if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return; + let slide = 0; + if (kind === "door") { + const open = doors?.[key] ?? + (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0); + if (open > 0) { + const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx; + const u = along - Math.floor(along); + if (u < open - 0.02) return; // the ray sails through the opening + slide = open; + } + } + best = { ...h, kind, slide, edge: key }; }; const hw = (side: Side) => HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07; @@ -104,10 +123,14 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S")); trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N")); if (best !== null) { - const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"] }; + const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string }; 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, worldU: along, warped }; + // A sliding door carries its texture with it: sample past the gap. + return { + dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide), + axis: b.axis, worldU: along, edge: b.edge, warped, + }; } // No slab in this cell: advance through the open boundary. diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts index f47533c..882faee 100644 --- a/packages/web/src/fpv/textures.ts +++ b/packages/web/src/fpv/textures.ts @@ -42,7 +42,7 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2); }); } -export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as const; +export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as const; /** The built-in bake: what ships when no painted file overrides it. */ export function proceduralTextures(): Record { @@ -107,6 +107,26 @@ export function proceduralTextures(): Record { } } }), + home: bake((c) => { + // A home base's floor emblem, laid over the flagstones and tinted by + // its owner's color at draw time — so paint it near-white, with + // transparency wherever the plain floor should show through. + c.strokeStyle = "rgba(240,235,225,0.85)"; + c.lineWidth = 3; + c.strokeRect(4.5, 4.5, TEX - 9, TEX - 9); + c.lineWidth = 2; + c.beginPath(); + c.moveTo(TEX / 2, 12); + c.lineTo(TEX - 12, TEX / 2); + c.lineTo(TEX / 2, TEX - 12); + c.lineTo(12, TEX / 2); + c.closePath(); + c.stroke(); + c.fillStyle = "rgba(240,235,225,0.35)"; + c.beginPath(); + c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2); + c.fill(); + }), warp: bake((c) => { const grad = c.createLinearGradient(0, 0, TEX, TEX); grad.addColorStop(0, "#2c1a4e"); diff --git a/research/fx3d-sprite-spec.md b/research/fx3d-sprite-spec.md index 89247f4..eb5b1bc 100644 --- a/research/fx3d-sprite-spec.md +++ b/research/fx3d-sprite-spec.md @@ -39,11 +39,22 @@ all nine are shown side by side at `/?tokens` under "The conjurations." | `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 | +| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands as a mound at the fallen edge for the rest of the reel. Unlike the others this is drawn OPAQUE (normal compositing) — paint it as solid debris, not light | 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. +hit (red), healed (green), or teleported (violet), the camera shake, the +door slide (doors animate by sliding `door.png` along its own edge — no +separate art), and the jammed-lock seethe (a pulsing rust glow the +renderer lays over the door) — those are renderer effects, not images. + +## One more paintable surface: the home tile + +`public/textures/home.png` is a floor overlay for home-base cells, +drawn over the flagstones and TINTED by the owning wizard's color at +draw time. Paint it near-white/neutral with transparency wherever the +plain floor should show through; the tint does the rest. Square, any +size, same as the other textures.