diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index e3a1fb3..f3f8e62 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -219,12 +219,22 @@ // ghost into real corridors the unrolled space happens to overlap. const zbuf = new Float64Array(W); const warpedCol = new Uint8Array(W); + // Known illusions: the ray passes, but a translucent ghost of a wall + // stands at the crossing — drawn after the sprites so bodies show + // through it, shimmering so nobody mistakes it for stone. + const ghosts: { col: number; depth: number; u: number; worldU: number }[] = []; 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, doors); const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye zbuf[col] = depth; warpedCol[col] = hit.warped ? 1 : 0; + if (hit.ghost) { + ghosts.push({ + col, depth: hit.ghost.dist * Math.cos(rayAngle - facing), + u: hit.ghost.u, worldU: hit.ghost.worldU, + }); + } 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!; @@ -250,6 +260,13 @@ ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; ctx.fillRect(col, top, 1, wallH); } + if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") { + // The same tell the board gives: an untested illusion's face + // shimmers faintly — maybe stone, maybe not. + const tell = 0.06 + 0.05 * Math.sin(time / 260 + hit.worldU * 13); + ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`; + 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); @@ -322,6 +339,21 @@ if (s.glow) ctx.globalAlpha = 1; } + // The ghosts of walls you know are lies: drawn over everything at + // their columns, thin as breath, rippling. + for (const gh of ghosts) { + const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05)); + const gTop = half - gHft / 2; + const tex = textures.wall!; + ctx.globalAlpha = 0.2; + ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), 0, + Math.max(1, tex.width / 96), tex.height, gh.col, gTop, 1, gHft); + ctx.globalAlpha = 1; + const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01); + ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`; + ctx.fillRect(gh.col, gTop, 1, gHft); + } + // A whisper of vignette holds the torchlit mood together. const vig = ctx.createRadialGradient(W / 2, half, H * 0.35, W / 2, half, H * 0.95); vig.addColorStop(0, "rgba(0,0,0,0)"); diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte index b515947..843bff9 100644 --- a/packages/web/src/fpv/FpvWorkshop.svelte +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -21,6 +21,12 @@ }); const povId = "Wanderer"; const view = viewFor(dealt, povId); + // ?illusion=H:2,8 (";"-separated): stand up known illusions for the + // renderer to rehearse — the edge opens, the ghost remains. + for (const k of (q.get("illusion") ?? "").split(";").filter(Boolean)) { + view.knownIllusionEdges.push(k); + delete view.board.edges[k]; + } const start = view.players.find((p) => p.id === povId)!.position; // ?x=&y=&dir= override the spawn — for standing the camera anywhere. diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 09c9048..c8469d1 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -21,10 +21,14 @@ 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). */ + /** The edge struck, for state the renderer dresses (jammed locks, + * untested illusions). */ edge?: string; /** The eye reached this through a warp: haze it other-worldly. */ warped?: boolean; + /** The first known-illusion edge the ray crossed before its solid hit: + * the eye passes, but a translucent ghost of a wall stands there. */ + ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" }; } export const SIDE_ANGLE: Record = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 }; @@ -80,6 +84,7 @@ export function castRay( // a mouth re-enters at the paired mouth and marches on, its hits hazed. let baseDist = 0; let warped = false; + let ghost: Hit["ghost"]; for (let traversal = 0; traversal < 3; traversal++) { const dx = Math.cos(angle); const dy = Math.sin(angle); @@ -129,7 +134,7 @@ export function castRay( // 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, + axis: b.axis, worldU: along, edge: b.edge, warped, ghost, }; } @@ -144,13 +149,17 @@ export function castRay( const u0 = along % 1; const texU = u0 < 0 ? u0 + 1 : u0; + const crossSide: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N"); + if (!ghost && view.knownIllusionEdges.includes(edgeKey({ x: cx, y: cy }, crossSide))) { + ghost = { dist: baseDist + dist, u: texU, worldU: along, axis }; + } const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })]; if (offBoard) { - const side: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N"); + const side: Side = crossSide; 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, worldU: along, warped }; + if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost }; // 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]]; @@ -167,14 +176,14 @@ export function castRay( break; } if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { - return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped }; + return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost }; } cx = nx; cy = ny; } if (!warped || traversal === 2) break; } - return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped }; + return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost }; } /** Can a wizard's body (not just their eye) cross this edge? Workshop