fertig-classic-games/tools/genSpireClimbCreatures.js

195 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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: 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 09 Act 1, 1016 Act 2, 1723 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 = 'assets/images/spireclimb-creatures.png';
writeFileSync(out, png);
console.log(`Wrote ${out} (${W}×${H}, ${CREATURES.length} frames)`);