Three blind reviews over the first-person arc, every finding verified against the source. History-narrating comments made timeless or cut; the stacked BUDDY comment collapsed to one voice. Dead code out: the orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter poke, the impossible-state sentinel. The never-produced "warp" hit kind resolved the right way — warp mouths now hang a translucent veil of the painted warp texture, so art that never rendered finally does. Types tightened (SlabStrike named once, ShareData rides CatchUpStep, botTier loses its casts), the gallery reads MATERIALS instead of a hand-copied list, the chronicle resets through one helper, a superseded share mint rejects instead of stranding, and the conjured safe gains the growth key its siblings had. Tests lose a triple assignment, a tautology, and two self-swallowing regex alternatives. The sprite spec — which still told the artist to paint for additive compositing the renderer no longer uses — now describes the renderer that exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
265 lines
9.6 KiB
TypeScript
265 lines
9.6 KiB
TypeScript
// The first-person materials. Each has two lives: a hand-paintable file
|
|
// at /textures/<name>.png (the one to improve — any size, sampled by
|
|
// column like the token art is swapped by file), and the procedural bake
|
|
// below that ships as the default and stands in while a file loads.
|
|
|
|
const FACE: Record<string, [number, number, number]> = {
|
|
wall: [126, 118, 100],
|
|
stone: [96, 96, 104],
|
|
door: [130, 92, 48],
|
|
rim: [82, 76, 66],
|
|
};
|
|
|
|
// Textures are baked once at 64x64 and sampled one column at a time —
|
|
// perspective-correct verticals for the price of a single drawImage.
|
|
const TEX = 64;
|
|
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
|
const t = document.createElement("canvas");
|
|
t.width = TEX; t.height = TEX;
|
|
const c = t.getContext("2d")!;
|
|
paint(c);
|
|
return t;
|
|
}
|
|
function h2(a: number, b: number): number {
|
|
// Cheap deterministic jitter for brick shading.
|
|
const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
|
|
return n - Math.floor(n);
|
|
}
|
|
function brickTexture(base: [number, number, number], rows: number, mortar: string): HTMLCanvasElement {
|
|
return bake((c) => {
|
|
const rh = TEX / rows;
|
|
for (let r = 0; r < rows; r++) {
|
|
const offset = (r % 2) * (TEX / 4);
|
|
for (let b = -1; b < 3; b++) {
|
|
const jit = 0.82 + 0.30 * h2(r, b);
|
|
c.fillStyle = `rgb(${base[0] * jit | 0},${base[1] * jit | 0},${base[2] * jit | 0})`;
|
|
c.fillRect(b * (TEX / 2) + offset, r * rh, TEX / 2 - 2, rh - 2);
|
|
}
|
|
}
|
|
c.fillStyle = mortar;
|
|
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
|
|
});
|
|
}
|
|
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as const;
|
|
|
|
/** The built-in bake: what ships when no painted file overrides it. */
|
|
function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
|
return {
|
|
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
|
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
|
|
stone: brickTexture(FACE.stone!, 2, "rgba(20,20,26,0.9)"),
|
|
door: bake((c) => {
|
|
const [r, g, b] = FACE.door!;
|
|
for (let p = 0; p < 4; p++) {
|
|
const jit = 0.86 + 0.22 * h2(p, 7);
|
|
c.fillStyle = `rgb(${r * jit | 0},${g * jit | 0},${b * jit | 0})`;
|
|
c.fillRect(p * (TEX / 4), 0, TEX / 4 - 2, TEX);
|
|
}
|
|
c.fillStyle = "rgba(30,20,10,0.9)";
|
|
for (let p = 0; p <= 4; p++) c.fillRect(p * (TEX / 4) - 1, 0, 2, TEX);
|
|
c.fillRect(0, 0, TEX, 4); // lintel
|
|
c.fillStyle = "rgba(60,60,66,0.9)"; // iron band and handle
|
|
c.fillRect(0, TEX * 0.42, TEX, 3);
|
|
c.beginPath();
|
|
c.arc(TEX * 0.78, TEX * 0.52, 2.5, 0, Math.PI * 2);
|
|
c.fill();
|
|
}),
|
|
firewall: bake((c) => {
|
|
const grad = c.createLinearGradient(0, 0, 0, TEX);
|
|
grad.addColorStop(0, "#7c1a14");
|
|
grad.addColorStop(0.55, "#d65c1c");
|
|
grad.addColorStop(1, "#f0a13a");
|
|
c.fillStyle = grad;
|
|
c.fillRect(0, 0, TEX, TEX);
|
|
for (let i = 0; i < 20; i++) {
|
|
c.fillStyle = `rgba(255,${180 + (h2(i, 3) * 60) | 0},80,${0.25 + h2(i, 5) * 0.3})`;
|
|
const w = 2 + h2(i, 9) * 4;
|
|
c.fillRect(h2(i, 1) * TEX, TEX - h2(i, 7) * TEX, w, h2(i, 7) * TEX);
|
|
}
|
|
}),
|
|
floor: bake((c) => {
|
|
// Flagstones: a 2x2 of worn slabs per cell, the mortar border carrying
|
|
// the tile seam so every cell reads as one square of the maze.
|
|
c.fillStyle = "#3a3226";
|
|
c.fillRect(0, 0, TEX, TEX);
|
|
for (let r = 0; r < 2; r++) {
|
|
for (let q = 0; q < 2; q++) {
|
|
const jit = 0.8 + 0.3 * h2(r * 3 + 1, q * 7 + 2);
|
|
c.fillStyle = `rgb(${74 * jit | 0},${64 * jit | 0},${48 * jit | 0})`;
|
|
c.fillRect(q * (TEX / 2) + 2, r * (TEX / 2) + 2, TEX / 2 - 4, TEX / 2 - 4);
|
|
}
|
|
}
|
|
c.strokeStyle = "rgba(16,12,8,0.9)";
|
|
c.lineWidth = 3;
|
|
c.strokeRect(0.5, 0.5, TEX - 1, TEX - 1);
|
|
}),
|
|
ceiling: bake((c) => {
|
|
// Heavy dark slabs overhead, barely catching the torchlight.
|
|
c.fillStyle = "#161310";
|
|
c.fillRect(0, 0, TEX, TEX);
|
|
for (let r = 0; r < 2; r++) {
|
|
for (let q = 0; q < 2; q++) {
|
|
const jit = 0.75 + 0.3 * h2(r * 5 + 3, q * 11 + 4);
|
|
c.fillStyle = `rgb(${34 * jit | 0},${30 * jit | 0},${26 * jit | 0})`;
|
|
c.fillRect(q * (TEX / 2) + 1, r * (TEX / 2) + 1, TEX / 2 - 2, TEX / 2 - 2);
|
|
}
|
|
}
|
|
}),
|
|
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();
|
|
}),
|
|
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.
|
|
c.fillStyle = "rgba(0,0,0,0.92)";
|
|
c.beginPath();
|
|
c.ellipse(TEX / 2, TEX / 2, TEX * 0.38, TEX * 0.34, 0, 0, Math.PI * 2);
|
|
c.fill();
|
|
c.strokeStyle = "rgba(20,16,10,0.9)";
|
|
c.lineWidth = 4;
|
|
c.stroke();
|
|
c.strokeStyle = "rgba(180,168,140,0.5)";
|
|
c.lineWidth = 2;
|
|
c.beginPath();
|
|
c.ellipse(TEX / 2, TEX / 2 - 2, TEX * 0.38, TEX * 0.34, 0, Math.PI * 1.1, Math.PI * 1.9);
|
|
c.stroke();
|
|
}),
|
|
slime: bake((c) => {
|
|
// A spill of green across the flags, glistening.
|
|
c.fillStyle = "rgba(70,140,40,0.55)";
|
|
c.beginPath();
|
|
c.ellipse(TEX * 0.5, TEX * 0.52, TEX * 0.4, TEX * 0.32, 0.4, 0, Math.PI * 2);
|
|
c.fill();
|
|
c.fillStyle = "rgba(110,190,60,0.5)";
|
|
for (let i = 0; i < 6; i++) {
|
|
c.beginPath();
|
|
c.ellipse(10 + h2(i, 2) * 44, 12 + h2(i, 5) * 40, 4 + h2(i, 7) * 5, 3 + h2(i, 9) * 4, 0, 0, Math.PI * 2);
|
|
c.fill();
|
|
}
|
|
c.fillStyle = "rgba(220,255,200,0.35)";
|
|
c.fillRect(TEX * 0.32, TEX * 0.4, 5, 2);
|
|
}),
|
|
tacks: bake((c) => {
|
|
// A scatter of little iron cruelties.
|
|
for (let i = 0; i < 14; i++) {
|
|
const x = 8 + h2(i, 3) * 48, y = 8 + h2(i, 6) * 48;
|
|
c.fillStyle = "rgba(70,70,78,0.9)";
|
|
c.beginPath();
|
|
c.moveTo(x, y - 3);
|
|
c.lineTo(x + 3, y + 2);
|
|
c.lineTo(x - 3, y + 2);
|
|
c.closePath();
|
|
c.fill();
|
|
c.fillStyle = "rgba(190,190,200,0.8)";
|
|
c.fillRect(x - 1, y - 4, 2, 2);
|
|
}
|
|
}),
|
|
dimwarp: bake((c) => {
|
|
// The dimensional warp's floor mouth: violet rings drawing inward.
|
|
for (let i = 5; i >= 0; i--) {
|
|
const a = 0.16 + i * 0.1;
|
|
c.strokeStyle = `rgba(${140 + i * 12},${80 + i * 14},${220},${a})`;
|
|
c.lineWidth = 3;
|
|
c.beginPath();
|
|
c.arc(TEX / 2, TEX / 2, 5 + i * 5, 0, Math.PI * 2);
|
|
c.stroke();
|
|
}
|
|
c.fillStyle = "rgba(240,225,255,0.8)";
|
|
c.beginPath();
|
|
c.arc(TEX / 2, TEX / 2, 3.5, 0, Math.PI * 2);
|
|
c.fill();
|
|
}),
|
|
warp: bake((c) => {
|
|
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
|
grad.addColorStop(0, "#2c1a4e");
|
|
grad.addColorStop(0.5, "#7a4ccc");
|
|
grad.addColorStop(1, "#3a2266");
|
|
c.fillStyle = grad;
|
|
c.fillRect(0, 0, TEX, TEX);
|
|
c.strokeStyle = "rgba(200,170,255,0.5)";
|
|
for (let i = 0; i < 6; i++) {
|
|
c.beginPath();
|
|
c.arc(TEX / 2, TEX / 2, 6 + i * 5, i, i + 4.2);
|
|
c.stroke();
|
|
}
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** The material set the renderer draws from: procedural now, each entry
|
|
* replaced in place the moment its painted file arrives. */
|
|
export function materialTextures(): Record<string, CanvasImageSource & { width: number; height: number }> {
|
|
const set: Record<string, CanvasImageSource & { width: number; height: number }> = proceduralTextures();
|
|
for (const name of MATERIALS) {
|
|
const img = new Image();
|
|
img.onload = () => { set[name] = img; };
|
|
img.src = `/textures/${name}.png`;
|
|
}
|
|
return set;
|
|
}
|