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