374 lines
15 KiB
JavaScript
374 lines
15 KiB
JavaScript
// spritegen.mjs — Generate 9 distinct 32x32 pixel art agent sprites
|
|
// Uses Sharp (no native deps issues) — writes raw RGBA buffers
|
|
// Output: assets/sprites/agent-{id}.png + agent-{id}-sheet.png
|
|
|
|
import sharp from 'sharp';
|
|
import { writeFileSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
const SIZE = 32;
|
|
const OUT_DIR = join(import.meta.dirname, 'assets', 'sprites');
|
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
|
|
// ── Color helpers ──────────────────────────────────────────
|
|
function shade(hex, percent) {
|
|
const num = parseInt(hex.replace('#', ''), 16);
|
|
const r = Math.min(255, Math.max(0, (num >> 16) + percent));
|
|
const g = Math.min(255, Math.max(0, ((num >> 8) & 0xFF) + percent));
|
|
const b = Math.min(255, Math.max(0, (num & 0xFF) + percent));
|
|
return '#' + (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
|
}
|
|
|
|
function toRGBA(hex, alpha = 255) {
|
|
const num = parseInt(hex.replace('#', ''), 16);
|
|
return [(num >> 16) & 0xFF, (num >> 8) & 0xFF, num & 0xFF, alpha];
|
|
}
|
|
|
|
// ── Pixel buffer manipulation ──────────────────────────────
|
|
function createBuffer(w, h, fill) {
|
|
const buf = Buffer.alloc(w * h * 4, 0);
|
|
if (fill) {
|
|
const [r, g, b, a] = toRGBA(fill);
|
|
for (let i = 0; i < buf.length; i += 4) {
|
|
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
|
|
}
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
function pixel(buf, w, h, x, y, color, size = 1) {
|
|
const [r, g, b, a] = toRGBA(color);
|
|
for (let dy = 0; dy < size; dy++) {
|
|
for (let dx = 0; dx < size; dx++) {
|
|
const px = x + dx;
|
|
const py = y + dy;
|
|
if (px >= 0 && px < w && py >= 0 && py < h) {
|
|
const i = (py * w + px) * 4;
|
|
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function filledRect(buf, w, h, x, y, rw, rh, color) {
|
|
const [r, g, b, a] = toRGBA(color);
|
|
for (let dy = 0; dy < rh; dy++) {
|
|
for (let dx = 0; dx < rw; dx++) {
|
|
const px = x + dx;
|
|
const py = y + dy;
|
|
if (px >= 0 && px < w && py >= 0 && py < h) {
|
|
const i = (py * w + px) * 4;
|
|
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function bufToPNG(buf, w, h) {
|
|
return sharp(buf, { raw: { width: w, height: h, channels: 4 } }).png();
|
|
}
|
|
|
|
// ── Draw character on buffer ───────────────────────────────
|
|
function drawAgent(buf, agent, w, h, frame = 0) {
|
|
const { skin, hair, cloth, accent, build, hat, prop, pose } = agent;
|
|
const cx = Math.floor(w / 2);
|
|
const by = 8; // body top Y
|
|
|
|
// -- Shadow --
|
|
filledRect(buf, w, h, cx - 5, h - 4, 12, 3, 'rgba(0,0,0,0.25)');
|
|
|
|
// -- Body dimensions --
|
|
let bodyW, bodyH;
|
|
switch (build) {
|
|
case 'stocky': bodyW = 12; bodyH = 11; break;
|
|
case 'medium-wide': bodyW = 10; bodyH = 11; break;
|
|
case 'medium-round': bodyW = 10; bodyH = 10; break;
|
|
case 'medium': bodyW = 10; bodyH = 11; break;
|
|
case 'lean': bodyW = 8; bodyH = 12; break;
|
|
case 'small-round': bodyW = 8; bodyH = 9; break;
|
|
case 'tall-thin': bodyW = 8; bodyH = 13; break;
|
|
case 'tall-upright': bodyW = 8; bodyH = 14; break;
|
|
default: bodyW = 10; bodyH = 11; break;
|
|
}
|
|
|
|
const bx = cx - Math.floor(bodyW / 2);
|
|
const pantsY = by + bodyH;
|
|
const darkerCloth = shade(cloth, -25);
|
|
const lighterCloth = shade(cloth, 20);
|
|
const darkerSkin = shade(skin, -15);
|
|
|
|
// -- Cape (presentations) --
|
|
if (prop === 'cape') {
|
|
filledRect(buf, w, h, cx - 6, by, 12, bodyH + 5, shade(accent, -10));
|
|
filledRect(buf, w, h, cx - 7, by + 2, 2, bodyH + 3, accent);
|
|
filledRect(buf, w, h, cx + 5, by + 2, 2, bodyH + 3, accent);
|
|
}
|
|
|
|
// -- Legs --
|
|
const legW = 3;
|
|
const legH = 6;
|
|
|
|
// Walking animation
|
|
let lx = cx - 4, rx = cx + 1;
|
|
if (frame === 1) { lx -= 1; rx += 1; }
|
|
else if (frame === 2) { lx += 0; rx += 2; }
|
|
else if (frame === 3) { lx += 1; rx -= 1; }
|
|
|
|
filledRect(buf, w, h, lx, pantsY, legW, legH, darkerCloth);
|
|
filledRect(buf, w, h, lx, pantsY + legH, 4, 2, '#1a1a1a'); // shoe
|
|
filledRect(buf, w, h, rx, pantsY, legW, legH, darkerCloth);
|
|
filledRect(buf, w, h, rx, pantsY + legH, 4, 2, '#1a1a1a'); // shoe
|
|
|
|
// -- Body --
|
|
filledRect(buf, w, h, bx, by, bodyW, bodyH, cloth);
|
|
// Shading
|
|
filledRect(buf, w, h, bx + 1, by + bodyH - 2, bodyW - 2, 1, darkerCloth);
|
|
filledRect(buf, w, h, bx + 2, by + bodyH - 1, bodyW - 4, 1, darkerCloth);
|
|
// Collar
|
|
filledRect(buf, w, h, bx + 2, by, bodyW - 4, 2, lighterCloth);
|
|
|
|
// -- Arms by pose --
|
|
const armW = 2;
|
|
switch (pose) {
|
|
case 'standing-open':
|
|
filledRect(buf, w, h, bx - armW, by + 2, armW, bodyH - 3, cloth);
|
|
filledRect(buf, w, h, bx + bodyW, by + 2, armW, bodyH - 3, cloth);
|
|
break;
|
|
case 'standing-wide':
|
|
filledRect(buf, w, h, bx - 3, by + 2, armW, bodyH - 2, cloth);
|
|
filledRect(buf, w, h, bx + bodyW + 1, by + 2, armW, bodyH - 2, cloth);
|
|
break;
|
|
case 'leaning-forward':
|
|
filledRect(buf, w, h, bx - 2, by + 3, armW, bodyH - 3, cloth);
|
|
filledRect(buf, w, h, bx + bodyW, by + 1, armW, bodyH - 2, cloth);
|
|
break;
|
|
case 'standing-guard':
|
|
filledRect(buf, w, h, bx - 2, by + 2, armW, bodyH - 2, cloth);
|
|
filledRect(buf, w, h, bx + bodyW, by + 3, armW, bodyH - 4, cloth);
|
|
break;
|
|
case 'standing-proud':
|
|
filledRect(buf, w, h, bx - 1, by + 1, armW, bodyH - 1, cloth);
|
|
filledRect(buf, w, h, bx + bodyW - 1, by + 1, armW, bodyH, cloth);
|
|
break;
|
|
case 'hunched':
|
|
filledRect(buf, w, h, bx - 1, by + 2, armW, bodyH - 2, cloth);
|
|
filledRect(buf, w, h, bx + bodyW - 1, by + 3, armW, bodyH - 3, cloth);
|
|
break;
|
|
case 'standing-leaning':
|
|
filledRect(buf, w, h, bx - 2, by + 2, armW, bodyH - 3, cloth);
|
|
filledRect(buf, w, h, bx + bodyW, by + 2, armW, bodyH - 3, cloth);
|
|
// One arm raised (holding quill)
|
|
filledRect(buf, w, h, bx + bodyW, by + 2, armW, 4, cloth);
|
|
break;
|
|
default:
|
|
filledRect(buf, w, h, bx - 2, by + 2, armW, bodyH - 3, cloth);
|
|
filledRect(buf, w, h, bx + bodyW, by + 2, armW, bodyH - 3, cloth);
|
|
break;
|
|
}
|
|
|
|
// -- Props --
|
|
switch (prop) {
|
|
case 'clipboard':
|
|
filledRect(buf, w, h, bx - 1, by + 3, 5, 7, '#f0f0e8');
|
|
filledRect(buf, w, h, bx, by + 4, 3, 5, '#e0dcd0');
|
|
filledRect(buf, w, h, bx, by + 2, 3, 1, '#aaaaaa');
|
|
break;
|
|
case 'wrench':
|
|
filledRect(buf, w, h, bx + bodyW + 1, by + bodyH - 3, 2, 5, '#c0c0d0');
|
|
pixel(buf, w, h, bx + bodyW + 2, by + bodyH - 4, '#c0c0d0', 3);
|
|
break;
|
|
case 'staff':
|
|
filledRect(buf, w, h, bx + bodyW, by - 2, 2, bodyH + 4, '#6b4226');
|
|
filledRect(buf, w, h, bx + bodyW - 1, by - 4, 4, 3, accent);
|
|
filledRect(buf, w, h, bx + bodyW, by - 5, 2, 1, '#f0e8c0');
|
|
break;
|
|
case 'shield':
|
|
filledRect(buf, w, h, bx + bodyW + 1, by + 3, 5, 6, accent);
|
|
filledRect(buf, w, h, bx + bodyW + 2, by + 4, 3, 4, '#ffffff');
|
|
pixel(buf, w, h, bx + bodyW + 3, by + 5, '#3355aa', 2);
|
|
break;
|
|
case 'magnifier':
|
|
pixel(buf, w, h, bx + bodyW + 1, by + 3, '#a0a0b0', 5); // glass
|
|
pixel(buf, w, h, bx + bodyW + 2, by + 4, '#ffffff', 3);
|
|
filledRect(buf, w, h, bx + bodyW + 6, by + 5, 2, 5, '#6b4226'); // handle
|
|
break;
|
|
case 'herbs':
|
|
filledRect(buf, w, h, bx + bodyW - 1, by + 1, 3, 3, '#4a8c3f');
|
|
pixel(buf, w, h, bx + bodyW, by, '#3a7c2f', 2);
|
|
break;
|
|
case 'quill':
|
|
filledRect(buf, w, h, bx + bodyW + 1, by + 4, 1, 6, '#e8e0d0');
|
|
filledRect(buf, w, h, bx + bodyW, by + 4, 2, 1, accent);
|
|
break;
|
|
case 'key':
|
|
filledRect(buf, w, h, bx + bodyW + 1, by + 4, 2, 4, accent);
|
|
pixel(buf, w, h, bx + bodyW, by + 4, accent, 3);
|
|
break;
|
|
case 'heart':
|
|
// Red heart on chest
|
|
pixel(buf, w, h, cx - 1, by + 4, '#ff3333', 3);
|
|
pixel(buf, w, h, cx - 2, by + 5, '#ff3333');
|
|
pixel(buf, w, h, cx + 2, by + 5, '#ff3333');
|
|
pixel(buf, w, h, cx - 1, by + 6, '#ff3333');
|
|
pixel(buf, w, h, cx, by + 6, '#ff3333');
|
|
pixel(buf, w, h, cx + 1, by + 6, '#ff3333');
|
|
break;
|
|
}
|
|
|
|
// -- Head --
|
|
const hy = by - 9; // head top Y
|
|
|
|
// Hair behind
|
|
if (hat !== 'wizard') {
|
|
filledRect(buf, w, h, cx - 5, hy + 2, 10, 3, hair);
|
|
}
|
|
|
|
// Hair sides (depending on hat)
|
|
if (hat !== 'hood' && hat !== 'helmet') {
|
|
pixel(buf, w, h, cx - 5, hy + 4, hair, 2);
|
|
pixel(buf, w, h, cx + 3, hy + 4, hair, 2);
|
|
}
|
|
|
|
// Head base
|
|
filledRect(buf, w, h, cx - 3, hy + 2, 6, 8, skin);
|
|
filledRect(buf, w, h, cx - 2, hy, 4, 4, skin);
|
|
|
|
// Ears
|
|
if (hat !== 'hood' && hat !== 'helmet') {
|
|
pixel(buf, w, h, cx - 4, hy + 4, darkerSkin);
|
|
pixel(buf, w, h, cx + 3, hy + 4, darkerSkin);
|
|
}
|
|
|
|
// Eyes
|
|
pixel(buf, w, h, cx - 2, hy + 4, '#ffffff', 2);
|
|
pixel(buf, w, h, cx + 1, hy + 4, '#ffffff', 2);
|
|
pixel(buf, w, h, cx - 1, hy + 5, '#1a1a1a');
|
|
pixel(buf, w, h, cx + 2, hy + 5, '#1a1a1a');
|
|
|
|
// Eyebrows (subtle)
|
|
if (hat !== 'helmet') {
|
|
pixel(buf, w, h, cx - 2, hy + 3, shade(hair, -10));
|
|
pixel(buf, w, h, cx + 1, hy + 3, shade(hair, -10));
|
|
}
|
|
|
|
// Mouth
|
|
pixel(buf, w, h, cx - 1, hy + 7, '#6b4a3a');
|
|
pixel(buf, w, h, cx, hy + 7, '#6b4a3a');
|
|
|
|
// -- Hat / Headgear --
|
|
switch (hat) {
|
|
case 'hardhat':
|
|
filledRect(buf, w, h, cx - 4, hy, 8, 2, '#e8c040');
|
|
filledRect(buf, w, h, cx - 3, hy - 1, 6, 1, '#daa520');
|
|
pixel(buf, w, h, cx - 4, hy + 1, '#daa520');
|
|
pixel(buf, w, h, cx + 3, hy + 1, '#daa520');
|
|
break;
|
|
case 'wizard':
|
|
// Pointed hat
|
|
filledRect(buf, w, h, cx - 4, hy + 1, 8, 3, shade(cloth, -10)); // brim
|
|
filledRect(buf, w, h, cx - 3, hy - 2, 6, 4, cloth); // body
|
|
pixel(buf, w, h, cx - 1, hy - 4, cloth, 4); // tip base
|
|
pixel(buf, w, h, cx, hy - 6, cloth, 2); // tip point
|
|
pixel(buf, w, h, cx, hy - 7, accent); // tip highlight
|
|
// Hair
|
|
filledRect(buf, w, h, cx - 4, hy + 2, 8, 2, hair);
|
|
break;
|
|
case 'helmet':
|
|
filledRect(buf, w, h, cx - 4, hy, 8, 3, '#a0a0b8');
|
|
filledRect(buf, w, h, cx - 4, hy + 2, 8, 1, '#8888a0');
|
|
filledRect(buf, w, h, cx - 1, hy - 2, 2, 3, '#b0b0c8'); // crest
|
|
break;
|
|
case 'hood':
|
|
filledRect(buf, w, h, cx - 5, hy - 1, 10, 5, cloth);
|
|
filledRect(buf, w, h, cx - 5, hy + 4, 10, 2, shade(cloth, -15));
|
|
pixel(buf, w, h, cx - 5, hy + 5, shade(cloth, -15));
|
|
pixel(buf, w, h, cx + 4, hy + 5, shade(cloth, -15));
|
|
break;
|
|
case 'bandana':
|
|
filledRect(buf, w, h, cx - 4, hy, 8, 2, '#cc3333');
|
|
// Hair on top
|
|
filledRect(buf, w, h, cx - 3, hy - 3, 6, 3, hair);
|
|
pixel(buf, w, h, cx - 3, hy - 4, hair, 2);
|
|
break;
|
|
case 'beret':
|
|
filledRect(buf, w, h, cx - 4, hy - 1, 8, 2, cloth);
|
|
pixel(buf, w, h, cx - 5, hy, cloth, 2);
|
|
pixel(buf, w, h, cx + 3, hy, cloth, 2);
|
|
filledRect(buf, w, h, cx, hy - 2, 2, 1, cloth);
|
|
break;
|
|
case 'glasses':
|
|
// Round glasses
|
|
filledRect(buf, w, h, cx - 3, hy + 4, 3, 3, 'rgba(255,255,255,0.4)');
|
|
filledRect(buf, w, h, cx + 1, hy + 4, 3, 3, 'rgba(255,255,255,0.4)');
|
|
pixel(buf, w, h, cx, hy + 5, '#888888');
|
|
filledRect(buf, w, h, cx - 4, hy, 8, 3, hair);
|
|
pixel(buf, w, h, cx - 4, hy - 1, hair, 2);
|
|
break;
|
|
case 'tophat':
|
|
// Tall formal hat
|
|
filledRect(buf, w, h, cx - 3, hy - 3, 6, 4, '#1a1a1a');
|
|
filledRect(buf, w, h, cx - 5, hy + 1, 10, 2, '#1a1a1a'); // brim
|
|
filledRect(buf, w, h, cx - 3, hy + 1, 6, 1, '#333333');
|
|
// Hair
|
|
filledRect(buf, w, h, cx - 4, hy + 2, 8, 2, hair);
|
|
break;
|
|
default:
|
|
// Default hair
|
|
filledRect(buf, w, h, cx - 4, hy - 1, 8, 3, hair);
|
|
pixel(buf, w, h, cx - 3, hy - 2, hair, 2);
|
|
break;
|
|
}
|
|
|
|
return buf;
|
|
}
|
|
|
|
// ── Agent definitions ───────────────────────────────────────
|
|
|
|
const AGENTS = [
|
|
{ id: 'cos', name: 'COS', skin: '#ffdbb4', hair: '#5c3a21', cloth: '#e8652d', accent: '#daa520', build: 'tall-thin', hat: 'none', prop: 'clipboard', pose: 'standing-upright' },
|
|
{ id: 'engineering', name: 'Eng', skin: '#e8b88a', hair: '#2c1810', cloth: '#3dbbb4', accent: '#b8b8c8', build: 'stocky', hat: 'hardhat', prop: 'wrench', pose: 'standing-wide' },
|
|
{ id: 'senior-eng', name: 'Sr.Eng', skin: '#ffdbb4', hair: '#888888', cloth: '#6b238e', accent: '#daa520', build: 'tall-thin', hat: 'wizard', prop: 'staff', pose: 'standing-upright' },
|
|
{ id: 'trust', name: 'Trust', skin: '#e8b88a', hair: '#d4a843', cloth: '#3d8bc9', accent: '#c0c0d8', build: 'medium-wide', hat: 'helmet', prop: 'shield', pose: 'standing-guard' },
|
|
{ id: 'market', name: 'Market', skin: '#c4956a', hair: '#5c3a21', cloth: '#5b9e4f', accent: '#6b4226', build: 'lean', hat: 'hood', prop: 'magnifier', pose: 'leaning-forward' },
|
|
{ id: 'health', name: 'Health', skin: '#ffdbb4', hair: '#8b3a1a', cloth: '#e8c547', accent: '#e8e0d0', build: 'medium-round', hat: 'bandana', prop: 'heart', pose: 'standing-open' },
|
|
{ id: 'content', name: 'Content', skin: '#e8b88a', hair: '#5c3a21', cloth: '#b47bb8', accent: '#daa520', build: 'medium', hat: 'beret', prop: 'quill', pose: 'standing-leaning' },
|
|
{ id: 'presentations', name: 'Pres.', skin: '#ffdbb4', hair: '#1a1a2e', cloth: '#e8716d', accent: '#ff4444', build: 'tall-upright', hat: 'tophat', prop: 'cape', pose: 'standing-proud' },
|
|
{ id: 'vault', name: 'Vault', skin: '#c4956a', hair: '#2c1810', cloth: '#6fbf8b', accent: '#daa520', build: 'small-round', hat: 'glasses', prop: 'key', pose: 'hunched' },
|
|
];
|
|
|
|
// ── Generate ────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
for (const agent of AGENTS) {
|
|
// Idle frame
|
|
const idleBuf = createBuffer(SIZE, SIZE);
|
|
drawAgent(idleBuf, agent, SIZE, SIZE, 0);
|
|
await bufToPNG(idleBuf, SIZE, SIZE).toFile(join(OUT_DIR, `agent-${agent.id}.png`));
|
|
|
|
// Sprite sheet: 4 frames in a row
|
|
const sheetW = SIZE * 4;
|
|
const sheetBuf = createBuffer(sheetW, SIZE);
|
|
for (let f = 0; f < 4; f++) {
|
|
const frameBuf = createBuffer(SIZE, SIZE);
|
|
drawAgent(frameBuf, agent, SIZE, SIZE, f);
|
|
// Copy frame into sheet
|
|
for (let y = 0; y < SIZE; y++) {
|
|
for (let x = 0; x < SIZE; x++) {
|
|
const src = (y * SIZE + x) * 4;
|
|
const dst = (y * sheetW + (f * SIZE + x)) * 4;
|
|
sheetBuf[dst] = frameBuf[src];
|
|
sheetBuf[dst + 1] = frameBuf[src + 1];
|
|
sheetBuf[dst + 2] = frameBuf[src + 2];
|
|
sheetBuf[dst + 3] = frameBuf[src + 3];
|
|
}
|
|
}
|
|
}
|
|
await bufToPNG(sheetBuf, sheetW, SIZE).toFile(join(OUT_DIR, `agent-${agent.id}-sheet.png`));
|
|
|
|
console.log(` ✅ ${agent.id} (${agent.name}) — ${agent.build} / ${agent.hat || 'none'} / ${agent.prop}`);
|
|
}
|
|
|
|
console.log(`\nGenerated ${AGENTS.length} agent sprites in ${OUT_DIR}/`);
|
|
}
|
|
|
|
main().catch(console.error);
|