177 lines
9.4 KiB
JavaScript
177 lines
9.4 KiB
JavaScript
// 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 = 5, ROWS = 2;
|
||
const W = FW * COLS, H = FH * ROWS;
|
||
|
||
// id → { frame, color, shape, tier } (mirrors SpireClimbData ENEMIES placeholders)
|
||
const CREATURES = [
|
||
{ 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 },
|
||
];
|
||
|
||
// 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)`);
|