/**
* Пайплайн генерации атласов персонажей из AI-листов (работа B, см. docs/plan.md):
* gen <id> — AI-листы ракурсов (sd_sheet.py, возобновляемо, ~6.6 мин/вид)
* build <id> — листы → кадры 16×24 → атлас + обзорный лист (build/<id>/)
* promote <id> — копия атласа в assets/ (только при status: approved)
*
* Всё манифест-драйвенно: atlas-manifest.json (промпт, seed, ракурсы, палитра).
* AI-вывод в assets/ напрямую не попадает — только через promote.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs';
import { findFigures, resizeRect, quantize } from '@rpg/engine/tools/imaging.mjs';
import { buildAtlas } from '@rpg/engine/tools/atlas.mjs';
import { squashFrame } from '@rpg/engine/tools/frames.mjs';
import { remap, parseColor } from './recolor.mjs';
import { PALETTE } from '../pixelart/palette.mjs';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const SHEETS = join(HERE, 'sheets');
const FRAMES = join(HERE, 'frames');
const BUILD = join(HERE, 'build');
const MANIFEST = join(HERE, 'atlas-manifest.json');
// ---------- контекст персонажа ----------
function loadChar(id) {
const { characters } = JSON.parse(readFileSync(MANIFEST, 'utf8'));
const entry = characters.find((c) => c.id === id);
if (!entry) throw new Error(`персонаж «${id}» не найден в atlas-manifest.json`);
return entry;
}
/** Палитра из манифеста: "game" — 32 цвета игры; иначе palettes/<name>.json (hex). */
function loadPalette(entry) {
const hexes = entry.palette === 'game'
? Object.values(PALETTE)
: JSON.parse(readFileSync(join(HERE, 'palettes', `${entry.palette}.json`), 'utf8'));
return hexes.map((hex) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
});
}
// ---------- gen: AI-листы ----------
function cmdGen(char, { force = false } = {}) {
const py = process.env.RPG_SD_PYTHON ?? `${process.env.HOME}/.cache/rpg-ai/venv-pxgpt/bin/python`;
const dir = join(SHEETS, char.id);
mkdirSync(dir, { recursive: true });
for (const view of char.views) {
const out = join(dir, `${view}_${char.seed}.png`);
if (existsSync(out) && !force) {
console.log(`[skip] ${out} уже есть (—force для перегенерации)`);
continue;
}
console.log(`[gen] ${view} (seed ${char.seed}) — это долго, ~6.6 мин`);
const r = spawnSync(py, [join(HERE, 'sd_sheet.py'), view, String(char.seed), dir, char.prompt], { stdio: 'inherit' });
if (r.status !== 0) throw new Error(`sd_sheet.py упал на ${view} (код ${r.status})`);
}
}
// ---------- build: кадры + атлас ----------
/** Лист ракурса: фигуры — crop-оверрайды из манифеста (rect[]) или автодетект. */
function sheetFigures(char, view) {
const sheet = decodePng(readFileSync(join(SHEETS, char.id, `${view}_${char.seed}.png`)));
const override = char.crop?.[view];
if (override) {
return { sheet, figures: override.map(([x, y, w, h]) => ({ x, y, w, h })) };
}
return { sheet, figures: findFigures(sheet, { maxCount: 4 }) };
}
/** Обзорный лист: кадры ×scale в сетке (для апрува человеком). */
function frameSheet(frames, fw, fh, scale = 4) {
const pad = 6, cellW = fw * scale, cellH = fh * scale;
const cols = 4, rows = Math.ceil(frames.length / cols);
const W = cols * (cellW + pad) + pad, H = rows * (cellH + pad) + pad;
const out = new Uint8Array(W * H * 4);
for (let p = 0; p < out.length; p += 4) { out[p] = 30; out[p + 1] = 32; out[p + 2] = 38; out[p + 3] = 255; }
frames.forEach((rgba, i) => {
const ox = pad + (i % cols) * (cellW + pad), oy = pad + ((i / cols) | 0) * (cellH + pad);
for (let y = 0; y < cellH; y++) for (let x = 0; x < cellW; x++) {
const si = (((y / scale) | 0) * fw + ((x / scale) | 0)) * 4;
if (rgba[si + 3] === 0) continue;
out.set(rgba.subarray(si, si + 4), ((oy + y) * W + ox + x) * 4);
}
});
return encodePng(W, H, Buffer.from(out));
}
function cmdBuild(char) {
const pal = loadPalette(char);
const [fw, fh] = char.frameSize;
// Ремап «поплавших» цветов генерации — после квантизации (точные цвета палитры).
const pairs = Object.entries(char.remap ?? {}).map(([from, to]) => [parseColor(from), parseColor(to)]);
const frames = [], names = [], animations = {};
mkdirSync(join(FRAMES, char.id), { recursive: true });
for (const [dirName, set] of Object.entries(char.frameSets)) {
if (!existsSync(join(SHEETS, char.id, `${set.view}_${char.seed}.png`))) {
console.log(`[skip] ${set.view}: лист ещё не сгенерирован (atlas.mjs gen ${char.id})`);
continue;
}
const { sheet, figures } = sheetFigures(char, set.view);
if (Math.max(...set.poses) >= figures.length) {
throw new Error(`${set.view}: найдено фигур ${figures.length}, поза ${Math.max(...set.poses)} не выбирается (см. crop в манифесте)`);
}
// Приседание: 2-й кадр — производный от первого (сдвиг вниз на 1px,
// верх прозрачный) — различимая ходьба из одинаковых стоячих поз;
// вторая поза листа тогда не используется.
const usedPoses = set.bob ? [set.poses[0]] : set.poses;
const setFirst = frames.length;
for (const pose of usedPoses) {
let rgba = quantize(resizeRect(sheet, figures[pose], fw, fh), pal);
if (pairs.length) rgba = remap(rgba, pairs);
const n = frames.length + 1;
frames.push(rgba);
names.push(`${char.id}_${dirName}_${n}`);
writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(rgba)));
if (set.bob) {
const bob = squashFrame(rgba, fw, fh);
frames.push(bob);
names.push(`${char.id}_${dirName}_${frames.length}`);
writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(bob)));
}
}
animations[`${char.id}_${dirName}`] = frames
.slice(setFirst)
.map((_, i) => setFirst + i);
}
if (!frames.length) throw new Error('ни одного ракурса нет — сначала atlas.mjs gen');
const outDir = join(BUILD, char.id);
mkdirSync(dirname(join(outDir, char.target)), { recursive: true });
// meta.image — имя PNG рядом с JSON (Pixi резолвит его относительно URL
// атласа; путь с каталогом даст /chars/chars/… → 404 в игре).
const { png, json } = buildAtlas(frames, names, {
frameW: fw, frameH: fh, image: basename(char.target), animations
});
writeFileSync(join(outDir, char.target), png);
writeFileSync(join(outDir, char.target.replace(/\.png$/, '.json')), JSON.stringify(json, null, 2));
writeFileSync(join(outDir, '_review.png'), frameSheet(frames, fw, fh));
console.log(`[build] ${frames.length} кадров -> ${outDir} (обзорный лист _review.png, статус: ${char.status})`);
}
// ---------- promote: в assets после апрува ----------
function cmdPromote(char) {
if (char.status !== 'approved') {
throw new Error(`статус «${char.status}» — promote только при approved (правь atlas-manifest.json после апрува обзорного листа)`);
}
const outDir = join(BUILD, char.id);
const dst = join(HERE, '..', '..', 'assets');
const jsonTarget = char.target.replace(/\.png$/, '.json');
copyFileSync(join(outDir, char.target), join(dst, char.target));
copyFileSync(join(outDir, jsonTarget), join(dst, jsonTarget));
console.log(`[promote] ${char.target} (+ JSON) -> assets. Внимание: npm run art перегенерирует кодовый атлас поверх.`);
}
// ---------- CLI ----------
const [, , cmd, id, ...rest] = process.argv;
if (!cmd || !id) {
console.log('Использование: node atlas.mjs <gen|build|promote> <id> [--force]');
process.exit(1);
}
const char = loadChar(id);
const handlers = { gen: cmdGen, build: cmdBuild, promote: cmdPromote };
if (!handlers[cmd]) throw new Error(`неизвестная команда «${cmd}»`);
handlers[cmd](char, { force: rest.includes('--force') });