/**
* Линтер арта — авто-чеклист арт-библии (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';
/** Палитра по имени: "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];
}
/** Известные слоты спрайтов (арт-библия + tall-объекты и комочки gen.mjs). */
const SLOTS = new Set(['14x12', '14x14', '16x16', '16x24', '20x20', '32x16', '32x32', '32x40', '32x48', '32x56', '32x80', '48x48', '64x80', '96x96', '128x112']);
/** Все непрозрачные пиксели — из палитры? Возвращает список нарушений. */
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);