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:
co-authored by
Claude Fable 5
parent
aac06dd2b5
commit
42d872df6d
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user