feat(frontend): polish — hover tooltips, horizontal names, faster movement, full view, BeOS tiles

This commit is contained in:
engineering Agent 2026-05-19 19:06:07 -07:00
parent 927585431e
commit 63ce6ac1fa
1567 changed files with 945 additions and 38 deletions

374
spritegen.mjs Normal file
View file

@ -0,0 +1,374 @@
// 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);