Milestone two: the pane becomes the wand hand

Clicks in the first-person view now act. The renderer keeps what the
last frame knew — per-column depth, warp bends, every wall hit's edge,
every sprite's screen rect with its identity — and hitTest inverts the
projection: the nearest visible sprite answers as its wizard or
creature, a struck wall or door answers as its edge, and the ground
answers as the square under the pixel, warp-bent columns mapping their
virtual floor back to real cells through the warp's own rigid motion.
The pane hands each target to the very functions the top-down board's
clicks use, so pane and map can never disagree: click the floor ahead
and you walk there; click a door with a key selected and the lock
turns; click a wizard with an attack and the spell flies.

The keymap token now wears a golden wedge showing the camera's compass
quadrant — the cockpit and the chart always agree on which way you
face. Verified live: a floor click became a real move command in the
room ledger, edge clicks resolve and defer correctly, and the wedge
tracks quarter-turns.

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:28:51 -04:00
co-authored by Claude Fable 5
parent aac06dd2b5
commit 42d872df6d
5 changed files with 183 additions and 11 deletions
+110 -3
View File
@@ -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<string, { x: number; y: number }>;
/** 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<string, HTMLImageElement>();
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<typeof hitFrame>, 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 @@
});
</script>
<canvas bind:this={canvas} {width} {height} class="fpv-canvas"></canvas>
<canvas bind:this={canvas} {width} {height} class="fpv-canvas" class:targeting={!!ontarget}
onclick={onCanvasClick}></canvas>
<style>
.targeting { cursor: crosshair; }
.fpv-canvas {
display: block;
width: 100%;