diff --git a/CLAUDE.md b/CLAUDE.md index ad8ce00..46be25a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ 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) +node apps/game/tools/aiart/tryon.mjs # примерка AI-сборки в игре до promote (скриншот /tmp/rpg_tryon.png) 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 index fa69621..6fff155 100644 --- a/apps/game/tools/aiart/atlas-manifest.json +++ b/apps/game/tools/aiart/atlas-manifest.json @@ -6,9 +6,9 @@ "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] } + "down": { "view": "FSS", "poses": [0, 1], "bob": true }, + "up": { "view": "BSS", "poses": [0, 1], "bob": true }, + "side": { "view": "LSS", "poses": [0, 1], "bob": true } }, "frameSize": [16, 24], "palette": "game", diff --git a/apps/game/tools/aiart/atlas.mjs b/apps/game/tools/aiart/atlas.mjs index 5b65f47..19050a2 100644 --- a/apps/game/tools/aiart/atlas.mjs +++ b/apps/game/tools/aiart/atlas.mjs @@ -8,7 +8,7 @@ * AI-вывод в assets/ напрямую не попадает — только через promote. */ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs'; @@ -107,18 +107,34 @@ 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) { + // Бобинг: 2-й кадр — производный от первого (сдвиг вниз на 1px, верх + // прозрачный) — различимая ходьба из одинаковых стоячих поз; вторая + // поза листа тогда не используется. + const usedPoses = set.bob ? [set.poses[0]] : set.poses; + for (const pose of usedPoses) { let rgba = quantize(resizeRect(sheet, figures[pose], fw, fh), pal); if (pairs.length) rgba = remap(rgba, pairs); + const n = frames.length + 1; frames.push(rgba); - names.push(`${char.id}_${dirName}_${pose + 1}`); + names.push(`${char.id}_${dirName}_${n}`); writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(rgba))); + if (set.bob) { + const bob = new Uint8Array(fw * fh * 4); + bob.set(rgba.subarray(0, (fh - 1) * fw * 4), fw * 4); + frames.push(bob); + names.push(`${char.id}_${dirName}_${frames.length}`); + writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(bob))); + } } } 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 }); + // meta.image — имя PNG рядом с JSON (Pixi резолвит его относительно URL + // атласа; путь с каталогом даст /chars/chars/… → 404 в игре). + const { png, json } = buildAtlas(frames, names, { + frameW: fw, frameH: fh, image: basename(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)); diff --git a/apps/game/tools/aiart/build/bellringer/_review.png b/apps/game/tools/aiart/build/bellringer/_review.png index 4f71192..85e5f43 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.json b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json index d834996..0c1cad6 100644 --- a/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json +++ b/apps/game/tools/aiart/build/bellringer/chars/hero_sheet.json @@ -28,7 +28,7 @@ "h": 24 } }, - "bellringer_up_1": { + "bellringer_up_3": { "frame": { "x": 32, "y": 0, @@ -42,7 +42,7 @@ "h": 24 } }, - "bellringer_up_2": { + "bellringer_up_4": { "frame": { "x": 48, "y": 0, @@ -56,7 +56,7 @@ "h": 24 } }, - "bellringer_side_1": { + "bellringer_side_5": { "frame": { "x": 64, "y": 0, @@ -70,7 +70,7 @@ "h": 24 } }, - "bellringer_side_2": { + "bellringer_side_6": { "frame": { "x": 80, "y": 0, @@ -86,7 +86,7 @@ } }, "meta": { - "image": "chars/hero_sheet.png", + "image": "hero_sheet.png", "size": { "w": 96, "h": 24 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 e8b38f0..7d4ddb3 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_2.png b/apps/game/tools/aiart/frames/bellringer/bellringer_down_2.png index 4730e22..44320ac 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_5.png b/apps/game/tools/aiart/frames/bellringer/bellringer_side_5.png new file mode 100644 index 0000000..a353c32 --- /dev/null +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_side_5.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png b/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png new file mode 100644 index 0000000..300f2fe --- /dev/null +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_side_6.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png b/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png new file mode 100644 index 0000000..14af22e --- /dev/null +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_up_3.png Binary files differ diff --git a/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png b/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png new file mode 100644 index 0000000..9426cda --- /dev/null +++ b/apps/game/tools/aiart/frames/bellringer/bellringer_up_4.png Binary files differ diff --git a/apps/game/tools/aiart/tryon.mjs b/apps/game/tools/aiart/tryon.mjs new file mode 100644 index 0000000..728ddab --- /dev/null +++ b/apps/game/tools/aiart/tryon.mjs @@ -0,0 +1,61 @@ +/** + * Примерка AI-атласа в игре ДО promote: временно подменяет целевой атлас + * сборкой из build//, снимает скриншот героя в локации, возвращает файлы. + * Апрув по обзорному листу — пиксели на клетке; примерка показывает якорь, + * масштаб и палитру в контексте мира. + * + * node tryon.mjs [файл-скриншота=/tmp/rpg_tryon.png] + * + * Файлы assets восстанавливаются в finally (даже при падении). + */ +import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startDevServer, openGame } from '../lib.mjs'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const id = process.argv[2] ?? 'bellringer'; +const shot = process.argv[3] ?? '/tmp/rpg_tryon.png'; + +// Цель берём из манифеста (target), как её видит promote. +const manifest = JSON.parse(readFileSync(join(HERE, 'atlas-manifest.json'), 'utf8')); +const char = manifest.characters.find((c) => c.id === id); +if (!char) throw new Error(`персонаж «${id}» не найден в atlas-manifest.json`); +const buildPng = join(HERE, 'build', id, char.target); +if (!existsSync(buildPng)) throw new Error(`нет сборки ${buildPng} — сначала atlas.mjs build ${id}`); + +const dst = join(HERE, '..', '..', 'assets'); +const dstPng = join(dst, char.target); +const dstJson = dstPng.replace(/\.png$/, '.json'); +const backup = { + png: existsSync(dstPng) ? readFileSync(dstPng) : null, + json: existsSync(dstJson) ? readFileSync(dstJson) : null +}; + +let server = null, browser = null; +try { + copyFileSync(buildPng, dstPng); + // Игра ждёт кадры с префиксом hero_ (LocationScene: hero_down/up/side) — + // переписываем имена кадров сборки в JSON-копии. + const atlasJson = JSON.parse(readFileSync(join(HERE, 'build', id, char.target.replace(/\.png$/, '.json')), 'utf8')); + atlasJson.frames = Object.fromEntries( + Object.entries(atlasJson.frames).map(([k, v]) => [k.replace(new RegExp(`^${id}_`), 'hero_'), v]) + ); + writeFileSync(dstJson, JSON.stringify(atlasJson)); + console.log(`[tryon] ${char.target} подменён сборкой «${id}» (status: ${char.status}, кадры → hero_*)`); + + server = await startDevServer({ port: 5199 }); + const { browser: b, page, agent } = await openGame({ url: server.url, newGame: true }); + browser = b; + await agent.step(60); // пара шагов, чтобы герой попал в кадр ходьбы + const snap = await agent.snapshot(); + console.log('снапшот:', JSON.stringify({ area: snap.area, hero: snap.hero?.tile })); + await page.screenshot({ path: shot }); + console.log(`Скриншот: ${shot}`); +} finally { + if (backup.png) writeFileSync(dstPng, backup.png); + if (backup.json) writeFileSync(dstJson, backup.json); + if (backup.png === null) console.log('[tryon] внимание: исходного атласа не было — проверь assets вручную'); + if (browser) await browser.close(); + if (server) server.stop(); +} \ No newline at end of file diff --git a/apps/game/tools/lib.mjs b/apps/game/tools/lib.mjs index 57fcd6b..1cb8be7 100644 --- a/apps/game/tools/lib.mjs +++ b/apps/game/tools/lib.mjs @@ -3,9 +3,16 @@ * @rpg/engine/tools/agent-lib.mjs: маяк загрузки игры и re-export общих * примитивов. Смоуки и checks/*.mjs импортируют отсюда. */ -import { openGame as engineOpenGame } from '@rpg/engine/tools/agent-lib.mjs'; +import { fileURLToPath } from 'node:url'; +import { openGame as engineOpenGame, startDevServer as engineStartDevServer } from '@rpg/engine/tools/agent-lib.mjs'; -export { CHROMIUM, launchBrowser, startDevServer, AgentClient, Checks, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +export { CHROMIUM, launchBrowser, AgentClient, Checks, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; + +/** Каталог игры (index.html + vite-конфиг) — дефолтный cwd dev-сервера. */ +const GAME_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +/** startDevServer по умолчанию поднимает vite из каталога игры, а не из корня монорепо. */ +export const startDevServer = (opts = {}) => engineStartDevServer({ cwd: GAME_ROOT, ...opts }); /** Консольный маяк BootScene: ассеты загружены, мост готов. */ export const BEACON = '[boot] ассеты загружены'; diff --git a/docs/engine/agent.md b/docs/engine/agent.md index c63abe7..b5a53e5 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -153,6 +153,15 @@ `apps/game/tools/lib.mjs` (там маяк загрузки игры) (проверка возвращает `false`/кидает исключение или `c.expect(cond, msg, details)`). +**startDevServer и cwd**: `startDevServer({ port, cwd })` поднимает vite из каталога +`cwd` (по умолчанию — корень монорепо); приложение обязано передать свой каталог +(index.html + vite-конфиг), иначе vite от корня отдаёт 404 на всё. Игровой +`lib.mjs` уже заворачивает это с `cwd: apps/game` — импортируйте `startDevServer` +оттуда. Грабли: (а) `--strictPort` при занятом порте гасит наш vite, но fetch +продолжил бы отвечать чужим сервером — каркас отслеживает выход процесса и падает; +(б) npx-обёртка при смерти оставляет зомби-vite, держащий порт — spawn идёт с +`detached: true`, `stop()` убивает всю группу (`process.kill(-pid)`). + **Правило**: каждая геймплейная система обязана иметь сценарий проверки в `apps/game/tools/checks/`. Предпочитай `snapshot`/`check` скриншотам: скриншот — для визуальных вопросов (арт, компоновка), состояние игры читается снапшотом. \ No newline at end of file diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md index 758313e..4b8030f 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -82,8 +82,10 @@ до w×h; пиксели ближе `dist` к фону становятся прозрачными (квантизация — отдельный шаг `quantize` с палитрой-параметром). -Сценарии применения (манифест персонажей, gen/build/promote, ремапер -`recolor.mjs`, линтер `lint-art.mjs`) — в игре: `apps/game/tools/aiart/` +Сценарии применения (манифест персонажей, gen/build/promote, примерка +`tryon.mjs` — подмена целевого атласа сборкой и скриншот в игре до +апрува, ремапер `recolor.mjs`, линтер `lint-art.mjs`) — в игре: +`apps/game/tools/aiart/` (см. `docs/plan.md`, работа B). ## Замена генератора на рисованный арт diff --git a/docs/plan.md b/docs/plan.md index 8d7f1d0..eedaf22 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -6,6 +6,11 @@ Дата ревизии: 2026-09-06. +**Статус проекта (2026-09-06): техно-демка движка.** Основные силы — движок +(`packages/engine`); игра — витрина и полигон проверки API. Приоритет решений: +что двигает движок (API, тулзы, доки, границы), то и делаем; игрового контента +ровно столько, сколько нужно демонстрации. + --- ## Актуальные задачи diff --git a/packages/engine/tools/agent-lib.mjs b/packages/engine/tools/agent-lib.mjs index f580c0e..a8d971e 100644 --- a/packages/engine/tools/agent-lib.mjs +++ b/packages/engine/tools/agent-lib.mjs @@ -23,23 +23,41 @@ }); } -/** Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}. */ -export async function startDevServer({ port = 5199 } = {}) { +/** + * Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}. + * cwd — каталог приложения с его vite-конфигом и index.html; по умолчанию + * корень монорепо (где vite есть, но конфига приложения нет — приложение + * должно передавать свой каталог, иначе получит 404 на всё). + */ +export async function startDevServer({ port = 5199, cwd = ROOT.pathname } = {}) { + // detached: true — процесс в своей группе: stop() убивает и детей npx-обёртки + // (голый kill обёртки оставляет зомби-вите, который держит порт). const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], { - cwd: ROOT.pathname, - stdio: ['ignore', 'pipe', 'pipe'] + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true }); + const stop = () => { + try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); } + }; + // strictPort при занятом порте завершает наш vite сразу, но fetch продолжил + // бы отвечать чужим сервером — фиксируем выход и падаем. + let exited = null; + proc.on('exit', (code, signal) => { exited = { code, signal }; }); const url = `http://localhost:${port}/`; const deadline = Date.now() + 30000; while (Date.now() < deadline) { + if (exited !== null) { + throw new Error(`vite (cwd ${cwd}) завершился сразу (код ${exited.code}) — порт ${port} занят чужим сервером?`); + } try { const res = await fetch(url); - if (res.ok) return { url, stop: () => proc.kill('SIGTERM') }; + if (res.ok) return { url, stop }; } catch { /* ещё не поднялся */ } await sleep(250); } - proc.kill('SIGTERM'); - throw new Error(`dev-сервер на :${port} не поднялся за 30 с`); + stop(); + throw new Error(`dev-сервер на :${port} не поднялся за 30 с (cwd ${cwd})`); } /**