The crosshair answers before the click commits

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-26 12:35:12 -04:00
co-authored by Claude Fable 5
parent 42d872df6d
commit 22c443d2c5
3 changed files with 142 additions and 12 deletions
+1 -1
View File
@@ -1901,7 +1901,7 @@
{#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
onstride={(side) => tryMove(side)} canStride={yourMoment}
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} />
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells} />
{/if}
<Board
{view}
+4 -2
View File
@@ -15,7 +15,7 @@
import { untrack } from "svelte";
import type { GameEvent, GameView, Side } from "@wizwar/engine";
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null }: {
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null }: {
view: GameView;
/** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */
@@ -30,6 +30,8 @@
/** 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;
/** Cell-card eligibility, shared with the board's dimming aid. */
litCells?: Set<string> | null;
} = $props();
const povId = $derived(view.you);
@@ -249,7 +251,7 @@
<FirstPerson {view} povId={cutawayShot ? "" : povId}
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos}
ontarget={ontarget ?? undefined} />
ontarget={ontarget ?? undefined} {litCells} />
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button>
<!-- The helm, tappable: edge strips turn and stride. -->
<button class="drive drive-left" onclick={() => manualTurn(-1)} aria-label="turn left"></button>
+137 -9
View File
@@ -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<string> | 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<string, HTMLImageElement>();
@@ -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 @@
</script>
<canvas bind:this={canvas} {width} {height} class="fpv-canvas" class:targeting={!!ontarget}
onclick={onCanvasClick}></canvas>
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)}></canvas>
<style>
.targeting { cursor: crosshair; }