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:
co-authored by
Claude Fable 5
parent
8a27c06b55
commit
cdfae39398
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user