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
@@ -54,6 +54,8 @@
|
|||||||
let boardFx = $state<BoardFx[]>([]);
|
let boardFx = $state<BoardFx[]>([]);
|
||||||
/** The live pane's feed: each event batch, numbered, with its view. */
|
/** The live pane's feed: each event batch, numbered, with its view. */
|
||||||
let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null);
|
let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null);
|
||||||
|
/** The pane camera's compass quadrant, worn by the keymap token. */
|
||||||
|
let fpvFacing = $state<Side | null>(null);
|
||||||
let fxCancels: (() => void)[] = [];
|
let fxCancels: (() => void)[] = [];
|
||||||
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
|
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
|
||||||
let trapNotice = $state<string | null>(null);
|
let trapNotice = $state<string | null>(null);
|
||||||
@@ -903,6 +905,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A click in the first-person pane, resolved by the renderer to the
|
||||||
|
* board's own vocabulary — then handled by the very same functions the
|
||||||
|
* top-down board uses, so the pane and the map can never disagree. */
|
||||||
|
function fpvTarget(t: import("./fpv/raycast").FpvTarget) {
|
||||||
|
if (t.kind === "player") clickPlayer(t.id);
|
||||||
|
else if (t.kind === "creature") clickCreature(t.id);
|
||||||
|
else if (t.kind === "edge") clickEdge(t.cell, t.side);
|
||||||
|
else clickCell(t.cell);
|
||||||
|
}
|
||||||
|
|
||||||
function clickPlayer(playerId: string) {
|
function clickPlayer(playerId: string) {
|
||||||
if (!view || !yourMoment) return;
|
if (!view || !yourMoment) return;
|
||||||
if (selectedCreature) {
|
if (selectedCreature) {
|
||||||
@@ -1888,7 +1900,8 @@
|
|||||||
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
||||||
{#if prefs.liveFp && view.you && !net.spectating}
|
{#if prefs.liveFp && view.you && !net.spectating}
|
||||||
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
||||||
onstride={(side) => tryMove(side)} canStride={yourMoment} />
|
onstride={(side) => tryMove(side)} canStride={yourMoment}
|
||||||
|
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} />
|
||||||
{/if}
|
{/if}
|
||||||
<Board
|
<Board
|
||||||
{view}
|
{view}
|
||||||
@@ -1910,6 +1923,7 @@
|
|||||||
onEdgePeek={(tip) => (peekEdge = tip)}
|
onEdgePeek={(tip) => (peekEdge = tip)}
|
||||||
{litCells}
|
{litCells}
|
||||||
{sightTrace}
|
{sightTrace}
|
||||||
|
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
onCellClick,
|
onCellClick,
|
||||||
onEdgeClick,
|
onEdgeClick,
|
||||||
onPlayerClick,
|
onPlayerClick,
|
||||||
|
povFacing = null,
|
||||||
onCreatureClick,
|
onCreatureClick,
|
||||||
onWarpClick,
|
onWarpClick,
|
||||||
onCellPeek,
|
onCellPeek,
|
||||||
@@ -43,6 +44,9 @@
|
|||||||
onCellClick?: (cell: { x: number; y: number }) => void;
|
onCellClick?: (cell: { x: number; y: number }) => void;
|
||||||
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||||
onPlayerClick?: (playerId: string) => void;
|
onPlayerClick?: (playerId: string) => void;
|
||||||
|
/** Which way YOUR first-person camera faces: a wedge on your token
|
||||||
|
* keeps the keymap and the pane pointing the same way. */
|
||||||
|
povFacing?: "N" | "E" | "S" | "W" | null;
|
||||||
onCreatureClick?: (creatureId: string) => void;
|
onCreatureClick?: (creatureId: string) => void;
|
||||||
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
|
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||||
/** Long-press on a square: read the card behind whatever occupies it. */
|
/** Long-press on a square: read the card behind whatever occupies it. */
|
||||||
@@ -539,6 +543,14 @@
|
|||||||
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)}
|
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)}
|
||||||
{@const cy = p.position.y * CELL + CELL * 0.36}
|
{@const cy = p.position.y * CELL + CELL * 0.36}
|
||||||
<g class="mover" class:snap={snapsTo(`w:${p.id}`, cx, cy)} style={`transform: translate(${cx}px, ${cy}px)`}>
|
<g class="mover" class:snap={snapsTo(`w:${p.id}`, cx, cy)} style={`transform: translate(${cx}px, ${cy}px)`}>
|
||||||
|
{#if povFacing && p.id === view.you}
|
||||||
|
{@const fr = CELL * 0.34}
|
||||||
|
{@const fa = povFacing === "E" ? 0 : povFacing === "S" ? Math.PI / 2 : povFacing === "W" ? Math.PI : -Math.PI / 2}
|
||||||
|
<polygon
|
||||||
|
points={`${Math.cos(fa) * fr},${Math.sin(fa) * fr} ${Math.cos(fa + 2.6) * fr * 0.55},${Math.sin(fa + 2.6) * fr * 0.55} ${Math.cos(fa - 2.6) * fr * 0.55},${Math.sin(fa - 2.6) * fr * 0.55}`}
|
||||||
|
class="pov-wedge"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<g
|
<g
|
||||||
role="button" tabindex="-1"
|
role="button" tabindex="-1"
|
||||||
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
|
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
|
||||||
@@ -765,6 +777,11 @@
|
|||||||
fill: #6a5c44;
|
fill: #6a5c44;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.pov-wedge {
|
||||||
|
fill: #c9a72a;
|
||||||
|
opacity: 0.85;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.peekable { cursor: pointer; pointer-events: all; }
|
.peekable { cursor: pointer; pointer-events: all; }
|
||||||
.sight-corner {
|
.sight-corner {
|
||||||
fill: none;
|
fill: none;
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
// instant replay uses, so spells, doors, and conjurations perform
|
// instant replay uses, so spells, doors, and conjurations perform
|
||||||
// here first.
|
// here first.
|
||||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||||
|
import type { FpvTarget } from "./fpv/raycast";
|
||||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
import { castRay } from "./fpv/raycast";
|
import { castRay } from "./fpv/raycast";
|
||||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
let { view, batch, onhide, onstride = null, canStride = false }: {
|
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null }: {
|
||||||
view: GameView;
|
view: GameView;
|
||||||
/** The latest live event batch, numbered so each plays once, with
|
/** The latest live event batch, numbered so each plays once, with
|
||||||
* the view the server sent alongside it. */
|
* the view the server sent alongside it. */
|
||||||
@@ -23,6 +24,12 @@
|
|||||||
/** Issue a real move command: the pane is the cockpit on your turn. */
|
/** Issue a real move command: the pane is the cockpit on your turn. */
|
||||||
onstride?: ((side: Side) => void) | null;
|
onstride?: ((side: Side) => void) | null;
|
||||||
canStride?: boolean;
|
canStride?: boolean;
|
||||||
|
/** Clicks in the pane resolve to board targets: the pane is not just
|
||||||
|
* the window but the wand hand. */
|
||||||
|
ontarget?: ((t: FpvTarget) => void) | null;
|
||||||
|
/** 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;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const povId = $derived(view.you);
|
const povId = $derived(view.you);
|
||||||
@@ -225,6 +232,14 @@
|
|||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
});
|
});
|
||||||
|
let lastQuad = -99;
|
||||||
|
$effect(() => {
|
||||||
|
const q = ((Math.round(cam.facing / (Math.PI / 2)) % 4) + 4) % 4;
|
||||||
|
if (q !== lastQuad) {
|
||||||
|
lastQuad = q;
|
||||||
|
onfacing?.(SIDES4[q]!);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
<svelte:window onkeydown={onKey} />
|
||||||
@@ -233,7 +248,8 @@
|
|||||||
<div class="live-fp">
|
<div class="live-fp">
|
||||||
<FirstPerson {view} povId={cutawayShot ? "" : povId}
|
<FirstPerson {view} povId={cutawayShot ? "" : povId}
|
||||||
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||||||
fx={fpFx} posOverride={actorPos} />
|
fx={fpFx} posOverride={actorPos}
|
||||||
|
ontarget={ontarget ?? undefined} />
|
||||||
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)">✕</button>
|
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)">✕</button>
|
||||||
<!-- The helm, tappable: edge strips turn and stride. -->
|
<!-- The helm, tappable: edge strips turn and stride. -->
|
||||||
<button class="drive drive-left" onclick={() => manualTurn(-1)} aria-label="turn left">‹</button>
|
<button class="drive drive-left" onclick={() => manualTurn(-1)} aria-label="turn left">‹</button>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
// GameView. Columns of wall shaded by distance and facing; token art
|
// GameView. Columns of wall shaded by distance and facing; token art
|
||||||
// billboarded for whatever stands in the corridors, occluded per column
|
// billboarded for whatever stands in the corridors, occluded per column
|
||||||
// by the same depth buffer the walls wrote.
|
// 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 { materialTextures } from "./textures";
|
||||||
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
|
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
|
||||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
fx = [],
|
fx = [],
|
||||||
posOverride,
|
posOverride,
|
||||||
rubble = [],
|
rubble = [],
|
||||||
|
ontarget,
|
||||||
}: {
|
}: {
|
||||||
view: GameView;
|
view: GameView;
|
||||||
povId: string;
|
povId: string;
|
||||||
@@ -38,11 +41,23 @@
|
|||||||
posOverride?: Record<string, { x: number; y: number }>;
|
posOverride?: Record<string, { x: number; y: number }>;
|
||||||
/** Where walls have died: a mound of stone marks each fallen edge. */
|
/** Where walls have died: a mound of stone marks each fallen edge. */
|
||||||
rubble?: { x: number; y: number }[];
|
rubble?: { x: number; y: number }[];
|
||||||
|
/** Present = the pane is an instrument: clicks resolve to targets. */
|
||||||
|
ontarget?: (t: FpvTarget) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const FOV = Math.PI / 2.9;
|
const FOV = Math.PI / 2.9;
|
||||||
let canvas: HTMLCanvasElement;
|
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.
|
// Token art loads lazily; a sprite draws once its image has arrived.
|
||||||
const images = new Map<string, HTMLImageElement>();
|
const images = new Map<string, HTMLImageElement>();
|
||||||
function imageFor(src: string): HTMLImageElement | null {
|
function imageFor(src: string): HTMLImageElement | null {
|
||||||
@@ -237,6 +252,7 @@
|
|||||||
// side must MATCH its column's, or bodies near a far mouth would
|
// side must MATCH its column's, or bodies near a far mouth would
|
||||||
// ghost into real corridors the unrolled space happens to overlap.
|
// ghost into real corridors the unrolled space happens to overlap.
|
||||||
const zbuf = new Float64Array(W);
|
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
|
// 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
|
// 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
|
// 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 wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
||||||
const top = half - wallH / 2;
|
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!;
|
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
||||||
// Sample by the texture's own size: painted files may be any scale.
|
// 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
|
// 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 });
|
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
||||||
}
|
}
|
||||||
sprites.sort((a, b) => b.sort - a.sort);
|
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 —
|
// The nearest sprite each column carries — depth AND vertical span —
|
||||||
// so the overlay passes (veils, ghosts, lintels, risings) can paint
|
// so the overlay passes (veils, ghosts, lintels, risings) can paint
|
||||||
// around a body standing in front of them instead of over it.
|
// around a body standing in front of them instead of over it.
|
||||||
@@ -535,6 +553,8 @@
|
|||||||
fallback?: string;
|
fallback?: string;
|
||||||
clampL?: number;
|
clampL?: number;
|
||||||
clampR?: 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
|
/** Sort key only: the near-bias orders sprites among THEMSELVES (a
|
||||||
* hedge over the wizard standing in it) but must never let one
|
* hedge over the wizard standing in it) but must never let one
|
||||||
* cheat past a wall — occlusion always uses the true depth. */
|
* 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;
|
b: { x: number; y: number; src: string; scale: number; rise: number;
|
||||||
aspect?: number; alpha?: number; glow?: boolean; bias?: number;
|
aspect?: number; alpha?: number; glow?: boolean; bias?: number;
|
||||||
fallback?: string; warped?: boolean; warpId?: 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,
|
ex: number, ey: number,
|
||||||
): Projected | null {
|
): Projected | null {
|
||||||
const relX = b.x - ex, relY = b.y - ey;
|
const relX = b.x - ex, relY = b.y - ey;
|
||||||
@@ -597,11 +619,94 @@
|
|||||||
return {
|
return {
|
||||||
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
|
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,
|
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,
|
left: screenX - wide / 2, right: screenX + wide / 2,
|
||||||
top: bottom - size, bottom,
|
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
|
// Redraw every frame: the fire flickers and the warps swirl even when
|
||||||
// the camera holds still.
|
// the camera holds still.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -612,9 +717,11 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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>
|
<style>
|
||||||
|
.targeting { cursor: crosshair; }
|
||||||
.fpv-canvas {
|
.fpv-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -292,6 +292,14 @@ export function edgeMid(cell: Cell, side: Side): { x: number; y: number } {
|
|||||||
return { x: cell.x + 0.5, y: cell.y };
|
return { x: cell.x + 0.5, y: cell.y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a click in the first-person pane resolved to — the pane speaks
|
||||||
|
* the board's own click vocabulary. */
|
||||||
|
export type FpvTarget =
|
||||||
|
| { kind: "player"; id: string }
|
||||||
|
| { kind: "creature"; id: string }
|
||||||
|
| { kind: "edge"; cell: Cell; side: Side }
|
||||||
|
| { kind: "cell"; cell: Cell };
|
||||||
|
|
||||||
export interface Billboard {
|
export interface Billboard {
|
||||||
/** World position (cell-centered). */
|
/** World position (cell-centered). */
|
||||||
x: number;
|
x: number;
|
||||||
@@ -324,6 +332,13 @@ export interface Billboard {
|
|||||||
* visible only through THAT warp's columns, beyond its mouth. */
|
* visible only through THAT warp's columns, beyond its mouth. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
warpId?: number;
|
warpId?: number;
|
||||||
|
/** Click identity: the specific body this sprite is (a wizard, a
|
||||||
|
* creature) — the pane's hit test hands it straight to the board's
|
||||||
|
* own click handlers. */
|
||||||
|
hit?: { kind: "player" | "creature"; id: string };
|
||||||
|
/** Click fallback: the REAL square this sprite stands in (a virtual
|
||||||
|
* warp copy keeps its source's square, not its drawn position). */
|
||||||
|
cell?: Cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The along=0 corner of a warp mouth — the anchor castRay's lane
|
/** The along=0 corner of a warp mouth — the anchor castRay's lane
|
||||||
@@ -384,6 +399,7 @@ export function billboards(
|
|||||||
out.push({
|
out.push({
|
||||||
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
|
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
|
||||||
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
|
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
|
||||||
|
hit: { kind: "player", id: p.id }, cell: { ...p.position },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const c of view.creatures) {
|
for (const c of view.creatures) {
|
||||||
@@ -391,6 +407,7 @@ export function billboards(
|
|||||||
out.push({
|
out.push({
|
||||||
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
|
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
|
||||||
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id,
|
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id,
|
||||||
|
hit: { kind: "creature", id: c.id }, cell: { ...c.position },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const t of view.treasures) {
|
for (const t of view.treasures) {
|
||||||
@@ -405,13 +422,14 @@ export function billboards(
|
|||||||
out.push({
|
out.push({
|
||||||
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5,
|
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5,
|
||||||
src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
|
src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
|
||||||
|
hit: { kind: "player", id: carrier.id }, cell: { ...carrier.position },
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!t.position) continue;
|
if (!t.position) continue;
|
||||||
out.push({
|
out.push({
|
||||||
x: t.position.x + 0.5, y: t.position.y + 0.5,
|
x: t.position.x + 0.5, y: t.position.y + 0.5,
|
||||||
src, scale: 0.4, rise: 0, label: "treasure",
|
src, scale: 0.4, rise: 0, label: "treasure", cell: { ...t.position },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// The floor's furniture, standing in the room. Bushes fill the square
|
// The floor's furniture, standing in the room. Bushes fill the square
|
||||||
@@ -426,25 +444,25 @@ export function billboards(
|
|||||||
out.push({
|
out.push({
|
||||||
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
|
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
|
||||||
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
|
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
|
||||||
clip: { x: tx, y: ty },
|
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||||
});
|
});
|
||||||
} else if (content.kind === "ooze") {
|
} else if (content.kind === "ooze") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/jello.png", fallback: "jello",
|
...at, src: "/terrain3d/jello.png", fallback: "jello",
|
||||||
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
|
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
|
||||||
clip: { x: tx, y: ty },
|
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||||
});
|
});
|
||||||
} else if (content.kind === "dust") {
|
} else if (content.kind === "dust") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/dust.png", fallback: "dust",
|
...at, src: "/terrain3d/dust.png", fallback: "dust",
|
||||||
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
|
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
|
||||||
clip: { x: tx, y: ty },
|
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||||
});
|
});
|
||||||
} else if (content.kind === "safe") {
|
} else if (content.kind === "safe") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
||||||
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
|
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
|
||||||
clip: { x: tx, y: ty },
|
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user