|
|
/**
|
|
|
* Batch-generate enemy.<slug>.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.<archetype>.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.<slug>.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);
|
|
|
});
|