diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 13c5fe0..fd632b1 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -54,6 +54,8 @@ let boardFx = $state([]); /** The live pane's feed: each event batch, numbered, with its view. */ let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null); + /** The pane camera's compass quadrant, worn by the keymap token. */ + let fpvFacing = $state(null); let fxCancels: (() => void)[] = []; /** A trap drawn by YOU earns a modal; buried log lines get missed. */ let trapNotice = $state(null); @@ -903,6 +905,16 @@ } } + /** A click in the first-person pane, resolved by the renderer to the + * board's own vocabulary — then handled by the very same functions the + * top-down board uses, so the pane and the map can never disagree. */ + function fpvTarget(t: import("./fpv/raycast").FpvTarget) { + if (t.kind === "player") clickPlayer(t.id); + else if (t.kind === "creature") clickCreature(t.id); + else if (t.kind === "edge") clickEdge(t.cell, t.side); + else clickCell(t.cell); + } + function clickPlayer(playerId: string) { if (!view || !yourMoment) return; if (selectedCreature) { @@ -1888,7 +1900,8 @@
{#if prefs.liveFp && view.you && !net.spectating} setPref("liveFp", false)} - onstride={(side) => tryMove(side)} canStride={yourMoment} /> + onstride={(side) => tryMove(side)} canStride={yourMoment} + ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} /> {/if} (peekEdge = tip)} {litCells} {sightTrace} + povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null} />
diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 3f606b1..44f14df 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -23,6 +23,7 @@ onCellClick, onEdgeClick, onPlayerClick, + povFacing = null, onCreatureClick, onWarpClick, onCellPeek, @@ -43,6 +44,9 @@ onCellClick?: (cell: { x: number; y: number }) => void; onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void; onPlayerClick?: (playerId: string) => void; + /** Which way YOUR first-person camera faces: a wedge on your token + * keeps the keymap and the pane pointing the same way. */ + povFacing?: "N" | "E" | "S" | "W" | null; onCreatureClick?: (creatureId: string) => void; onWarpClick?: (cell: { x: number; y: number }, side: Side) => void; /** Long-press on a square: read the card behind whatever occupies it. */ @@ -539,6 +543,14 @@ {@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)} {@const cy = p.position.y * CELL + CELL * 0.36} + {#if povFacing && p.id === view.you} + {@const fr = CELL * 0.34} + {@const fa = povFacing === "E" ? 0 : povFacing === "S" ? Math.PI / 2 : povFacing === "W" ? Math.PI : -Math.PI / 2} + + {/if} { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }} @@ -765,6 +777,11 @@ fill: #6a5c44; pointer-events: none; } + .pov-wedge { + fill: #c9a72a; + opacity: 0.85; + pointer-events: none; + } .peekable { cursor: pointer; pointer-events: all; } .sight-corner { fill: none; diff --git a/packages/web/src/LiveFirstPerson.svelte b/packages/web/src/LiveFirstPerson.svelte index 6e917bb..205fbd3 100644 --- a/packages/web/src/LiveFirstPerson.svelte +++ b/packages/web/src/LiveFirstPerson.svelte @@ -8,13 +8,14 @@ // instant replay uses, so spells, doors, and conjurations perform // here first. import FirstPerson from "./fpv/FirstPerson.svelte"; + import type { FpvTarget } from "./fpv/raycast"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; import { castRay } from "./fpv/raycast"; import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { untrack } from "svelte"; import type { GameEvent, GameView, Side } from "@wizwar/engine"; - let { view, batch, onhide, onstride = null, canStride = false }: { + let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null }: { view: GameView; /** The latest live event batch, numbered so each plays once, with * the view the server sent alongside it. */ @@ -23,6 +24,12 @@ /** Issue a real move command: the pane is the cockpit on your turn. */ onstride?: ((side: Side) => void) | null; canStride?: boolean; + /** Clicks in the pane resolve to board targets: the pane is not just + * the window but the wand hand. */ + ontarget?: ((t: FpvTarget) => void) | null; + /** Reports the camera's compass quadrant whenever it settles on a new + * one — the keymap wears it as a wedge on your token. */ + onfacing?: ((side: Side) => void) | null; } = $props(); const povId = $derived(view.you); @@ -225,6 +232,14 @@ raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }); + let lastQuad = -99; + $effect(() => { + const q = ((Math.round(cam.facing / (Math.PI / 2)) % 4) + 4) % 4; + if (q !== lastQuad) { + lastQuad = q; + onfacing?.(SIDES4[q]!); + } + }); @@ -233,7 +248,8 @@
+ fx={fpFx} posOverride={actorPos} + ontarget={ontarget ?? undefined} /> diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 2658ee3..eb86667 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -3,7 +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 } from "./raycast"; + import { + warpMotion, + type FpvTarget, castRay, billboards } from "./raycast"; import { materialTextures } from "./textures"; import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d"; @@ -22,6 +24,7 @@ fx = [], posOverride, rubble = [], + ontarget, }: { view: GameView; povId: string; @@ -38,11 +41,23 @@ posOverride?: Record; /** Where walls have died: a mound of stone marks each fallen edge. */ rubble?: { x: number; y: number }[]; + /** Present = the pane is an instrument: clicks resolve to targets. */ + ontarget?: (t: FpvTarget) => void; } = $props(); const FOV = Math.PI / 2.9; let canvas: HTMLCanvasElement; + /** Everything the LAST drawn frame knew, kept for click resolution: + * the pane is an instrument only because the renderer remembers what + * stood under every pixel. */ + let hitFrame: { + W: number; H: number; ex: number; ey: number; facing: number; + zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array; + cols: ({ edge?: string; kind: string; top: number; h: number } | null)[]; + sprites: Projected[]; + } | null = null; + // Token art loads lazily; a sprite draws once its image has arrived. const images = new Map(); function imageFor(src: string): HTMLImageElement | null { @@ -237,6 +252,7 @@ // 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 hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null); // Per column: which warp (if any) the ray bent through, and how far // away that mouth stood. A REAL body paints a column only if it is // nearer than the mouth (it stands in front of the window); a @@ -281,6 +297,7 @@ } const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); const top = half - wallH / 2; + hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH }; const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!; // Sample by the texture's own size: painted files may be any scale. // Fire shifts its slice per world cell, so a blaze spanning edges @@ -363,6 +380,7 @@ if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); } sprites.sort((a, b) => b.sort - a.sort); + hitFrame = { W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, sprites }; // The nearest sprite each column carries — depth AND vertical span — // so the overlay passes (veils, ghosts, lintels, risings) can paint // around a body standing in front of them instead of over it. @@ -535,6 +553,8 @@ fallback?: string; clampL?: number; clampR?: number; + hit?: { kind: "player" | "creature"; id: string }; + cell?: { x: number; y: number }; /** Sort key only: the near-bias orders sprites among THEMSELVES (a * hedge over the wizard standing in it) but must never let one * cheat past a wall — occlusion always uses the true depth. */ @@ -544,7 +564,9 @@ b: { x: number; y: number; src: string; scale: number; rise: number; aspect?: number; alpha?: number; glow?: boolean; bias?: number; fallback?: string; warped?: boolean; warpId?: number; - clip?: { x: number; y: number } }, + clip?: { x: number; y: number }; + hit?: { kind: "player" | "creature"; id: string }; + cell?: { x: number; y: number } }, ex: number, ey: number, ): Projected | null { const relX = b.x - ex, relY = b.y - ey; @@ -597,11 +619,94 @@ return { src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId, alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR, + hit: b.hit, cell: b.cell, left: screenX - wide / 2, right: screenX + wide / 2, top: bottom - size, bottom, }; } + /** Resolve a canvas-pixel click to what stood under it: the nearest + * visible sprite, else the struck wall or door face, else the floor + * (or vault) square the pixel lies on — warp-bent columns mapping + * their virtual ground back to real cells through the warp's own + * rigid motion. */ + export function hitTest(px: number, py: number): FpvTarget | null { + const f = hitFrame; + if (!f) return null; + const col = Math.max(0, Math.min(f.W - 1, px | 0)); + const half = f.H / 2; + + // Sprites first, nearest first, honoring the draw pass's own + // visibility rules for this column. + const byDepth = [...f.sprites].sort((a, b) => a.depth - b.depth); + for (const sp of byDepth) { + if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble) + if (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue; + if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) continue; + if (sp.depth >= f.zbuf[col]!) continue; + if (sp.warped) { + if (f.warpIdCol[col] !== sp.warpId || sp.depth <= f.warpDistCol[col]!) continue; + } else if (sp.depth >= f.warpDistCol[col]!) continue; + if (sp.hit) return sp.hit; + return { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } }; + } + + // The wall span: doors and walls answer as their EDGE. + const c = f.cols[col]; + if (c && py >= c.top && py <= c.top + c.h) { + if ((c.kind === "wall" || c.kind === "door" || c.kind === "firewall") && c.edge) { + const [kind, coords] = c.edge.split(":") as [string, string]; + const [x, y] = coords.split(",").map(Number) as [number, number]; + return { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" }; + } + if (c.kind === "stone") { + // A stone fill is a square, not an edge: the cell just past the + // struck face along this column's ray. + const t = f.zbuf[col]! + 0.05; + const pt = groundPoint(f, col, t); + return pt ? { kind: "cell", cell: pt } : null; + } + return null; // rims and frame posts are nobody's target + } + + // Ground (or vault): each row below the horizon lies at one depth. + const dz = py > half ? py - half : half - py; + if (dz < 1) return null; + const d = (f.H / 2) / dz; + if (d >= f.zbuf[col]!) return null; // past the wall: nothing to click + const pt = groundPoint(f, col, d); + return pt ? { kind: "cell", cell: pt } : null; + } + + /** The real-world square at perpendicular depth d down column col — + * mapping through the column's warp when the ray bent. */ + function groundPoint( + f: NonNullable, col: number, d: number, + ): { x: number; y: number } | null { + const flen = (f.W / 2) / Math.tan(FOV / 2); + const side = (col - f.W / 2) * (d / flen); + const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing); + let wx = f.ex + d * cosF - side * sinF; + let wy = f.ey + d * sinF + side * cosF; + if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) { + const w = view.board.warps[f.warpIdCol[col]!]; + if (!w) return null; + const real = warpMotion(w).toReal({ x: wx, y: wy }); + wx = real.x; wy = real.y; + } + const cell = { x: Math.floor(wx), y: Math.floor(wy) }; + return view.board.cells[`${cell.x},${cell.y}`] ? cell : null; + } + + function onCanvasClick(e: MouseEvent) { + if (!ontarget || !canvas) return; + const rect = canvas.getBoundingClientRect(); + const px = (e.clientX - rect.left) * (width / rect.width); + const py = (e.clientY - rect.top) * (height / rect.height); + const t = hitTest(px, py); + if (t) ontarget(t); + } + // Redraw every frame: the fire flickers and the warps swirl even when // the camera holds still. $effect(() => { @@ -612,9 +717,11 @@ }); - +