// genSpireClimbCreatures.js — generates a PLACEHOLDER creature spritesheet for // Spire Climb so the game ships with dedicated (non-shared) creature art that an // artist can paint over. Pure Node (zlib only); no image libraries. // // node genSpireClimbCreatures.js // // Output: public/assets/images/spireclimb-creatures.png // 10 frames, 300×300 each, laid out 5 columns × 2 rows (Phaser frame order is // row-major, so frame 0 = top-left … frame 9 = bottom-right). The frame index // under each creature matches /data/spireclimb-artwork.json "creatures". // // To replace with real art: keep the same 300×300 / 5×2 layout and frame order, // drop your PNG at the same path. No code or JSON changes needed. import zlib from 'node:zlib'; import { writeFileSync } from 'node:fs'; const FW = 300, FH = 300, COLS = 6, ROWS = 4; const W = FW * COLS, H = FH * ROWS; // id → { frame, color, shape, tier } (mirrors SpireClimbData ENEMIES placeholders). // 24 frames, row-major: frames 0–9 Act 1, 10–16 Act 2, 17–23 Act 3. const CREATURES = [ // —— Act 1 —— { id: 'jawworm', frame: 0, color: [0x9c, 0x6b, 0x3a], shape: 'worm', tier: 1 }, { id: 'cultist', frame: 1, color: [0x6a, 0x4d, 0x8a], shape: 'robed', tier: 1 }, { id: 'louse', frame: 2, color: [0xc0, 0x53, 0x3a], shape: 'bug', tier: 1 }, { id: 'fungi', frame: 3, color: [0x4b, 0x8c, 0x5a], shape: 'shroom',tier: 1 }, { id: 'spikeslime', frame: 4, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 1 }, { id: 'sentry', frame: 5, color: [0x9a, 0xa7, 0xb5], shape: 'orb', tier: 2 }, { id: 'gremlinnob', frame: 6, color: [0xb5, 0x50, 0x3a], shape: 'horns', tier: 2 }, { id: 'lagavulin', frame: 7, color: [0x3a, 0x7a, 0x6b], shape: 'slime', tier: 2 }, { id: 'guardian', frame: 8, color: [0x8a, 0x6b, 0x3a], shape: 'golem', tier: 3 }, { id: 'slimeboss', frame: 9, color: [0x4a, 0x6b, 0xbf], shape: 'slime', tier: 3 }, // —— Act 2 —— { id: 'centurion', frame: 10, color: [0xb8, 0xa2, 0x4a], shape: 'golem', tier: 2 }, { id: 'mystic', frame: 11, color: [0x5a, 0x8f, 0xc0], shape: 'robed', tier: 1 }, { id: 'byrd', frame: 12, color: [0xd2, 0x9a, 0x3a], shape: 'bug', tier: 1 }, { id: 'snecko', frame: 13, color: [0x6a, 0xae, 0x4a], shape: 'worm', tier: 2 }, { id: 'taskmaster', frame: 14, color: [0xc0, 0x5a, 0x3a], shape: 'horns', tier: 2 }, { id: 'bookwyrm', frame: 15, color: [0x7a, 0x4d, 0x8a], shape: 'slime', tier: 3 }, { id: 'automaton', frame: 16, color: [0xb8, 0x90, 0x30], shape: 'golem', tier: 3 }, // —— Act 3 —— { id: 'darkling', frame: 17, color: [0x8a, 0x3a, 0x5a], shape: 'slime', tier: 2 }, { id: 'spiker', frame: 18, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 2 }, { id: 'maw', frame: 19, color: [0x6a, 0x4d, 0x3a], shape: 'horns', tier: 3 }, { id: 'wraith', frame: 20, color: [0x9a, 0x5f, 0xd0], shape: 'robed', tier: 2 }, { id: 'nemesis', frame: 21, color: [0x9a, 0xa7, 0xb5], shape: 'horns', tier: 3 }, { id: 'giant', frame: 22, color: [0x8a, 0x7a, 0x4a], shape: 'golem', tier: 3 }, { id: 'awakened', frame: 23, color: [0xc9, 0x4f, 0x8a], shape: 'horns', tier: 3 }, ]; // RGBA pixel buffer const buf = new Uint8Array(W * H * 4); // transparent by default const px = (x, y, r, g, b, a = 255) => { if (x < 0 || y < 0 || x >= W || y >= H) return; const i = (y * W + x) * 4; const ia = a / 255; buf[i] = Math.round(r * ia + buf[i] * (1 - ia)); buf[i + 1] = Math.round(g * ia + buf[i + 1] * (1 - ia)); buf[i + 2] = Math.round(b * ia + buf[i + 2] * (1 - ia)); buf[i + 3] = Math.max(buf[i + 3], a); }; const rect = (x0, y0, w, h, c, a = 255) => { for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) px(x, y, c[0], c[1], c[2], a); }; const disc = (cx, cy, rad, c, a = 255) => { for (let y = cy - rad; y <= cy + rad; y++) for (let x = cx - rad; x <= cx + rad; x++) { const dx = x - cx, dy = y - cy; if (dx * dx + dy * dy <= rad * rad) px(x, y, c[0], c[1], c[2], a); } }; const ellipse = (cx, cy, rx, ry, c, a = 255) => { for (let y = cy - ry; y <= cy + ry; y++) for (let x = cx - rx; x <= cx + rx; x++) { const dx = (x - cx) / rx, dy = (y - cy) / ry; if (dx * dx + dy * dy <= 1) px(x, y, c[0], c[1], c[2], a); } }; const tri = (x0, y0, x1, y1, x2, y2, c, a = 255) => { const minx = Math.min(x0, x1, x2), maxx = Math.max(x0, x1, x2), miny = Math.min(y0, y1, y2), maxy = Math.max(y0, y1, y2); const area = (ax, ay, bx, by, cx, cy) => (bx - ax) * (cy - ay) - (cx - ax) * (by - ay); const A = area(x0, y0, x1, y1, x2, y2); for (let y = miny; y <= maxy; y++) for (let x = minx; x <= maxx; x++) { const w0 = area(x1, y1, x2, y2, x, y), w1 = area(x2, y2, x0, y0, x, y), w2 = area(x0, y0, x1, y1, x, y); if (A === 0) continue; if ((w0 >= 0 && w1 >= 0 && w2 >= 0) || (w0 <= 0 && w1 <= 0 && w2 <= 0)) px(x, y, c[0], c[1], c[2], a); } }; const dark = (c, f = 0.55) => c.map((v) => Math.round(v * f)); const light = (c, f = 0.4) => c.map((v) => Math.round(v + (255 - v) * f)); // tiny 3×5 digit font for the frame-index badge const FONT = { '0': ['111', '101', '101', '101', '111'], '1': ['010', '110', '010', '010', '111'], '2': ['111', '001', '111', '100', '111'], '3': ['111', '001', '111', '001', '111'], '4': ['101', '101', '111', '001', '001'], '5': ['111', '100', '111', '001', '111'], '6': ['111', '100', '111', '101', '111'], '7': ['111', '001', '010', '010', '010'], '8': ['111', '101', '111', '101', '111'], '9': ['111', '101', '111', '001', '111'], }; const digit = (ch, x, y, s, c) => { const g = FONT[ch]; if (!g) return; for (let r = 0; r < 5; r++) for (let col = 0; col < 3; col++) if (g[r][col] === '1') rect(x + col * s, y + r * s, s, s, c); }; const number = (n, x, y, s, c) => { const str = String(n); str.split('').forEach((ch, i) => digit(ch, x + i * (4 * s), y, s, c)); }; function drawFrame(cr) { const ox = (cr.frame % COLS) * FW; const oy = Math.floor(cr.frame / COLS) * FH; const cx = ox + FW / 2, cy = oy + FH / 2; const base = cr.color, bg = dark(base, 0.28), edge = light(base, 0.5); // cell background + border + corner tier accent rect(ox, oy, FW, FH, bg); // vignette glow disc(cx, cy + 10, 130, dark(base, 0.42), 110); // border rect(ox, oy, FW, 6, edge); rect(ox, oy + FH - 6, FW, 6, edge); rect(ox, oy, 6, FH, edge); rect(ox + FW - 6, oy, 6, FH, edge); const sz = cr.tier === 3 ? 1.25 : cr.tier === 2 ? 1.08 : 0.92; // boss bigger const body = base, belly = light(base, 0.35), shade = dark(base, 0.6); const r = Math.round(78 * sz); // shape-specific silhouette switch (cr.shape) { case 'worm': for (let s = 0; s < 4; s++) disc(cx - 60 + s * 40, cy + 20 - s * 6, Math.round(34 * sz), s % 2 ? body : light(body, 0.15)); ellipse(cx + 78, cy - 4, Math.round(40 * sz), Math.round(34 * sz), body); break; case 'shroom': rect(cx - 26, cy, 52, Math.round(70 * sz), belly); // stalk ellipse(cx, cy - 6, Math.round(86 * sz), Math.round(54 * sz), body); // cap for (let i = 0; i < 5; i++) disc(cx - 60 + i * 30, cy - 16, 10, light(body, 0.5)); break; case 'slime': ellipse(cx, cy + 20, Math.round(96 * sz), Math.round(70 * sz), body); ellipse(cx, cy + 44, Math.round(96 * sz), Math.round(30 * sz), shade); // base shadow ellipse(cx - 26, cy - 6, 22, 16, belly, 160); break; case 'spiky': for (let a = 0; a < 12; a++) { const an = (a / 12) * Math.PI * 2; tri(cx + Math.cos(an) * r, cy + Math.sin(an) * r, cx + Math.cos(an + 0.25) * (r + 34), cy + Math.sin(an + 0.25) * (r + 34), cx + Math.cos(an + 0.5) * r, cy + Math.sin(an + 0.5) * r, shade); } disc(cx, cy, r, body); break; case 'orb': disc(cx, cy, r, shade); disc(cx, cy, Math.round(r * 0.8), body); disc(cx, cy, Math.round(r * 0.32), light(body, 0.6)); break; case 'horns': tri(cx - r + 4, cy - 40, cx - r - 34, cy - 96, cx - r + 30, cy - 70, light(body, 0.2)); tri(cx + r - 4, cy - 40, cx + r + 34, cy - 96, cx + r - 30, cy - 70, light(body, 0.2)); disc(cx, cy + 6, r, body); break; case 'golem': rect(cx - r, cy - r, r * 2, Math.round(r * 2.1), body); // blocky body rect(cx - r, cy - r, r * 2, 10, light(body, 0.3)); rect(cx - r + 14, cy + 30, 28, r, shade); rect(cx + r - 42, cy + 30, 28, r, shade); // leg gap break; case 'robed': tri(cx, cy - r - 10, cx - Math.round(r * 0.95), cy + r, cx + Math.round(r * 0.95), cy + r, body); // hood/robe disc(cx, cy - Math.round(r * 0.4), Math.round(r * 0.42), shade); // face shadow break; case 'bug': default: ellipse(cx, cy + 6, Math.round(r * 0.95), Math.round(r * 1.05), body); for (let i = 0; i < 3; i++) { rect(cx - r, cy - 20 + i * 26, 26, 6, shade); rect(cx + r - 26, cy - 20 + i * 26, 26, 6, shade); } // legs break; } // eyes (skip for golem/orb which read as constructs but give them a core eye) const ey = cy - Math.round(r * 0.2), espread = Math.round(r * 0.42), eR = Math.max(9, Math.round(r * 0.2)); if (cr.shape === 'orb' || cr.shape === 'golem') { disc(cx, cy - (cr.shape === 'golem' ? Math.round(r * 0.4) : 0), eR, [0xff, 0x66, 0x44]); disc(cx, cy - (cr.shape === 'golem' ? Math.round(r * 0.4) : 0), Math.round(eR * 0.5), [0xff, 0xdd, 0xaa]); } else { for (const sx of [cx - espread, cx + espread]) { disc(sx, ey, eR, [0xf6, 0xf2, 0xe8]); disc(sx + 2, ey + 2, Math.round(eR * 0.5), [0x18, 0x12, 0x1e]); } } // frame-index badge (top-left of cell) rect(ox + 12, oy + 12, 4 * 6 * String(cr.frame).length + 8, 30, [0, 0, 0], 150); number(cr.frame, ox + 16, oy + 17, 4, light(base, 0.7)); } for (const cr of CREATURES) drawFrame(cr); // ── encode PNG (truecolour + alpha, 8-bit) ── function crc32(b) { let c = ~0; for (let i = 0; i < b.length; i++) { c ^= b[i]; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); } return ~c >>> 0; } function chunk(type, data) { const t = Buffer.from(type, 'ascii'); const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([t, data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([len, body, crc]); } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 6; // RGBA // filtered scanlines (filter 0) const raw = Buffer.alloc((W * 4 + 1) * H); for (let y = 0; y < H; y++) { raw[y * (W * 4 + 1)] = 0; Buffer.from(buf.buffer, y * W * 4, W * 4).copy(raw, y * (W * 4 + 1) + 1); } const idat = zlib.deflateSync(raw, { level: 9 }); const png = Buffer.concat([ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0)), ]); const out = 'public/assets/images/spireclimb-creatures.png'; writeFileSync(out, png); console.log(`Wrote ${out} (${W}×${H}, ${CREATURES.length} frames)`);