Conjurations grow into the world, wrapped in glowing swirls
Nothing appears fully-formed anymore. Summoned creatures and conjured terrain volumes scale up from a sliver, bottom-anchored, as if growing out of the floor. Conjured SOLIDS — walls, firewalls, stone, and yes, illusion walls for whoever they fool — RISE: while growing, the rays pass over them so the room behind stays visible, and the renderer draws the young face climbing bottom-up with violet swirls running across it, solidifying as it tops out. Every creation also blooms the shimmer at its cell. The growth is driven through the fx channel, so the director's slate applies: things grow only once the camera faces them. Two camera notes squared away en route: the slate-setter read back the state it had just written, registering itself as its own dependency (an update-depth ping-pong under fast stepping) — locals now; and a reel whose FINAL step strides into a corner gets one more turn after the walk lands, to a view worth ending on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
cd9ce9fddf
commit
285c91b2c8
@@ -313,28 +313,31 @@
|
|||||||
aimed = true;
|
aimed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// How good is the view down heading `a` from (px,py)? Corridor depth,
|
||||||
|
// and at the reel's end, a bonus for visible subjects.
|
||||||
|
const viewScoreAt = (px: number, py: number, a: number): number => {
|
||||||
|
let score = Math.min(castRay(v, px, py, a).dist, 8);
|
||||||
|
if (!atEnd) return score;
|
||||||
|
const bonus = (cell: { x: number; y: number }, worth: number) => {
|
||||||
|
const bx = cell.x + 0.5, by = cell.y + 0.5;
|
||||||
|
const bearing = Math.atan2(by - py, bx - px);
|
||||||
|
if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0;
|
||||||
|
const d = Math.hypot(bx - px, by - py);
|
||||||
|
if (d < 0.1) return 0;
|
||||||
|
const sight = castRay(v, px, py, bearing);
|
||||||
|
return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0;
|
||||||
|
};
|
||||||
|
for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6);
|
||||||
|
for (const c of v.creatures) score += bonus(c.position, 5);
|
||||||
|
for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3);
|
||||||
|
return score;
|
||||||
|
};
|
||||||
if (!aimed) {
|
if (!aimed) {
|
||||||
// Nothing aims the eyes. If they'd idle nose-to-brick (the opening
|
// Nothing aims the eyes. If they'd idle nose-to-brick (the opening
|
||||||
// cut, a dead-end beat — or the reel's CLOSING shot, which gets a
|
// cut, a dead-end beat — or the reel's CLOSING shot, which gets a
|
||||||
// higher standard), find a better view: the deepest corridor, and
|
// higher standard), find a better view: the deepest corridor, and
|
||||||
// on the final frame, preferably one with something in it.
|
// on the final frame, preferably one with something in it.
|
||||||
const viewScore = (a: number): number => {
|
const viewScore = (a: number): number => viewScoreAt(tx, ty, a);
|
||||||
let score = Math.min(castRay(v, tx, ty, a).dist, 8);
|
|
||||||
if (!atEnd) return score;
|
|
||||||
const bonus = (cell: { x: number; y: number }, worth: number) => {
|
|
||||||
const bx = cell.x + 0.5, by = cell.y + 0.5;
|
|
||||||
const bearing = Math.atan2(by - ty, bx - tx);
|
|
||||||
if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0;
|
|
||||||
const d = Math.hypot(bx - tx, by - ty);
|
|
||||||
if (d < 0.1) return 0;
|
|
||||||
const sight = castRay(v, tx, ty, bearing);
|
|
||||||
return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0;
|
|
||||||
};
|
|
||||||
for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6);
|
|
||||||
for (const c of v.creatures) score += bonus(c.position, 5);
|
|
||||||
for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3);
|
|
||||||
return score;
|
|
||||||
};
|
|
||||||
// A cut lands with whatever stale facing it carried, so a cut with
|
// A cut lands with whatever stale facing it carried, so a cut with
|
||||||
// nothing aimed always re-picks its view from the new position.
|
// nothing aimed always re-picks its view from the new position.
|
||||||
const staring = willCut ||
|
const staring = willCut ||
|
||||||
@@ -351,15 +354,32 @@
|
|||||||
targetFacing = bestA;
|
targetFacing = bestA;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The reel's LAST step may be a stride into a corner: the walk keeps
|
||||||
|
// its own facing, then the camera turns once more to a view worth
|
||||||
|
// ending on.
|
||||||
|
let closingArc = 0;
|
||||||
|
if (atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) {
|
||||||
|
const current = viewScoreAt(tx, ty, targetFacing);
|
||||||
|
let bestA = targetFacing, bestScore = current;
|
||||||
|
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||||
|
const sc = viewScoreAt(tx, ty, a);
|
||||||
|
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
|
||||||
|
}
|
||||||
|
closingArc = shortestArc(targetFacing, bestA);
|
||||||
|
}
|
||||||
const stepIdx = Math.min(idx, steps.length - 1);
|
const stepIdx = Math.min(idx, steps.length - 1);
|
||||||
const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null;
|
const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null;
|
||||||
/** Hold the OLD world on screen this long, so the outcome lands only
|
/** Hold the OLD world on screen this long, so the outcome lands only
|
||||||
* once the eyes face its recipient: a cutaway gets a beat of the
|
* once the eyes face its recipient: a cutaway gets a beat of the
|
||||||
* "before"; a turn holds until the turn is done. */
|
* "before"; a turn holds until the turn is done. */
|
||||||
const setSlate = (holdMs: number) => {
|
const setSlate = (holdMs: number) => {
|
||||||
heldView = holdMs > 60 && prevView ? prevView : null;
|
// Locals only: reading heldView back here would register it as a
|
||||||
camPlan = { idx: stepIdx, holdMs: heldView ? holdMs : 0 };
|
// dependency of this very effect, and the timer clearing it would
|
||||||
if (heldView) {
|
// re-trigger us into a ping-pong.
|
||||||
|
const hold = holdMs > 60 && prevView ? prevView : null;
|
||||||
|
heldView = hold;
|
||||||
|
camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 };
|
||||||
|
if (hold) {
|
||||||
const t = setTimeout(() => (heldView = null), holdMs);
|
const t = setTimeout(() => (heldView = null), holdMs);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}
|
}
|
||||||
@@ -379,6 +399,7 @@
|
|||||||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||||||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||||||
const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
|
const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
|
||||||
|
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
@@ -394,8 +415,12 @@
|
|||||||
const ease = w * w * (3 - 2 * w);
|
const ease = w * w * (3 - 2 * w);
|
||||||
cam.x = fromX + (tx - fromX) * ease;
|
cam.x = fromX + (tx - fromX) * ease;
|
||||||
cam.y = fromY + (ty - fromY) * ease;
|
cam.y = fromY + (ty - fromY) * ease;
|
||||||
|
} else if (closingArc !== 0 && t < turnMs + walkMs + closingMs) {
|
||||||
|
cam.x = tx; cam.y = ty;
|
||||||
|
const w = (t - turnMs - walkMs) / closingMs;
|
||||||
|
cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w);
|
||||||
} else {
|
} else {
|
||||||
cam.facing = fromF + arc;
|
cam.facing = fromF + arc + closingArc;
|
||||||
cam.x = tx; cam.y = ty;
|
cam.x = tx; cam.y = ty;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// by the same depth buffer the walls wrote.
|
// by the same depth buffer the walls wrote.
|
||||||
import { castRay, billboards } from "./raycast";
|
import { castRay, billboards } from "./raycast";
|
||||||
import { materialTextures } from "./textures";
|
import { materialTextures } from "./textures";
|
||||||
import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
|
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
|
||||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||||
import { tokenArt } from "../art";
|
import { tokenArt } from "../art";
|
||||||
import { PLAYER_COLORS } from "../colors";
|
import { PLAYER_COLORS } from "../colors";
|
||||||
@@ -150,12 +150,21 @@
|
|||||||
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
|
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Doors mid-swing this frame, from the animated fx.
|
// Doors mid-swing and conjurations mid-growth this frame.
|
||||||
let doors: Record<string, number> | undefined;
|
let doors: Record<string, number> | undefined;
|
||||||
|
let growing: Record<string, number> | undefined;
|
||||||
|
const spawn: Record<string, number> = {};
|
||||||
for (const f of fx) {
|
for (const f of fx) {
|
||||||
if (f.kind !== "door") continue;
|
if (f.kind === "door") {
|
||||||
const a = doorOpenness((time - f.t0) / f.dur);
|
const a = doorOpenness((time - f.t0) / f.dur);
|
||||||
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
|
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
|
||||||
|
} else if (f.kind === "grow") {
|
||||||
|
const p = (time - f.t0) / f.dur;
|
||||||
|
if (p < 0 || p >= 1) continue;
|
||||||
|
const g = growProgress(p);
|
||||||
|
if (f.slot === "solid") (growing ??= {})[f.key] = g;
|
||||||
|
else spawn[f.key] = g;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ground and the vault overhead, cast per pixel: each screen row
|
// The ground and the vault overhead, cast per pixel: each screen row
|
||||||
@@ -248,11 +257,13 @@
|
|||||||
// stands at the crossing — drawn after the sprites so bodies show
|
// stands at the crossing — drawn after the sprites so bodies show
|
||||||
// through it, shimmering so nobody mistakes it for stone.
|
// through it, shimmering so nobody mistakes it for stone.
|
||||||
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
|
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
|
||||||
// Open doorways collect their lintels, hung after the sprites.
|
// Open doorways collect their lintels, hung after the sprites; and
|
||||||
|
// conjurations still rising collect their growing columns.
|
||||||
const lintels: { col: number; depth: number; u: number }[] = [];
|
const lintels: { col: number; depth: number; u: number }[] = [];
|
||||||
|
const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = [];
|
||||||
for (let col = 0; col < W; col++) {
|
for (let col = 0; col < W; col++) {
|
||||||
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
|
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
|
||||||
const hit = castRay(view, ex, ey, rayAngle, doors);
|
const hit = castRay(view, ex, ey, rayAngle, doors, growing);
|
||||||
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
|
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
|
||||||
zbuf[col] = depth;
|
zbuf[col] = depth;
|
||||||
if (hit.warpId !== undefined) {
|
if (hit.warpId !== undefined) {
|
||||||
@@ -268,6 +279,12 @@
|
|||||||
if (hit.doorway) {
|
if (hit.doorway) {
|
||||||
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
|
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
|
||||||
}
|
}
|
||||||
|
if (hit.rising) {
|
||||||
|
risings.push({
|
||||||
|
col, depth: hit.rising.dist * Math.cos(rayAngle - facing),
|
||||||
|
u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g,
|
||||||
|
});
|
||||||
|
}
|
||||||
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;
|
||||||
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
||||||
@@ -331,7 +348,10 @@
|
|||||||
|
|
||||||
// Sprites, far to near, sliced against the depth buffer.
|
// Sprites, far to near, sliced against the depth buffer.
|
||||||
const sprites = billboards(view, povId, tokenArt, posOverride)
|
const sprites = billboards(view, povId, tokenArt, posOverride)
|
||||||
.map((b) => project(b, ex, ey))
|
.map((b) => {
|
||||||
|
const g = b.key !== undefined ? spawn[b.key] : undefined;
|
||||||
|
return project(g !== undefined ? { ...b, scale: b.scale * g } : b, ex, ey);
|
||||||
|
})
|
||||||
.filter((s): s is Projected => s !== null);
|
.filter((s): s is Projected => s !== null);
|
||||||
for (const spot of rubble) {
|
for (const spot of rubble) {
|
||||||
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
|
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
|
||||||
@@ -392,6 +412,22 @@
|
|||||||
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conjurations rising: the wall (or stone) grows bottom-up where it
|
||||||
|
// will stand, glowing swirls running over the young face.
|
||||||
|
for (const ri of risings) {
|
||||||
|
const full = Math.min(H * 2.5, H / Math.max(ri.depth, 0.05));
|
||||||
|
const top0 = half + full / 2 - full * ri.g;
|
||||||
|
const tex = textures[ri.kind] ?? textures.wall!;
|
||||||
|
ctx.globalAlpha = 0.55 + 0.45 * ri.g;
|
||||||
|
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width), 0,
|
||||||
|
Math.max(1, tex.width / 96), tex.height, ri.col, top0, 1, full * ri.g);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
const swirl = (0.35 * (1 - ri.g) + 0.08) *
|
||||||
|
(0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21));
|
||||||
|
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
||||||
|
ctx.fillRect(ri.col, top0, 1, full * ri.g);
|
||||||
|
}
|
||||||
|
|
||||||
// Doorway lintels: the frame's top rows hung across each opening.
|
// Doorway lintels: the frame's top rows hung across each opening.
|
||||||
for (const li of lintels) {
|
for (const li of lintels) {
|
||||||
const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05));
|
const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05));
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ export type FpFx =
|
|||||||
rise?: number }
|
rise?: number }
|
||||||
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
|
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
|
||||||
| { id: number; kind: "shake"; mag: number; t0: number; dur: number }
|
| { id: number; kind: "shake"; mag: number; t0: number; dur: number }
|
||||||
| { id: number; kind: "door"; edge: string; t0: number; dur: number };
|
| { id: number; kind: "door"; edge: string; t0: number; dur: number }
|
||||||
|
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
|
||||||
|
|
||||||
|
/** Eased growth 0..1 for a conjuration's rise. */
|
||||||
|
export function growProgress(p: number): number {
|
||||||
|
if (p <= 0) return 0.01;
|
||||||
|
if (p >= 1) return 1;
|
||||||
|
return Math.max(0.01, p * p * (3 - 2 * p));
|
||||||
|
}
|
||||||
|
|
||||||
/** How far open an animated door stands at progress p: swings open,
|
/** How far open an animated door stands at progress p: swings open,
|
||||||
* holds for the crossing, swings shut. */
|
* holds for the crossing, swings shut. */
|
||||||
@@ -144,11 +152,34 @@ export function fpFxForEvents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.type === "wallCreated" && "edge" in e) {
|
if ((e.type === "wallCreated" || e.type === "firewallCreated" || e.type === "illusionWallCreated") && "edge" in e) {
|
||||||
// Stone conjured from nothing: dust where it rises.
|
// Conjured from nothing: the edge RISES from the floor, wrapped in
|
||||||
const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell,
|
// dust and shimmer. (An illusion rises too — for the deceived, it
|
||||||
(e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
|
// is a wall in every way.)
|
||||||
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 550 }, delay: 0 });
|
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
|
||||||
|
const key = edgeKey(edge.cell, edge.side);
|
||||||
|
out.push({ fx: { id: nextId++, kind: "grow", slot: "solid", key, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
const at = edgeMid(edge.cell, edge.side);
|
||||||
|
if (e.type === "wallCreated") {
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 550 }, delay: 0 });
|
||||||
|
}
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
}
|
||||||
|
if (e.type === "creatureCreated" && "at" in e) {
|
||||||
|
const at = (e as { at: { x: number; y: number } }).at;
|
||||||
|
const cid = (e as { creatureId: string }).creatureId;
|
||||||
|
out.push({ fx: { id: nextId++, kind: "grow", slot: "sprite", key: cid, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
|
||||||
|
}
|
||||||
|
if (e.type === "squareFilled" && "cell" in e) {
|
||||||
|
const cell = (e as { cell: { x: number; y: number } }).cell;
|
||||||
|
const kind = (e as { kind: string }).kind;
|
||||||
|
const cellK = `${cell.x},${cell.y}`;
|
||||||
|
out.push({
|
||||||
|
fx: { id: nextId++, kind: "grow", slot: kind === "stone" ? "solid" : "sprite", key: cellK, t0: 0, dur: 750 },
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: cell.x + 0.5, y: cell.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
|
||||||
}
|
}
|
||||||
if (e.type === "sectorRotated" || e.type === "sectorRelocated") {
|
if (e.type === "sectorRotated" || e.type === "sectorRelocated") {
|
||||||
// The maze ITSELF moves: the whole world flashes violet and heaves.
|
// The maze ITSELF moves: the whole world flashes violet and heaves.
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export interface Hit {
|
|||||||
/** The first OPEN doorway the ray passed through: the renderer hangs
|
/** The first OPEN doorway the ray passed through: the renderer hangs
|
||||||
* the lintel of the frame across it. */
|
* the lintel of the frame across it. */
|
||||||
doorway?: { dist: number; u: number };
|
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. */
|
/** The struck slab is a doorway's jamb post, dressed in frame stone. */
|
||||||
frame?: boolean;
|
frame?: boolean;
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,9 @@ export function castRay(
|
|||||||
* view holds open stand at 1 without an entry. Wolf3D-style: an open
|
* 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. */
|
* door has slid along its own edge, the gap growing from u=0. */
|
||||||
doors?: Record<string, number>,
|
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 {
|
): Hit {
|
||||||
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
|
// 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.
|
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
|
||||||
@@ -117,6 +123,7 @@ export function castRay(
|
|||||||
let warpDist: number | undefined;
|
let warpDist: number | undefined;
|
||||||
let ghost: Hit["ghost"];
|
let ghost: Hit["ghost"];
|
||||||
let doorway: Hit["doorway"];
|
let doorway: Hit["doorway"];
|
||||||
|
let rising: Hit["rising"];
|
||||||
for (let traversal = 0; traversal < 3; traversal++) {
|
for (let traversal = 0; traversal < 3; traversal++) {
|
||||||
const dx = Math.cos(angle);
|
const dx = Math.cos(angle);
|
||||||
const dy = Math.sin(angle);
|
const dy = Math.sin(angle);
|
||||||
@@ -140,6 +147,15 @@ export function castRay(
|
|||||||
if (!kind) return;
|
if (!kind) return;
|
||||||
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
||||||
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
|
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 slide = 0;
|
||||||
let frame = false;
|
let frame = false;
|
||||||
if (kind === "door") {
|
if (kind === "door") {
|
||||||
@@ -178,7 +194,7 @@ export function castRay(
|
|||||||
// A sliding door carries its texture with it: sample past the gap.
|
// A sliding door carries its texture with it: sample past the gap.
|
||||||
return {
|
return {
|
||||||
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
|
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,
|
axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, ghost, doorway, rising,
|
||||||
...(b.frame ? { frame: true } : {}),
|
...(b.frame ? { frame: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -205,7 +221,7 @@ export function castRay(
|
|||||||
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
|
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
|
||||||
);
|
);
|
||||||
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
|
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 (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising };
|
||||||
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; }
|
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; }
|
||||||
// Step through: re-enter at the paired mouth, heading inward, the
|
// Step through: re-enter at the paired mouth, heading inward, the
|
||||||
// lane carried by the same proper rotation the heading turns by —
|
// lane carried by the same proper rotation the heading turns by —
|
||||||
@@ -224,14 +240,19 @@ export function castRay(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
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 };
|
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, ghost, doorway, rising };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cx = nx;
|
cx = nx;
|
||||||
cy = ny;
|
cy = ny;
|
||||||
}
|
}
|
||||||
if (!warped || traversal === 2) break;
|
if (!warped || traversal === 2) break;
|
||||||
}
|
}
|
||||||
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway };
|
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway, rising };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
||||||
@@ -286,6 +307,8 @@ export interface Billboard {
|
|||||||
bias?: number;
|
bias?: number;
|
||||||
/** Procedural stand-in name while the painted file loads. */
|
/** Procedural stand-in name while the painted file loads. */
|
||||||
fallback?: string;
|
fallback?: string;
|
||||||
|
/** Identity for growth animation: a creature's id, or a cell key. */
|
||||||
|
key?: string;
|
||||||
/** A reflection seen through a warp mouth, not the thing itself —
|
/** A reflection seen through a warp mouth, not the thing itself —
|
||||||
* visible only through THAT warp's columns, beyond its mouth. */
|
* visible only through THAT warp's columns, beyond its mouth. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
@@ -356,7 +379,7 @@ export function billboards(
|
|||||||
const o = posOverride?.[c.id];
|
const o = posOverride?.[c.id];
|
||||||
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,
|
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const t of view.treasures) {
|
for (const t of view.treasures) {
|
||||||
@@ -378,17 +401,17 @@ export function billboards(
|
|||||||
if (content.kind === "thornbush" || content.kind === "rosebush") {
|
if (content.kind === "thornbush" || content.kind === "rosebush") {
|
||||||
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,
|
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
|
||||||
});
|
});
|
||||||
} 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",
|
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
|
||||||
});
|
});
|
||||||
} 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",
|
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
|
||||||
});
|
});
|
||||||
} else if (content.kind === "safe") {
|
} else if (content.kind === "safe") {
|
||||||
out.push({
|
out.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user