diff --git a/.gitignore b/.gitignore index 7bd1068..f8d225d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ dist/ *.log -.DS_Store \ No newline at end of file +.DS_Store +# Сырые AI-листы (перегенерируются npm run aiart gen по фиксированному seed) +apps/game/tools/aiart/sheets/ diff --git a/CLAUDE.md b/CLAUDE.md index 0e16237..2462efd 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 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) npm run agent:check # полный прогон проверок через агентный мост (JSON) diff --git a/apps/game/tools/aiart/atlas-manifest.json b/apps/game/tools/aiart/atlas-manifest.json new file mode 100644 index 0000000..a8f444c --- /dev/null +++ b/apps/game/tools/aiart/atlas-manifest.json @@ -0,0 +1,24 @@ +{ + "characters": [ + { + "id": "bellringer", + "prompt": "a bell ringer wearing a dark blue cloak and a wide brimmed hat, holding a small golden hand bell, pixel art character sprite, plain white background", + "seed": 42, + "views": ["FSS", "BSS", "LSS"], + "frameSets": { + "down": { "view": "FSS", "poses": [0, 1] }, + "up": { "view": "BSS", "poses": [0, 1] }, + "side": { "view": "LSS", "poses": [0, 1] } + }, + "frameSize": [16, 24], + "palette": "game", + "crop": { + "FSS": [[8, 150, 122, 245], [136, 150, 122, 245], [264, 150, 122, 245], [392, 150, 122, 245]], + "BSS": [[8, 150, 122, 245], [136, 150, 122, 245], [264, 150, 122, 245], [392, 150, 122, 245]], + "LSS": [[8, 150, 122, 245], [136, 150, 122, 245], [264, 150, 122, 245], [392, 150, 122, 245]] + }, + "target": "chars/hero_sheet.png", + "status": "draft" + } + ] +} \ No newline at end of file diff --git a/apps/game/tools/aiart/atlas.mjs b/apps/game/tools/aiart/atlas.mjs new file mode 100644 index 0000000..b4032de --- /dev/null +++ b/apps/game/tools/aiart/atlas.mjs @@ -0,0 +1,148 @@ +/** + * Пайплайн генерации атласов персонажей из AI-листов (работа B, см. docs/plan.md): + * gen — AI-листы ракурсов (sd_sheet.py, возобновляемо, ~6.6 мин/вид) + * build — листы → кадры 16×24 → атлас + обзорный лист (build//) + * promote — копия атласа в assets/ (только при status: approved) + * + * Всё манифест-драйвенно: atlas-manifest.json (промпт, seed, ракурсы, палитра). + * AI-вывод в assets/ напрямую не попадает — только через promote. + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +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 { PALETTE } from '../pixelart/palette.mjs'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const SHEETS = join(HERE, 'sheets'); +const FRAMES = join(HERE, 'frames'); +const BUILD = join(HERE, 'build'); +const MANIFEST = join(HERE, 'atlas-manifest.json'); + +// ---------- контекст персонажа ---------- + +function loadChar(id) { + const { characters } = JSON.parse(readFileSync(MANIFEST, 'utf8')); + const entry = characters.find((c) => c.id === id); + if (!entry) throw new Error(`персонаж «${id}» не найден в atlas-manifest.json`); + return entry; +} + +/** Палитра из манифеста: "game" — 32 цвета игры; иначе palettes/.json (hex). */ +function loadPalette(entry) { + const hexes = entry.palette === 'game' + ? Object.values(PALETTE) + : JSON.parse(readFileSync(join(HERE, 'palettes', `${entry.palette}.json`), 'utf8')); + return hexes.map((hex) => { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + }); +} + +// ---------- gen: AI-листы ---------- + +function cmdGen(char, { force = false } = {}) { + const py = process.env.RPG_SD_PYTHON ?? `${process.env.HOME}/.cache/rpg-ai/venv-pxgpt/bin/python`; + const dir = join(SHEETS, char.id); + mkdirSync(dir, { recursive: true }); + for (const view of char.views) { + const out = join(dir, `${view}_${char.seed}.png`); + if (existsSync(out) && !force) { + console.log(`[skip] ${out} уже есть (—force для перегенерации)`); + continue; + } + console.log(`[gen] ${view} (seed ${char.seed}) — это долго, ~6.6 мин`); + const r = spawnSync(py, [join(HERE, 'sd_sheet.py'), view, String(char.seed), dir, char.prompt], { stdio: 'inherit' }); + if (r.status !== 0) throw new Error(`sd_sheet.py упал на ${view} (код ${r.status})`); + } +} + +// ---------- build: кадры + атлас ---------- + +/** Лист ракурса: фигуры — crop-оверрайды из манифеста (rect[]) или автодетект. */ +function sheetFigures(char, view) { + const sheet = decodePng(readFileSync(join(SHEETS, char.id, `${view}_${char.seed}.png`))); + const override = char.crop?.[view]; + if (override) { + return { sheet, figures: override.map(([x, y, w, h]) => ({ x, y, w, h })) }; + } + return { sheet, figures: findFigures(sheet, { maxCount: 4 }) }; +} + +/** Обзорный лист: кадры ×scale в сетке (для апрува человеком). */ +function frameSheet(frames, fw, fh, scale = 4) { + const pad = 6, cellW = fw * scale, cellH = fh * scale; + const cols = 4, rows = Math.ceil(frames.length / cols); + const W = cols * (cellW + pad) + pad, H = rows * (cellH + pad) + pad; + const out = new Uint8Array(W * H * 4); + for (let p = 0; p < out.length; p += 4) { out[p] = 30; out[p + 1] = 32; out[p + 2] = 38; out[p + 3] = 255; } + frames.forEach((rgba, i) => { + const ox = pad + (i % cols) * (cellW + pad), oy = pad + ((i / cols) | 0) * (cellH + pad); + for (let y = 0; y < cellH; y++) for (let x = 0; x < cellW; x++) { + const si = (((y / scale) | 0) * fw + ((x / scale) | 0)) * 4; + if (rgba[si + 3] === 0) continue; + out.set(rgba.subarray(si, si + 4), ((oy + y) * W + ox + x) * 4); + } + }); + return encodePng(W, H, Buffer.from(out)); +} + +function cmdBuild(char) { + const pal = loadPalette(char); + const [fw, fh] = char.frameSize; + const frames = [], names = []; + mkdirSync(join(FRAMES, char.id), { recursive: true }); + for (const [dirName, set] of Object.entries(char.frameSets)) { + if (!existsSync(join(SHEETS, char.id, `${set.view}_${char.seed}.png`))) { + console.log(`[skip] ${set.view}: лист ещё не сгенерирован (atlas.mjs gen ${char.id})`); + continue; + } + const { sheet, figures } = sheetFigures(char, set.view); + if (Math.max(...set.poses) >= figures.length) { + 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); + 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))); + } + } + if (!frames.length) throw new Error('ни одного ракурса нет — сначала atlas.mjs gen'); + const outDir = join(BUILD, char.id); + mkdirSync(dirname(join(outDir, char.target)), { recursive: true }); + const { png, json } = buildAtlas(frames, names, { frameW: fw, frameH: fh, image: char.target }); + writeFileSync(join(outDir, char.target), png); + writeFileSync(join(outDir, char.target.replace(/\.png$/, '.json')), JSON.stringify(json, null, 2)); + writeFileSync(join(outDir, '_review.png'), frameSheet(frames, fw, fh)); + console.log(`[build] ${frames.length} кадров -> ${outDir} (обзорный лист _review.png, статус: ${char.status})`); +} + +// ---------- promote: в assets после апрува ---------- + +function cmdPromote(char) { + if (char.status !== 'approved') { + throw new Error(`статус «${char.status}» — promote только при approved (правь atlas-manifest.json после апрува обзорного листа)`); + } + const outDir = join(BUILD, char.id); + const dst = join(HERE, '..', '..', 'assets'); + const jsonTarget = char.target.replace(/\.png$/, '.json'); + copyFileSync(join(outDir, char.target), join(dst, char.target)); + copyFileSync(join(outDir, jsonTarget), join(dst, jsonTarget)); + console.log(`[promote] ${char.target} (+ JSON) -> assets. Внимание: npm run art перегенерирует кодовый атлас поверх.`); +} + +// ---------- CLI ---------- + +const [, , cmd, id, ...rest] = process.argv; +if (!cmd || !id) { + console.log('Использование: node atlas.mjs [--force]'); + process.exit(1); +} +const char = loadChar(id); +const handlers = { gen: cmdGen, build: cmdBuild, promote: cmdPromote }; +if (!handlers[cmd]) throw new Error(`неизвестная команда «${cmd}»`); +handlers[cmd](char, { force: rest.includes('--force') }); \ No newline at end of file diff --git a/apps/game/tools/aiart/build/bellringer/_review.png b/apps/game/tools/aiart/build/bellringer/_review.png new file mode 100644 index 0000000..a0095f9 --- /dev/null +++ b/apps/game/tools/aiart/build/bellringer/_review.png Binary files differ diff --git a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json new file mode 100644 index 0000000..d834996 --- /dev/null +++ b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json @@ -0,0 +1,96 @@ +{ + "frames": { + "bellringer_down_1": { + "frame": { + "x": 0, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_down_2": { + "frame": { + "x": 16, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_up_1": { + "frame": { + "x": 32, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_up_2": { + "frame": { + "x": 48, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_side_1": { + "frame": { + "x": 64, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + }, + "bellringer_side_2": { + "frame": { + "x": 80, + "y": 0, + "w": 16, + "h": 24 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 16, + "h": 24 + } + } + }, + "meta": { + "image": "chars/hero_sheet.png", + "size": { + "w": 96, + "h": 24 + }, + "scale": 1 + } +} \ No newline at end of file diff --git a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.png new file mode 100644 index 0000000..b6fa859 --- /dev/null +++ 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 new file mode 100644 index 0000000..9b14c45 --- /dev/null +++ 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 new file mode 100644 index 0000000..37e1e01 --- /dev/null +++ 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 new file mode 100644 index 0000000..2809fd3 --- /dev/null +++ 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 new file mode 100644 index 0000000..705b0bc --- /dev/null +++ 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 new file mode 100644 index 0000000..f8b7e77 --- /dev/null +++ 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 new file mode 100644 index 0000000..16c45a8 --- /dev/null +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_up_2.png Binary files differ diff --git a/apps/game/tools/aiart/sd_sheet.py b/apps/game/tools/aiart/sd_sheet.py new file mode 100644 index 0000000..4654c86 --- /dev/null +++ b/apps/game/tools/aiart/sd_sheet.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""AI-шаг пайплайна атласов: генерация листа одного ракурса (SD1.5, CPU). + +Модель: SD_PixelArt_SpriteSheet_Generator (Onodofthenorth, Apache-2.0). +Токены ракурсов: PixelartFSS (фронт), PixelartRSS (право), PixelartBSS (зад), +PixelartLSS (лево). 15 шагов / cfg 7.0 — рабочий режим пробы B3 (~6.6 мин/вид +на 4 потоках fp32). Промпт персонажа приходит из манифеста пайплайна. + +Запуск (интерпретатор — env RPG_SD_PYTHON, дефолт venv-pxgpt): + $RPG_SD_PYTHON apps/game/tools/aiart/sd_sheet.py +""" +import sys +import time +from pathlib import Path + +import torch +from diffusers import StableDiffusionPipeline +from huggingface_hub import snapshot_download + +MODEL_ID = 'Onodofthenorth/SD_PixelArt_SpriteSheet_Generator' +STEPS = 15 +CFG = 7.0 +NEG = ('blurry, photo, realistic, 3d render, smooth gradients, text, watermark, ' + 'multiple characters, cropped') + + +def main() -> None: + view, seed, out_dir = sys.argv[1], int(sys.argv[2]), Path(sys.argv[3]) + prompt = sys.argv[4] + out_dir.mkdir(parents=True, exist_ok=True) + + torch.set_num_threads(4) + base = snapshot_download(MODEL_ID, local_files_only=True) + t0 = time.time() + pipe = StableDiffusionPipeline.from_pretrained(str(base), safety_checker=None) + pipe.set_progress_bar_config(disable=True) + print(f'[load] {time.time() - t0:.0f}s', flush=True) + + t0 = time.time() + img = pipe( + f'Pixelart{view} of {prompt}', + negative_prompt=NEG, + num_inference_steps=STEPS, + guidance_scale=CFG, + generator=torch.Generator('cpu').manual_seed(seed), + ).images[0] + print(f'[gen] {time.time() - t0:.0f}s', flush=True) + + out = out_dir / f'{view}_{seed}.png' + img.convert('RGBA').save(out) + print(f'[ok] {out}', flush=True) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/docs/art-style.md b/docs/art-style.md index 670044f..d336aa8 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -88,6 +88,10 @@ - большое здание 4×4: **128×112**. Плотность пикселя — та же, что у тайлов и персонажей (1 px спрайта = 1 px мира): апскейл-болванки из AI-генерации в эти слоты не попадают — только как плейсхолдеры. +- AI-атласы персонажей: пайплайн `npm run aiart` (gen/build/promote, манифест + `apps/game/tools/aiart/atlas-manifest.json`); в `assets/` — только после + ручного апрува обзорного листа (`status: approved`). Палитра — параметр + манифеста; по умолчанию эти 32 цвета. - Шрифт интерфейса: системный monospace до появления пиксельного шрифта (8px в виртуальном разрешении). ## 3. Стиль diff --git a/docs/plan.md b/docs/plan.md index a35aaa9..4e11ddb 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -100,8 +100,12 @@ 1. **`sheet2atlas` — сборщик атласа из AI-листов** (главная дыра). SD-лист 512² → кроп фигур (bbox, отсев фона) → нормализация в слоты 16×24/32×48 → раскладка в сетку `hero_sheet.png` + `hero_sheet.json` (контракт арт-библии). - Пока это ручное демо (`crop-demo.mjs` из пробы B3) — оформить как этап - `tools/aiart/` с манифестом и обзорным листом «до/после». + ✅ **Сделан (2026-09-06)**: `tools/aiart/atlas.mjs` (gen/build/promote) + + `atlas-manifest.json` + `sd_sheet.py`; движковая база — `findFigures`/ + `resizeRect`/`buildAtlas` в `@rpg/engine/tools`. Палитра — параметр манифеста + (`game` или `palettes/.json` на 64/128/256 цветов). Фон листов модели + неоднородный + сплошная «земля» под фигурами — автодетект фигур не срабатывает, + в манифесте работают crop-оверрайды (раскладка 4 фигуры в ряд стабильна). 2. **Палитровый ремапер (recolor)**: перекраска готового квантованного спрайта маппингом цветов палитры. Две потребности: сгладить «плавающие» промпт-цвета SD (приводить плащ к каноническому) и дешёвые цветовые вариации NPC из одного diff --git a/package.json b/package.json index 96316cd..1b1d42b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "npm run dev --workspace @rpg/game", "build": "npm run build --workspace @rpg/game", "art": "node apps/game/tools/pixelart/gen.mjs", + "aiart": "node apps/game/tools/aiart/atlas.mjs", "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",