/** * Batch-generate enemy..south map objects via PixelLab API v2, using archetype PNGs as init_image. * * Prerequisites: * - PIXELLAB_API_TOKEN in env (https://pixellab.ai/account or MCP setup). * - Reference sprites at frontend/assets/enemies/enemy..png (from manifest archetypes). * * Usage: * set PIXELLAB_API_TOKEN=... # PowerShell: $env:PIXELLAB_API_TOKEN="..." * npm run gen:enemy-south:pixellab -- --limit 3 # smoke test * npm run gen:enemy-south:pixellab -- --slugs "wolf_l1_1_meadow,boar_l2_2_meadow" # explicit list * npm run gen:enemy-south:pixellab # all ~220 (long run; resumable) * * Resume: state in scripts/.enemy-south-pixellab-state.json * Output: assets/enemies/enemy..south.png + manifest entries (pixellabObjectId, rotation south). */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, '..'); const ASSETS = path.join(ROOT, 'assets'); const ENEMIES_DIR = path.join(ASSETS, 'enemies'); const MANIFEST_PATH = path.join(ROOT, 'public/assets/game/manifest.json'); const SLUGS_FILE = path.join(ROOT, 'src/game/enemyTemplateSlugs.ts'); const STATE_PATH = path.join(__dirname, '.enemy-south-pixellab-state.json'); const API_BASE = process.env.PIXELLAB_API_BASE ?? 'https://api.pixellab.ai/v2'; const TOKEN = process.env.PIXELLAB_API_TOKEN ?? ''; const PREFIX = `Dark fantasy, brutal mood, stylized painterly look inspired by Arcane series, cinematic lighting, strong readable silhouette, muted desaturated colors with rare accent highlights, single game asset, transparent background, no text, no watermark, no border. Isometric three-quarter view toward camera, consistent with tile-based RPG ground plane.`; const SLUG_RE = /^(.+)_l(\d+)_(\d+)_(meadow|forest|ruins|canyon|swamp|volcanic|astral)$/; const ICE_ELEMENT_SLUGS = new Set([ 'element_l12_14_forest', 'element_l15_16_ruins', 'element_l17_18_canyon', 'element_l19_20_swamp', 'element_l21_22_astral', ]); /** Manifest texture key → filename stem under enemies/ (enemy.wolf.png) */ const ARCH_TO_REF = { wolf: 'enemy.wolf', boar: 'enemy.boar', zombie: 'enemy.zombie', spider: 'enemy.spider', orc: 'enemy.orc', skeleton: 'enemy.skeleton_archer', battle_lizard: 'enemy.battle_lizard', demon: 'enemy.fire_demon', skeleton_king: 'enemy.skeleton_king', forest_warden: 'enemy.forest_warden', titan: 'enemy.lightning_titan', element_ice: 'enemy.ice_guardian', element_water: 'enemy.water_element', golem: 'enemy.fire_demon', wraith: 'enemy.water_element', bandit: 'enemy.orc', cultist: 'enemy.skeleton_archer', treant: 'enemy.forest_warden', basilisk: 'enemy.battle_lizard', wyvern: 'enemy.battle_lizard', harpy: 'enemy.spider', manticore: 'enemy.boar', shade: 'enemy.water_element', }; const BIOME_HINT = { meadow: 'open meadow, soft daylight, grassy hints', forest: 'dense forest floor, cool greens and bark', ruins: 'crumbling stone ruins, dust and cold gray', canyon: 'dry canyon, warm rock and dust', swamp: 'murky swamp, violet-brown rot and mist', volcanic: 'volcanic ash, ember accents, scorched rock', astral: 'ethereal astral void, faint cyan-violet glow', }; function parseSlugs() { const text = fs.readFileSync(SLUGS_FILE, 'utf8'); const lb = text.indexOf('[', text.indexOf('ENEMY_TEMPLATE_SLUGS')); const rb = text.indexOf('] as const', lb); const body = text.slice(lb + 1, rb); const slugs = []; for (const m of body.matchAll(/'([^']+)'/g)) { slugs.push(m[1]); } return slugs; } function refKeyForSlug(slug, arch) { if (arch === 'element') { return ICE_ELEMENT_SLUGS.has(slug) ? 'element_ice' : 'element_water'; } return arch in ARCH_TO_REF ? arch : 'wolf'; } function buildDescription(slug, arch, low, high, biome) { const refK = refKeyForSlug(slug, arch); const archetypeHint = arch === 'element' ? ICE_ELEMENT_SLUGS.has(slug) ? 'elemental ice creature' : 'elemental water creature' : `${arch.replace(/_/g, ' ')} monster`; const biomeHint = BIOME_HINT[biome] ?? biome; return `${PREFIX} Game unit: ${archetypeHint}, template "${slug}", levels ~${low}–${high}. Biome flavor: ${biomeHint}. Match the silhouette and style family of the reference init image, but make this a clearly distinct individual (markings, gear, mutations, wear). Facing south (toward the camera), full body, feet near bottom center, pixel art, RPG enemy sprite.`; } function loadState() { try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch { return { jobs: {} }; } } function saveState(state) { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); } function extractObjectId(json) { if (!json || typeof json !== 'object') return null; if (json.object_id) return json.object_id; if (json.data?.object_id) return json.data.object_id; if (json.id) return json.id; if (json.data?.id) return json.data.id; return null; } async function postMapObject(body) { const res = await fetch(`${API_BASE}/map-objects`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = { raw: text }; } if (!res.ok) { throw new Error(`POST map-objects ${res.status}: ${text.slice(0, 500)}`); } return json; } async function waitForDownload(objectId, timeoutMs = 600_000, pollMs = 4000) { const url = `https://api.pixellab.ai/mcp/map-objects/${objectId}/download`; const t0 = Date.now(); while (Date.now() - t0 < timeoutMs) { const res = await fetch(url); const ct = res.headers.get('content-type') ?? ''; if (res.ok && (ct.includes('png') || ct.includes('octet-stream'))) { return Buffer.from(await res.arrayBuffer()); } if (res.status === 404 || res.status === 425) { await sleep(pollMs); continue; } if (!res.ok) { const errText = await res.text(); if (res.status >= 500) { await sleep(pollMs); continue; } throw new Error(`download ${res.status}: ${errText.slice(0, 200)}`); } await sleep(pollMs); } throw new Error(`timeout waiting for object ${objectId}`); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function readRefInitImage(refStem) { const p = path.join(ENEMIES_DIR, `${refStem}.png`); if (!fs.existsSync(p)) return null; const buf = fs.readFileSync(p); return { type: 'base64', base64: buf.toString('base64'), format: 'png', }; } function parseArgs() { const a = process.argv.slice(2); let limit = Infinity; let dry = false; /** If set, only these slugs (comma-separated on CLI). */ let slugList = null; for (let i = 0; i < a.length; i++) { if (a[i] === '--limit' && a[i + 1]) { limit = parseInt(a[i + 1], 10); i++; } if (a[i] === '--slugs' && a[i + 1]) { slugList = a[i + 1] .split(',') .map((s) => s.trim()) .filter(Boolean); i++; } if (a[i] === '--dry-run') dry = true; } return { limit, dry, slugList }; } function setManifestEntry(slug, objectId) { const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); const key = `enemy.${slug}.south`; const file = `enemies/enemy.${slug}.south.png`; manifest.textures[key] = { file, kind: 'map_object', pixellabObjectId: objectId, rotation: 'south', }; fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`); } function bumpManifestVersion() { const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); manifest.version = (manifest.version | 0) + 1; fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`); } async function main() { const { limit, dry, slugList } = parseArgs(); if (!TOKEN && !dry) { console.error('Set PIXELLAB_API_TOKEN (Bearer token from PixelLab account / MCP).'); process.exit(1); } let slugs = slugList?.length ? slugList : parseSlugs(); if (!slugList?.length && Number.isFinite(limit)) slugs = slugs.slice(0, limit); fs.mkdirSync(ENEMIES_DIR, { recursive: true }); const state = loadState(); let updatedManifest = false; for (const slug of slugs) { const m = slug.match(SLUG_RE); if (!m) { console.warn('skip bad slug', slug); continue; } const arch = m[1]; const low = m[2]; const high = m[3]; const biome = m[4]; const outPath = path.join(ENEMIES_DIR, `enemy.${slug}.south.png`); if (fs.existsSync(outPath) && state.jobs[slug]?.done) { console.log('skip existing', slug); continue; } const description = buildDescription(slug, arch, low, high, biome); const refStem = ARCH_TO_REF[refKeyForSlug(slug, arch)]; const initImage = readRefInitImage(refStem); if (dry) { console.log('---', slug, refStem, initImage ? 'has ref' : 'no ref'); console.log(description.slice(0, 200) + '…'); continue; } let objectId = state.jobs[slug]?.objectId; if (!objectId) { const body = { description, image_size: { width: 96, height: 112 }, view: 'low top-down', outline: 'single color outline', shading: 'medium shading', detail: 'medium detail', text_guidance_scale: 8, }; if (initImage) { body.init_image = initImage; body.init_image_strength = 420; } console.log('create', slug, initImage ? `+ref ${refStem}` : 'text only'); let created; try { created = await postMapObject(body); } catch (e) { if (initImage && String(e.message).includes('422')) { console.warn('retry without init_image', slug); delete body.init_image; delete body.init_image_strength; created = await postMapObject(body); } else { throw e; } } objectId = extractObjectId(created); if (!objectId) { console.error('No object_id in response:', JSON.stringify(created).slice(0, 400)); process.exit(1); } state.jobs[slug] = { objectId, startedAt: Date.now() }; saveState(state); await sleep(6000); } if (!fs.existsSync(outPath)) { console.log('poll', slug, objectId); const png = await waitForDownload(objectId); fs.writeFileSync(outPath, png); } state.jobs[slug] = { objectId, done: true, finishedAt: Date.now() }; saveState(state); setManifestEntry(slug, objectId); updatedManifest = true; console.log('ok', slug); await sleep(1500); } if (!dry && updatedManifest) bumpManifestVersion(); console.log('Done.'); } main().catch((e) => { console.error(e); process.exit(1); });