DUKK's floor-to-ceiling white sliver, decoded: the sprite gate only asked "did this column's ray warp?" — so a REAL body standing in front of a warp mouth was clipped out of the mouth's columns (leaving a baffling sliver of a huge close sprite), and a body virtualized through warp A could paint columns bent by warp B. Every column now remembers WHICH warp bent its ray and how far away that mouth stood; a real body paints a column only nearer than its mouth (in front of the window), and a virtual one only through its own warp's columns, beyond the mouth. Projectile legs carry the same identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
435 lines
19 KiB
TypeScript
435 lines
19 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" | "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 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, and
|
|
* how far along the ray that mouth stood — the gate a sprite must be
|
|
* beyond (its own warp) or in front of (any) to paint this column. */
|
|
warpId?: number;
|
|
warpDist?: 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 };
|
|
/** 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 [ex2, ey] = LANE_AXIS[to];
|
|
return rx * ex2 + ry * ey < 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";
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
/** 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>,
|
|
): 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 ghost: Hit["ghost"];
|
|
let doorway: Hit["doorway"];
|
|
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"]; slide: number; edge: string; frame?: boolean } | 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;
|
|
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 { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
|
|
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, ghost, doorway,
|
|
...(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, ghost, doorway };
|
|
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; }
|
|
// 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;
|
|
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, warpId, warpDist, ghost, doorway };
|
|
}
|
|
cx = nx;
|
|
cy = ny;
|
|
}
|
|
if (!warped || traversal === 2) break;
|
|
}
|
|
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway };
|
|
}
|
|
|
|
/** 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;
|
|
/** 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,
|
|
});
|
|
}
|
|
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, 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,
|
|
});
|
|
} 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",
|
|
});
|
|
} 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",
|
|
});
|
|
} 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",
|
|
});
|
|
}
|
|
}
|
|
// 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;
|
|
}
|