Newer
Older
rpg / apps / game / tools / aiart / post.mjs
/**
 * Пост-обработка AI-спрайтов «Пепельных лугов» — без зависимостей.
 * Запуск: node apps/game/tools/aiart/post.mjs <каталог с PNG>
 *   → рядом ../processed/*.png  (кроп по альфе, квантизация в палитру)
 *   → ../processed/_sheet.png   (обзорный лист для апрува)
 *
 * AI-вывод — только сырьё: в игру попадает после ручного апрува (манифест).
 */
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
import { inflateSync } from 'node:zlib';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { encodePng } from '../pixelart/png.mjs';
import { PALETTE, TRANSPARENT } from '../pixelart/palette.mjs';

const REVIEW = process.argv[2] ?? 'review';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const OUT = join(HERE, 'processed');
const SIZE = 16; // целевой размер спрайта (арт-библия)

// ---------- декодер PNG (RGBA 8-бит, без чересстрочности) ----------

/** PNG-буфер -> { width, height, rgba }. Поддержан только color type 6. */
export function decodePng(buf) {
    if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('не PNG');
    let pos = 8, width = 0, height = 0, bitDepth = 0, colorType = 0;
    const idat = [];
    while (pos < buf.length) {
        const len = buf.readUInt32BE(pos);
        const type = buf.toString('ascii', pos + 4, pos + 8);
        const data = buf.subarray(pos + 8, pos + 8 + len);
        if (type === 'IHDR') {
            width = data.readUInt32BE(0);
            height = data.readUInt32BE(4);
            bitDepth = data[8];
            colorType = data[9];
        } else if (type === 'IDAT') idat.push(data);
        pos += 12 + len;
    }
    if (colorType !== 6 || bitDepth !== 8) {
        throw new Error(`PNG colorType=${colorType} bitDepth=${bitDepth} — жду RGBA 8-бит`);
    }
    const raw = inflateSync(Buffer.concat(idat));
    const stride = width * 4;
    const rgba = Buffer.alloc(height * stride);
    let prev = Buffer.alloc(stride);
    for (let y = 0; y < height; y++) {
        const filter = raw[y * (stride + 1)];
        const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
        const cur = rgba.subarray(y * stride, (y + 1) * stride);
        for (let x = 0; x < stride; x++) {
            const a = x >= 4 ? cur[x - 4] : 0;
            const b = prev[x];
            const c = x >= 4 ? prev[x - 4] : 0;
            let v = line[x];
            if (filter === 1) v += a;
            else if (filter === 2) v += b;
            else if (filter === 3) v += (a + b) >> 1;
            else if (filter === 4) {
                const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
                v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
            }
            cur[x] = v & 0xff;
        }
        prev = cur;
    }
    return { width, height, rgba };
}

// ---------- кроп / квантизация ----------

/** Габариты непрозрачного содержимого (alpha >= 128). */
function bbox({ width, height, rgba }) {
    let x0 = width, y0 = height, x1 = -1, y1 = -1;
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            if (rgba[(y * width + x) * 4 + 3] >= 128) {
                if (x < x0) x0 = x;
                if (y < y0) y0 = y;
                if (x > x1) x1 = x;
                if (y > y1) y1 = y;
            }
        }
    }
    return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
}

/** Пиксель (x,y) кропнутого изображения. */
function at({ width, rgba }, b, x, y) {
    const i = ((b.y + y) * width + b.x + x) * 4;
    return [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]];
}

/** Кроп по альфе + nearest-масштаб до SIZE×SIZE (вписывание с центровкой). */
export function normalizeToSize(img, size = SIZE) {
    const b = bbox(img);
    if (!b) return new Uint8Array(size * size * 4); // пустой спрайт
    const k = Math.max(b.w / size, b.h / size);
    const dw = Math.max(1, Math.round(b.w / k));
    const dh = Math.max(1, Math.round(b.h / k));
    const ox = (size - dw) >> 1, oy = (size - dh) >> 1;
    const out = new Uint8Array(size * size * 4);
    for (let y = 0; y < dh; y++) {
        for (let x = 0; x < dw; x++) {
            const sx = b.x + Math.min(b.w - 1, Math.floor((x + 0.5) * k));
            const sy = b.y + Math.min(b.h - 1, Math.floor((y + 0.5) * k));
            const src = at(img, b, sx - b.x, sy - b.y);
            const i = ((oy + y) * size + (ox + x)) * 4;
            out.set(src, i);
        }
    }
    return out;
}

/** RGB в палитре (hex -> [r,g,b]). */
const PAL = Object.entries(PALETTE).map(([k, hex]) => {
    const n = parseInt(hex.slice(1), 16);
    return [k, (n >> 16) & 255, (n >> 8) & 255, n & 255];
});

/** Квантизация RGBA в палитру: тёмные пиксели -> TRANSPARENT, остальные — ближайший цвет. */
export function quantize(rgba) {
    const out = new Uint8Array(rgba.length);
    for (let p = 0; p < rgba.length; p += 4) {
        if (rgba[p + 3] < 128) continue;
        let best = PAL[0], bd = Infinity;
        for (const c of PAL) {
            const d = (rgba[p] - c[1]) ** 2 + (rgba[p + 1] - c[2]) ** 2 + (rgba[p + 2] - c[3]) ** 2;
            if (d < bd) { bd = d; best = c; }
        }
        out[p] = best[1]; out[p + 1] = best[2]; out[p + 2] = best[3]; out[p + 3] = 255;
    }
    return out;
}

// ---------- обзорный лист ----------

/** Лист PNG: сетка спрайтов x4 с подписями имён файлов. */
export function reviewSheet(files, path, cols = 6) {
    const s = 4, pad = 6, cell = SIZE * s;
    const rows = Math.ceil(files.length / cols);
    const W = cols * (cell + pad) + pad;
    const H = rows * (cell + pad) + 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; }
    files.forEach(({ name, rgba }, i) => {
        const cx = pad + (i % cols) * (cell + pad);
        const cy = pad + Math.floor(i / cols) * (cell + pad);
        for (let y = 0; y < cell; y++) {
            for (let x = 0; x < cell; x++) {
                const sp = ((y / s) | 0) * SIZE + ((x / s) | 0);
                const si = sp * 4, di = (cy + y) * W + cx + x;
                if (rgba[si + 3] === 0) continue;
                bg.set(rgba.subarray(si, si + 4), di * 4);
            }
        }
    });
    writeFileSync(path, encodePng(W, H, Buffer.from(bg)));
}

// ---------- CLI ----------

const names = readdirSync(join(HERE, REVIEW)).filter((f) => f.endsWith('.png'));
mkdirSync(OUT, { recursive: true });
const processed = names.map((name) => {
    const img = decodePng(readFileSync(join(HERE, REVIEW, name)));
    const rgba = quantize(normalizeToSize(img));
    writeFileSync(join(OUT, name), encodePng(SIZE, SIZE, Buffer.from(rgba)));
    return { name, rgba };
});
reviewSheet(processed, join(OUT, '_sheet.png'));
console.log(`обработано ${processed.length} -> ${OUT}`);