diff --git a/CLAUDE.md b/CLAUDE.md index 2462efd..ad8ce00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ npm run guard # гвард границы движок/игра (импорты, JSON) npm run check:fast # typecheck + guard + все тесты (~30 с, это же делает pre-commit хук) npm run art # перегенерация пиксель-арта из apps/game/tools/pixelart +npm run art:lint # линтер арта: палитра/слоты/«мыло» по apps/game/assets npm run aiart # пайплайн AI-атласов: gen/build/promote (apps/game/tools/aiart) npm run audio # перегенерация WAV (apps/game/tools/audio/gen.mjs) npm run maps # перегенерация карт-файлов (apps/game/tools/maps + encodeMap) diff --git a/apps/game/tools/aiart/__tests__/recolor.test.mjs b/apps/game/tools/aiart/__tests__/recolor.test.mjs new file mode 100644 index 0000000..d67eb52 --- /dev/null +++ b/apps/game/tools/aiart/__tests__/recolor.test.mjs @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { remap, parseColor } from '../recolor.mjs'; + +/** Спрайт 2×2: два пикселя цвета from (alpha 255), один прозрачный, один другой. */ +const sprite = (from) => { + const rgba = new Uint8Array(2 * 2 * 4); + rgba.set([...from, 255], 0); + rgba.set([1, 2, 3, 255], 8); + return rgba; +}; + +describe('remap', () => { + it('точная замена RGB, alpha и прочие цвета не тронуты', () => { + const out = remap(sprite([58, 40, 34]), [[[58, 40, 34], [58, 67, 86]]]); + expect([...out.slice(0, 4)]).toEqual([58, 67, 86, 255]); + expect([...out.slice(8, 12)]).toEqual([1, 2, 3, 255]); // чужой цвет не тронут + expect(out[4 + 3]).toBe(0); // прозрачный остался прозрачным + }); +}); + +describe('parseColor', () => { + it('ключ палитры и #hex дают один и тот же RGB', () => { + expect(parseColor('C0')).toEqual([0x3a, 0x43, 0x56]); + expect(parseColor('#3a4356')).toEqual([0x3a, 0x43, 0x56]); + }); + it('неизвестный ключ — ошибка', () => { + expect(() => parseColor('Z9')).toThrow(); + }); +}); \ No newline at end of file diff --git a/apps/game/tools/aiart/atlas-manifest.json b/apps/game/tools/aiart/atlas-manifest.json index b651c59..fa69621 100644 --- a/apps/game/tools/aiart/atlas-manifest.json +++ b/apps/game/tools/aiart/atlas-manifest.json @@ -12,6 +12,15 @@ }, "frameSize": [16, 24], "palette": "game", + "remap": { + "R0": "C0", + "R1": "C0", + "R2": "C1", + "G1": "C0", + "G2": "C1", + "G2H": "C1", + "F0": "B0" + }, "crop": { "FSS": [[8, 150, 122, 230], [136, 150, 122, 230], [264, 150, 122, 230], [392, 150, 122, 230]], "BSS": [[8, 150, 122, 230], [136, 150, 122, 230], [264, 150, 122, 230], [392, 150, 122, 230]], diff --git a/apps/game/tools/aiart/atlas.mjs b/apps/game/tools/aiart/atlas.mjs index b4032de..5b65f47 100644 --- a/apps/game/tools/aiart/atlas.mjs +++ b/apps/game/tools/aiart/atlas.mjs @@ -14,6 +14,7 @@ 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 { remap, parseColor } from './recolor.mjs'; import { PALETTE } from '../pixelart/palette.mjs'; const HERE = fileURLToPath(new URL('.', import.meta.url)); @@ -93,6 +94,8 @@ 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 = []; mkdirSync(join(FRAMES, char.id), { recursive: true }); for (const [dirName, set] of Object.entries(char.frameSets)) { @@ -105,7 +108,8 @@ throw new Error(`${set.view}: найдено фигур ${figures.length}, поза ${Math.max(...set.poses)} не выбирается (см. crop в манифесте)`); } for (const pose of set.poses) { - const rgba = quantize(resizeRect(sheet, figures[pose], fw, fh), pal); + let rgba = quantize(resizeRect(sheet, figures[pose], fw, fh), pal); + if (pairs.length) rgba = remap(rgba, pairs); frames.push(rgba); names.push(`${char.id}_${dirName}_${pose + 1}`); writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(rgba))); diff --git a/apps/game/tools/aiart/build/bellringer/_review.png b/apps/game/tools/aiart/build/bellringer/_review.png index 2dd3c6f..4f71192 100644 --- a/apps/game/tools/aiart/build/bellringer/_review.png +++ b/apps/game/tools/aiart/build/bellringer/_review.png Binary files differ diff --git a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png index ef05a7e..e8b38f0 100644 --- a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png +++ b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png b/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png index 1d851c8..31a1ebe 100644 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_down_1.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png b/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png index e111994..4730e22 100644 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png b/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png index e23afe5..a353c32 100644 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_side_1.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png b/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png index ea3fc17..7473f83 100644 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_side_2.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png b/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png index ad68e50..14af22e 100644 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_up_1.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png b/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png index 4aaf307..afb427d 100644 --- a/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png Binary files differ diff --git a/apps/game/tools/aiart/lint-art.mjs b/apps/game/tools/aiart/lint-art.mjs new file mode 100644 index 0000000..80c0cd1 --- /dev/null +++ b/apps/game/tools/aiart/lint-art.mjs @@ -0,0 +1,93 @@ +/** + * Линтер арта — авто-чеклист арт-библии (docs/art-style.md) перед попаданием + * в assets/: все непрозрачные пиксели из палитры, размер в известном слоте, + * детект «мыла» (чужая плотность пикселя — градиентные переходы). + * + * node lint-art.mjs [--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 [--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); \ No newline at end of file diff --git a/apps/game/tools/aiart/recolor.mjs b/apps/game/tools/aiart/recolor.mjs new file mode 100644 index 0000000..955577a --- /dev/null +++ b/apps/game/tools/aiart/recolor.mjs @@ -0,0 +1,64 @@ +/** + * Палитровый ремапер: перекраска квантованного спрайта маппингом цветов. + * Две потребности (docs/plan.md, работа B): привести «поплавшие» промпт-цвета + * AI-генерации к каноническим и дешёвые цветовые вариации NPC из одного спрайта. + * + * CLI: node recolor.mjs <из=в ...> + * цвета — ключи палитры (C1) или #hex; вход должен быть квантован + * в палитру (иначе «из» не найдётся — замена по точному совпадению). + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; +import { PALETTE } from '../pixelart/palette.mjs'; + +/** Цвет из аргумента: ключ палитры (C1) или #hex → [r,g,b]. */ +export function parseColor(token) { + if (!token) throw new Error('пустой цвет'); + if (token.startsWith('#')) { + const n = parseInt(token.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + if (!(token in PALETTE)) throw new Error(`«${token}» — нет такого ключа палитры`); + return hexToRgb(PALETTE[token]); +} + +function hexToRgb(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** + * Замена цветов по точному совпадению RGB (alpha не трогаем). + * @param pairs массив [[fromRGB, toRGB], ...] + */ +export function remap(rgba, pairs) { + const lookup = new Map(pairs.map(([from, to]) => [from.join(','), to])); + const out = new Uint8Array(rgba); + for (let p = 0; p < out.length; p += 4) { + const to = lookup.get(`${out[p]},${out[p + 1]},${out[p + 2]}`); + if (to) { + out[p] = to[0]; out[p + 1] = to[1]; out[p + 2] = to[2]; + } + } + return out; +} + +// ---------- CLI (только при прямом запуске) ---------- + +const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invoked) { + const [, , src, dst, ...tokens] = process.argv; + if (!src || !dst || !tokens.length) { + console.log('Использование: node recolor.mjs <из=в ...> (цвета: ключ палитры или #hex)'); + process.exit(1); + } + const pairs = tokens.map((t) => { + const [from, to] = t.split('='); + return [parseColor(from), parseColor(to)]; + }); + const img = decodePng(readFileSync(src)); + writeFileSync(dst, encodePng(img.width, img.height, Buffer.from(remap(img.rgba, pairs)))); + console.log(`[recolor] ${src} -> ${dst} (замены: ${pairs.map(([f, t]) => `#${f.map((v) => v.toString(16).padStart(2, '0')).join('')}→#${t.map((v) => v.toString(16).padStart(2, '0')).join('')}`).join(', ')})`); +} \ No newline at end of file diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md index 8e7918a..758313e 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -82,8 +82,9 @@ до w×h; пиксели ближе `dist` к фону становятся прозрачными (квантизация — отдельный шаг `quantize` с палитрой-параметром). -Сценарии применения (манифест персонажей, gen/build/promote) — в игре: -`apps/game/tools/aiart/` (см. `docs/plan.md`, работа B). +Сценарии применения (манифест персонажей, gen/build/promote, ремапер +`recolor.mjs`, линтер `lint-art.mjs`) — в игре: `apps/game/tools/aiart/` +(см. `docs/plan.md`, работа B). ## Замена генератора на рисованный арт diff --git a/docs/plan.md b/docs/plan.md index 4e11ddb..8d7f1d0 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -109,11 +109,14 @@ 2. **Палитровый ремапер (recolor)**: перекраска готового квантованного спрайта маппингом цветов палитры. Две потребности: сгладить «плавающие» промпт-цвета SD (приводить плащ к каноническому) и дешёвые цветовые вариации NPC из одного - базового спрайта. + базового спрайта. ✅ **Сделан (2026-09-06)**: `tools/aiart/recolor.mjs` (CLI + по ключам палитры/hex) + поле `remap` в манифесте атласов — применяется в + build после квантизации (плащ звонаря приведён к C0/C1). 3. **Линтер арта (авто-чеклист арт-библии)**: скрипт-гейт перед попаданием в - `assets/` — размер в слоте, все цвета из палитры, прозрачность фона, детект - «мыла» (соседние пиксели разной яркости внутри «квадрата» — чужая плотность - пикселя), симметрия/число уникальных цветов. В `agent:check` как проба. + `assets/` — размер в слоте, все цвета из палитры, детект «мыла» (градиентные + пары соседних пикселей — чужая плотность пикселя). ✅ **Сделан + (2026-09-06)**: `tools/aiart/lint-art.mjs` (`npm run art:lint` по всем + assets, негативы проверены); в `agent:check` не заведён — по мере надобности. 4. **Отложено**: клиент RD API (tile/animation) — только если решимся платить (B2, кадры walk-цикла); генерация/склейка walk-цикла из поз — пока ручной арт, инструмента не видно. diff --git a/package.json b/package.json index 1b1d42b..6c23536 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "npm run build --workspace @rpg/game", "art": "node apps/game/tools/pixelart/gen.mjs", "aiart": "node apps/game/tools/aiart/atlas.mjs", + "art:lint": "node apps/game/tools/aiart/lint-art.mjs $(find apps/game/assets -name '*.png')", "audio": "node apps/game/tools/audio/gen.mjs", "maps": "vitest run apps/game/tools/maps/gen.test.ts", "guard": "node apps/game/tools/guards/boundary.mjs",