Three polishes aimed at the instant-replay dream. Other wizards and creatures now GLIDE between steps in first person instead of blinking cell to cell — a stride tweens, a teleport still simply arrives. A projectile whose sight line runs through a warp now flies as two simultaneous legs under the mouths' rigid motion, so the fireball plunges into the opening on one side and bursts from the pair on the other, whichever room the camera stands in. And the saved video is no longer a bare canvas grab: it composites into a shareable 1280x720 frame with the step's caption burned in — gold actor name, the reel's own words — and the game's name in the corner, so a clip passed along explains itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
294 lines
13 KiB
TypeScript
294 lines
13 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, TERRAIN_ART } 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" | "warp";
|
|
/** 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 eye reached this through a warp: haze it other-worldly. */
|
|
warped?: boolean;
|
|
}
|
|
|
|
const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
|
const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
|
|
|
|
/** Is this edge passable to the EYE (rays), and if not, what is it? */
|
|
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
|
const e = view.board.edges[key] ?? "open";
|
|
if (e === "open") return null;
|
|
if (e === "door") {
|
|
// An open or held door is a doorway; the eye passes through the gap.
|
|
if (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key)) return null;
|
|
return "door";
|
|
}
|
|
if (e === "firewall") return "firewall";
|
|
return "wall";
|
|
}
|
|
|
|
/**
|
|
* 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 (then a shimmering opening).
|
|
*/
|
|
/** 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" };
|
|
}
|
|
|
|
export function castRay(view: GameView, ox: number, oy: number, angle: 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;
|
|
for (let traversal = 0; traversal < 3; traversal++) {
|
|
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: { t: number; axis: "x" | "y"; kind: Hit["kind"] } | null = null;
|
|
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
|
|
const kind = edgeObstacle(view, edgeKey({ x: cx, y: cy }, side));
|
|
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)) best = { ...h, kind };
|
|
};
|
|
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 { t: number; axis: "x" | "y"; kind: Hit["kind"] };
|
|
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
|
|
const u = along - Math.floor(along);
|
|
return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, worldU: along, warped };
|
|
}
|
|
|
|
// 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 offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
|
|
if (offBoard) {
|
|
const side: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N");
|
|
const warp = view.board.warps.find(
|
|
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
|
|
);
|
|
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped };
|
|
// Step through: re-enter at the paired mouth, heading inward, the
|
|
// offset along the edge preserved (a wraparound keeps its lane).
|
|
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
|
|
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
|
|
const c = warp.to.cell;
|
|
const lane = 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;
|
|
i = 64; // restart the DDA from the far mouth
|
|
break;
|
|
}
|
|
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
|
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped };
|
|
}
|
|
cx = nx;
|
|
cy = ny;
|
|
}
|
|
if (!warped || traversal === 2) break;
|
|
}
|
|
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped };
|
|
}
|
|
|
|
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
|
* collision: walls and closed doors stop you, fire and warps do not. */
|
|
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 false; // the rim (warp-walk later)
|
|
if (view.squareContents[cellKey(to)]?.kind === "stone") return false;
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
/** A reflection seen through a warp mouth, not the thing itself. */
|
|
warped?: boolean;
|
|
}
|
|
|
|
/** 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);
|
|
const a2 = mouthAnchor(w.to);
|
|
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,
|
|
});
|
|
}
|
|
for (const t of view.treasures) {
|
|
if (!t.position || t.carriedBy) continue;
|
|
out.push({
|
|
x: t.position.x + 0.5, y: t.position.y + 0.5,
|
|
src: art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects"),
|
|
scale: 0.4, rise: 0, label: "treasure",
|
|
});
|
|
}
|
|
// The floor's furniture: bushes stand tall, hazards squat low. Stone is
|
|
// a wall to the rays and needs no sprite.
|
|
const TERRAIN_SCALE: Record<string, number> = {
|
|
thornbush: 0.7, rosebush: 0.7, safe: 0.55, ooze: 0.35, slime: 0.3,
|
|
tacks: 0.25, pit: 0.35, dust: 0.6,
|
|
};
|
|
for (const [k, content] of Object.entries(view.squareContents)) {
|
|
if (content.kind === "stone") continue;
|
|
const file = TERRAIN_ART[content.kind];
|
|
const scale = TERRAIN_SCALE[content.kind];
|
|
if (!file || !scale) continue;
|
|
const [tx, ty] = k.split(",").map(Number) as [number, number];
|
|
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "terrain"), scale, rise: 0, label: content.kind });
|
|
}
|
|
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 (const w of view.board.warps) {
|
|
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 });
|
|
}
|
|
}
|
|
return out;
|
|
}
|