Walls you know are lies stand as ghosts
A wizard who has seen through an illusion no longer walks toward empty air where the board shows a dashed ghost: the ray records the first known-illusion edge it crosses without stopping, and the renderer stands a wall there at one-fifth opacity, violet ripple running through it, drawn over the sprites so bodies show through its breath. Untested illusions get the same tell the board gives — a faint shimmer on their solid face, maybe stone, maybe not — while a believed illusion stays honest brick. The workshop rehearses it with ?illusion=H:2,8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b3bc4d079b
commit
727b9144d0
@@ -219,12 +219,22 @@
|
|||||||
// ghost into real corridors the unrolled space happens to overlap.
|
// ghost into real corridors the unrolled space happens to overlap.
|
||||||
const zbuf = new Float64Array(W);
|
const zbuf = new Float64Array(W);
|
||||||
const warpedCol = new Uint8Array(W);
|
const warpedCol = new Uint8Array(W);
|
||||||
|
// Known illusions: the ray passes, but a translucent ghost of a wall
|
||||||
|
// stands at the crossing — drawn after the sprites so bodies show
|
||||||
|
// through it, shimmering so nobody mistakes it for stone.
|
||||||
|
const ghosts: { col: number; depth: number; u: number; worldU: 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);
|
||||||
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;
|
||||||
warpedCol[col] = hit.warped ? 1 : 0;
|
warpedCol[col] = hit.warped ? 1 : 0;
|
||||||
|
if (hit.ghost) {
|
||||||
|
ghosts.push({
|
||||||
|
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
|
||||||
|
u: hit.ghost.u, worldU: hit.ghost.worldU,
|
||||||
|
});
|
||||||
|
}
|
||||||
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 = textures[hit.kind] ?? textures.wall!;
|
const tex = textures[hit.kind] ?? textures.wall!;
|
||||||
@@ -250,6 +260,13 @@
|
|||||||
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
||||||
ctx.fillRect(col, top, 1, wallH);
|
ctx.fillRect(col, top, 1, wallH);
|
||||||
}
|
}
|
||||||
|
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
||||||
|
// The same tell the board gives: an untested illusion's face
|
||||||
|
// shimmers faintly — maybe stone, maybe not.
|
||||||
|
const tell = 0.06 + 0.05 * Math.sin(time / 260 + hit.worldU * 13);
|
||||||
|
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`;
|
||||||
|
ctx.fillRect(col, top, 1, wallH);
|
||||||
|
}
|
||||||
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
|
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
|
||||||
// A jammed lock seethes: a rusty seal pulsing across the wood.
|
// A jammed lock seethes: a rusty seal pulsing across the wood.
|
||||||
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22);
|
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22);
|
||||||
@@ -322,6 +339,21 @@
|
|||||||
if (s.glow) ctx.globalAlpha = 1;
|
if (s.glow) ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ghosts of walls you know are lies: drawn over everything at
|
||||||
|
// their columns, thin as breath, rippling.
|
||||||
|
for (const gh of ghosts) {
|
||||||
|
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
|
||||||
|
const gTop = half - gHft / 2;
|
||||||
|
const tex = textures.wall!;
|
||||||
|
ctx.globalAlpha = 0.2;
|
||||||
|
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), 0,
|
||||||
|
Math.max(1, tex.width / 96), tex.height, gh.col, gTop, 1, gHft);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01);
|
||||||
|
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`;
|
||||||
|
ctx.fillRect(gh.col, gTop, 1, gHft);
|
||||||
|
}
|
||||||
|
|
||||||
// A whisper of vignette holds the torchlit mood together.
|
// A whisper of vignette holds the torchlit mood together.
|
||||||
const vig = ctx.createRadialGradient(W / 2, half, H * 0.35, W / 2, half, H * 0.95);
|
const vig = ctx.createRadialGradient(W / 2, half, H * 0.35, W / 2, half, H * 0.95);
|
||||||
vig.addColorStop(0, "rgba(0,0,0,0)");
|
vig.addColorStop(0, "rgba(0,0,0,0)");
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
});
|
});
|
||||||
const povId = "Wanderer";
|
const povId = "Wanderer";
|
||||||
const view = viewFor(dealt, povId);
|
const view = viewFor(dealt, povId);
|
||||||
|
// ?illusion=H:2,8 (";"-separated): stand up known illusions for the
|
||||||
|
// renderer to rehearse — the edge opens, the ghost remains.
|
||||||
|
for (const k of (q.get("illusion") ?? "").split(";").filter(Boolean)) {
|
||||||
|
view.knownIllusionEdges.push(k);
|
||||||
|
delete view.board.edges[k];
|
||||||
|
}
|
||||||
const start = view.players.find((p) => p.id === povId)!.position;
|
const start = view.players.find((p) => p.id === povId)!.position;
|
||||||
|
|
||||||
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ export interface Hit {
|
|||||||
/** The un-wrapped along-face coordinate: u plus the world cell it lies
|
/** 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. */
|
* in, so multi-cell surfaces (a long wall of fire) can read as one. */
|
||||||
worldU: number;
|
worldU: number;
|
||||||
/** The edge struck, for state the renderer dresses (jammed locks). */
|
/** The edge struck, for state the renderer dresses (jammed locks,
|
||||||
|
* untested illusions). */
|
||||||
edge?: string;
|
edge?: string;
|
||||||
/** The eye reached this through a warp: haze it other-worldly. */
|
/** The eye reached this through a warp: haze it other-worldly. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
|
/** 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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
||||||
@@ -80,6 +84,7 @@ export function castRay(
|
|||||||
// 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.
|
||||||
let baseDist = 0;
|
let baseDist = 0;
|
||||||
let warped = false;
|
let warped = false;
|
||||||
|
let ghost: Hit["ghost"];
|
||||||
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);
|
||||||
@@ -129,7 +134,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,
|
axis: b.axis, worldU: along, edge: b.edge, warped, ghost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +149,17 @@ export function castRay(
|
|||||||
const u0 = along % 1;
|
const u0 = along % 1;
|
||||||
const texU = u0 < 0 ? u0 + 1 : u0;
|
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 })];
|
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
|
||||||
if (offBoard) {
|
if (offBoard) {
|
||||||
const side: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N");
|
const side: Side = crossSide;
|
||||||
const warp = view.board.warps.find(
|
const warp = view.board.warps.find(
|
||||||
(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,
|
||||||
);
|
);
|
||||||
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped };
|
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost };
|
||||||
// Step through: re-enter at the paired mouth, heading inward, the
|
// Step through: re-enter at the paired mouth, heading inward, the
|
||||||
// offset along the edge preserved (a wraparound keeps its lane).
|
// offset along the edge preserved (a wraparound keeps its lane).
|
||||||
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
|
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
|
||||||
@@ -167,14 +176,14 @@ 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 };
|
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost };
|
||||||
}
|
}
|
||||||
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 };
|
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
|
|||||||
Reference in New Issue
Block a user