diff --git a/CLAUDE.md b/CLAUDE.md index 16ccb61..0e16237 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,15 +18,13 @@ npm run guard # гвард границы движок/игра (импорты, JSON) npm run check:fast # typecheck + guard + все тесты (~30 с, это же делает pre-commit хук) npm run art # перегенерация пиксель-арта из apps/game/tools/pixelart +npm run audio # перегенерация WAV (apps/game/tools/audio/gen.mjs) npm run maps # перегенерация карт-файлов (apps/game/tools/maps + encodeMap) npm run agent:check # полный прогон проверок через агентный мост (JSON) node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs # один сценарий проверки node apps/game/tools/agent.mjs snapshot --new-game # снапшот игры (JSON) node apps/game/tools/smoke.mjs # смоук-тест в реальном Chromium (скриншот + консоль) node apps/game/tools/smoke-act1.mjs # полный прогон акта 1 через агентный мост -node apps/game/tools/smoke-ponds.mjs # смоук перехода луга -> Серые пруды -node apps/game/tools/smoke-zvenets.mjs # смоук перехода луга -> Звенец -node apps/game/tools/smoke-quest.mjs # смоук диалога с Ирвином и сумки/журнала ``` Dev-сервер по умолчанию на http://localhost:5173; для отдельного порта: `npm run dev -- --port 5199`. @@ -57,7 +55,8 @@ - `packages/engine/src/` — модули движка: `core/` (Engine, GameLoop, Tween, GameState, StateMachine, Settings, EventBus), `scene/` (SceneManager + fade-переходы), `render/` (Renderer, Camera, IsoDepthLayer, Particles, scale), `input/` (действия, геймпад, VirtualJoystick), `map/` (изометрия, A*, mapFormat, Tiled-импорт), `ui/` (PixelText/VT323, Panel, Button, MenuList, DialogueBox), `dialogue/` (DialogueRunner — view-агностик), `audio/` (шины master/music/sfx), `assets/` (AssetLoader + атласы), `save/`, `math/` (iso, rng, easing). - `apps/game/src/data/` — весь контент: карта (`map.ts`), переходы и области (`locations.ts` — `TransitionDef`/`AREAS`), интерактивные объекты (`interactables.ts` — `INTERACTABLES`), диалоги (`dialogues.ts` — графы `DialogueGraph`), NPC (`npcs.ts`); `validate.ts` — runtime-валидация контента → `Invariant[]` (проверяется в `agent:invariants` и юнит-тестах). - `apps/game/src/agent/` — контентный слой агентного моста: `snapshot.ts` (сборка слоёв), `GameAgent.ts` (`window.__agent`, только DEV). -- `apps/game/tools/` — все тулзы игры (перенесены из корневого `tools/`): `agent.mjs`/`agent-lib.mjs` (агентный CLI), `checks/` (сценарии проверок), `pixelart/`, `maps/`, `audio/`, смоуки; `guards/boundary.mjs` — гвард границы движок/игра (`npm run guard`, входит в `agent:check`). +- `apps/game/tools/` — **сценарии применения тулз под эту игру** (знают контент: палитру, тайлы, локации, сценарии акта): `agent.mjs` (диспетчер агентных проверок) + `lib.mjs` (маяк загрузки поверх движкового каркаса), `checks/`, `pixelart/` (`gen.mjs` + `palette.mjs`), `audio/gen.mjs`, `aiart/`, `maps/`, смоуки; `guards/boundary.mjs` — сценарий запуска гварда (`npm run guard`, входит в `agent:check`). +- `packages/engine/tools/` — **жанронезависимые тулзы движка** (ничего не знают об игре, палитра/маяк/корни — параметры): `png.mjs` (PNG-кодек), `canvas.mjs` (мини-канвас, ASCII-карты), `imaging.mjs` (кроп/квантизация/обзорный лист), `wav.mjs` (WAV-энкодер + синтез), `agent-lib.mjs` (браузерный каркас агентных проверок), `boundary.mjs` (правила гварда + тесты). Импорт из игры — только через `@rpg/engine/tools/*` (см. `exports` движка). Правило: тулза знает контент игры — ей место в `apps/game/tools`; нет — в движке. - `apps/game/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). LocationScene — только оркестратор вьюх: клик-роутинг вынесен в `systems/ClickRouting.ts` (`resolveClick` — чистый резолвер приоритетов + `InteractionRouter`), агентный мост — в `agent/SceneAgentView.ts`. - `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox), интерактивные объекты (`Interactables.ts` — резолв реакций, used-флаги, `InteractSink`), маршрутизация кликов по миру (`ClickRouting.ts` — приоритеты: NPC → переход/заперто → интерактив → цветок → враг → движение). - Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). **Флаги/вары — только через реестры `data/ids.ts`** (`FLAGS`/`VARS`, типы `FlagId`/`VarId`, `usedFlag(id)` для used:): строковое упоминание вне реестра ловится `validateReferences()` как error, незадействованный ключ — как warn. diff --git a/README.md b/README.md index e920282..ca78436 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ apps/game/tools/pixelart # генератор арта: PNG из кода, палитра в одном месте apps/game/tools/audio # генератор WAV: звуки и музыкальные петли из кода apps/game/tools/maps # генерация карт-файлов через encodeMap (+тесты формата) +packages/engine/tools # жанронезависимые тулзы: PNG-кодек, канвас, WAV-тулкит, гвард, каркас агентных проверок ``` Правила: игра использует только публичный API `@rpg/engine`; движок ничего не знает о жанре игры; тёплый цвет в арте — только жизнь (цветы, бронза, огонь). diff --git a/apps/game/tools/agent-lib.mjs b/apps/game/tools/agent-lib.mjs deleted file mode 100644 index d7ef1d6..0000000 --- a/apps/game/tools/agent-lib.mjs +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Библиотека для агентных проверок игры (puppeteer-core + системный Chromium). - * Единственное место с браузерным бойлерплейтом: смоуки и сценарии - * tools/checks/*.mjs строятся поверх openGame()/AgentClient. - */ -import puppeteer from 'puppeteer-core'; -import { spawn } from 'node:child_process'; -import { setTimeout as sleep } from 'node:timers/promises'; - -/** Корень репозитория (vite-скрипты и workspaces живут в нём). */ -const ROOT = new URL('../../..', import.meta.url); - -export const CHROMIUM = '/usr/bin/chromium'; -export const BEACON = '[boot] ассеты загружены'; - -/** Запустить браузер с нужными headless-флагами (см. грабли autoplay-policy). */ -export async function launchBrowser() { - return puppeteer.launch({ - executablePath: CHROMIUM, - headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] - }); -} - -/** Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}. */ -export async function startDevServer({ port = 5199 } = {}) { - const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], { - cwd: ROOT.pathname, - stdio: ['ignore', 'pipe', 'pipe'] - }); - const url = `http://localhost:${port}/`; - const deadline = Date.now() + 30000; - while (Date.now() < deadline) { - try { - const res = await fetch(url); - if (res.ok) return { url, stop: () => proc.kill('SIGTERM') }; - } catch { /* ещё не поднялся */ } - await sleep(250); - } - proc.kill('SIGTERM'); - throw new Error(`dev-сервер на :${port} не поднялся за 30 с`); -} - -/** - * Открыть игру и дождаться готовности агента. - * newGame=true — кликнуть «Новая игра» через мост. - * Возвращает {browser, page, agent}. - */ -export async function openGame({ url = 'http://localhost:5199/', newGame = true } = {}) { - const browser = await launchBrowser(); - const page = await browser.newPage(); - await page.setViewport({ width: 960, height: 540 }); - const consoleLogs = []; - page.on('console', (m) => consoleLogs.push(m.text())); - page.on('pageerror', (e) => consoleLogs.push(`[pageerror] ${e.message}`)); - - const booted = new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error(`маяк «${BEACON}» не пришёл за 30 с`)), 30000); - page.on('console', (m) => { if (m.text().includes(BEACON)) { clearTimeout(t); resolve(); } }); - }); - await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); - await booted; - // Дождаться конца fade-перехода BootScene->Menu: begin() молча отбрасывает - // команды, пришедшие во время перехода. - await sleep(1500); - - const agent = new AgentClient(page); - if (newGame) { - await agent.newGame(); - // fade-переход в локацию ещё идёт: сцена игнорирует клики до его конца. - await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); - } - return { browser, page, agent, consoleLogs }; -} - -/** - * Тонкий клиент поверх window.__agent: каждое поле — прокси-вызов page.evaluate. - * snapshot()/invariants() возвращают готовые объекты; waitFor — строка-pred. - */ -export class AgentClient { - constructor(page) { this.page = page; } - - async call(method, ...args) { - return this.page.evaluate((m, a) => { - const api = window.__agent; - if (!api) return { __agentError: 'window.__agent не зарегистрирован (DEV-сборка?)' }; - return api[m](...a); - }, method, args); - } - - snapshot() { return this.call('snapshot'); } - invariants() { return this.call('invariants'); } - step(n = 1, opts) { return this.call('step', n, opts); } - waitFor(pred, opts) { return this.call('waitFor', pred, opts); } - tapTile(x, y) { return this.call('tapTile', x, y); } - tapVirtual(x, y) { return this.call('tapVirtual', x, y); } - press(action, holdTicks) { return this.call('press', action, holdTicks); } - key(code) { return this.call('key', code); } - command(name, args) { return this.call('command', name, args); } - walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); } - runDialogue() { return this.call('runDialogue'); } - newGame() { return this.call('newGame'); } - currentArea() { return this.call('currentArea'); } - - async screenshot(path) { return this.page.screenshot({ path }); } -} - -/** - * Каркас сценариев: run() ловит исключения, finish() печатает JSON и ставит код. - * Использование: const c = new Checks('имя'); await c.run('шаг', async () => ...); c.finish(); - */ -export class Checks { - constructor(name) { - this.name = name; - this.results = []; - this.started = Date.now(); - } - /** Добавить проверку; исключение/ложь → неуспех, прогон продолжается. */ - async run(checkName, fn) { - const t0 = Date.now(); - try { - const details = await fn(); - this.results.push({ name: checkName, ok: details !== false, ms: Date.now() - t0, - details: details === true ? null : details ?? null }); - } catch (err) { - this.results.push({ name: checkName, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }); - } - return this; - } - /** Ассерт-хелпер: неистинное условие кидает исключение с сообщением. */ - expect(cond, msg, details) { - if (!cond) throw new Error(msg + (details !== undefined ? `: ${JSON.stringify(details)}` : '')); - return details; - } - /** Напечатать JSON-отчёт и вернуть его (код выхода — через report.ok). */ - finish({ pretty = false } = {}) { - const report = { - name: this.name, - ok: this.results.every((r) => r.ok), - ms: Date.now() - this.started, - results: this.results - }; - console.log(pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); - return report; - } -} - -/** Разбор общих флагов CLI: --pretty/--json/--port/--only/--skip. */ -export function parseArgs(argv = process.argv.slice(2)) { - const out = { _: [] }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--pretty' || a === '--json') out.pretty = a === '--pretty'; - else if (a === '--port') out.port = Number(argv[++i]); - else if (a === '--only') out.only = (argv[++i] ?? '').split(',').filter(Boolean); - else if (a === '--skip') out.skip = (argv[++i] ?? '').split(',').filter(Boolean); - else if (a === '--new-game') out.newGame = true; - else if (a === '--out') out.out = argv[++i]; - else if (a === '--steps') out.steps = Number(argv[++i]); - else out._.push(a); - } - return out; -} \ No newline at end of file diff --git a/apps/game/tools/cam-probe.mjs b/apps/game/tools/cam-probe.mjs deleted file mode 100644 index e84d7f5..0000000 --- a/apps/game/tools/cam-probe.mjs +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Проба плавности камеры: отправляем героя на запад и сэмплируем - * worldRoot.position каждые 100 мс — шаги должны быть монотонными и плавными. - * Запуск: node tools/cam-probe.mjs [url] - */ -import puppeteer from 'puppeteer-core'; - -const url = process.argv[2] ?? 'http://localhost:5201/'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', - headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); - -const booted = new Promise((resolve) => { - page.on('console', (msg) => { - if (msg.text().includes('[location]')) resolve(); - }); -}); - -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await new Promise((r) => setTimeout(r, 2000)); -await page.mouse.click(480, 283); -await booted; -await new Promise((r) => setTimeout(r, 1500)); - -await page.evaluate(() => { - const sc = window.__game.scenes.current; - const t = sc.player.currentTile(); - const p = sc.tileCenter(t.x - 8, t.y); - const canvas = document.querySelector('canvas'); - const rect = canvas.getBoundingClientRect(); - const wr = window.__game.renderer.worldRoot.position; - window.__pt = { x: rect.left + ((p.x + wr.x) / 480) * rect.width, - y: rect.top + ((p.y + wr.y) / 270) * rect.height }; -}); -await page.mouse.click(await page.evaluate(() => window.__pt.x), - await page.evaluate(() => window.__pt.y)); - -for (let i = 0; i < 12; i++) { - const p = await page.evaluate(() => { - const wr = window.__game.renderer.worldRoot.position; - return `${wr.x.toFixed(1)},${wr.y.toFixed(1)}`; - }); - console.log(`${String(i * 100).padStart(4)}мс мир: ${p}`); - await new Promise((r) => setTimeout(r, 100)); -} -await browser.close(); \ No newline at end of file diff --git a/apps/game/tools/far-test.mjs b/apps/game/tools/far-test.mjs deleted file mode 100644 index 56beb2e..0000000 --- a/apps/game/tools/far-test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import puppeteer from 'puppeteer-core'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); -page.on('pageerror', (e) => console.log(`[ошибка] ${e.message}`)); -const booted = new Promise((r) => page.on('console', (m) => { if (m.text().includes('[location]')) r(); })); -await page.goto('http://localhost:5201/', { waitUntil: 'networkidle2', timeout: 20000 }); -await new Promise((r) => setTimeout(r, 2000)); -await page.mouse.click(480, 283); -await booted; -await new Promise((r) => setTimeout(r, 1500)); - -// Клик по тайлу Милы (16,12) издалека. -const pt = await page.evaluate(() => { - const g = window.__game; - const sc = g.scenes.current; - const p = sc.tileCenter(16, 12); - const rect = document.querySelector('canvas').getBoundingClientRect(); - const wr = g.renderer.worldRoot.position; - return { x: rect.left + ((p.x + wr.x) / 480) * rect.width, - y: rect.top + ((p.y + wr.y) / 270) * rect.height }; -}); -await page.mouse.click(pt.x, pt.y); -await new Promise((r) => setTimeout(r, 400)); - -const moving = await page.evaluate(() => window.__game.scenes.current.player.moving); -console.log(`Герой пошёл: ${moving}`); - -// Ждём, пока диалог начнётся (подошёл и заговорил) -for (let i = 0; i < 20; i++) { - const st = await page.evaluate(() => ({ - active: window.__game.scenes.current.dialogue.active, - tile: window.__game.scenes.current.player.currentTile() - })); - if (st.active) { - console.log(`Диалог начался, герой на тайле (${st.tile.x},${st.tile.y}) — рядом с Милой (16,12)`); - break; - } - await new Promise((r) => setTimeout(r, 200)); -} -await page.screenshot({ path: '/tmp/far_check.png' }); -await browser.close(); diff --git a/apps/game/tools/font-probe.mjs b/apps/game/tools/font-probe.mjs deleted file mode 100644 index 953aa3a..0000000 --- a/apps/game/tools/font-probe.mjs +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Проба читаемости VT323 c НАСТОЯЩИМ конвейером игры: текст растеризуется - * в канвас 480×270 (как текстура Pixi при resolution 1), затем растягивается - * целым множителем с pixelated. Сравнение кеглей и resolution-подхода. - * Запуск: node tools/font-probe.mjs [скриншот] - */ -import puppeteer from 'puppeteer-core'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const ttf = readFileSync(path.join(root, 'packages/engine/assets/fonts/VT323-Regular.ttf')).toString('base64'); -const shot = process.argv[2] ?? '/tmp/rpg_font_probe.png'; - -const html = ` - -`; - -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', - headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); -await page.setContent(html, { waitUntil: 'networkidle0' }); -await page.evaluate(() => document.fonts.ready); -await page.evaluate(() => window.start()); -await new Promise((r) => setTimeout(r, 300)); -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -await browser.close(); \ No newline at end of file diff --git a/apps/game/tools/skin-info.mjs b/apps/game/tools/skin-info.mjs deleted file mode 100644 index 0139a91..0000000 --- a/apps/game/tools/skin-info.mjs +++ /dev/null @@ -1,34 +0,0 @@ -/** Проба: печатает информацию о текущей текстуре спрайта героя из браузера. */ -import puppeteer from 'puppeteer-core'; -const url = process.argv[2] ?? 'http://localhost:5201/'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); -page.on('pageerror', (e) => console.log(`[ошибка] ${e.message}`)); -const booted = new Promise((r) => page.on('console', (m) => { if (m.text().includes('[location]')) r(); })); -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await new Promise((r) => setTimeout(r, 2000)); -await page.mouse.click(480, 283); -await booted; -await new Promise((r) => setTimeout(r, 1500)); -const info = await page.evaluate(() => { - const g = window.__game; - const scene = g.scenes?.current ?? g.sceneManager?.current; - const player = scene?.player; - if (!player) return { error: 'нет scene.player: ' + Object.keys(scene ?? {}) }; - const tex = player.currentTexture; - const src = tex.source; - return { - width: tex.width, height: tex.height, - frame: tex.frame, trim: tex.trim, - srcWidth: src.width, srcHeight: src.height, - label: tex.label ?? null, - textureDatas: tex.textureCacheIds ?? null - }; -}); -console.log(JSON.stringify(info, null, 2)); -await browser.close(); diff --git a/apps/game/tools/skin-probe.mjs b/apps/game/tools/skin-probe.mjs deleted file mode 100644 index 24e117e..0000000 --- a/apps/game/tools/skin-probe.mjs +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Проба дебага спрайта: включаем просмотр кадра героя (KeyP) и снимаем скриншот. - * Запуск: node tools/skin-probe.mjs [url] [скриншот] - */ -import puppeteer from 'puppeteer-core'; -const url = process.argv[2] ?? 'http://localhost:5201/'; -const shot = process.argv[3] ?? '/tmp/skin_check.png'; -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); -page.on('pageerror', (e) => console.log(`[ошибка] ${e.message}`)); -const booted = new Promise((r) => page.on('console', (m) => { if (m.text().includes('[location]')) r(); })); -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await new Promise((r) => setTimeout(r, 2000)); -await page.mouse.click(480, 283); -await booted; -await new Promise((r) => setTimeout(r, 1500)); -await page.keyboard.press('KeyP'); -await new Promise((r) => setTimeout(r, 500)); -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -await browser.close(); diff --git a/apps/game/tools/smoke-act1.mjs b/apps/game/tools/smoke-act1.mjs index 0d57938..27fddbe 100644 --- a/apps/game/tools/smoke-act1.mjs +++ b/apps/game/tools/smoke-act1.mjs @@ -33,7 +33,7 @@ await agent.tapTile(12, 9); if (!(await agent.runDialogue())) console.log(' ! диалог с Ирвином не открылся'); -// 4) Сбор цветов — эмуляция var'ом (вылазка проверяется smoke-ponds). +// 4) Сбор цветов — эмуляция var'ом (переход в пруды проверяется checks/transitions). console.log('шаг 4: цветы собраны (эмуляция)'); await agent.command('scene:setVar', { id: 'flowers', value: 3 }); diff --git a/apps/game/tools/smoke-ponds.mjs b/apps/game/tools/smoke-ponds.mjs deleted file mode 100644 index 9cc0761..0000000 --- a/apps/game/tools/smoke-ponds.mjs +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Смоук перехода луга -> Серые пруды: герой идёт через мост к триггеру (2,2). - * Запуск: node tools/smoke-ponds.mjs [url] [скриншот] - */ -import { openGame } from './lib.mjs'; - -const url = process.argv[2] ?? 'http://localhost:5199/'; -const shot = process.argv[3] ?? '/tmp/rpg_ponds.png'; - -const { browser, page, agent, consoleLogs } = await openGame({ url, newGame: true }); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); - -// Усыпить сгустков: путь героя не должен прерываться уроном. -await page.evaluate(() => { - const g = window.__game; - for (const [, en] of g.scenes.current.combat.enemies) en.brain.putToSleep(9999); -}); - -// Через мост: маршрут A* до триггера (2,2) — клики в юнитах, видимость не важна. -const arrived = await agent.walkTo(2, 2); -if (!arrived) console.log(' ! герой не дошёл до (2,2)'); - -// Fade-переход и enter() новой сцены. -await agent.waitFor('s.area === "ponds"', { timeoutTicks: 300 }); -const area = await agent.currentArea(); -console.log(`Локация после перехода: ${area}`); - -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -if (!consoleLogs.some((l) => l.includes('[location] ponds'))) { - console.log(' ! маяк [location] ponds не найден'); -} -await browser.close(); \ No newline at end of file diff --git a/apps/game/tools/smoke-quest.mjs b/apps/game/tools/smoke-quest.mjs deleted file mode 100644 index 835be9e..0000000 --- a/apps/game/tools/smoke-quest.mjs +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Смоук квестовой рамки: новая игра → Звенец → диалог с Ирвином (задание) → - * сумка с журналом (I). Запуск: node tools/smoke-quest.mjs [url] [скриншот] - */ -import { openGame } from './lib.mjs'; - -const url = process.argv[2] ?? 'http://localhost:5199/'; -const shot = process.argv[3] ?? '/tmp/rpg_quest.png'; - -const { browser, page, agent } = await openGame({ url, newGame: true }); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); - -// Усыпить сгустков: путь героя не должен прерываться уроном. -await page.evaluate(() => { - const g = window.__game; - for (const [, en] of g.scenes.current.combat.enemies) en.brain.putToSleep(9999); -}); - -// В Звенец, затем клик по Ирвину (12,9) — сцена сама подведёт героя. -await agent.walkTo(26, 14); -await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); -await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); -await agent.tapTile(12, 9); -const talked = await agent.runDialogue(); -console.log(`Диалог с Ирвином: ${talked ? 'сыгран' : 'НЕ открылся'}`); - -const flags = (await agent.snapshot()).flags; -console.log('Флаги:', JSON.stringify(flags)); -if (!flags.includes('metElder')) console.log(' ! флаг metElder не поставлен'); - -// Сумка с журналом (I) — через инъекцию клавиши. -await agent.key('KeyI'); -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -await browser.close(); \ No newline at end of file diff --git a/apps/game/tools/smoke-zvenets.mjs b/apps/game/tools/smoke-zvenets.mjs deleted file mode 100644 index 24e9ef2..0000000 --- a/apps/game/tools/smoke-zvenets.mjs +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Смоук перехода луга -> Звенец: герой идёт через мост к триггеру (26,14). - * Запуск: node tools/smoke-zvenets.mjs [url] [скриншот] - */ -import { openGame } from './lib.mjs'; - -const url = process.argv[2] ?? 'http://localhost:5199/'; -const shot = process.argv[3] ?? '/tmp/rpg_zvenets.png'; - -const { browser, page, agent, consoleLogs } = await openGame({ url, newGame: true }); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); - -// Усыпить сгустков на лугах: путь героя не должен прерываться уроном. -await page.evaluate(() => { - const g = window.__game; - for (const [, en] of g.scenes.current.combat.enemies) en.brain.putToSleep(9999); -}); - -// Через мост: маршрут A* до триггера (26,14) — клики в юнитах. -const arrived = await agent.walkTo(26, 14); -if (!arrived) console.log(' ! герой не дошёл до (26,14)'); - -await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); -const area = await agent.currentArea(); -console.log(`Локация после перехода: ${area}`); - -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -if (!consoleLogs.some((l) => l.includes('[location] zvenets'))) { - console.log(' ! маяк [location] zvenets не найден'); -} -await browser.close(); \ No newline at end of file diff --git a/apps/game/tools/walk-probe.mjs b/apps/game/tools/walk-probe.mjs deleted file mode 100644 index 8ec0158..0000000 --- a/apps/game/tools/walk-probe.mjs +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Проба анимации ходьбы героя: запускаем игру, отправляем героя на запад, - * снимаем серию скриншотов во время движения. - * Запуск: node tools/walk-probe.mjs [url] [префикс] - */ -import puppeteer from 'puppeteer-core'; - -const url = process.argv[2] ?? 'http://localhost:5201/'; -const prefix = process.argv[3] ?? '/tmp/walk'; - -const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium', - headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', - '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] -}); -const page = await browser.newPage(); -await page.setViewport({ width: 960, height: 540 }); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}`)); - -const booted = new Promise((resolve) => { - page.on('console', (msg) => { - if (msg.text().includes('[location]')) resolve(); - }); -}); - -await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); -await new Promise((r) => setTimeout(r, 2000)); -await page.mouse.click(480, 283); // «Новая игра» -await booted; -await new Promise((r) => setTimeout(r, 1500)); - -// Клик по мировому тайлу слева от героя (запад) — через window.__game. -await page.evaluate(() => { - const g = window.__game; - const sc = g.scenes.current; - const t = sc.player.currentTile(); - const p = sc.tileCenter(t.x - 6, t.y); - const canvas = document.querySelector('canvas'); - const rect = canvas.getBoundingClientRect(); - const wr = g.renderer.worldRoot.position; - return { x: rect.left + ((p.x + wr.x) / 480) * rect.width, - y: rect.top + ((p.y + wr.y) / 270) * rect.height }; -}).then((pt) => page.mouse.click(pt.x, pt.y)); - -// Серия кадров во время ходьбы -for (let i = 0; i < 6; i++) { - await page.screenshot({ path: `${prefix}_${i}.png` }); - await new Promise((r) => setTimeout(r, 130)); -} -console.log('Готово'); -await browser.close(); \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index 8c14ccd..9693500 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -36,7 +36,7 @@ | math/rng (mulberry32) | генерация карт и частиц, детерминированный shake | готово | | AgentHost/EngineAgent (agent/) | `window.__agent`: снапшот, ручной шаг, инъекция ввода, инварианты | готово | | data/validate.ts → Invariant[] | runtime-валидация контента (диалоги/NPC/локации/враги) | готово | -| apps/game/tools/agent.mjs + apps/game/tools/agent-lib.mjs | CLI check/run/snapshot/screenshot/dev; смоуки на мосту | готово | +| apps/game/tools/agent.mjs + packages/engine/tools/agent-lib.mjs | CLI check/run/snapshot/screenshot/dev; смоуки на мосту (каркас — в движке) | готово | ## Сюжетная рамка (акт 1, по docs/world.md) @@ -68,9 +68,12 @@ - `systems/` — PlayerController (путь + прямое движение), DialogueSystem, `combat/` (CombatWorld, EnemyBrain, PlayerCombat, CombatViews, HealthBar, stats). - Инструменты: `apps/game/tools/pixelart` (PNG-атласы), `apps/game/tools/audio` (WAV-генератор), - `apps/game/tools/maps` (карты-файлы), `apps/game/tools/agent.mjs` + `apps/game/tools/agent-lib.mjs` + - `apps/game/tools/checks/` (агентный мост: check/snapshot/сценарии), `apps/game/tools/smoke*.mjs` - (смоуки через мост, в реальном Chromium). + `apps/game/tools/maps` (карты-файлы), `apps/game/tools/agent.mjs` + `apps/game/tools/lib.mjs` + + `apps/game/tools/checks/` (агентный мост: check/snapshot/сценарии; браузерный каркас — + `packages/engine/tools/agent-lib.mjs`), `apps/game/tools/smoke*.mjs` + (смоуки через мост, в реальном Chromium). Разграничение: тулзы, знающие контент + игры, — в `apps/game/tools`; жанронезависимая машинерия — `packages/engine/tools/` + (png/canvas/imaging/wav/agent-lib/boundary через `@rpg/engine/tools/*`). ## Новое в движке за срез diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 0e630cd..c63abe7 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -18,7 +18,8 @@ GameAgent.ts регистрирует window.__agent (только DEV), high-level хелперы apps/game/src/data/validate.ts — runtime-валидация контента → Invariant[] -apps/game/tools/agent-lib.mjs — браузерный клиент (puppeteer, openGame, Checks) +packages/engine/tools/agent-lib.mjs — браузерный каркас (puppeteer, openGame, Checks) +apps/game/tools/lib.mjs — сценарный слой игры (маяк загрузки поверх каркаса) apps/game/tools/agent.mjs — CLI: check / run / snapshot / screenshot / dev ``` @@ -148,7 +149,8 @@ `check` поднимает dev-сервер сам (и гасит в finally): `typecheck`, `tests`, `maps`, `maps:fresh` (файлы карт равны генераторам), `agent:invariants` (новая игра → 300 шагов → инварианты чисты). Сценарии геймплея — в `apps/game/tools/checks/*.mjs`, каркас — -`Checks` из `apps/game/tools/agent-lib.mjs` (проверка возвращает `false`/кидает исключение +`Checks` из `packages/engine/tools/agent-lib.mjs` — импортируйте через сценарный слой +`apps/game/tools/lib.mjs` (там маяк загрузки игры) (проверка возвращает `false`/кидает исключение или `c.expect(cond, msg, details)`). **Правило**: каждая геймплейная система обязана иметь сценарий проверки в diff --git a/docs/engine/art-pipeline.md b/docs/engine/art-pipeline.md index e5561cc..d0df08a 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -1,7 +1,7 @@ # Арт-пайплайн: генерация пиксель-арта из кода -Весь арт первой игры генерируется кодом: `apps/game/tools/pixelart/*.mjs` рисуют спрайты -на собственном Canvas и кодируют PNG без сторонних зависимостей (`node:zlib` + +Арт игры генерируется кодом: сценарий `apps/game/tools/pixelart/gen.mjs` рисует спрайты +на канвасе из движка и кодирует PNG без сторонних зависимостей (`node:zlib` + свой CRC32). Позже генератор можно заменить на рисованный арт — движку всё равно, откуда текстуры. @@ -9,10 +9,11 @@ | Файл | Что делает | |---|---| -| `apps/game/tools/pixelart/palette.mjs` | Палитра из 32 цветов (hex + RGBA) | -| `apps/game/tools/pixelart/canvas.mjs` | Canvas: set/get пикселя, `fillDiamond` (изо-ромб), `fromAscii`, seeded rng | -| `apps/game/tools/pixelart/png.mjs` | `encodePng` — сборка PNG вручную | -| `apps/game/tools/pixelart/gen.mjs` | Генерация всех спрайтов игры → `apps/game/assets/` | +| `packages/engine/tools/png.mjs` | PNG-кодек без зависимостей: `encodePng`/`decodePng` (RGBA 8 бит) | +| `packages/engine/tools/canvas.mjs` | Canvas (таблица цветов — параметр): set/get пикселя, `fillDiamond` (изо-ромб), `fromAscii`, seeded rng | +| `packages/engine/tools/imaging.mjs` | Кроп по альфе, nearest-масштаб, квантизация в палитру, обзорный лист | +| `apps/game/tools/pixelart/palette.mjs` | Палитра игры из 32 цветов (hex + RGBA) — сценарный слой | +| `apps/game/tools/pixelart/gen.mjs` | Генерация всех спрайтов игры → `apps/game/assets/` (канвас с палитрой игры) | ## Запуск diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 1446c5e..a007671 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -134,13 +134,16 @@ 2. Движок не знает ничего об RPG-контенте; если движку нужна точка расширения — интерфейс с инъекцией (образцы: `StorageLike`, `SceneAgent`), а не импорт из `apps/game`. -3. Чистое ядро гварда (`importSpecs`/`checkBoundary`) покрыто тестами - (`guards/__tests__/boundary.test.mjs`) — новый класс нарушений добавляй - как правило + тест. +3. Чистое ядро гварда (`importSpecs`/`checkBoundary`) живёт в движке + (`packages/engine/tools/boundary.mjs`, тесты рядом в `__tests__/`) — новый + класс нарушений добавляй как правило + тест; корни пакетов и публичное + API — опции, сценарий запуска конкретного репо — у приложения. ## Ситуация: пишу новый сценарий apps/game/tools/checks/ -1. Каркас: `import { startDevServer, openGame, Checks } from '../agent-lib.mjs'`; +1. Каркас: `import { startDevServer, openGame, Checks } from '../lib.mjs'` + (сценарный слой игры поверх жанронезависимого `@rpg/engine/tools/agent-lib.mjs` — + там маяк загрузки игры); `export default async function ({ pretty })`; вернуть `c.finish({pretty}).ok ? 0 : 1` — CLI `agent.mjs run` ждёт числовой код. 2. Проверка: возвращает `false` или кидает исключение при провале (строка — @@ -185,11 +188,17 @@ ## Грабли среды (кратко, подробности в CLAUDE.md) -- **Тулзы живут в `apps/game/tools/` (3 уровня ниже корня)**: скриптам, - спавнящим процессы с путями от корня репо, нужен `ROOT = new URL('../../..', - import.meta.url)` (не `'../..'` — остаток от жизни в корневом `tools/`). +- **Раскладка тулз по правилу «знает ли контент игры»**: знает (палитра, + тайлы, локации, сценарии акта) — `apps/game/tools/` (сценарии применения); + нет — `packages/engine/tools/` (png/canvas/imaging/wav/agent-lib/boundary, + импорт из игры через `@rpg/engine/tools/*` из `exports` движка). Обе — + 3 уровня ниже корня: скриптам, спавнящим процессы с путями от корня репо, + нужен `ROOT = new URL('../../..', import.meta.url)` (не `'../..'`). Коварство: `npm run` из `apps/` всё равно сработает (npm ищет package.json вверх), а вот vitest с корне-относительным путём файла — упадёт «не зелёный». +- Контентный параметр выноси из движковой тулзы, не дублируй: палитра — + параметр `quantize`/`Canvas`, маяк загрузки — параметр `openGame({beacon})`, + корни пакетов гварда — опции `checkBoundary`. - `Assets.load` без `Assets.init()` висит навсегда — грузи только через `AssetLoader`. - Headless Chromium: `--autoplay-policy=no-user-gesture-required` обязателен; `ctx.resume()` без него не резолвится. diff --git a/docs/llms.txt b/docs/llms.txt index 0545e33..6e2e041 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -28,9 +28,9 @@ - [Практики](engine/practices.md): живой документ приёмов по ситуациям агента; пополняется в каждом коммите. - `apps/game/tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). -- `apps/game/tools/agent-lib.mjs`: openGame, AgentClient, Checks — каркас сценариев. +- `packages/engine/tools/`: жанронезависимые тулзы (png/canvas/imaging/wav, agent-lib, boundary) — импорт через `@rpg/engine/tools/*`; тулзы, знающие контент игры, остаются в `apps/game/tools/` как сценарии применения. - `apps/game/tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. -- `apps/game/tools/guards/boundary.mjs` (`npm run guard`): гвард границы движок/игра — импорты только через публичное API. +- `apps/game/tools/guards/boundary.mjs` (`npm run guard`): сценарий запуска гварда; правила — `packages/engine/tools/boundary.mjs`. ## Мир и арт diff --git a/package.json b/package.json index 82267dc..96316cd 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", + "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", "test": "vitest run",