/**
* B4: пост-обработка кандидатов gen_b4.py в слоты арт-библии.
* Запуск: node apps/game/tools/aiart/post_b4.mjs
* raw/<id>_<seed>.png → кроп фигуры (findFigures) → nearest в слот B4_SLOTS
* → квантизация в палитру игры → processed-b4/<id>_<seed>.png
* → обзорный лист processed-b4/_sheet.png (кандидаты по объектам, для апрува)
*
* AI-вывод — только сырьё: в игру (assets/) попадает после ручного апрува.
*/
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs';
import { findFigures, quantize } from '@rpg/engine/tools/imaging.mjs';
import { PALETTE } from '../pixelart/palette.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const RAW = join(HERE, 'review-b4', 'raw');
const OUT = join(HERE, 'review-b4', 'processed');
/** Слоты кандидатов (id → w×h): интерактивы — по родам плейсхолдеров, иконки — 8×8. */
const SLOTS = {
signpost: [16, 24],
mote: [16, 16],
tone_tree: [32, 48],
resonator: [32, 40],
bell_rope: [16, 24],
hearth: [32, 32],
note: [16, 16],
counter: [32, 16],
spit: [8, 8],
icon_clock: [8, 8],
icon_cloth: [8, 8],
icon_bellflower: [8, 8],
icon_salt: [8, 8],
icon_flask: [8, 8],
icon_mote: [8, 8],
icon_map: [8, 8]
};
/** Палитра игры как массив [r,g,b]. */
const PAL = Object.entries(PALETTE).map(([, hex]) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
});
/** Фигура листа: bbox крупнейшей связной компоненты (фон — угловой пиксель). */
function figureRect(img) {
const boxes = findFigures(img, { maxCount: 8, minArea: 2000 });
if (boxes.length) {
return boxes.reduce((a, b) => (a.w * a.h >= b.w * b.h ? a : b));
}
return { x: 0, y: 0, w: img.width, h: img.height };
}
/**
* Маска фона наращиванием от краёв (BFS): фон RD-вывода — гладкий градиент,
* угловой пиксель его не описывает. Шаг с малым допуском (фон плавный), у
* границы объекта скачок — рост останавливается.
*/
function bgMask(img) {
const W = img.width;
const H = img.height;
const mask = new Uint8Array(W * H);
const queue = [];
const seed = (x, y) => {
const p = y * W + x;
if (!mask[p] && img.rgba[p * 4 + 3] >= 128) {
mask[p] = 1;
queue.push(x, y);
}
};
for (let x = 0; x < W; x++) {
seed(x, 0);
seed(x, H - 1);
}
for (let y = 0; y < H; y++) {
seed(0, y);
seed(W - 1, y);
}
const close = (a, b) =>
Math.abs(img.rgba[a] - img.rgba[b]) + Math.abs(img.rgba[a + 1] - img.rgba[b + 1]) +
Math.abs(img.rgba[a + 2] - img.rgba[b + 2]) < 40;
for (let q = 0; q < queue.length; q += 2) {
const x = queue[q];
const y = queue[q + 1];
const p = y * W + x;
const nb = [
[x - 1, y],
[x + 1, y],
[x, y - 1],
[x, y + 1]
];
for (const [nx, ny] of nb) {
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const n = ny * W + nx;
if (!mask[n] && close(p, n)) {
mask[n] = 1;
queue.push(nx, ny);
}
}
}
return mask;
}
/**
* Даунскейл прямоугольника усреднением ячеек (RD-вывод гладкий — nearest
* сэмплирует шум, усреднение даёт ровные цветовые области). Пиксели фоновой
* маски в среднее не входят; ячейка без фигуры — прозрачная.
*/
function meanRect(img, rect, w, h) {
const bg = bgMask(img);
const out = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const sx0 = rect.x + Math.floor((x * rect.w) / w);
const sx1 = rect.x + Math.max(sx0 + 1, Math.floor(((x + 1) * rect.w) / w));
const sy0 = rect.y + Math.floor((y * rect.h) / h);
const sy1 = rect.y + Math.max(sy0 + 1, Math.floor(((y + 1) * rect.h) / h));
let r = 0, g = 0, b = 0, n = 0;
for (let sy = sy0; sy < sy1 && sy < img.height; sy++) {
for (let sx = sx0; sx < sx1 && sx < img.width; sx++) {
const i = (sy * img.width + sx) * 4;
if (bg[sy * img.width + sx] || img.rgba[i + 3] < 128) continue;
r += img.rgba[i]; g += img.rgba[i + 1]; b += img.rgba[i + 2]; n++;
}
}
if (n > 0) {
const i = (y * w + x) * 4;
out[i] = r / n; out[i + 1] = g / n; out[i + 2] = b / n; out[i + 3] = 255;
}
}
}
return out;
}
/** Обзорный лист: кандидаты группами по объекту (имя — подпись группы). */
function groupSheet(groups, path) {
const scale = 3;
const pad = 8;
const cellW = 40 * scale;
const cellH = 52 * scale;
const cols = Math.max(...groups.map((g) => g.items.length));
const W = cols * (cellW + pad) + pad;
const H = groups.length * (cellH + pad + 14) + pad;
const bg = Buffer.alloc(W * H * 4);
for (let p = 0; p < bg.length; p += 4) {
bg[p] = 30; bg[p + 1] = 32; bg[p + 2] = 38; bg[p + 3] = 255;
}
groups.forEach((g, gy) => {
const y0 = pad + gy * (cellH + pad + 14);
g.items.forEach(({ rgba, w, h }, i) => {
const x0 = pad + i * (cellW + pad);
for (let y = 0; y < h * scale; y++) {
for (let x = 0; x < w * scale; x++) {
const sp = ((y / scale) | 0) * w + ((x / scale) | 0);
if (rgba[sp * 4 + 3] === 0) continue;
bg.set(
rgba.subarray(sp * 4, sp * 4 + 4),
((y0 + y) * W + x0 + x) * 4
);
}
}
});
});
writeFileSync(path, encodePng(W, H, Buffer.from(bg)));
return { W, H };
}
const files = readdirSync(RAW).filter((f) => f.endsWith('.png'));
mkdirSync(OUT, { recursive: true });
const byId = new Map();
for (const name of files) {
const id = name.replace(/_\d+\.png$/, '');
const [w, h] = SLOTS[id];
if (!w) throw new Error(`нет слота для ${id}`);
const img = decodePng(readFileSync(join(RAW, name)));
const rgba = quantize(meanRect(img, figureRect(img), w, h), PAL);
writeFileSync(join(OUT, name), encodePng(w, h, Buffer.from(rgba)));
if (!byId.has(id)) byId.set(id, []);
byId.get(id).push({ name, rgba, w, h });
}
const groups = [...byId.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, items]) => ({ id, items }));
const info = groupSheet(groups, join(OUT, '_sheet.png'));
console.log(
`обработано ${files.length} (${groups.length} объектов) -> ${OUT}\n` +
`лист: ${info.W}x${info.H}`
);