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
+15 -1
View File
@@ -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>
+17
View File
@@ -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;
+18 -2
View File
@@ -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>
+110 -3
View File
@@ -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%;
+23 -5
View File
@@ -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 },
}); });
} }
} }