Newer
Older
rpg / apps / game / tools / aiart / lint-art.mjs
/**
 * Линтер арта — авто-чеклист арт-библии (docs/art-style.md) перед попаданием
 * в assets/: все непрозрачные пиксели из палитры, размер в известном слоте,
 * детект «мыла» (чужая плотность пикселя — градиентные переходы).
 *
 *   node lint-art.mjs <png...> [--palette game|<файл.json>] [--size 16x24,...]
 *
 * Выход: построчные вердикты, код 1 при нарушениях. Гейт апрува — человек,
 * линтер ловит только механику.
 */
import { readFileSync } from 'node:fs';
import { decodePng } from '@rpg/engine/tools/png.mjs';
import { PALETTE } from '../pixelart/palette.mjs';
import { SLOTS } from '../pixelart/sizes.mjs';

/** Палитра по имени: "game" — 32 цвета игры; иначе JSON-файл с массивом hex. */
function loadPalette(name) {
    if (name === 'game') {
        return Object.values(PALETTE).map((hex) => hexToRgb(hex));
    }
    return JSON.parse(readFileSync(name, 'utf8')).map(hexToRgb);
}

function hexToRgb(hex) {
    const n = parseInt(hex.slice(1), 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

/** Все непрозрачные пиксели — из палитры? Возвращает список нарушений. */
function checkPalette(img, pal) {
    const set = new Set(pal.map((c) => c.join(',')));
    const bad = new Map();
    for (let p = 0; p < img.rgba.length; p += 4) {
        if (img.rgba[p + 3] === 0) continue;
        const key = `${img.rgba[p]},${img.rgba[p + 1]},${img.rgba[p + 2]}`;
        if (!set.has(key) && !bad.has(key)) bad.set(key, 1);
    }
    return [...bad.keys()];
}

/**
 * Детект «мыла»: доля горизонтальных пар соседних непрозрачных пикселей с
 * маленьким (1..10 на канал), но ненулевым расхождением — градиент внутри
 * «квадрата». У честного пиксель-арта соседи либо совпадают, либо различны.
 */
function checkSoap(img) {
    let pairs = 0, gradient = 0;
    for (let y = 0; y < img.height; y++) {
        for (let x = 0; x < img.width - 1; x++) {
            const a = (y * img.width + x) * 4, b = a + 4;
            if (img.rgba[a + 3] === 0 || img.rgba[b + 3] === 0) continue;
            pairs++;
            const d = [0, 1, 2].map((c) => Math.abs(img.rgba[a + c] - img.rgba[b + c]));
            if (d.some((v) => v >= 1 && v <= 10) && d.every((v) => v <= 10)) gradient++;
        }
    }
    return pairs ? gradient / pairs : 0;
}

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

const args = process.argv.slice(2);
const files = args.filter((a) => !a.startsWith('--'));
const opt = (name, def) => {
    const i = args.indexOf(`--${name}`);
    return i >= 0 ? args[i + 1] : def;
};
if (!files.length) {
    console.log('Использование: node lint-art.mjs <png...> [--palette game|<файл.json>] [--size 16x24,16x16]');
    process.exit(1);
}
const pal = loadPalette(opt('palette', 'game'));
const sizes = opt('size', null); // переопределение слотов (через запятую)
const slots = sizes ? new Set(sizes.split(',')) : SLOTS;

let failed = 0;
for (const file of files) {
    const img = decodePng(readFileSync(file));
    const problems = [];
    const dim = `${img.width}x${img.height}`;
    const atlasRow = [12, 14, 16, 20, 24, 32, 48].includes(img.height) && img.width > img.height
        && (img.width % 16 === 0 || img.width % img.height === 0);
    if (!slots.has(dim) && !atlasRow) problems.push(`размер ${dim} вне слотов`);
    const badColors = checkPalette(img, pal);
    if (badColors.length) problems.push(`вне палитры: ${badColors.length} цв. (${badColors.slice(0, 3).join(' ')})`);
    const soap = checkSoap(img);
    if (soap > 0.1) problems.push(`мыло: ${(soap * 100).toFixed(0)}% градиентных пар`);
    if (problems.length) failed++;
    console.log(`${problems.length ? 'FAIL' : ' ok '} ${file}${problems.length ? ' — ' + problems.join('; ') : ''}`);
}
process.exit(failed ? 1 : 0);