Odds, ends, and every missing effect: doorways dressed, victory earns its fireworks
The audit Eric called for, delivered whole. Open doors are DOORWAYS now: stone jamb posts at both ends and a lintel hung across the top (paintable as textures/doorframe.png), the way through open to the eye. Battle damage shows — the engine's per-edge wallDamage wears painted cracks into any wall, heavier as harm mounts. The wall safe stands as a strongbox volume instead of a flat token. Hedges take root: an underbrush floor decal covers the whole square beneath them (Eric's report — a billboard at center depth can't reach the floor trapezoid's near edge), and the hedge itself widens past the cell. Rubble stretches across the fallen wall's full gap, corner piles landing at the posts, exactly as its art was painted. The first-person effect gaps close: victory now throws SIX fireworks climbing over the winner's home with gold washing the screen; sector rotations flash violet and heave the whole world; the Thumb of God lands with a burst and a shudder; walls conjured from nothing rise in stone dust; and every hazard pratfall plays — pit falls (with a dark drop and jolt for the faller's own eyes), ooze slips, tack yelps, thorn snaps, slime, dust. Dying in first person fades long and dark. And the whole tale travels: a finished game's full replay carries the share button too — turn -1 mints a link to every turn of the game, each through its wizard's own eyes, titled for the winner's triumph. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a94ab5c6f5
commit
5fc5805354
@@ -114,10 +114,15 @@
|
||||
parseInt(hex.slice(5, 7), 16) / 255,
|
||||
]);
|
||||
}
|
||||
const DECAL_KIND: Record<string, string> = {
|
||||
pit: "pit", slime: "slime", tacks: "tacks",
|
||||
thornbush: "underbrush", rosebush: "underbrush",
|
||||
};
|
||||
for (const [k, content] of Object.entries(v.squareContents)) {
|
||||
if (content.kind !== "pit" && content.kind !== "slime" && content.kind !== "tacks") continue;
|
||||
const tex = DECAL_KIND[content.kind];
|
||||
if (!tex) continue;
|
||||
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
||||
put(cx, cy, content.kind);
|
||||
put(cx, cy, tex);
|
||||
}
|
||||
for (const w of v.dimWarps) {
|
||||
put(w.a.x, w.a.y, "dimwarp");
|
||||
@@ -238,6 +243,8 @@
|
||||
// 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 }[] = [];
|
||||
// Open doorways collect their lintels, hung after the sprites.
|
||||
const lintels: { col: number; depth: number; u: number }[] = [];
|
||||
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, doors);
|
||||
@@ -250,9 +257,12 @@
|
||||
u: hit.ghost.u, worldU: hit.ghost.worldU,
|
||||
});
|
||||
}
|
||||
if (hit.doorway) {
|
||||
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
|
||||
}
|
||||
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!;
|
||||
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
||||
// Sample by the texture's own size: painted files may be any scale.
|
||||
// Fire and warps shift their slice per world cell, so a blaze
|
||||
// spanning edges reads as one long fire, not a repeated flame.
|
||||
@@ -261,6 +271,17 @@
|
||||
: hit.u;
|
||||
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
|
||||
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
|
||||
if (!hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
|
||||
// Harm shows: accumulated damage wears fractures into the face.
|
||||
const dmg = view.wallDamage[hit.edge];
|
||||
if (dmg) {
|
||||
const ck = textures.cracks!;
|
||||
ctx.globalAlpha = Math.min(0.9, 0.35 + dmg * 0.2);
|
||||
ctx.drawImage(ck, Math.min(ck.width - 1, texU * ck.width), 0,
|
||||
Math.max(1, ck.width / 96), ck.height, col, top, 1, wallH);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
// Distance and orientation carve the light; overlays animate it.
|
||||
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
||||
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
||||
@@ -305,7 +326,7 @@
|
||||
.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.55, 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);
|
||||
if (s) sprites.push({ ...s, fallback: "rubble" });
|
||||
}
|
||||
for (const f of fx) {
|
||||
@@ -318,7 +339,7 @@
|
||||
const s = project({
|
||||
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,
|
||||
rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3,
|
||||
warped: f.kind === "projectile" ? f.warped : undefined,
|
||||
}, ex, ey);
|
||||
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
||||
@@ -360,6 +381,20 @@
|
||||
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Doorway lintels: the frame's top rows hung across each opening.
|
||||
for (const li of lintels) {
|
||||
const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05));
|
||||
const lTop = half - lh / 2;
|
||||
const ft = textures.doorframe!;
|
||||
ctx.drawImage(ft, Math.min(ft.width - 1, li.u * ft.width), 0,
|
||||
Math.max(1, ft.width / 96), ft.height * 0.16, li.col, lTop, 1, lh * 0.16);
|
||||
const dark = 1 - Math.min(1, 1.35 / (1 + li.depth * 0.45));
|
||||
if (dark > 0.02) {
|
||||
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
|
||||
ctx.fillRect(li.col, lTop, 1, lh * 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
// The ghosts of walls you know are lies: drawn over everything at
|
||||
// their columns, thin as breath, rippling.
|
||||
for (const gh of ghosts) {
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
view.knownIllusionEdges.push(k);
|
||||
delete view.board.edges[k];
|
||||
}
|
||||
// ?open=V:2,7 props doors open; ?crack=V:2,7@3 wears damage into walls.
|
||||
for (const k of (q.get("open") ?? "").split(";").filter(Boolean)) {
|
||||
view.openDoorEdges.push(k);
|
||||
}
|
||||
for (const spec of (q.get("crack") ?? "").split(";").filter(Boolean)) {
|
||||
const [k, n] = spec.split("@") as [string, string?];
|
||||
view.wallDamage[k] = Number(n ?? 1) || 1;
|
||||
}
|
||||
// ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer
|
||||
// (and the artist) to rehearse. dimwarp@x,y opens a floor mouth.
|
||||
for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) {
|
||||
|
||||
@@ -15,7 +15,9 @@ export type FpFx =
|
||||
/** 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: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number;
|
||||
/** Lifted off the floor (fireworks burst overhead); default 0.3. */
|
||||
rise?: 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: "door"; edge: string; t0: number; dur: number };
|
||||
@@ -42,11 +44,15 @@ let nextId = 1;
|
||||
const PROJECTILE_DUR: Record<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
|
||||
/** Board-effect kinds that land here as an impact billboard. */
|
||||
const IMPACT_ART: Record<string, string> = {
|
||||
burst: "burst", splash: "splash", fireworks: "burst",
|
||||
hit: "hit", pow: "hit", claw: "hit",
|
||||
burst: "burst", splash: "splash",
|
||||
hit: "hit", pow: "hit", claw: "hit", "tacks-ow": "hit", "thorn-snap": "hit",
|
||||
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
|
||||
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
|
||||
shield: "shield", absorb: "shield",
|
||||
"ooze-slip": "splash", "slime-stuck": "splash",
|
||||
"pit-fall": "dust", "dust-puff": "dust",
|
||||
"edge-dust": "rubble",
|
||||
// fireworks and die-drop are staged by the raw-event pass below.
|
||||
};
|
||||
|
||||
/** One flight, possibly seen from two rooms: a projectile whose straight
|
||||
@@ -137,6 +143,41 @@ export function fpFxForEvents(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.type === "wallCreated" && "edge" in e) {
|
||||
// Stone conjured from nothing: dust where it rises.
|
||||
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: 550 }, delay: 0 });
|
||||
}
|
||||
if (e.type === "sectorRotated" || e.type === "sectorRelocated") {
|
||||
// The maze ITSELF moves: the whole world flashes violet and heaves.
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.5, t0: 0, dur: 900 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.09, t0: 0, dur: 800 }, delay: 0 });
|
||||
}
|
||||
if (e.type === "thumbOfGod" && "landedAt" in e) {
|
||||
const at = (e as { landedAt: { x: number; y: number } }).landedAt;
|
||||
out.push({ fx: { id: nextId++, kind: "impact", art: "burst", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 700 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.07, t0: 0, dur: 600 }, delay: 0 });
|
||||
}
|
||||
if (e.type === "gameWon" && "player" in e) {
|
||||
// Victory earns real fireworks: bursts climbing over the winner's
|
||||
// home, gold washing the sky, again and again.
|
||||
const home = view.players.find((p) => p.id === (e as { player: string }).player)?.home;
|
||||
const SPREAD = [[0, 0], [0.35, -0.25], [-0.35, 0.2], [0.2, 0.35], [-0.25, -0.3], [0.1, -0.1]];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (home) {
|
||||
out.push({
|
||||
fx: {
|
||||
id: nextId++, kind: "impact", art: "burst",
|
||||
at: { x: home.x + 0.5 + SPREAD[i]![0]!, y: home.y + 0.5 + SPREAD[i]![1]! },
|
||||
rise: 0.45 + (i % 3) * 0.22, t0: 0, dur: 850,
|
||||
},
|
||||
delay: i * 300,
|
||||
});
|
||||
}
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#e0b34a", peak: 0.22, t0: 0, dur: 550 }, delay: i * 300 + 120 });
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -153,6 +194,17 @@ export function fpFxForEvents(
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay });
|
||||
} else if (e.type === "lifeGained" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay });
|
||||
} else if (e.type === "died" && "player" in e && (e as { player: string }).player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#100812", peak: 0.6, t0: 0, dur: 1100 }, delay: povDelay });
|
||||
} else if (e.type === "fellInPit" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#201810", peak: 0.5, t0: 0, dur: 500 }, delay: 0 });
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.06, t0: 0, dur: 450 }, delay: 0 });
|
||||
} else if (
|
||||
(e.type === "slippedInOoze" || e.type === "steppedOnTacks" ||
|
||||
e.type === "enteredThornbush" || e.type === "stuckInSlime") &&
|
||||
"player" in e && (e as { player: string }).player === povId
|
||||
) {
|
||||
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
|
||||
} else if (e.type === "teleported" && e.player === povId) {
|
||||
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 });
|
||||
} else if (e.type === "moved" && e.via === "warp" && e.player === povId) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
import { objectArt, TERRAIN_ART } from "../art";
|
||||
import { objectArt } from "../art";
|
||||
|
||||
export interface Hit {
|
||||
/** Distance along the ray (perpendicular-corrected by the caller). */
|
||||
@@ -29,6 +29,11 @@ export interface Hit {
|
||||
/** 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" };
|
||||
/** The first OPEN doorway the ray passed through: the renderer hangs
|
||||
* the lintel of the frame across it. */
|
||||
doorway?: { dist: number; u: number };
|
||||
/** The struck slab is a doorway's jamb post, dressed in frame stone. */
|
||||
frame?: boolean;
|
||||
}
|
||||
|
||||
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
||||
@@ -85,6 +90,7 @@ export function castRay(
|
||||
let baseDist = 0;
|
||||
let warped = false;
|
||||
let ghost: Hit["ghost"];
|
||||
let doorway: Hit["doorway"];
|
||||
for (let traversal = 0; traversal < 3; traversal++) {
|
||||
const dx = Math.cos(angle);
|
||||
const dy = Math.sin(angle);
|
||||
@@ -101,7 +107,7 @@ export function castRay(
|
||||
// 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"]; slide: number; edge: string } | null = null;
|
||||
let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean } | null = null;
|
||||
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
|
||||
const key = edgeKey({ x: cx, y: cy }, side);
|
||||
const kind = edgeObstacle(view, key);
|
||||
@@ -109,17 +115,29 @@ export function castRay(
|
||||
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
||||
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
|
||||
let slide = 0;
|
||||
let frame = false;
|
||||
if (kind === "door") {
|
||||
const open = doors?.[key] ??
|
||||
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
|
||||
if (open > 0) {
|
||||
if (open >= 0.98) {
|
||||
// A door standing open is a DOORWAY: posts at both ends, a
|
||||
// lintel hung across, and the way through open to the eye.
|
||||
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
||||
const u = along - Math.floor(along);
|
||||
const JAMB = 0.08;
|
||||
if (u > JAMB && u < 1 - JAMB) {
|
||||
if (!doorway) doorway = { dist: baseDist + h.t, u };
|
||||
return;
|
||||
}
|
||||
frame = true;
|
||||
} else 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 };
|
||||
best = { ...h, kind, slide, edge: key, frame };
|
||||
};
|
||||
const hw = (side: Side) =>
|
||||
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
|
||||
@@ -128,13 +146,14 @@ export function castRay(
|
||||
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"]; slide: number; edge: string };
|
||||
const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
|
||||
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
|
||||
const u = along - Math.floor(along);
|
||||
// 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, ghost,
|
||||
axis: b.axis, worldU: along, edge: b.edge, warped, ghost, doorway,
|
||||
...(b.frame ? { frame: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,7 +178,7 @@ export function castRay(
|
||||
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, worldU: along, warped, ghost };
|
||||
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost, doorway };
|
||||
// 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]];
|
||||
@@ -176,14 +195,14 @@ export function castRay(
|
||||
break;
|
||||
}
|
||||
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
||||
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost };
|
||||
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost, doorway };
|
||||
}
|
||||
cx = nx;
|
||||
cy = ny;
|
||||
}
|
||||
if (!warped || traversal === 2) break;
|
||||
}
|
||||
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost };
|
||||
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost, doorway };
|
||||
}
|
||||
|
||||
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
||||
@@ -320,7 +339,7 @@ export function billboards(
|
||||
if (content.kind === "thornbush" || content.kind === "rosebush") {
|
||||
out.push({
|
||||
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
|
||||
scale: 0.5, aspect: 2, rise: 0, bias: 0.18, label: content.kind,
|
||||
scale: 0.5, aspect: 2.35, rise: 0, bias: 0.18, label: content.kind,
|
||||
});
|
||||
} else if (content.kind === "ooze") {
|
||||
out.push({
|
||||
@@ -333,7 +352,10 @@ export function billboards(
|
||||
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust",
|
||||
});
|
||||
} else if (content.kind === "safe") {
|
||||
out.push({ ...at, src: art(TERRAIN_ART["safe"]!, "terrain"), scale: 0.55, rise: 0, label: "safe" });
|
||||
out.push({
|
||||
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
||||
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Dimensional warp mouths throw pillars of light from their floor rings.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// paintable sprite at public/terrain3d/<name>.png (transparent PNG, any
|
||||
// size); the bakes below stand in until painted.
|
||||
|
||||
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow"] as const;
|
||||
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow", "safe"] as const;
|
||||
|
||||
const baked = new Map<string, HTMLCanvasElement>();
|
||||
export function terrainFallback(name: string): HTMLCanvasElement {
|
||||
@@ -70,6 +70,28 @@ export function terrainFallback(name: string): HTMLCanvasElement {
|
||||
g2.arc(14 + h2(i, 47) * 100, 14 + h2(i, 53) * 100, 12 + h2(i, 59) * 16, 0, Math.PI * 2);
|
||||
g2.fill();
|
||||
}
|
||||
} else if (name === "safe") {
|
||||
// A squat iron strongbox with its combination dial.
|
||||
t.width = 128;
|
||||
t.height = 128;
|
||||
const g2 = t.getContext("2d")!;
|
||||
g2.fillStyle = "#3c3f46";
|
||||
g2.fillRect(14, 34, 100, 90);
|
||||
g2.fillStyle = "#54585f";
|
||||
g2.fillRect(20, 40, 88, 78);
|
||||
g2.strokeStyle = "#23252a";
|
||||
g2.lineWidth = 4;
|
||||
g2.strokeRect(16, 36, 96, 86);
|
||||
g2.fillStyle = "#8b8f96";
|
||||
g2.beginPath();
|
||||
g2.arc(64, 78, 16, 0, Math.PI * 2);
|
||||
g2.fill();
|
||||
g2.fillStyle = "#2a2c31";
|
||||
g2.beginPath();
|
||||
g2.arc(64, 78, 10, 0, Math.PI * 2);
|
||||
g2.fill();
|
||||
g2.fillStyle = "#8b8f96";
|
||||
g2.fillRect(96, 66, 8, 24);
|
||||
} else {
|
||||
// warpglow: a pillar of violet light, brightest at its core.
|
||||
t.width = 64;
|
||||
|
||||
@@ -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", "home", "pit", "slime", "tacks", "dimwarp"] as const;
|
||||
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "doorframe", "cracks", "underbrush"] as const;
|
||||
|
||||
/** The built-in bake: what ships when no painted file overrides it. */
|
||||
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||
@@ -127,6 +127,54 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}),
|
||||
underbrush: bake((c) => {
|
||||
// The ground beneath a hedge: leaf litter and roots to the square's
|
||||
// edges, so the standing tangle above never floats on bare flags.
|
||||
for (let i = 0; i < 46; i++) {
|
||||
const g = 40 + (h2(i, 61) * 45 | 0);
|
||||
c.fillStyle = `rgba(${g * 0.4 | 0},${g},${g * 0.35 | 0},${0.5 + h2(i, 67) * 0.4})`;
|
||||
c.beginPath();
|
||||
c.ellipse(h2(i, 71) * TEX, h2(i, 73) * TEX, 3 + h2(i, 79) * 6, 2 + h2(i, 83) * 4,
|
||||
h2(i, 89) * Math.PI, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
}),
|
||||
doorframe: bake((c) => {
|
||||
// An empty doorway's dressing: stone posts up both sides and a
|
||||
// lintel across the top, transparent in the middle where the way
|
||||
// through stands open. Posts live in the outer ~8% columns; the
|
||||
// lintel in the top ~16% rows.
|
||||
const stone = (x: number, y: number, w: number, h: number, jit: number) => {
|
||||
const g = 120 * (0.85 + 0.25 * h2(jit, 3)) | 0;
|
||||
c.fillStyle = `rgb(${g},${g * 0.93 | 0},${g * 0.8 | 0})`;
|
||||
c.fillRect(x, y, w, h);
|
||||
c.strokeStyle = "rgba(30,24,16,0.8)";
|
||||
c.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
};
|
||||
for (let r = 0; r < 5; r++) {
|
||||
stone(0, 10 + r * 11, 6, 10, r);
|
||||
stone(TEX - 6, 10 + r * 11, 6, 10, r + 7);
|
||||
}
|
||||
stone(0, 0, TEX / 2, 9, 13);
|
||||
stone(TEX / 2, 0, TEX / 2, 9, 17);
|
||||
}),
|
||||
cracks: bake((c) => {
|
||||
// Battle damage, worn over any wall by accumulated harm: dark
|
||||
// fractures on transparency, drawn heavier as damage mounts.
|
||||
c.strokeStyle = "rgba(14,10,6,0.85)";
|
||||
c.lineWidth = 1.6;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
let x = h2(i, 21) * TEX, y = 0;
|
||||
c.beginPath();
|
||||
c.moveTo(x, y);
|
||||
while (y < TEX) {
|
||||
x += (h2(x, y) - 0.5) * 14;
|
||||
y += 6 + h2(y, x) * 8;
|
||||
c.lineTo(x, y);
|
||||
}
|
||||
c.stroke();
|
||||
}
|
||||
}),
|
||||
pit: bake((c) => {
|
||||
// A hole in the world: black heart, ragged broken-flag edge, a rim
|
||||
// highlight on the lit side selling the depth.
|
||||
|
||||
Reference in New Issue
Block a user