The maze performs: doors swing, walls die loudly, homes wear their colors

Entertainment polish for the instant replay, all through one wizard's
eyes. Doors now slide open along their own edge — Wolf3D style — when
anyone steps through in a reel, hold for the crossing, and swing shut;
the raycaster takes a per-edge openness map and doors carry their
texture with them, leaving a sliver of jamb at the far post. A wall
that dies bursts into stone and shakes the camera, then leaves a mound
of rubble at the fallen edge for the rest of the reel (a tenth
paintable sprite, drawn opaque — debris, not light). Jammed locks
seethe: a rusty pulse across the wood, keyed off the view's own door
states. Walking THROUGH a wall shimmers on both faces and washes the
walker's own screen stone-gray. And every home base wears its owner's
emblem on the flagstones — a near-white paintable overlay tinted by
wizard color per cell in the floor cast.

Testing surfaced a real ghost: a body standing near a far warp mouth
also rendered at its virtual position, which can overlap real
corridors — the depth buffer alone cannot clip it. Every column now
remembers whether its ray bent, and a sprite draws only where its warp
side matches the column's; projectile legs carry the same flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 16:30:35 -04:00
co-authored by Claude Fable 5
parent 8a27c06b55
commit cdfae39398
9 changed files with 238 additions and 37 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+20 -2
View File
@@ -5,7 +5,7 @@
import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay } from "./fpv/raycast";
import { castRay, edgeMid } from "./fpv/raycast";
import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
@@ -51,6 +51,24 @@
// attack in progress, so a replay-watcher can see how a spell reached them.
const sightTrace = $derived(stackSightTrace(step.view));
/** Every wall destroyed so far in the reel leaves a mound of rubble on
* the first-person floor for the rest of it. */
const rubbleSpots = $derived.by(() => {
const spots: { x: number; y: number }[] = [];
const seen = new Set<string>();
for (let i = 0; i <= Math.min(idx, steps.length - 1); i++) {
for (const e of steps[i]!.events) {
if (e.type !== "wallDestroyed" || !("edge" in e)) continue;
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
const key = `${edge.cell.x},${edge.cell.y}:${edge.side}`;
if (seen.has(key)) continue;
seen.add(key);
spots.push(edgeMid(edge.cell, edge.side));
}
}
return spots;
});
/** Each step's spells flare on the reel exactly as they did at the table. */
let boardFx = $state<BoardFx[]>([]);
$effect(() => {
@@ -320,7 +338,7 @@
{#if fp}
<FirstPerson view={step.view} {povId}
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
fx={fpFx} posOverride={actorPos} />
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
{:else}
<Board view={step.view} effects={boardFx} {sightTrace} />
{/if}
+1 -1
View File
@@ -95,7 +95,7 @@
wherever a file is missing.
</p>
<div class="grid">
{#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as name (name)}
{#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as name (name)}
<figure>
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
<figcaption>{name}</figcaption>
+83 -6
View File
@@ -5,8 +5,9 @@
// by the same depth buffer the walls wrote.
import { castRay, billboards } from "./raycast";
import { materialTextures } from "./textures";
import { fxFallback, type FpFx } from "./fx3d";
import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
import { tokenArt } from "../art";
import { PLAYER_COLORS } from "../colors";
import type { GameView } from "@wizwar/engine";
let {
@@ -19,6 +20,7 @@
height = 440,
fx = [],
posOverride,
rubble = [],
}: {
view: GameView;
povId: string;
@@ -33,6 +35,8 @@
fx?: FpFx[];
/** Bodies mid-glide: world positions that override the view's cells. */
posOverride?: Record<string, { x: number; y: number }>;
/** Where walls have died: a mound of stone marks each fallen edge. */
rubble?: { x: number; y: number }[];
} = $props();
const FOV = Math.PI / 2.9;
@@ -84,6 +88,33 @@
}
let frame: ImageData | null = null;
// Home bases wear their owner's emblem on the floor: a per-view grid of
// owner tints, indexed by integer cell for the per-pixel pass.
const homeCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; tints: [number, number, number][] }>();
function homesOf(v: GameView) {
let h = homeCache.get(v);
if (!h) {
const bw = v.board.width, bh = v.board.height;
const grid = new Int16Array(bw * bh).fill(-1);
const tints: [number, number, number][] = [];
for (const p of v.players) {
if (!p.home) continue;
const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888";
tints.push([
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
]);
if (p.home.x >= 0 && p.home.y >= 0 && p.home.x < bw && p.home.y < bh) {
grid[p.home.y * bw + p.home.x] = tints.length - 1;
}
}
h = { grid, bw, bh, tints };
homeCache.set(v, h);
}
return h;
}
function draw(time: number) {
const ctx = canvas?.getContext("2d");
if (!ctx) return;
@@ -100,6 +131,14 @@
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
}
// Doors mid-swing this frame, from the animated fx.
let doors: Record<string, number> | undefined;
for (const f of fx) {
if (f.kind !== "door") continue;
const a = doorOpenness((time - f.t0) / f.dur);
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
}
// The ground and the vault overhead, cast per pixel: each screen row
// below (or above) the horizon lies at one fixed depth, so the row is
// walked with a constant world-space step and sampled from the floor
@@ -123,6 +162,8 @@
continue;
}
const d = (H / 2) / dz;
const homes = below ? homesOf(view) : null;
const hp = below ? pixelsOf(textures.home!) : null;
const tex = below ? fl : ce;
const tw = tex.width, th = tex.height;
const tp = tex.data;
@@ -142,9 +183,28 @@
let u = wx % 1; if (u < 0) u += 1;
let v = wy % 1; if (v < 0) v += 1;
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
buf[o] = tp[ti]! * shade;
buf[o + 1] = tp[ti + 1]! * shade;
buf[o + 2] = tp[ti + 2]! * shade;
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
if (homes) {
const hx = (wx - u) | 0, hy = (wy - v) | 0;
if (hx >= 0 && hy >= 0 && hx < homes.bw && hy < homes.bh) {
const owner = homes.grid[hy * homes.bw + hx]!;
if (owner >= 0 && hp) {
// The emblem sits over the flagstones, wearing its
// owner's color.
const hi = (((v * hp.height) | 0) * hp.width + ((u * hp.width) | 0)) * 4;
const ha = hp.data[hi + 3]! / 255;
if (ha > 0) {
const [tr, tg, tb] = homes.tints[owner]!;
r = r * (1 - ha) + hp.data[hi]! * tr * ha;
g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha;
b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha;
}
}
}
}
buf[o] = r * shade;
buf[o + 1] = g * shade;
buf[o + 2] = b * shade;
}
buf[o + 3] = 255;
wx += stepX;
@@ -153,13 +213,18 @@
}
ctx.putImageData(frame, 0, 0);
// Walls, one ray per column; remember each column's depth for sprites.
// Walls, one ray per column; remember each column's depth for
// sprites, and whether its ray bent through a warp — a sprite's warp
// side must MATCH its column's, or bodies near a far mouth would
// ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W);
const warpedCol = new Uint8Array(W);
for (let col = 0; col < W; col++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
const hit = castRay(view, ex, ey, rayAngle);
const hit = castRay(view, ex, ey, rayAngle, doors);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth;
warpedCol[col] = hit.warped ? 1 : 0;
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2;
const tex = textures[hit.kind] ?? textures.wall!;
@@ -185,6 +250,12 @@
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(col, top, 1, wallH);
}
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
// A jammed lock seethes: a rusty seal pulsing across the wood.
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22);
ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
}
if (dark > 0.02) {
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
ctx.fillRect(col, top, 1, wallH);
@@ -201,6 +272,10 @@
const sprites = billboards(view, povId, tokenArt, posOverride)
.map((b) => project(b, ex, ey))
.filter((s): s is Projected => s !== null);
for (const spot of rubble) {
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.4, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" });
}
for (const f of fx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur;
@@ -212,6 +287,7 @@
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
rise: 0.3,
warped: f.kind === "projectile" ? f.warped : undefined,
}, ex, ey);
if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
}
@@ -225,6 +301,7 @@
if (s.glow) ctx.globalAlpha = s.alpha ?? 1;
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue;
if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue;
const texX = ((col - s.left) / (s.right - s.left));
if (img) {
ctx.drawImage(
+65 -13
View File
@@ -6,21 +6,35 @@
import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom";
import { castRay, warpMotion } from "./raycast";
import { castRay, edgeMid, warpMotion } from "./raycast";
import { edgeKey } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
export type FpFx =
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number }
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number;
/** This leg flies in a warp mouth's virtual space: visible only
* through the opening, on columns whose rays bent. */
warped?: boolean }
| { id: number; kind: "impact"; art: string; at: { x: number; y: 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 };
/** How far open an animated door stands at progress p: swings open,
* holds for the crossing, swings shut. */
export function doorOpenness(p: number): number {
if (p < 0 || p >= 1) return 0;
if (p < 0.3) return p / 0.3;
if (p < 0.7) return 1;
return (1 - p) / 0.3;
}
/** The paintable conjuration sprites: public/fx3d/<name>.png, square with
* transparent ground, drawn as a billboard mid-air. A procedural glow
* stands in wherever a file is missing. */
export const FX_ART = [
"fireball", "bolt", "waterbolt", "spark",
"burst", "splash", "hit", "shimmer", "shield",
"burst", "splash", "hit", "shimmer", "shield", "rubble",
] as const;
let nextId = 1;
@@ -42,7 +56,7 @@ const IMPACT_ART: Record<string, string> = {
* clipped by the depth buffer to the side the camera stands on. */
function projectileLegs(
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
): { from: { x: number; y: number }; to: { x: number; y: number } }[] {
): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean }[] {
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 0.05) return [{ from: a, to: b }];
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
@@ -54,7 +68,7 @@ function projectileLegs(
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
if (hit.warped && hit.dist >= vlen - 0.4) {
return [
{ from: a, to: vt },
{ from: a, to: vt, warped: true },
{ from: motion.toReal(a), to: b },
];
}
@@ -76,7 +90,7 @@ export function fpFxForEvents(
out.push({
fx: {
id: nextId++, kind: "projectile", art,
from: leg.from, to: leg.to,
from: leg.from, to: leg.to, warped: leg.warped,
t0: 0, dur: PROJECTILE_DUR[art]!,
},
delay,
@@ -94,6 +108,30 @@ export function fpFxForEvents(
});
}
}
// The maze itself performs: doors swing for whoever steps through,
// walls die into rubble, and walking THROUGH stone shimmers.
for (const e of events) {
if ((e.type === "moved" || e.type === "creatureMoved") && "direction" in e) {
const via = e.type === "moved" ? e.via : "step";
const key = edgeKey(e.from, e.direction);
if (via === "step" && view.board.edges[key] === "door") {
out.push({ fx: { id: nextId++, kind: "door", edge: key, t0: 0, dur: 1100 }, delay: 0 });
}
if (e.type === "moved" && e.via === "passWall") {
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.from.x + 0.5, y: e.from.y + 0.5 }, t0: 0, dur: 500 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.to.x + 0.5, y: e.to.y + 0.5 }, t0: 0, dur: 500 }, delay: 200 });
if (e.player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#9a8fb0", peak: 0.35, t0: 0, dur: 450 }, delay: 0 });
}
}
}
if (e.type === "wallDestroyed" && "edge" in e) {
const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell,
(e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 700 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
}
}
// Blows that land on the point of view: give impacts a beat to arrive
// when something visibly flew first.
const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0;
@@ -129,6 +167,7 @@ const FX_COLORS: Record<string, [string, string]> = {
hit: ["#ffd0c0", "#c02020"],
shimmer: ["#f0e0ff", "#7040c0"],
shield: ["#ffffff", "#4060d0"],
rubble: ["#b0a898", "#5c544a"],
};
const baked = new Map<string, HTMLCanvasElement>();
export function fxFallback(name: string): HTMLCanvasElement {
@@ -139,12 +178,25 @@ export function fxFallback(name: string): HTMLCanvasElement {
t.height = 64;
const c = t.getContext("2d")!;
const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!;
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
g.addColorStop(0, core);
g.addColorStop(0.55, rim);
g.addColorStop(1, "rgba(0,0,0,0)");
c.fillStyle = g;
c.fillRect(0, 0, 64, 64);
if (name === "rubble") {
// A mound of broken stone, opaque — this one is debris, not light.
for (let i = 0; i < 9; i++) {
const jx = 12 + ((i * 37) % 40);
const jy = 40 + ((i * 23) % 18);
const r = 6 + ((i * 13) % 8);
c.fillStyle = i % 2 ? core : rim;
c.beginPath();
c.arc(jx, jy, r, 0, Math.PI * 2);
c.fill();
}
} else {
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
g.addColorStop(0, core);
g.addColorStop(0.55, rim);
g.addColorStop(1, "rgba(0,0,0,0)");
c.fillStyle = g;
c.fillRect(0, 0, 64, 64);
}
baked.set(name, t);
}
return t;
+35 -12
View File
@@ -21,6 +21,8 @@ export interface Hit {
/** 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). */
edge?: string;
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
}
@@ -28,15 +30,13 @@ export interface Hit {
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" };
/** Is this edge passable to the EYE (rays), and if not, what is it? */
/** 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") {
// 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 === "door") return "door";
if (e === "firewall") return "firewall";
return "wall";
}
@@ -69,7 +69,13 @@ function slabHit(
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
}
export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit {
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;
@@ -90,12 +96,25 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
// 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;
let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string } | null = null;
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
const kind = edgeObstacle(view, edgeKey({ x: cx, y: cy }, side));
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)) best = { ...h, kind };
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
let slide = 0;
if (kind === "door") {
const open = doors?.[key] ??
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
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 };
};
const hw = (side: Side) =>
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
@@ -104,10 +123,14 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
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 b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string };
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 };
// 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,
};
}
// No slab in this cell: advance through the open boundary.
+21 -1
View File
@@ -42,7 +42,7 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
});
}
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as const;
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as const;
/** The built-in bake: what ships when no painted file overrides it. */
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
@@ -107,6 +107,26 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
}
}
}),
home: bake((c) => {
// A home base's floor emblem, laid over the flagstones and tinted by
// its owner's color at draw time — so paint it near-white, with
// transparency wherever the plain floor should show through.
c.strokeStyle = "rgba(240,235,225,0.85)";
c.lineWidth = 3;
c.strokeRect(4.5, 4.5, TEX - 9, TEX - 9);
c.lineWidth = 2;
c.beginPath();
c.moveTo(TEX / 2, 12);
c.lineTo(TEX - 12, TEX / 2);
c.lineTo(TEX / 2, TEX - 12);
c.lineTo(12, TEX / 2);
c.closePath();
c.stroke();
c.fillStyle = "rgba(240,235,225,0.35)";
c.beginPath();
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
c.fill();
}),
warp: bake((c) => {
const grad = c.createLinearGradient(0, 0, TEX, TEX);
grad.addColorStop(0, "#2c1a4e");