Stage three: spells fly through your own eyes
The reel's first-person mode now shows the fight, not just the rooms. The board flourishes' event map is recast into world space (fx3d): fireballs, bolts, and thrown things streak caster to target as glowing billboards; landings swell and fade; blows that land on YOU wash the screen red and jolt the camera; warp-steps and teleports flash violet; a hurled body glides fast and straight with its eyes held steady. Nine paintable conjuration sprites ship at public/fx3d (spec for the artist in research/), shown in the token workshop beside the masonry. Three rough edges sharpened along the way: sprites now project through warp mouths under the same rigid motion the rays use, clipped by the depth buffer to the opening; fire and warp surfaces pull a different texture slice per world cell so a long blaze reads as one; and the replay camera only turns toward an actor its eyes could actually see. Two more gifts: the reel can record its first-person canvas straight to a downloadable WebM (⏺ save video), and the About tab now shows visitors the way into the three workshops. One buried mine defused: rAF hands frame-start times that can precede the tween's own clock, and a zero-length turn divided by it — one NaN facing poisoned every ray black and re-triggered the camera effect forever. The tween clamps and guards its phases now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fcafdb06de
commit
e8c6b93c71
@@ -18,6 +18,9 @@ export interface Hit {
|
||||
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;
|
||||
}
|
||||
@@ -104,7 +107,7 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
|
||||
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, warped };
|
||||
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.
|
||||
@@ -114,7 +117,8 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
|
||||
if (crossingX) { nx = cx + stepX; sideDistX += dDistX; }
|
||||
else { ny = cy + stepY; sideDistY += dDistY; }
|
||||
const axis: Hit["axis"] = crossingX ? "x" : "y";
|
||||
const u0 = crossingX ? (oy + dist * dy) % 1 : (ox + dist * dx) % 1;
|
||||
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 })];
|
||||
@@ -123,31 +127,31 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
|
||||
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, warped };
|
||||
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 along = texU;
|
||||
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + along; }
|
||||
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + along; }
|
||||
else if (warp.to.side === "S") { ox = c.x + along; oy = c.y + 1 - 1e-4; }
|
||||
else { ox = c.x + along; oy = c.y + 1e-4; }
|
||||
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, warped };
|
||||
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", warped };
|
||||
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
|
||||
@@ -178,6 +182,18 @@ export interface Billboard {
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
/** Everything standing in the maze that the reel should draw as a sprite. */
|
||||
@@ -228,5 +244,28 @@ export function billboards(
|
||||
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 delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
|
||||
const a1 = mouthAnchor(w.from);
|
||||
const a2 = mouthAnchor(w.to);
|
||||
const cosD = Math.cos(-delta), sinD = Math.sin(-delta);
|
||||
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,
|
||||
x: a1.x + rx * cosD - ry * sinD,
|
||||
y: a1.y + rx * sinD + ry * cosD,
|
||||
warped: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user