// 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);