From 22c443d2c5b7fefbd7355d5219e3828830ae0d67 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 26 Aug 2026 12:35:12 -0400 Subject: [PATCH] The crosshair answers before the click commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering the pane now shows what a click would do: a wizard or creature outlines in gold with its name at the crosshair, a wall or door face lights across every column it owns, and a floor square draws its perspective-true quad on the ground. Warp-bent ground still targets truly but labels itself "through the warp" rather than drawing a quad in the wrong geometry. And when a cell-target card is selected, the ineligible ground dims in the 3D view itself — the same litCells the board's shadow uses, rasterized into the floor pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/web/src/App.svelte | 2 +- packages/web/src/LiveFirstPerson.svelte | 6 +- packages/web/src/fpv/FirstPerson.svelte | 146 ++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index fd632b1..ec4b547 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1901,7 +1901,7 @@ {#if prefs.liveFp && view.you && !net.spectating} setPref("liveFp", false)} onstride={(side) => tryMove(side)} canStride={yourMoment} - ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} /> + ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells} /> {/if} void) | null; + /** Cell-card eligibility, shared with the board's dimming aid. */ + litCells?: Set | null; } = $props(); const povId = $derived(view.you); @@ -249,7 +251,7 @@ + ontarget={ontarget ?? undefined} {litCells} /> diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index eb86667..2cf2528 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -25,6 +25,7 @@ posOverride, rubble = [], ontarget, + litCells = null, }: { view: GameView; povId: string; @@ -43,6 +44,9 @@ rubble?: { x: number; y: number }[]; /** Present = the pane is an instrument: clicks resolve to targets. */ ontarget?: (t: FpvTarget) => void; + /** Squares a selected cell-target card may aim at: the pane dims the + * ineligible ground exactly as the board dims its squares. */ + litCells?: Set | null; } = $props(); const FOV = Math.PI / 2.9; @@ -57,6 +61,9 @@ cols: ({ edge?: string; kind: string; top: number; h: number } | null)[]; sprites: Projected[]; } | null = null; + /** Crosshair position in canvas pixels, while the pointer is over the + * pane and the pane is an instrument. */ + let mouse: { x: number; y: number } | null = null; // Token art loads lazily; a sprite draws once its image has arrived. const images = new Map(); @@ -183,6 +190,17 @@ const fl = pixelsOf(textures.floor!); const ce = pixelsOf(textures.ceiling!); const FOG = 13; + // A selected cell-card's eligibility, rasterized once for the pixel + // loop: 1 = castable ground, 0 = dimmed. Absent card = null = all lit. + let litGrid: Uint8Array | null = null; + const litBw = view.board.width, litBh = view.board.height; + if (litCells) { + litGrid = new Uint8Array(litBw * litBh); + for (const k of litCells) { + const [lx, ly] = k.split(",").map(Number) as [number, number]; + if (lx >= 0 && ly >= 0 && lx < litBw && ly < litBh) litGrid[ly * litBw + lx] = 1; + } + } for (let row = 0; row < H; row++) { const below = row > half; const dz = below ? row - half : half - row; @@ -216,6 +234,11 @@ let v = wy % 1; if (v < 0) v += 1; const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4; let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!; + let sh = shade; + if (litGrid && below) { + const gx = (wx - u) | 0, gy = (wy - v) | 0; + if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3; + } if (dec) { const hx = (wx - u) | 0, hy = (wy - v) | 0; if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) { @@ -236,9 +259,9 @@ } } } - buf[o] = r * shade; - buf[o + 1] = g * shade; - buf[o + 2] = b * shade; + buf[o] = r * sh; + buf[o + 1] = g * sh; + buf[o + 2] = b * sh; } buf[o + 3] = 255; wx += stepX; @@ -537,6 +560,69 @@ ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; } + + // The crosshair's answer, before the click commits: what the pane + // would target here, outlined in the table's gold with its name. + if (ontarget && mouse) drawHover(ctx, W, H, half); + } + + /** Paint the hover cue for whatever stands under the crosshair. */ + function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) { + const f = hitFrame; + if (!f || !mouse) return; + const found = resolveHover(mouse.x, mouse.y); + if (!found) return; + ctx.save(); + ctx.strokeStyle = "#c9a72a"; + ctx.fillStyle = "rgba(201,167,42,0.14)"; + ctx.lineWidth = 1.5; + let label = ""; + if (found.shape.kind === "rect") { + const r = found.shape; + ctx.strokeRect(r.left, r.top, r.right - r.left, r.bottom - r.top); + ctx.fillRect(r.left, r.top, r.right - r.left, r.bottom - r.top); + label = found.label; + } else if (found.shape.kind === "face") { + // Tint every column that shows THIS edge: the whole face answers. + for (let col = 0; col < f.W; col++) { + const c = f.cols[col]; + if (!c || c.edge !== found.shape.edge) continue; + ctx.fillRect(col, c.top, 1, c.h); + } + label = found.label; + } else { + // A ground square: its four corners projected onto the floor plane. + const flen = (f.W / 2) / Math.tan(FOV / 2); + const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing); + const { x: cx0, y: cy0 } = found.shape.cell; + const pts: [number, number][] = []; + for (const [ox, oy] of [[0, 0], [1, 0], [1, 1], [0, 1]] as const) { + const rx = cx0 + ox - f.ex, ry = cy0 + oy - f.ey; + const depth = rx * cosF + ry * sinF; + if (depth < 0.12) { pts.length = 0; break; } + const side = -rx * sinF + ry * cosF; + pts.push([f.W / 2 + (side / depth) * flen, half + (f.H / 2) / depth]); + } + if (pts.length === 4) { + ctx.beginPath(); + ctx.moveTo(pts[0]![0], pts[0]![1]); + for (const [px, py] of pts.slice(1)) ctx.lineTo(px, py); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + } + if (label) { + ctx.font = "12px 'Courier Prime', monospace"; + const wTxt = ctx.measureText(label).width; + const lx = Math.min(f.W - wTxt - 10, Math.max(4, mouse.x + 10)); + const ly = Math.max(16, mouse.y - 8); + ctx.fillStyle = "rgba(13,12,18,0.85)"; + ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16); + ctx.fillStyle = "#e9e1cb"; + ctx.fillText(label, lx, ly); + } + ctx.restore(); } interface Projected { @@ -631,6 +717,19 @@ * their virtual ground back to real cells through the warp's own * rigid motion. */ export function hitTest(px: number, py: number): FpvTarget | null { + return resolveHover(px, py)?.target ?? null; + } + + /** The full answer for a canvas pixel: the target, the screen shape to + * highlight, and the name to whisper beside the crosshair. */ + function resolveHover(px: number, py: number): { + target: FpvTarget; + label: string; + shape: + | { kind: "rect"; left: number; right: number; top: number; bottom: number } + | { kind: "face"; edge: string } + | { kind: "ground"; cell: { x: number; y: number } }; + } | null { const f = hitFrame; if (!f) return null; const col = Math.max(0, Math.min(f.W - 1, px | 0)); @@ -647,8 +746,10 @@ 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 } }; + const shape = { kind: "rect" as const, left: sp.left, right: sp.right, top: sp.top, bottom: sp.bottom }; + const label = sp.hit ? (sp.hit.kind === "player" ? sp.hit.id : labelOf(sp)) : labelOf(sp); + if (sp.hit) return { target: sp.hit, label, shape }; + return { target: { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } }, label, shape }; } // The wall span: doors and walls answer as their EDGE. @@ -657,14 +758,18 @@ 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" }; + return { + target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" }, + label: c.kind === "firewall" ? "wall of fire" : c.kind, + shape: { kind: "face", edge: c.edge }, + }; } 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 pt ? { target: { kind: "cell", cell: pt }, label: "solid stone", shape: { kind: "ground", cell: pt } } : null; } return null; // rims and frame posts are nobody's target } @@ -674,8 +779,25 @@ if (dz < 1) return null; const d = (f.H / 2) / dz; if (d >= f.zbuf[col]!) return null; // past the wall: nothing to click + if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) { + // Warp-bent ground still TARGETS truly, but the highlight quad + // cannot be drawn in this frame's geometry: label it instead. + const pt = groundPoint(f, col, d); + return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "face", edge: "\u0000never" } } : null; + } const pt = groundPoint(f, col, d); - return pt ? { kind: "cell", cell: pt } : null; + return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null; + } + + /** A sprite's spoken name: the creature or thing under the crosshair. */ + function labelOf(sp: Projected): string { + if (sp.hit?.kind === "creature") { + const c = view.creatures.find((k) => k.id === sp.hit!.id); + return c ? c.kind.replace(/-/g, " ") : "creature"; + } + if (sp.fallback) return sp.fallback.replace(/-/g, " "); + const m = sp.src.match(/\/([a-z0-9-]+)\.png/i); + return m ? m[1]!.replace(/-/g, " ") : ""; } /** The real-world square at perpendicular depth d down column col — @@ -718,7 +840,13 @@ + onclick={onCanvasClick} + onpointermove={(e) => { + if (!ontarget || !canvas) return; + const rect = canvas.getBoundingClientRect(); + mouse = { x: (e.clientX - rect.left) * (width / rect.width), y: (e.clientY - rect.top) * (height / rect.height) }; + }} + onpointerleave={() => (mouse = null)}>