Three blind reviews over the first-person arc, every finding verified against the source. History-narrating comments made timeless or cut; the stacked BUDDY comment collapsed to one voice. Dead code out: the orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter poke, the impossible-state sentinel. The never-produced "warp" hit kind resolved the right way — warp mouths now hang a translucent veil of the painted warp texture, so art that never rendered finally does. Types tightened (SlabStrike named once, ShareData rides CatchUpStep, botTier loses its casts), the gallery reads MATERIALS instead of a hand-copied list, the chronicle resets through one helper, a superseded share mint rejects instead of stranding, and the conjured safe gains the growth key its siblings had. Tests lose a triple assignment, a tautology, and two self-swallowing regex alternatives. The sprite spec — which still told the artist to paint for additive compositing the renderer no longer uses — now describes the renderer that exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
486 lines
21 KiB
TypeScript
486 lines
21 KiB
TypeScript
// First-person raycasting over the maze. The world is the GameView's board
|
|
// — one unit per cell, walls living on EDGES between cells rather than in
|
|
// them — so a ray marches cell boundaries (DDA) and asks each crossing
|
|
// what stands there. Crucially the board arrives from viewFor(), which has
|
|
// already rendered this wizard's BELIEFS: an illusion they have not seen
|
|
// through is a wall here too, and the deception carries into first person.
|
|
|
|
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
|
|
import type { GameView } from "@wizwar/engine";
|
|
import { objectArt } from "../art";
|
|
|
|
export interface Hit {
|
|
/** Distance along the ray (perpendicular-corrected by the caller). */
|
|
dist: number;
|
|
/** What the ray struck. */
|
|
kind: "wall" | "door" | "firewall" | "stone" | "rim";
|
|
/** 0..1 across the struck face (texture coordinate). */
|
|
u: number;
|
|
/** Vertical faces get a different shade than horizontal ones. */
|
|
axis: "x" | "y";
|
|
/** The un-wrapped along-face coordinate: u plus the world cell it lies
|
|
* in, so multi-cell surfaces (a long wall of fire) can read as one. */
|
|
worldU: number;
|
|
/** The edge struck, for state the renderer dresses (jammed locks,
|
|
* untested illusions). */
|
|
edge?: string;
|
|
/** The eye reached this through a warp: haze it other-worldly. */
|
|
warped?: boolean;
|
|
/** Which warp (index in board.warps) the ray first bent through, how
|
|
* far along the ray that mouth stood, and where across it — the gate a
|
|
* sprite must be beyond (its own warp) or in front of (any) to paint
|
|
* this column, and the veil the renderer hangs in the opening. */
|
|
warpId?: number;
|
|
warpDist?: number;
|
|
warpU?: number;
|
|
/** The first known-illusion edge the ray crossed before its solid hit:
|
|
* the eye passes, but a translucent ghost of a wall stands there. */
|
|
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
|
|
/** The first OPEN doorway the ray passed through: the renderer hangs
|
|
* the lintel of the frame across it. */
|
|
doorway?: { dist: number; u: number };
|
|
/** Something conjured is still RISING here: the ray passes over it,
|
|
* and the renderer draws it growing bottom-up where it stood. */
|
|
rising?: { dist: number; u: number; worldU: number; kind: Hit["kind"]; g: number };
|
|
/** The struck slab is a doorway's jamb post, dressed in frame stone. */
|
|
frame?: boolean;
|
|
}
|
|
|
|
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
|
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
|
|
|
|
/** Unit vector along an edge's lane axis (the direction `along` grows). */
|
|
const LANE_AXIS: Record<Side, [number, number]> = {
|
|
E: [0, 1], W: [0, 1], N: [1, 0], S: [1, 0],
|
|
};
|
|
|
|
/** Does this warp pair MIRROR the lane? A traversal is a proper rigid
|
|
* rotation; for half the side pairings the rotated entry axis points
|
|
* AGAINST the exit edge's axis, and preserving the raw lane there would
|
|
* smuggle in a reflection — rays crossing over at the mouth, parallax
|
|
* inverted, the far room stretching as the eye moves. */
|
|
export function warpLaneMirrored(from: Side, to: Side): boolean {
|
|
const d = SIDE_ANGLE[OPPOSITE[to]] - SIDE_ANGLE[from];
|
|
const [x, y] = LANE_AXIS[from];
|
|
const rx = x * Math.cos(d) - y * Math.sin(d);
|
|
const ry = x * Math.sin(d) + y * Math.cos(d);
|
|
const [ux, uy] = LANE_AXIS[to];
|
|
return rx * ux + ry * uy < 0;
|
|
}
|
|
|
|
/** Is this edge passable to the EYE (rays), and if not, what is it?
|
|
* Doors always report as doors; how far each stands open — fully for an
|
|
* open or held door, mid-swing during an animation — is castRay's call. */
|
|
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
|
const e = view.board.edges[key] ?? "open";
|
|
if (e === "open") return null;
|
|
if (e === "door") return "door";
|
|
if (e === "firewall") return "firewall";
|
|
return "wall";
|
|
}
|
|
|
|
/** Half-thickness per material: walls are masonry, doors joinery, fire a
|
|
* sheet. The slab gives every wall END a visible cap face, so corners and
|
|
* doorways read as three-dimensional stone rather than paper. */
|
|
const HALF_THICK: Record<string, number> = { wall: 0.07, rim: 0.07, door: 0.05, firewall: 0.025 };
|
|
|
|
/** Ray vs axis-aligned box; returns entry distance and the axis of the
|
|
* face struck, or null for a miss. */
|
|
function slabHit(
|
|
ox: number, oy: number, dx: number, dy: number,
|
|
minX: number, maxX: number, minY: number, maxY: number,
|
|
): { t: number; axis: "x" | "y" } | null {
|
|
const ix = 1 / (dx || 1e-9);
|
|
const iy = 1 / (dy || 1e-9);
|
|
let tx1 = (minX - ox) * ix, tx2 = (maxX - ox) * ix;
|
|
if (tx1 > tx2) [tx1, tx2] = [tx2, tx1];
|
|
let ty1 = (minY - oy) * iy, ty2 = (maxY - oy) * iy;
|
|
if (ty1 > ty2) [ty1, ty2] = [ty2, ty1];
|
|
const tNear = Math.max(tx1, ty1);
|
|
const tFar = Math.min(tx2, ty2);
|
|
if (tNear > tFar || tFar < 0) return null;
|
|
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
|
|
}
|
|
|
|
/** What a slab test found: the strike, and how the door slid under it. */
|
|
type SlabStrike = { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
|
|
|
|
/**
|
|
* March one ray from (ox, oy) at `angle` and return the first thing that
|
|
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
|
|
* is a warp mouth — the ray passes through those, remembering the first.
|
|
*/
|
|
export function castRay(
|
|
view: GameView, ox: number, oy: number, angle: number,
|
|
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
|
|
* view holds open stand at 1 without an entry. Wolf3D-style: an open
|
|
* door has slid along its own edge, the gap growing from u=0. */
|
|
doors?: Record<string, number>,
|
|
/** Conjurations mid-growth (0..1), keyed by edge key or stone cell
|
|
* key: while below 1 the ray passes, and the hit records a rising. */
|
|
growing?: Record<string, number>,
|
|
): Hit {
|
|
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
|
|
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
|
|
let baseDist = 0;
|
|
let warped = false;
|
|
let warpId: number | undefined;
|
|
let warpDist: number | undefined;
|
|
let warpU: number | undefined;
|
|
let ghost: Hit["ghost"];
|
|
let doorway: Hit["doorway"];
|
|
let rising: Hit["rising"];
|
|
for (let traversal = 0; traversal < 3; traversal++) {
|
|
let traversed = false;
|
|
const dx = Math.cos(angle);
|
|
const dy = Math.sin(angle);
|
|
let cx = Math.floor(ox);
|
|
let cy = Math.floor(oy);
|
|
const stepX = dx > 0 ? 1 : -1;
|
|
const stepY = dy > 0 ? 1 : -1;
|
|
const dDistX = Math.abs(1 / (dx || 1e-9));
|
|
const dDistY = Math.abs(1 / (dy || 1e-9));
|
|
let sideDistX = (dx > 0 ? cx + 1 - ox : ox - cx) * dDistX;
|
|
let sideDistY = (dy > 0 ? cy + 1 - oy : oy - cy) * dDistY;
|
|
|
|
for (let i = 0; i < 64; i++) {
|
|
// Every blocked edge of the current cell stands as a slab; the
|
|
// nearest strike inside this cell's span wins.
|
|
const exit = Math.min(sideDistX, sideDistY);
|
|
let best: SlabStrike | null = null;
|
|
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
|
|
const key = edgeKey({ x: cx, y: cy }, side);
|
|
const kind = edgeObstacle(view, key);
|
|
if (!kind) return;
|
|
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
|
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
|
|
const g = growing?.[key];
|
|
if (g !== undefined && g < 1) {
|
|
// Still being conjured: the eye passes; the growth is drawn.
|
|
if (!rising) {
|
|
const along0 = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
|
rising = { dist: baseDist + h.t, u: along0 - Math.floor(along0), worldU: along0, kind, g };
|
|
}
|
|
return;
|
|
}
|
|
let slide = 0;
|
|
let frame = false;
|
|
if (kind === "door") {
|
|
const open = doors?.[key] ??
|
|
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
|
|
if (open >= 0.98) {
|
|
// A door standing open is a DOORWAY: posts at both ends, a
|
|
// lintel hung across, and the way through open to the eye.
|
|
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
|
const u = along - Math.floor(along);
|
|
const JAMB = 0.08;
|
|
if (u > JAMB && u < 1 - JAMB) {
|
|
if (!doorway) doorway = { dist: baseDist + h.t, u };
|
|
return;
|
|
}
|
|
frame = true;
|
|
} else if (open > 0) {
|
|
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
|
const u = along - Math.floor(along);
|
|
if (u < open - 0.02) return; // the ray sails through the opening
|
|
slide = open;
|
|
}
|
|
}
|
|
best = { ...h, kind, slide, edge: key, frame };
|
|
};
|
|
const hw = (side: Side) =>
|
|
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
|
|
trySide("E", cx + 1 - hw("E"), cx + 1 + hw("E"), cy, cy + 1);
|
|
trySide("W", cx - hw("W"), cx + hw("W"), cy, cy + 1);
|
|
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
|
|
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
|
|
if (best !== null) {
|
|
const b = best as SlabStrike;
|
|
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
|
|
const u = along - Math.floor(along);
|
|
// A sliding door carries its texture with it: sample past the gap.
|
|
return {
|
|
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
|
|
axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, warpU, ghost, doorway, rising,
|
|
...(b.frame ? { frame: true } : {}),
|
|
};
|
|
}
|
|
|
|
// No slab in this cell: advance through the open boundary.
|
|
const crossingX = sideDistX < sideDistY;
|
|
const dist = crossingX ? sideDistX : sideDistY;
|
|
let nx = cx, ny = cy;
|
|
if (crossingX) { nx = cx + stepX; sideDistX += dDistX; }
|
|
else { ny = cy + stepY; sideDistY += dDistY; }
|
|
const axis: Hit["axis"] = crossingX ? "x" : "y";
|
|
const along = crossingX ? oy + dist * dy : ox + dist * dx;
|
|
const u0 = along % 1;
|
|
const texU = u0 < 0 ? u0 + 1 : u0;
|
|
|
|
const crossSide: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N");
|
|
if (!ghost && view.knownIllusionEdges.includes(edgeKey({ x: cx, y: cy }, crossSide))) {
|
|
ghost = { dist: baseDist + dist, u: texU, worldU: along, axis };
|
|
}
|
|
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
|
|
if (offBoard) {
|
|
const side: Side = crossSide;
|
|
const wIdx = view.board.warps.findIndex(
|
|
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
|
|
);
|
|
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
|
|
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
|
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; warpU = texU; }
|
|
// Step through: re-enter at the paired mouth, heading inward, the
|
|
// lane carried by the same proper rotation the heading turns by —
|
|
// mirrored for the pairings whose axes land head-to-head.
|
|
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
|
|
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
|
|
const c = warp.to.cell;
|
|
const lane = warpLaneMirrored(warp.from.side, warp.to.side) ? 1 - texU : texU;
|
|
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; }
|
|
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; }
|
|
else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; }
|
|
else { ox = c.x + lane; oy = c.y + 1e-4; }
|
|
baseDist += dist;
|
|
warped = true;
|
|
traversed = true;
|
|
break; // restart the DDA from the far mouth
|
|
}
|
|
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
|
const sg = growing?.[cellKey({ x: nx, y: ny })];
|
|
if (sg !== undefined && sg < 1) {
|
|
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
|
|
} else {
|
|
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
|
}
|
|
}
|
|
cx = nx;
|
|
cy = ny;
|
|
}
|
|
if (!traversed) break;
|
|
}
|
|
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
|
}
|
|
|
|
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
|
* collision: walls and closed doors stop you; fire does not, and an open
|
|
* rim edge carries you only where a warp mouth waits. */
|
|
export function canWalk(view: GameView, from: Cell, side: Side): boolean {
|
|
const key = edgeKey(from, side);
|
|
const e = view.board.edges[key] ?? "open";
|
|
if (e === "wall") return false;
|
|
if (e === "door" &&
|
|
!view.openDoorEdges.includes(key) && !view.heldDoorEdges.includes(key)) return false;
|
|
const to = {
|
|
x: from.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
|
y: from.y + (side === "S" ? 1 : side === "N" ? -1 : 0),
|
|
};
|
|
if (!view.board.cells[cellKey(to)]) {
|
|
return view.board.warps.some(
|
|
(w) => cellKey(w.from.cell) === cellKey(from) && w.from.side === side,
|
|
);
|
|
}
|
|
if (view.squareContents[cellKey(to)]?.kind === "stone") return false;
|
|
return true;
|
|
}
|
|
|
|
/** The midpoint of one cell edge, in world units. */
|
|
export function edgeMid(cell: Cell, side: Side): { x: number; y: number } {
|
|
if (side === "E") return { x: cell.x + 1, y: cell.y + 0.5 };
|
|
if (side === "W") return { x: cell.x, y: cell.y + 0.5 };
|
|
if (side === "S") return { x: cell.x + 0.5, y: cell.y + 1 };
|
|
return { x: cell.x + 0.5, y: cell.y };
|
|
}
|
|
|
|
export interface Billboard {
|
|
/** World position (cell-centered). */
|
|
x: number;
|
|
y: number;
|
|
/** Art URL, resolved by the caller (token art respects preferences). */
|
|
src: string;
|
|
/** Fraction of wall height (a wizard stands taller than a chest). */
|
|
scale: number;
|
|
/** Lifted off the floor (0 = feet on the ground). */
|
|
rise: number;
|
|
label: string;
|
|
/** Width as a multiple of height (1 = square; 2 = a cell-wide hedge). */
|
|
aspect?: number;
|
|
/** Translucency (jello, dust); 1 or absent is opaque. */
|
|
alpha?: number;
|
|
/** Drawn additively, as light (warp pillars). */
|
|
glow?: boolean;
|
|
/** Pulled this much nearer for draw order: a hedge wins the tie
|
|
* against the wizard standing in its own square, who then peeks over. */
|
|
bias?: number;
|
|
/** Procedural stand-in name while the painted file loads. */
|
|
fallback?: string;
|
|
/** Identity for growth animation: a creature's id, or a cell key. */
|
|
key?: string;
|
|
/** Cell-bound volumes never paint outside their own square: a
|
|
* camera-facing hedge viewed obliquely would otherwise poke its ends
|
|
* through the neighboring walls. */
|
|
clip?: { x: number; y: number };
|
|
/** A reflection seen through a warp mouth, not the thing itself —
|
|
* visible only through THAT warp's columns, beyond its mouth. */
|
|
warped?: boolean;
|
|
warpId?: number;
|
|
}
|
|
|
|
/** The along=0 corner of a warp mouth — the anchor castRay's lane
|
|
* preservation measures from. */
|
|
function mouthAnchor(m: { cell: Cell; side: Side }): { x: number; y: number } {
|
|
const c = m.cell;
|
|
if (m.side === "E") return { x: c.x + 1, y: c.y };
|
|
if (m.side === "W") return { x: c.x, y: c.y };
|
|
if (m.side === "S") return { x: c.x, y: c.y + 1 };
|
|
return { x: c.x, y: c.y };
|
|
}
|
|
|
|
/** The rigid motion one warp applies to sight: far-region points map into
|
|
* the near mouth's virtual space (where a straight ray would put them),
|
|
* and back again. Anchored and rotated exactly as castRay re-enters. */
|
|
export function warpMotion(w: GameView["board"]["warps"][number]): {
|
|
toVirtual(p: { x: number; y: number }): { x: number; y: number };
|
|
toReal(p: { x: number; y: number }): { x: number; y: number };
|
|
} {
|
|
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
|
|
const a1 = mouthAnchor(w.from);
|
|
// Mirrored pairings anchor the far mouth at its OTHER corner — the
|
|
// lane runs backwards there, and this keeps the motion a pure
|
|
// rotation matching castRay's traversal.
|
|
const a2 = { ...mouthAnchor(w.to) };
|
|
if (warpLaneMirrored(w.from.side, w.to.side)) {
|
|
const [lx, ly] = LANE_AXIS[w.to.side];
|
|
a2.x += lx;
|
|
a2.y += ly;
|
|
}
|
|
const c = Math.cos(delta), s = Math.sin(delta);
|
|
return {
|
|
toVirtual(p) {
|
|
const rx = p.x - a2.x, ry = p.y - a2.y;
|
|
return { x: a1.x + rx * c + ry * s, y: a1.y - rx * s + ry * c };
|
|
},
|
|
toReal(p) {
|
|
const rx = p.x - a1.x, ry = p.y - a1.y;
|
|
return { x: a2.x + rx * c - ry * s, y: a2.y + rx * s + ry * c };
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Everything standing in the maze that the reel should draw as a sprite.
|
|
* `posOverride` maps a player or creature id to an in-flight world
|
|
* position — the reel glides bodies between steps instead of blinking
|
|
* them from cell to cell. */
|
|
export function billboards(
|
|
view: GameView,
|
|
povId: string,
|
|
art: (file: string, cat: "players" | "creatures" | "objects" | "terrain") => string,
|
|
posOverride?: Record<string, { x: number; y: number }>,
|
|
): Billboard[] {
|
|
const out: Billboard[] = [];
|
|
for (const p of view.players) {
|
|
if (!p.alive || p.id === povId) continue;
|
|
const o = posOverride?.[p.id];
|
|
out.push({
|
|
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,
|
|
});
|
|
}
|
|
for (const c of view.creatures) {
|
|
const o = posOverride?.[c.id];
|
|
out.push({
|
|
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,
|
|
});
|
|
}
|
|
for (const t of view.treasures) {
|
|
const src = art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects");
|
|
if (t.carriedBy) {
|
|
// A carried treasure rides its carrier at chest height, drawn just
|
|
// in front of them — the most important status in the game should
|
|
// be visible on the thief's own body.
|
|
const carrier = view.players.find((p) => p.id === t.carriedBy && p.alive);
|
|
if (!carrier || carrier.id === povId) continue;
|
|
const o = posOverride?.[carrier.id];
|
|
out.push({
|
|
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",
|
|
});
|
|
continue;
|
|
}
|
|
if (!t.position) continue;
|
|
out.push({
|
|
x: t.position.x + 0.5, y: t.position.y + 0.5,
|
|
src, scale: 0.4, rise: 0, label: "treasure",
|
|
});
|
|
}
|
|
// The floor's furniture, standing in the room. Bushes fill the square
|
|
// to half height (a wizard peeks over); the killer ooze is a whole
|
|
// translucent cube of jello; dust billows. Pits, slime, tacks, and
|
|
// dimensional warps are painted INTO the floor by the decal pass, and
|
|
// stone is a wall to the rays — none of those need a sprite.
|
|
for (const [k, content] of Object.entries(view.squareContents)) {
|
|
const [tx, ty] = k.split(",").map(Number) as [number, number];
|
|
const at = { x: tx + 0.5, y: ty + 0.5 };
|
|
if (content.kind === "thornbush" || content.kind === "rosebush") {
|
|
out.push({
|
|
...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,
|
|
clip: { x: tx, y: ty },
|
|
});
|
|
} else if (content.kind === "ooze") {
|
|
out.push({
|
|
...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,
|
|
clip: { x: tx, y: ty },
|
|
});
|
|
} else if (content.kind === "dust") {
|
|
out.push({
|
|
...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,
|
|
clip: { x: tx, y: ty },
|
|
});
|
|
} else if (content.kind === "safe") {
|
|
out.push({
|
|
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
|
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
|
|
clip: { x: tx, y: ty },
|
|
});
|
|
}
|
|
}
|
|
// Dimensional warp mouths throw pillars of light from their floor rings.
|
|
for (const w of view.dimWarps) {
|
|
for (const cellAt of [w.a, w.b]) {
|
|
out.push({
|
|
x: cellAt.x + 0.5, y: cellAt.y + 0.5,
|
|
src: "/terrain3d/warpglow.png", fallback: "warpglow",
|
|
scale: 0.95, aspect: 0.5, rise: 0, alpha: 0.6, glow: true, label: "dimwarp",
|
|
});
|
|
}
|
|
}
|
|
for (const [k, cards] of Object.entries(view.groundObjects)) {
|
|
const file = cards[0] ? objectArt(cards[0].cardId) : null;
|
|
if (!file) continue;
|
|
const [tx, ty] = k.split(",").map(Number) as [number, number];
|
|
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "objects"), scale: 0.25, rise: 0, label: cards[0]!.cardId });
|
|
}
|
|
// Rays see through warp mouths; the standing world should follow. Each
|
|
// sprite near a far mouth also appears in the near mouth's frame, under
|
|
// the same rigid motion the rays use — the zbuffer clips it to the
|
|
// opening, since everything around the mouth is nearer wall.
|
|
const real = out.slice();
|
|
for (let wi = 0; wi < view.board.warps.length; wi++) {
|
|
const w = view.board.warps[wi]!;
|
|
const motion = warpMotion(w);
|
|
const a2 = mouthAnchor(w.to);
|
|
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
|
|
const inX = Math.cos(inward), inY = Math.sin(inward);
|
|
for (const b of real) {
|
|
const rx = b.x - a2.x, ry = b.y - a2.y;
|
|
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue;
|
|
out.push({ ...b, ...motion.toVirtual(b), warped: true, warpId: wi });
|
|
}
|
|
}
|
|
return out;
|
|
}
|