diff --git a/fpv-treasure-corridor.png b/fpv-treasure-corridor.png new file mode 100644 index 0000000..693bc49 Binary files /dev/null and b/fpv-treasure-corridor.png differ diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 6824634..0129931 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -8,6 +8,7 @@ import Replay from "./Replay.svelte"; import FxGallery from "./FxGallery.svelte"; import TokenGallery from "./TokenGallery.svelte"; + import FpvWorkshop from "./fpv/FpvWorkshop.svelte"; import { scheduleFx, type BoardFx } from "./fx"; import { prefs, savePrefs } from "./prefs.svelte"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; @@ -41,6 +42,7 @@ /** Which pending attack the player has already acknowledged (modal dismissed). */ const fxWorkshop = new URLSearchParams(location.search).has("fx"); const tokenWorkshop = new URLSearchParams(location.search).has("tokens"); + const fpvWorkshop = new URLSearchParams(location.search).has("fpv"); let attackNoticeSeen = $state(null); /** The interruption fanfare, dismissed once per window. */ let momentSeen = $state(false); @@ -1231,7 +1233,9 @@ } -{#if tokenWorkshop} +{#if fpvWorkshop} + +{:else if tokenWorkshop} {:else if fxWorkshop} diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte new file mode 100644 index 0000000..546968a --- /dev/null +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -0,0 +1,180 @@ + + + + + diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte new file mode 100644 index 0000000..49e5f17 --- /dev/null +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -0,0 +1,128 @@ + + + onKey(e, true)} onkeyup={(e) => onKey(e, false)} /> + +
+

The maze, through {povId}'s eyes

+

+ Arrows / WASD walk and turn. Walls and shut doors refuse you; fire and + stranger things do not — this workshop has eyes, not rules. Seed with + ?fpv&seed=N, stand anywhere with &x=&y=&dir=. +

+
+ + + {#each cells as c (cellKey({ x: c.cx, y: c.cy }))} + + {/each} + {#each mmWalls as w (w.kind + w.ex + "," + w.ey)} + {#if w.kind === "V"} + + {:else} + + {/if} + {/each} + + + + +
+
+ + diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts new file mode 100644 index 0000000..f252c72 --- /dev/null +++ b/packages/web/src/fpv/raycast.ts @@ -0,0 +1,153 @@ +// First-person raycasting over the maze. The world is the GameView's board +// — one unit per cell, walls living on EDGES between cells rather than in +// them — so a ray marches cell boundaries (DDA) and asks each crossing +// what stands there. Crucially the board arrives from viewFor(), which has +// already rendered this wizard's BELIEFS: an illusion they have not seen +// through is a wall here too, and the deception carries into first person. + +import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine"; +import type { GameView } from "@wizwar/engine"; + +export interface Hit { + /** Distance along the ray (perpendicular-corrected by the caller). */ + dist: number; + /** What the ray struck. */ + kind: "wall" | "door" | "firewall" | "stone" | "rim" | "warp"; + /** 0..1 across the struck face (texture coordinate). */ + u: number; + /** Vertical faces get a different shade than horizontal ones. */ + axis: "x" | "y"; +} + +/** Is this edge passable to the EYE (rays), and if not, what is it? */ +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 === "firewall") return "firewall"; + return "wall"; +} + +/** + * March one ray from (ox, oy) at `angle` and return the first thing that + * stops the eye. Off-board is the maze's rim: a wall, unless the crossing + * is a warp mouth (then a shimmering opening). + */ +export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit { + const dx = Math.cos(angle); + const dy = Math.sin(angle); + let cx = Math.floor(ox); + let cy = Math.floor(oy); + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + // Distance along the ray between successive x / y grid lines. + const dDistX = Math.abs(1 / (dx || 1e-9)); + const dDistY = Math.abs(1 / (dy || 1e-9)); + let sideDistX = (dx > 0 ? cx + 1 - ox : ox - cx) * dDistX; + let sideDistY = (dy > 0 ? cy + 1 - oy : oy - cy) * dDistY; + + for (let i = 0; i < 64; i++) { + const crossingX = sideDistX < sideDistY; + const dist = crossingX ? sideDistX : sideDistY; + // The edge being crossed lives between the current cell and the next. + let key: string; + let nx = cx, ny = cy; + if (crossingX) { + key = edgeKey({ x: stepX > 0 ? cx : cx - 1, y: cy }, "E"); + nx = cx + stepX; + sideDistX += dDistX; + } else { + key = edgeKey({ x: cx, y: stepY > 0 ? cy : cy - 1 }, "S"); + ny = cy + stepY; + sideDistY += dDistY; + } + const axis: Hit["axis"] = crossingX ? "x" : "y"; + const u = crossingX + ? (oy + dist * dy) % 1 + : (ox + dist * dx) % 1; + const texU = u < 0 ? u + 1 : u; + + const blocked = edgeObstacle(view, key); + if (blocked) return { dist, kind: blocked, u: texU, axis }; + + const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })]; + if (offBoard) { + const mouth = view.board.warps.some( + (w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && + w.from.side === (crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N")), + ); + return { dist, kind: mouth ? "warp" : "rim", u: texU, axis }; + } + if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { + return { dist, kind: "stone", u: texU, axis }; + } + cx = nx; + cy = ny; + } + return { dist: 64, kind: "rim", u: 0, axis: "x" }; +} + +/** Can a wizard's body (not just their eye) cross this edge? Workshop + * collision: walls and closed doors stop you, fire and warps do not. */ +export function canWalk(view: GameView, from: Cell, side: Side): boolean { + const key = edgeKey(from, side); + const e = view.board.edges[key] ?? "open"; + if (e === "wall") return false; + if (e === "door" && + !view.openDoorEdges.includes(key) && !view.heldDoorEdges.includes(key)) return false; + const to = { + x: from.x + (side === "E" ? 1 : side === "W" ? -1 : 0), + y: from.y + (side === "S" ? 1 : side === "N" ? -1 : 0), + }; + if (!view.board.cells[cellKey(to)]) return false; // the rim (warp-walk later) + if (view.squareContents[cellKey(to)]?.kind === "stone") return false; + return true; +} + +export interface Billboard { + /** World position (cell-centered). */ + x: number; + y: number; + /** Art URL, resolved by the caller (token art respects preferences). */ + src: string; + /** Fraction of wall height (a wizard stands taller than a chest). */ + scale: number; + /** Lifted off the floor (0 = feet on the ground). */ + rise: number; + label: string; +} + +/** Everything standing in the maze that the reel should draw as a sprite. */ +export function billboards( + view: GameView, + povId: string, + art: (file: string, cat: "players" | "creatures" | "objects" | "terrain") => string, +): Billboard[] { + const out: Billboard[] = []; + for (const p of view.players) { + if (!p.alive || p.id === povId) continue; + out.push({ + x: p.position.x + 0.5, y: p.position.y + 0.5, + src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id, + }); + } + for (const c of view.creatures) { + out.push({ + x: c.position.x + 0.5, y: c.position.y + 0.5, + src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, + }); + } + for (const t of view.treasures) { + if (!t.position || t.carriedBy) continue; + out.push({ + x: t.position.x + 0.5, y: t.position.y + 0.5, + src: art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects"), + scale: 0.4, rise: 0, label: "treasure", + }); + } + return out; +}