diff --git a/CLAUDE.md b/CLAUDE.md index bad373b..01c3dab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,16 +15,17 @@ npm test # все тесты (Vitest) npx vitest run packages/engine/src/map/__tests__/pathfinding.test.ts # один файл тестов npm run typecheck # tsc --noEmit для обоих пакетов -npm run art # перегенерация пиксель-арта из tools/pixelart -npm run maps # перегенерация карт-файлов (tools/maps + encodeMap) +npm run guard # гвард границы движок/игра (импорты, JSON) +npm run art # перегенерация пиксель-арта из apps/game/tools/pixelart +npm run maps # перегенерация карт-файлов (apps/game/tools/maps + encodeMap) npm run agent:check # полный прогон проверок через агентный мост (JSON) -node tools/agent.mjs run tools/checks/xxx.mjs # один сценарий проверки -node tools/agent.mjs snapshot --new-game # снапшот игры (JSON) -node tools/smoke.mjs # смоук-тест в реальном Chromium (скриншот + консоль) -node tools/smoke-act1.mjs # полный прогон акта 1 через агентный мост -node tools/smoke-ponds.mjs # смоук перехода луга -> Серые пруды -node tools/smoke-zvenets.mjs # смоук перехода луга -> Звенец -node tools/smoke-quest.mjs # смоук диалога с Ирвином и сумки/журнала +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`. @@ -33,7 +34,7 @@ Monorepo из двух workspace-пакетов с **жёсткой границей API**: -- `packages/engine` (`@rpg/engine`) — жанронезависимый движок-библиотека. **Не должен знать ничего о RPG-контенте** (квесты, предметы, сюжет). Вся игра импортирует движок **только через `packages/engine/src/index.ts`** — другие внутренние пути движка импортировать нельзя. +- `packages/engine` (`@rpg/engine`) — жанронезависимый движок-библиотека. **Не должен знать ничего о RPG-контенте** (квесты, предметы, сюжет). Вся игра импортирует движок **только через `packages/engine/src/index.ts`** — другие внутренние пути движка импортировать нельзя. Границу проверяет гвард `npm run guard` (проба `guard:boundary` в `agent:check`). - `apps/game` (`@rpg/game`) — сама RPG: контент, сцены, геймплейные системы. ### Ключевые решения, которые нельзя сломать @@ -54,16 +55,17 @@ - `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/src/scenes/` — BootScene (грузит ассеты и шрифт) → MenuScene (MenuList) → LocationScene; сцены меняются через `SceneManager.replace/push/pop` (опционально с fade). - `apps/game/src/systems/` — геймплейные механики: движение героя (A* + плавный путь + анимация из атласа), диалоги (обёртка над DialogueRunner + DialogueBox), интерактивные объекты (`Interactables.ts` — резолв реакций, used-флаги, `InteractSink`). - Флаги/переменные сюжета — в `GameState` (`game.state`), сериализуются в автосейв `autosave` (Esc в локации); настройки — `game.settings` (отдельный слот, не в сейвах). ### Пиксель-арт -- `tools/pixelart/gen.mjs` — генератор ассетов (PNG из кода, без зависимостей): `node tools/pixelart/gen.mjs`. +- `apps/game/tools/pixelart/gen.mjs` — генератор ассетов (PNG из кода, без зависимостей): `node apps/game/tools/pixelart/gen.mjs`. - Выход — `apps/game/assets/` (`tiles/*.png`, `chars/*.png`); загружается `AssetLoader` в BootScene, ключ = путь без расширения. - Атласы (кадры героя): `gen.mjs` собирает `chars/hero_sheet.png` + `hero_sheet.json` (Pixi Spritesheet); загрузка — `assets.loadAtlas('chars/hero_sheet.json')`, кадры — `assets.frames(key, prefix)`. -- Палитра — только из `tools/pixelart/palette.mjs` (она же в `docs/art-style.md`); тёплые цвета (B*/F*) — только «жизнь»: цветы, бронза, огонь. +- Палитра — только из `apps/game/tools/pixelart/palette.mjs` (она же в `docs/art-style.md`); тёплые цвета (B*/F*) — только «жизнь»: цветы, бронза, огонь. - Имена файлов и размеры спрайтов фиксированы арт-библией — при замене сгенерированного арта нарисованным руками их не менять. - Тайл «дерево» — высокий объект: `TileMapData.tall` c `TallSpec { height, ground }` (спрайт якорится низом в центр ромба, под ним рисуется ground-тайл). @@ -78,13 +80,13 @@ - **Атласы грузятся только через `assets.loadAtlas('<ключ>.json')`** — ключ с `.json` резолвится как есть, без `.png`. `Assets.load` на JSON вернёт Texture без `.textures` — не перепутай с `texture()`. - **Fade-оверлей переходов должен быть поверх UI**: `SceneManager` поднимает его наверх `uiRoot` в `begin()` — сцены добавляют свои view позже, и «наивный» оверлей из `Engine.init()` оказывается под ними (карты просвечивают сквозь меню). - **TweenManager.tick учитывает пересечение границы `delay`**: часть dt, приходящаяся на delay, вычитается из движения — не упрощай тик до последовательных прибавлений. -- **Headless-скриншоты с `--virtual-time-budget` зависают на загрузке картинок** (колбэки декодинга не срабатывают) — для визуальных проверок использовать `tools/smoke.mjs` (puppeteer-core + системный Chromium, реальное время). +- **Headless-скриншоты с `--virtual-time-budget` зависают на загрузке картинок** (колбэки декодинга не срабатывают) — для визуальных проверок использовать `apps/game/tools/smoke.mjs` (puppeteer-core + системный Chromium, реальное время). - **В headless Chromium `ctx.resume()` без флага `--autoplay-policy=no-user-gesture-required` не резолвится** — смоук-скрипты обязаны передавать этот флаг, а навигация не должна ждать `.then` у `audio.play`. - **Одна клавиша маппится на несколько действий** (`bindActions` дополняет, а не перезаписывает): Space = `advance` в диалоге и `attack` вне его — не развести обратно нельзя, гардится уровнем сцены. - **Координаты указателя и worldRoot — виртуальные пиксели** (480×270), экранные CSS-пиксели — это виртуальные × масштаб: `экран = мир + worldRoot.position` (перевод в CSS — домножением на scale). Цепочка клика по миру: `pointer px − worldRoot.position → screenToWorld → юниты → worldToTile`. Не смешивай пространства: позиция героя/врагов — юниты, вью — px. - **Поворот героя (facing) — по экранным компонентам мирового смещения** (`worldToScreen(delta)`), не по мировым: экранные оси — мировые диагонали, кадры down/up/side соответствуют экранному направлению. - **Клик по тайлу вне экрана не работает**: если целевой тайл за краем канваса (виртуальный y > 270 или x < 0), клик уходит мимо канваса — герой не двигается. В смоук-скриптах целиться в промежуточный тайл рядом с героем по направлению к цели (шаг по доминирующей оси). -- Мир/арт сверять с `docs/world.md` и `docs/art-style.md`: тёплые цвета — только жизнь; палитра — только из `tools/pixelart/palette.mjs`. +- Мир/арт сверять с `docs/world.md` и `docs/art-style.md`: тёплые цвета — только жизнь; палитра — только из `apps/game/tools/pixelart/palette.mjs`. - **Агентный мост** (`window.__agent`, DEV): предпочитай `snapshot`/`agent:check` скриншотам — состояние игры читается снапшотом, скриншот только для визуальных вопросов. Fade-переход молча глотает параллельные `begin()` и клики (`transitioning`) — перед действием `waitFor('!s.transitioning')`. Инъекция ввода и шаг — атомарны (один JS-стек): высокоуровневые `tapTile/press/key/walkTo` моста уже атомарны, сырые `inject*` из страницы + отдельный шаг — гонка с rAF. Подробности: `docs/engine/agent.md`, приёмы: `docs/engine/practices.md` (пополнять в том же коммите). ## Рабочие привычки diff --git a/README.md b/README.md index d02d381..e920282 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ packages/engine # движок: цикл, сцены, ввод, камера, изометрия, A*, сейвы, # диалоги, твины, GameState, UI-кит, частицы, аудио, карты apps/game # RPG: сцены, локации, NPC, диалоги, бой, сюжет -apps/game/assets # пиксель-арт и карты (tools/pixelart, tools/maps) -tools/pixelart # генератор арта: PNG из кода, палитра в одном месте -tools/audio # генератор WAV: звуки и музыкальные петли из кода -tools/maps # генерация карт-файлов через encodeMap (+тесты формата) +apps/game/assets # пиксель-арт и карты (apps/game/tools/pixelart, apps/game/tools/maps) +apps/game/tools/pixelart # генератор арта: PNG из кода, палитра в одном месте +apps/game/tools/audio # генератор WAV: звуки и музыкальные петли из кода +apps/game/tools/maps # генерация карт-файлов через encodeMap (+тесты формата) ``` Правила: игра использует только публичный API `@rpg/engine`; движок ничего не знает о жанре игры; тёплый цвет в арте — только жизнь (цветы, бронза, огонь). diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index d7f6e6d..2f4cfa9 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -25,7 +25,7 @@ readonly saves: SaveManager; readonly assets: AssetLoader; - /** Звуки и музыка (WAV из tools/audio/gen.mjs). */ + /** Звуки и музыка (WAV из apps/game/tools/audio/gen.mjs). */ readonly audio = new AudioManager((key) => `${import.meta.env.BASE_URL}audio/${key}.wav`); /** Флаги и переменные прохождения. */ readonly state = new GameState(); @@ -84,10 +84,10 @@ this.audio.setBusVolume('master', this.settings.data.master); this.audio.setBusVolume('music', this.settings.data.music); this.audio.setBusVolume('sfx', this.settings.data.sfx); - // Хук для смоук-тестов (tools/smoke*.mjs): доступ к контексту из страницы. + // Хук для смоук-тестов (apps/game/tools/smoke*.mjs): доступ к контексту из страницы. if (import.meta.env.DEV) { (window as unknown as { __game?: Game }).__game = this; - // Агентный мост: window.__agent (см. docs/engine/agent.md, tools/agent.mjs). + // Агентный мост: window.__agent (см. docs/engine/agent.md, apps/game/tools/agent.mjs). import('./agent/GameAgent').then(({ registerAgent }) => registerAgent(this)); } } diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index 28869a6..850cae9 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -15,7 +15,7 @@ * Агентный мост игры: поверх движкового каркаса (EngineAgent) добавляет * контентные слои (флаги/вары/инвентарь/сцена) и помощники высокого уровня * (ходьба по маршруту, диалоги, «новая игра»). Регистрируется как - * `window.__agent` (только DEV) — инструменты в tools/ ходят через него. + * `window.__agent` (только DEV) — инструменты в apps/game/tools/ ходят через него. */ export interface AgentApi { @@ -102,7 +102,7 @@ pred: string, opts?: { timeoutTicks?: number; render?: boolean } ): Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }> { - // Строка-выражение приходит из tools/ (доверенная среда репозитория). + // Строка-выражение приходит из apps/game/tools/ (доверенная среда репозитория). const fn = new Function('s', `"use strict"; return (${pred});`) as (s: SnapshotLayer) => boolean; const limit = opts?.timeoutTicks ?? 600; const render = opts?.render ?? false; diff --git a/apps/game/tools/agent-lib.mjs b/apps/game/tools/agent-lib.mjs new file mode 100644 index 0000000..d7ef1d6 --- /dev/null +++ b/apps/game/tools/agent-lib.mjs @@ -0,0 +1,164 @@ +/** + * Библиотека для агентных проверок игры (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/agent.mjs b/apps/game/tools/agent.mjs new file mode 100644 index 0000000..b6b971e --- /dev/null +++ b/apps/game/tools/agent.mjs @@ -0,0 +1,179 @@ +/** + * CLI-диспетчер агентных инструментов. Вывод по умолчанию — JSON + * (машинночитаемый), --pretty — для человека. + * + * node apps/game/tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] + * node apps/game/tools/agent.mjs run [--pretty] + * node apps/game/tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] + * node apps/game/tools/agent.mjs screenshot [--out файл] [--steps N] [--pretty] + * node apps/game/tools/agent.mjs dev [--port N] + */ +import { spawnSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs, startDevServer, openGame, Checks } from './agent-lib.mjs'; + +/** Корень репозитория (тулзы живут в apps/game/tools, скрипты npm — в корне). */ +const ROOT = new URL('../../..', import.meta.url); + +const args = parseArgs(); +const cmd = args._[0] ?? 'check'; + +/** Один «серверный» шаг проверки: результат + ms + детали (fn может быть async). */ +async function stepResult(name, fn) { + const t0 = Date.now(); + try { + const details = await fn(); + return { name, ok: true, ms: Date.now() - t0, details: details ?? null }; + } catch (err) { + return { name, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }; + } +} + +/** Существующие .map равны генераторам (gen.test.ts сравнивает сиды с файлами). */ +const mapsFresh = () => stepResult('maps:fresh', () => { + const gen = spawnSync('npx', ['vitest', 'run', 'apps/game/tools/maps/gen.test.ts'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (gen.status !== 0) throw new Error(gen.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'gen.test.ts не зелёный'); + return 'файлы карт равны генераторам'; +}); + +/** Браузерная проверка: новая игра -> N шагов -> инварианты пусты (error). */ +async function agentInvariants() { + const c = new Checks('agent:invariants'); + const server = await startDevServer({ port: args.port ?? 5199 }); + let ctx; + try { + await c.run('новая игра + 300 шагов', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.step(300); + const inv = await ctx.agent.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + if (errs.length) return errs; + return 'инварианты чисты'; + }); + await c.run('снапшот валиден', async () => { + const s = await ctx.agent.snapshot(); + if (!s.hero || !Array.isArray(s.flags)) return 'снапшот без hero/flags'; + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty: args.pretty }); +} + +/** Сценарий из apps/game/tools/checks/ как шаг check: exit code -> результат. */ +async function runScenario(name, file) { + return stepResult(name, async () => { + const mod = await import(pathToFileURL(resolve(ROOT.pathname, file))); + const code = await mod.default({ args, pretty: args.pretty }); + if (code !== 0) throw new Error(`сценарий ${file} не зелёный (exit ${code})`); + return null; + }); +} + +const commands = { + /** Полный прогон проверок. */ + async check() { + const all = [ + { name: 'typecheck', run: () => stepResult('typecheck', () => { + const r = spawnSync('npm', ['run', 'typecheck'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('error TS')).join('\n')); + return null; + }) }, + { name: 'guard:boundary', run: () => stepResult('guard:boundary', () => { + const r = spawnSync('npm', ['run', 'guard'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('"rule"') || l.includes('"ok": false')).join('\n') || 'граница движок/игра нарушена'); + return null; + }) }, + { name: 'tests', run: () => stepResult('tests', () => { + const r = spawnSync('npm', ['test'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'тесты не зелёные'); + return null; + }) }, + { name: 'maps', run: () => stepResult('maps', () => { + const r = spawnSync('npm', ['run', 'maps'], { encoding: 'utf8', cwd: ROOT.pathname }); + if (r.status !== 0) throw new Error('npm run maps не зелёный'); + return null; + }) }, + { name: 'maps:fresh', run: mapsFresh }, + { name: 'agent:invariants', run: agentInvariants }, + { name: 'transitions', run: () => runScenario('transitions', 'apps/game/tools/checks/transitions.mjs') }, + { name: 'interact', run: () => runScenario('interact', 'apps/game/tools/checks/interact.mjs') }, + { name: 'interact-world', run: () => runScenario('interact-world', 'apps/game/tools/checks/interact-world.mjs') }, + { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') } + ]; + const only = args.only ?? all.map((p) => p.name); + const skip = args.skip ?? []; + const report = { name: 'check', ok: true, ms: 0, results: [] }; + const t0 = Date.now(); + for (const probe of all) { + if (!only.includes(probe.name) || skip.includes(probe.name)) continue; + let res; + try { + res = await probe.run(); + } catch (err) { + res = { name: probe.name, ok: false, ms: 0, details: String(err?.message ?? err) }; + } + report.results.push(res); + report.ok = report.ok && res.ok; + } + report.ms = Date.now() - t0; + console.log(args.pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); + return report.ok ? 0 : 1; + }, + + /** Один сценарий из apps/game/tools/checks/. */ + async run() { + const file = args._[1]; + if (!file) throw new Error('укажи путь к сценарию: node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs'); + const mod = await import(pathToFileURL(file).href); + if (typeof mod.default !== 'function') throw new Error(`${file}: нет export default (async ({args}) => exitCode)`); + return mod.default({ args, pretty: args.pretty }); + }, + + /** Снимок снапшота в файл/stdout. */ + async snapshot() { + const server = await startDevServer({ port: args.port ?? 5199 }); + try { + const ctx = await openGame({ url: server.url, newGame: args.newGame ?? false }); + const snap = await ctx.agent.snapshot(); + if (args.steps) await ctx.agent.step(args.steps); + if (args.out) writeFileSync(args.out, JSON.stringify(snap, null, 2)); + console.log(args.out ? `снапшот: ${args.out}` : JSON.stringify(snap, null, 2)); + await ctx.browser.close(); + return 0; + } finally { server.stop(); } + }, + + /** Скриншот + консоль (для визуальных проверок, когда снапшота мало). */ + async screenshot() { + const server = await startDevServer({ port: args.port ?? 5199 }); + try { + const ctx = await openGame({ url: server.url, newGame: args.newGame ?? true }); + if (args.steps) await ctx.agent.step(args.steps); + const out = args.out ?? '/tmp/rpg_agent.png'; + await ctx.agent.screenshot(out); + console.log(JSON.stringify({ screenshot: out, logs: ctx.consoleLogs.slice(-10) })); + await ctx.browser.close(); + return 0; + } finally { server.stop(); } + }, + + /** Dev-сервер + URL для ручной работы (не гасит сервер до Ctrl+C). */ + async dev() { + const server = await startDevServer({ port: args.port ?? 5199 }); + console.log(`dev: ${server.url}`); + process.on('SIGINT', () => { server.stop(); process.exit(0); }); + await new Promise(() => {}); + } +}; + +const fn = commands[cmd]; +if (!fn) { + console.error(`Неизвестная команда «${cmd}». Доступны: ${Object.keys(commands).join(', ')}`); + process.exit(2); +} +process.exit(await fn()); \ No newline at end of file diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs new file mode 100644 index 0000000..dbf2848 --- /dev/null +++ b/apps/game/tools/audio/gen.mjs @@ -0,0 +1,298 @@ +/** + * Генератор звуков «Пепельных лугов» — WAV из кода, без зависимостей. + * Запуск: node tools/audio/gen.mjs + * Выход: apps/game/assets/audio/sfx/*.wav, apps/game/assets/audio/music/*.wav + * + * Формат: моно, 22050 Гц, 16-бит PCM (хватает для пиксель-арта со звуком, + * файлы вдвое меньше). Меланхоличный пепельный саунд: глухие удары, шум, низкие пады. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const OUT = fileURLToPath(new URL('../../assets/audio/', import.meta.url)); +const RATE = 22050; + +// ---------- WAV-энкодер ---------- + +/** Собрать WAV-файл из Float32 сэмплов (-1..1). */ +function encodeWav(samples) { + const n = samples.length; + const data = new ArrayBuffer(44 + n * 2); + const view = new DataView(data); + const str = (offset, s) => { + for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); + }; + str(0, 'RIFF'); + view.setUint32(4, 36 + n * 2, true); + str(8, 'WAVE'); + str(12, 'fmt '); + view.setUint32(16, 16, true); // размер fmt + view.setUint16(20, 1, true); // PCM + view.setUint16(22, 1, true); // моно + view.setUint32(24, RATE, true); + view.setUint32(28, RATE * 2, true); // байт/сек + view.setUint16(32, 2, true); // блок + view.setUint16(34, 16, true); // бит + str(36, 'data'); + view.setUint32(40, n * 2, true); + for (let i = 0; i < n; i++) { + const s = Math.max(-1, Math.min(1, samples[i])); + view.setInt16(44 + i * 2, Math.round(s * 32767), true); + } + return Buffer.from(data); +} + +// ---------- примитивы ---------- + +/** Детерминированный шум. */ +function makeRng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ +function synth(seconds, fn) { + const n = Math.floor(seconds * RATE); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = fn(i / RATE, i); + return out; +} + +/** Экспоненциальный спад: полный на старте, ~0 к концу. */ +function decay(t, dur, power = 4) { + return Math.pow(Math.max(0, 1 - t / dur), power); +} + +/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ +function bellPartial(freq, stretch, dur, power) { + return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); +} + +/** Полосовой шум через однополюсные фильтры (низ + верх). */ +function bandNoise(seed, lowHz, highHz, dur) { + const r = makeRng(seed); + let l1 = 0; + let l2 = 0; + const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); + const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); + return () => { + const white = r() * 2 - 1; + l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим + l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим + return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); + }; +} + +/** Нормализовать по пику. */ +function normalize(samples, peak = 0.9) { + let max = 0; + for (const s of samples) max = Math.max(max, Math.abs(s)); + if (max === 0) return samples; + const k = peak / max; + return samples.map((s) => s * k); +} + +/** Сшить луп: кроссфейд первых fade-секунд с концом. */ +function loopify(samples, fadeSeconds = 0.5) { + const n = Math.floor(fadeSeconds * RATE); + for (let i = 0; i < n; i++) { + const k = i / n; + samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); + } + return samples.slice(0, samples.length - n); +} + +// ---------- звуки ---------- + +/** Гулкий удар колокольчиком: 4 негармоничных партиала + шумовой клик. */ +function bellHit() { + const dur = 0.35; + const s = synth(dur, (t) => { + const body = + bellPartial(520, 1.0, dur, 5)(t) * 0.5 + + bellPartial(520, 1.52, dur, 6)(t) * 0.3 + + bellPartial(520, 2.38, dur * 0.6, 7)(t) * 0.2 + + bellPartial(520, 3.91, dur * 0.4, 8)(t) * 0.12; + const click = t < 0.008 ? (makeRng(1)() * 2 - 1) * (1 - t / 0.008) * 0.4 : 0; + return body * 0.8 + click; + }); + return normalize(s, 0.7); +} + +/** Низкий резонанс: медленный гул с тремоло — «усыпляет пепел». */ +function bellLow() { + const dur = 0.9; + const s = synth(dur, (t) => { + const trem = 1 + 0.3 * Math.sin(2 * Math.PI * 6 * t); + const body = + Math.sin(2 * Math.PI * 110 * t) * 0.5 + + Math.sin(2 * Math.PI * 165 * t) * 0.25 + + Math.sin(2 * Math.PI * 220.5 * t) * 0.12; + return body * decay(t, dur, 2) * trem; + }); + return normalize(s, 0.6); +} + +/** Шипение: сгусток просыпается. */ +function ashHiss() { + const dur = 0.4; + const noise = bandNoise(11, 900, 4200, dur); + const s = synth(dur, (t) => noise() * Math.min(1, t / 0.08) * decay(t, dur, 2)); + return normalize(s, 0.55); +} + +/** Смерть сгустка: шум с падающим «выдохом». */ +function ashDie() { + const dur = 0.6; + const noise = bandNoise(23, 300, 2400, dur); + const s = synth(dur, (t) => { + const pitch = 1 - 0.7 * (t / dur); // темнеет к концу + return noise() * decay(t, dur, 3) * (0.4 + pitch); + }); + return normalize(s, 0.6); +} + +/** Плевок: синус с падающей высотой + шипящий хвост. */ +function spit() { + const dur = 0.2; + const noise = bandNoise(37, 2000, 6000, dur); + const s = synth(dur, (t) => { + const f = 700 - 350 * (t / dur); + return (Math.sin(2 * Math.PI * f * t) * 0.6 + noise() * 0.4) * decay(t, dur, 3); + }); + return normalize(s, 0.5); +} + +/** Урон герою: глухой низкий удар. */ +function hurt() { + const dur = 0.25; + const noise = bandNoise(41, 80, 400, dur); + const s = synth(dur, (t) => { + const thump = Math.sin(2 * Math.PI * 90 * t) * decay(t, dur, 3); + return thump * 0.8 + noise() * 0.3 * decay(t, dur * 0.5, 2); + }); + return normalize(s, 0.65); +} + +/** Шаг по пеплу: короткий шорох. */ +function step() { + const dur = 0.12; + const noise = bandNoise(53, 300, 1500, dur); + const s = synth(dur, (t) => noise() * decay(t, dur, 2) * 0.5); + return normalize(s, 0.3); +} + +/** Клик UI. */ +function uiClick() { + const dur = 0.06; + const s = synth(dur, (t) => Math.sin(2 * Math.PI * 880 * t) * decay(t, dur, 3) * 0.5); + return normalize(s, 0.4); +} + +/** Колокольчик Милы: высокий, почти хрустальный. */ +function chime() { + const dur = 0.5; + const s = synth(dur, (t) => { + const body = + Math.sin(2 * Math.PI * 1560 * t) * 0.5 + + Math.sin(2 * Math.PI * 1560 * 1.61 * t) * 0.25; + return body * decay(t, dur, 3); + }); + return normalize(s, 0.45); +} + +/** Амбиент лугов: низкий ветер + редкий гул, зациклен. */ +function musicMeadows() { + const dur = 8; + const wind = bandNoise(61, 100, 600, dur); + const r = makeRng(7); + const gusts = []; + for (let i = 0; i < 3; i++) gusts.push({ at: r() * dur, len: 1.5 + r() }); + const s = synth(dur, (t) => { + let v = wind() * 0.25; + // медленное «дыхание» ветра + v *= 0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2 + 1); + for (const g of gusts) { + const d = t - g.at + (t < g.at ? dur : 0); // зациклить порывы + if (d >= 0 && d < g.len) v += Math.sin(2 * Math.PI * 82 * d) * 0.1 * decay(d, g.len, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** Амбиент прудов: ниже, влажнее, с редкой капелью. */ +function musicPonds() { + const dur = 8; + const wind = bandNoise(71, 60, 400, dur); + const r = makeRng(13); + const drops = []; + for (let i = 0; i < 4; i++) drops.push({ at: r() * dur, f: 900 + r() * 600 }); + const s = synth(dur, (t) => { + let v = wind() * 0.3 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 3)); + for (const d of drops) { + const dt = t - d.at + (t < d.at ? dur : 0); + if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.08 * decay(dt, 0.15, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** + * Амбиент Звенца: низкий колокольный тон (два удара за цикл) над тихим + * ветром. Колокол башни отлит из рельсов — голос ниже лугового гула. + */ +function musicZvenets() { + const dur = 8; + const wind = bandNoise(81, 80, 500, dur); + const strikes = [ + { at: 1.0, f: 148 }, + { at: 5.0, f: 111 } + ]; + const s = synth(dur, (t) => { + let v = wind() * 0.2 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 0.5)); + for (const s of strikes) { + const dt = t - s.at + (t < s.at ? dur : 0); // зациклить удары + if (dt >= 0 && dt < 2.5) { + v += + (Math.sin(2 * Math.PI * s.f * dt) * 0.22 + + Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.08 + + Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.04) * + decay(dt, 2.5, 3); + } + } + return v; + }); + return loopify(normalize(s, 0.55)); +} + +// ---------- запись ---------- + +const SFX = { + 'sfx/bell_hit': bellHit, + 'sfx/bell_low': bellLow, + 'sfx/ash_hiss': ashHiss, + 'sfx/ash_die': ashDie, + 'sfx/spit': spit, + 'sfx/hurt': hurt, + 'sfx/step': step, + 'sfx/ui_click': uiClick, + 'sfx/chime': chime, + 'music/meadows': musicMeadows, + 'music/ponds': musicPonds, + 'music/zvenets': musicZvenets +}; + +mkdirSync(`${OUT}sfx`, { recursive: true }); +mkdirSync(`${OUT}music`, { recursive: true }); +for (const [name, gen] of Object.entries(SFX)) { + const buf = encodeWav(gen()); + writeFileSync(`${OUT}${name}.wav`, buf); + console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`); +} \ No newline at end of file diff --git a/apps/game/tools/cam-probe.mjs b/apps/game/tools/cam-probe.mjs new file mode 100644 index 0000000..e84d7f5 --- /dev/null +++ b/apps/game/tools/cam-probe.mjs @@ -0,0 +1,52 @@ +/** + * Проба плавности камеры: отправляем героя на запад и сэмплируем + * 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/checks/agent-invariants.mjs b/apps/game/tools/checks/agent-invariants.mjs new file mode 100644 index 0000000..f421d3a --- /dev/null +++ b/apps/game/tools/checks/agent-invariants.mjs @@ -0,0 +1,38 @@ +/** + * Сценарий agent:invariants — мост жив, мир стабилен. + * Новая игра -> 300 фиксированных шагов -> движковые/сценические/контентные + * инварианты не содержат ошибок. + * Запуск: node tools/agent.mjs run tools/checks/agent-invariants.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('agent-invariants'); + const server = await startDevServer(); + let ctx; + try { + await c.run('новая игра через мост', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + const s = await ctx.agent.snapshot(); + c.expect(s.scene === 'location', `сцена не location`, { scene: s.scene, err: s.error }); + return null; + }); + await c.run('300 шагов без ошибок инвариантов', async () => { + await ctx.agent.step(300); + const inv = await ctx.agent.invariants(); + const errs = inv.filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, 'инварианты нарушены', errs); + return null; + }); + await c.run('снапшот содержит героя и флаги', async () => { + const s = await ctx.agent.snapshot(); + c.expect(!!s.hero?.tile, 'нет s.hero.tile', s); + c.expect(Array.isArray(s.flags), 'нет s.flags'); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/apps/game/tools/checks/ai.mjs b/apps/game/tools/checks/ai.mjs new file mode 100644 index 0000000..1b6fbe2 --- /dev/null +++ b/apps/game/tools/checks/ai.mjs @@ -0,0 +1,108 @@ +/** + * Сценарий ai — ИИ врагов (батч 4). + * 1) тяжеловес с патрулём стартует бодрым в patrol и реально ходит; + * 2) тихий шум (0.35) при далёком герое — настороженность (wary); + * 3) не увидел героя — wary гаснет, возврат к дому и снова patrol; + * 4) герой рядом с LOS — погоня (chase); + * 5) герой далеко — потеря погони (return -> patrol); + * 6) мало hp — отступление бегом (flee), дистанция растёт; + * 7) инварианты (enemy-state-legal, enemy-in-bounds) чисты. + * Запуск: node tools/agent.mjs run tools/checks/ai.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); +/** Тяжеловес из снапшота. */ +const heavy = (s) => (s.enemies ?? []).find((e) => e.kind === 'heavy'); + +export default async function ({ pretty }) { + const c = new Checks('ai'); + const server = await startDevServer(); + let ctx; + try { + await c.run('патруль: тяжеловес бодр и ходит по маршруту', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + const s0 = await ctx.agent.snapshot(); + const h0 = heavy(s0); + c.expect(h0?.state === 'patrol', 'тяжеловес не в patrol на старте', h0); + // Спавн (6,22), патруль к (4,20): позиция меняется без участия героя. + const w = await ctx.agent.waitFor( + '(s.enemies.find((e) => e.kind === "heavy")?.pos.x ?? 9) < 6', + { timeoutTicks: 600 } + ); + c.expect(w.ok, 'тяжеловес не сдвинулся с места в патруле', w.snapshot.enemies); + return null; + }); + + await c.run('тихий шум настораживает патрульного', async () => { + // Герой далеко (спавн 14,14), шум у маршрута тяжеловеса. + await ctx.agent.command('scene:noise', { x: 5, y: 21, level: 0.35 }); + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "wary"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не насторожился от шума', w.snapshot.enemies); + return null; + }); + + await c.run('настороженность гаснет: wary -> return -> patrol', async () => { + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'тяжеловес не вернулся к патрулю', w.snapshot.enemies); + return null; + }); + + await c.run('герой рядом — погоня', async () => { + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + await ctx.agent.command('scene:teleport', { x: 5, y: 22 }); + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "chase"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не погнался за героем', w.snapshot.enemies); + return null; + }); + + await c.run('герой далеко — погоня гаснет и враг уходит домой', async () => { + await ctx.agent.command('scene:teleport', { x: 14, y: 14 }); + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'тяжеловес не отступил к дому', w.snapshot.enemies); + return null; + }); + + await c.run('мало hp — отступление бегом', async () => { + await ctx.agent.command('scene:teleport', { x: 6, y: 21 }); + const dmg = await ctx.agent.command('scene:damageEnemy', { id: 'heavy', value: 6 }); + c.expect(dmg === true, 'scene:damageEnemy не нашла тяжеловеса'); + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "flee"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не отступает при низком hp', w.snapshot.enemies); + // Держится подальше: дистанция до неподвижного героя растёт. + const s1 = await ctx.agent.snapshot(); + const d1 = dist(heavy(s1).pos, s1.hero.pos); + await ctx.agent.waitFor('false', { timeoutTicks: 60 }); // 1 сек наблюдения + const s2 = await ctx.agent.snapshot(); + const d2 = dist(heavy(s2).pos, s2.hero.pos); + c.expect(d2 > d1, 'отступающий не отдаляется', { d1, d2 }); + return null; + }); + + await c.run('инварианты ИИ чисты', async () => { + const inv = await ctx.agent.invariants(); + const errors = (inv ?? []).filter((i) => i.severity === 'error'); + c.expect(errors.length === 0, 'инварианты нарушены', errors); + return null; + }); + } finally { + await ctx?.browser?.close().catch(() => {}); + await server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/apps/game/tools/checks/interact-world.mjs b/apps/game/tools/checks/interact-world.mjs new file mode 100644 index 0000000..76d3b91 --- /dev/null +++ b/apps/game/tools/checks/interact-world.mjs @@ -0,0 +1,128 @@ +/** + * Сценарий interact-world — взаимодействие с миром шире (батч 3). + * 1) столб у тропы: тост с текстом знака; + * 2) подбор мота: предмет в сумке, вар motes растёт, used:; + * 3) второй и третий моты: вар копится (addVar, порядок не важен); + * 4) знак наката у прудов; + * 5) верёвка колокола: флаг rang_tower, повтор — другой текст; + * 6) Мила: знакомство -> повторный диалог с веткой по мотам; + * 7) инвариант interact-used-consistent чист. + * Запуск: node tools/agent.mjs run tools/checks/interact-world.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('interact-world'); + const server = await startDevServer(); + let ctx; + try { + await c.run('столб у тропы: тост с текстом знака', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.command('scene:sleepAll'); + // Столб (25,13) у тропы в Звенец — клик издалека: герой подойдёт сам. + await ctx.agent.tapTile(25, 13); + const w = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { + timeoutTicks: 1500 + }); + c.expect(w.ok, 'знак не показал текст', w.snapshot.lastToast); + return null; + }); + await c.run('мот 1: предмет + вар + used', async () => { + await ctx.agent.tapTile(11, 14); + const w = await ctx.agent.waitFor( + '(s.inventory.find((i) => i.id === "mote")?.count ?? 0) === 1 && s.vars.motes === 1', + { timeoutTicks: 1500 } + ); + c.expect(w.ok, 'подбор мота не дал предмет и вар', { + inventory: w.snapshot.inventory, + vars: w.snapshot.vars + }); + const s = await ctx.agent.snapshot(); + const mote = (s.interactables ?? []).find((o) => o.id === 'mote_1'); + c.expect(mote?.used === true, 'мот не помечен used', mote); + return null; + }); + await c.run('моты 2 и 3: вар копится (addVar)', async () => { + await ctx.agent.tapTile(14, 20); + const w2 = await ctx.agent.waitFor('s.vars.motes === 2', { timeoutTicks: 1500 }); + c.expect(w2.ok, 'второй мот не поднял вар до 2', w2.snapshot.vars); + await ctx.agent.tapTile(14, 6); + const w3 = await ctx.agent.waitFor('s.vars.motes === 3', { timeoutTicks: 1500 }); + c.expect(w3.ok, 'третий мот не поднял вар до 3', w3.snapshot.vars); + return null; + }); + await c.run('знак наката у прудов', async () => { + // Тропа на пруды (2,2): step-переход; у берега — столб (3,2). + const w = await ctx.agent.walkTo(2, 2, { timeoutTicks: 3000 }); + c.expect(w, 'не дошёл до тропы на пруды'); + await ctx.agent.waitFor('s.area === "ponds"', { timeoutTicks: 600 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(3, 2); + const ws = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("накатом")', { + timeoutTicks: 900 + }); + c.expect(ws.ok, 'знак наката не показал текст', ws.snapshot.lastToast); + // Обратно на луга (2,2) прудов. + const wb = await ctx.agent.walkTo(2, 2, { timeoutTicks: 3000 }); + c.expect(wb, 'не вернулся к тропе с прудов'); + await ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 600 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return null; + }); + await c.run('верёвка колокола: флаг + повтор с другим текстом', async () => { + // Звенец через тропу (26,14); верёвка у башни (13,7) — клик издалека. + const w = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(w, 'не дошёл до тропы в Звенец'); + await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(13, 7); + const wr = await ctx.agent.waitFor('s.flags.includes("rang_tower")', { + timeoutTicks: 1500 + }); + c.expect(wr.ok, 'звон не поднял rang_tower', wr.snapshot.flags); + const s1 = await ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('ушёл в землю'), 'первый звон — не тот текст', text1); + await ctx.agent.tapTile(13, 7); + await ctx.agent.step(150); + const s2 = await ctx.agent.snapshot(); + const text2 = s2.lastToast?.text ?? ''; + c.expect( + text2.includes('снова') && text2 !== text1, + 'повторный звон не сменил текст', + text2 + ); + return null; + }); + await c.run('Мила: знакомство -> повтор с веткой по мотам', async () => { + // Первое знакомство (trader_first): полотно. + await ctx.agent.tapTile(17, 11); + const d1 = await ctx.agent.runDialogue(900); + c.expect(d1, 'первый диалог с Милой не завершился'); + // Повтор (trader_repeat): при моты >= 1 после первой реплики идёт + // ветка о мота́х — листаем вручную и ловим её текст. + await ctx.agent.tapTile(17, 11); + const o = await ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 900 }); + c.expect(o.ok, 'повторный диалог не открылся'); + await ctx.agent.press('advance'); + const w = await ctx.agent.waitFor( + 's.dialogue != null && (s.dialogue.text ?? "").includes("Моты")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'в повторном диалоге нет ветки о мота́х', w.snapshot.dialogue); + const d2 = await ctx.agent.runDialogue(600); + c.expect(d2, 'повторный диалог не завершился'); + return null; + }); + await c.run('инвариант: used-флаги и снапшот согласованы', async () => { + const inv = await ctx.agent.invariants(); + const bad = inv.filter((i) => i.severity === 'error'); + c.expect(bad.length === 0, 'ошибки инвариантов', bad); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/apps/game/tools/checks/interact.mjs b/apps/game/tools/checks/interact.mjs new file mode 100644 index 0000000..91c822b --- /dev/null +++ b/apps/game/tools/checks/interact.mjs @@ -0,0 +1,133 @@ +/** + * Сценарий interact — интерьеры и интерактивные объекты. + * 1) дверь: клик по дому Ирвина -> интерьер house_elder; + * 2) сундук (once): предмет в сумке, used:, повтор молчит; + * 3) очаг: реакция зависит от флага quest_bells_done; + * 4) записка (once): флаг read_note; + * 5) выход через проём: step kind 'return' -> Звенец, герой на тайле входа; + * 6) лавка Милы: прилавок меняет реакцию с флагом. + * Запуск: node tools/agent.mjs run tools/checks/interact.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('interact'); + const server = await startDevServer(); + let ctx; + let entryTile = null; // тайл, с которого герой кликнул дверь (цель kind 'return') + try { + await c.run('дверь: дом Ирвина -> интерьер', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.command('scene:sleepAll'); + // Добираемся до Звенца пешком (луга -> тропа (26,14)). + const w0 = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(w0, 'walkTo(26,14) не дошёл'); + const wz = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); + c.expect(wz.ok, 'не в Звенце после перехода', { area: wz.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + // Подводим героя к дому (тайл дома (7,6)) и кликаем по нему. + const wh = await ctx.agent.walkTo(8, 6, { timeoutTicks: 2000 }); + c.expect(wh, 'не дошёл до дома Ирвина'); + entryTile = (await ctx.agent.snapshot()).hero.tile; + await ctx.agent.tapTile(7, 6); + const w = await ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'клик по дому не открыл интерьер', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 5 && s.hero.tile.y === 7, 'герой не у проёма (5,7)', s.hero.tile); + return null; + }); + await c.run('сундук: предмет + used, повтор молчит', async () => { + const before = await ctx.agent.snapshot(); + const had = before.inventory.find((i) => i.id === 'cloth')?.count ?? 0; + // Клик по сундуку (3,3) — герой подходит и открывает. + await ctx.agent.tapTile(3, 3); + const w = await ctx.agent.waitFor( + `(s.inventory.find((i) => i.id === 'cloth')?.count ?? 0) === ${had + 1}`, + { timeoutTicks: 1200 } + ); + c.expect(w.ok, 'сундук не дал полотно', { inventory: w.snapshot.inventory }); + const s = await ctx.agent.snapshot(); + const chest = (s.interactables ?? []).find((o) => o.id === 'chest_elder'); + c.expect(chest?.used === true, 'сундук не помечен used', chest); + c.expect((s.flags ?? []).includes('used:chest_elder'), 'нет флага used:chest_elder'); + // Повтор: тик последнего тоста не меняется. + const t0 = s.lastToast?.tick ?? -1; + await ctx.agent.tapTile(3, 3); + await ctx.agent.step(120); + const s2 = await ctx.agent.snapshot(); + c.expect((s2.lastToast?.tick ?? -1) === t0, 'повторное взаимодействие не молчит', s2.lastToast); + return null; + }); + await c.run('очаг: реакция меняется с флагом', async () => { + await ctx.agent.tapTile(8, 0); + await ctx.agent.step(120); + const s1 = await ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('щепки'), 'реакция очага до флага не та', text1); + await ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); + await ctx.agent.tapTile(8, 0); + await ctx.agent.step(120); + const s2 = await ctx.agent.snapshot(); + c.expect( + (s2.lastToast?.text ?? '').includes('ровнее'), + 'реакция очага после флага не та', + s2.lastToast + ); + return null; + }); + await c.run('записка: флаг read_note', async () => { + await ctx.agent.tapTile(10, 4); + await ctx.agent.step(120); + const s = await ctx.agent.snapshot(); + c.expect((s.flags ?? []).includes('read_note'), 'записка не подняла read_note', s.flags); + return null; + }); + await c.run('выход через проём: возврат в Звенец', async () => { + // Клик по тайлу проёма (5,8) — герой доходит, step-переход kind 'return'. + await ctx.agent.tapTile(5, 8); + const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'проём не вернул в Звенец', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === entryTile.x && s.hero.tile.y === entryTile.y, + 'герой не на тайле, откуда кликнул дверь', + { entry: entryTile, after: s.hero.tile } + ); + return null; + }); + await c.run('лавка Милы: прилавок меняет реакцию', async () => { + // Клик по лавке (21,9) — интерьер; прилавок (5,3). + await ctx.agent.tapTile(21, 9); + const w = await ctx.agent.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'лавка не открылась', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(5, 3); + await ctx.agent.step(120); + const s1 = await ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('соль'), 'реакция прилавка до флага не та', text1); + await ctx.agent.tapTile(5, 3); + await ctx.agent.step(120); + const s2 = await ctx.agent.snapshot(); + c.expect( + (s2.lastToast?.text ?? '').includes('цветы'), + 'прилавок не изменил реакцию после флага', + s2.lastToast + ); + return null; + }); + await c.run('снапшот: интерактивные объекты области', async () => { + const s = await ctx.agent.snapshot(); + const list = s.interactables ?? []; + c.expect(list.some((o) => o.id === 'counter_shop'), 'прилавка нет в снапшоте', list); + c.expect(list.every((o) => typeof o.used === 'boolean'), 'объект без поля used', list); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/apps/game/tools/checks/transitions.mjs b/apps/game/tools/checks/transitions.mjs new file mode 100644 index 0000000..47b74a4 --- /dev/null +++ b/apps/game/tools/checks/transitions.mjs @@ -0,0 +1,64 @@ +/** + * Сценарий transitions — единый механизм переходов. + * 1) step-переход: пешком в Звенец -> area==='zvenets'. + * 2) клик-переход: клик по колодцу у дома Ирвина -> телепорт на луга (entry (22,21)). + * 3) обратный колодец: клик по луговому колодцу -> возврат в Звенец. + * 4) снапшот перечисляет переходы области с триггерами. + * Запуск: node tools/agent.mjs run tools/checks/transitions.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +export default async function ({ pretty }) { + const c = new Checks('transitions'); + const server = await startDevServer(); + let ctx; + try { + await c.run('step-переход: луга -> Звенец пешком', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + // Спим врагов, чтобы путь был безопасным; идём к тропе (26,14). + await ctx.agent.command('scene:sleepAll'); + const walked = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(walked, 'walkTo(26,14) не дошёл'); + const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); + c.expect(w.ok, 'после тропы не в Звенце', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return null; + }); + await c.run('клик-переход: колодец Звенца -> луга', async () => { + // Колодец (9,6) у дома Ирвина; entry на лугах — (22,21). + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await ctx.agent.tapTile(9, 6); + const w = await ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'колодец не телепортировал на луга', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 22 && s.hero.tile.y === 21, 'герой не на entry (22,21)', s.hero.tile); + return null; + }); + await c.run('обратный колодец: луга -> Звенец', async () => { + // Луговой колодец (22,20) стоит рядом с entry — кликаем по нему. + await ctx.agent.tapTile(22, 20); + const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'луговой колодец не вернул в Звенец', { area: w.snapshot.area }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 9 && s.hero.tile.y === 7, 'герой не на entry (9,7) Звенца', s.hero.tile); + return null; + }); + await c.run('снапшот: переходы области с триггерами', async () => { + const s = await ctx.agent.snapshot(); + const wells = (s.transitions ?? []).filter((t) => t.trigger === 'click'); + c.expect(wells.length >= 1, 'нет click-переходов в снапшоте', s.transitions); + c.expect( + (s.transitions ?? []).every((t) => typeof t.to === 'string' && t.to.length > 0), + 'переход без цели', + s.transitions + ); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/apps/game/tools/far-test.mjs b/apps/game/tools/far-test.mjs new file mode 100644 index 0000000..56beb2e --- /dev/null +++ b/apps/game/tools/far-test.mjs @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..953aa3a --- /dev/null +++ b/apps/game/tools/font-probe.mjs @@ -0,0 +1,54 @@ +/** + * Проба читаемости 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/guards/__tests__/boundary.test.mjs b/apps/game/tools/guards/__tests__/boundary.test.mjs new file mode 100644 index 0000000..9f7058e --- /dev/null +++ b/apps/game/tools/guards/__tests__/boundary.test.mjs @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { checkBoundary, importSpecs } from '../boundary.mjs'; + +/** Пути в test-параметрах пишутся со слэшем, приводим к системным сепараторам. */ +const sep = process.platform === 'win32' ? '\\' : '/'; +const files = (entries) => new Map(entries.map(([f, src]) => [f.replaceAll('/', sep), src])); + +describe('importSpecs', () => { + it('вытаскивает статические, side-effect, re-export и динамические импорты', () => { + const src = [ + "import { a } from './a';", + "import './b';", + 'import * as ns from "../c/d";', + "const m = await import('./dynamic');", + "export { x } from './reexport';" + ].join('\n'); + expect(importSpecs(src)).toEqual(['./a', './b', '../c/d', './dynamic', './reexport']); + }); +}); + +describe('checkBoundary: движок', () => { + it('импорт из игры — error', () => { + const v = checkBoundary(files([ + ['packages/engine/src/core/x.ts', "import { hero } from '../../../apps/game/src/data/npcs';"] + ])); + expect(v).toEqual([ + expect.objectContaining({ severity: 'error', rule: 'engine-no-game' }) + ]); + }); + + it('относительный импорт мимо пакета — error', () => { + const v = checkBoundary(files([ + ['packages/engine/src/core/x.ts', "import { t } from '../../../CLAUDE.md';"] + ])); + expect(v[0]).toMatchObject({ rule: 'engine-self-contained', severity: 'error' }); + }); + + it('npm-импорты и внутренние относительные — чисто', () => { + const v = checkBoundary(files([ + ['packages/engine/src/render/y.ts', "import { Container } from 'pixi.js';\nimport { a } from '../core/a';"] + ])); + expect(v).toEqual([]); + }); +}); + +describe('checkBoundary: игра', () => { + it('под-путь движка мимо index.ts — error', () => { + const v = checkBoundary(files([ + ['apps/game/src/systems/z.ts', "import { iso } from '@rpg/engine/math/iso';"] + ])); + expect(v[0]).toMatchObject({ rule: 'game-engine-public-api', severity: 'error', spec: '@rpg/engine/math/iso' }); + }); + + it('публичное API разрешено: сам пакет и assets/*', () => { + const v = checkBoundary(files([ + ['apps/game/src/main.ts', "import { Game } from '@rpg/engine';\nimport fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';"] + ])); + expect(v).toEqual([]); + }); + + it('относительный импорт в движок мимо API — error', () => { + const v = checkBoundary(files([ + ['apps/game/src/data/map.ts', "import { rleDecode } from '../../../packages/engine/src/map/mapFormat';"] + ])); + expect(v[0]).toMatchObject({ rule: 'game-self-contained', severity: 'error' }); + }); + + it('npm-импорты и внутренние относительные — чисто', () => { + const v = checkBoundary(files([ + ['apps/game/src/scenes/s.ts', "import * as PIXI from 'pixi.js';\nimport { AREAS } from '../data/locations';"] + ])); + expect(v).toEqual([]); + }); +}); \ No newline at end of file diff --git a/apps/game/tools/guards/boundary.mjs b/apps/game/tools/guards/boundary.mjs new file mode 100644 index 0000000..3f342e3 --- /dev/null +++ b/apps/game/tools/guards/boundary.mjs @@ -0,0 +1,101 @@ +/** + * Гвард границы «движок / игра». Проверяет импорты исходников: + * + * - packages/engine/src — не импортирует ничего из игры (apps/game) и + * не выходит за пределы пакета относительными импортами; + * - apps/game/src — импортирует движок только через публичное API + * (`@rpg/engine`, `@rpg/engine/assets/*`) и не выходит за пределы пакета. + * + * Запуск: node apps/game/tools/guards/boundary.mjs (npm run guard) + * Нарушения печатаются JSON-ом, exit 1 при наличии error-нарушений. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Корень репозитория (гвард живёт в apps/game/tools/guards — 4 уровня). */ +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); +const ENGINE = join(ROOT, 'packages', 'engine'); +const GAME = join(ROOT, 'apps', 'game'); + +/** @returns {import('node:fs').Dirent[]} файлы .ts/.tsx рекурсивно. */ +export function listSources(dir) { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...listSources(p)); + else if (e.isFile() && /\.tsx?$/.test(e.name)) out.push(p); + } + return out; +} + +/** Спецификаторы всех статических и динамических импортов файла. */ +export function importSpecs(source) { + const specs = []; + // Тело импорта без ';', чтобы не переползти в следующий import (side-effect + // `import './b'` — без from: группа пропускается, кавычка берётся сразу). + const re = /(?:\bimport\s+(?:[^;'"]*?\sfrom\s*)?|\bexport\s+[^;'"]*?\sfrom\s+|import\s*\(\s*)(['"])([^'"]+)\1/g; + for (const m of source.matchAll(re)) specs.push(m[2]); + return specs; +} + +/** + * Чистая проверка: пары «путь файла → содержимое» (относительно корня репо, + * с системными разделителями). Возвращает нарушения: + * {severity, file, spec, rule, message}. + */ +export function checkBoundary(files) { + const out = []; + for (const [rel, source] of files) { + const pkg = rel.startsWith('packages' + sep) ? 'engine' : 'game'; + for (const spec of importSpecs(source)) { + // Движок не должен знать ничего об игре. + if (pkg === 'engine') { + if (spec.includes('apps/game') || /(^|[\\/])game([\\/]|$)/.test(spec)) { + out.push({ severity: 'error', file: rel, spec, rule: 'engine-no-game', message: 'движок импортирует игру' }); + continue; + } + if (spec.startsWith('.')) { + const target = resolve(dirname(join(ROOT, rel)), spec); + if (!target.startsWith(resolve(ENGINE) + sep)) { + out.push({ severity: 'error', file: rel, spec, rule: 'engine-self-contained', message: 'относительный импорт выходит из packages/engine' }); + } + } + continue; + } + // Игра: движок только через публичное API. + if (spec.startsWith('@rpg/engine')) { + if (spec !== '@rpg/engine' && !spec.startsWith('@rpg/engine/assets/')) { + out.push({ severity: 'error', file: rel, spec, rule: 'game-engine-public-api', message: 'под-путь движка мимо index.ts' }); + } + continue; + } + if (spec.startsWith('.')) { + const target = resolve(dirname(join(ROOT, rel)), spec); + if (!target.startsWith(resolve(GAME) + sep)) { + out.push({ severity: 'error', file: rel, spec, rule: 'game-self-contained', message: 'относительный импорт выходит из apps/game' }); + } + } + } + } + return out; +} + +/** Собрать исходники обоих пакетов: путь (относительно корня) → содержимое. */ +export function collectSources() { + const files = new Map(); + for (const dir of [join(ENGINE, 'src'), join(GAME, 'src')]) { + for (const abs of listSources(dir)) { + files.set(relative(ROOT, abs), readFileSync(abs, 'utf8')); + } + } + return files; +} + +function main() { + const violations = checkBoundary(collectSources()); + console.log(JSON.stringify({ ok: violations.length === 0, violations }, null, 2)); + process.exit(violations.length ? 1 : 0); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); \ No newline at end of file diff --git a/apps/game/tools/maps/gen.test.ts b/apps/game/tools/maps/gen.test.ts new file mode 100644 index 0000000..08e11e9 --- /dev/null +++ b/apps/game/tools/maps/gen.test.ts @@ -0,0 +1,71 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { encodeMap, parseMap } from '../../../../packages/engine/src/map/mapFormat'; +import { + buildMeadowsMap, + buildPondsMap, + buildZvenetsMap, + buildHouseElderMap, + buildShopMap +} from '../../src/data/map'; + +/** + * Генератор карт-файлов: `npm run maps` (vitest run apps/game/tools/maps/gen.test.ts). + * Карты строятся из data/map.ts (единый источник истины), пакуются в RLE + * через движковый encodeMap и пишутся в apps/game/assets/maps/*.map. + * Здесь же проверяется обратная распаковка: parseMap должен вернуть карту + * попиксельно равную исходной. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(here, '../../assets/maps'); + +const MAPS: Record> = { + meadows: buildMeadowsMap(), + ponds: buildPondsMap(), + zvenets: buildZvenetsMap(), + house_elder: buildHouseElderMap(), + shop: buildShopMap() +}; + +describe('карты-файлы на диске равны генераторам (fresh)', () => { + for (const [id, data] of Object.entries(MAPS)) { + it(`${id}.map не разъехался с генератором (npm run maps лечит)`, () => { + const path = resolve(outDir, `${id}.map`); + const onDisk = JSON.parse(readFileSync(path, 'utf8')); + expect(onDisk).toEqual(encodeMap(data)); + }); + } +}); + +describe('генерация карт-файлов (rpg-map, RLE)', () => { + for (const [id, data] of Object.entries(MAPS)) { + it(`${id}: encode -> файл -> parse восстанавливает карту`, () => { + const file = encodeMap(data); + const back = parseMap(JSON.parse(JSON.stringify(file))); + + expect(back.width).toBe(data.width); + expect(back.height).toBe(data.height); + expect(back.blocked).toEqual(data.blocked); + expect(back.tiles).toEqual(data.tiles); + expect(back.tall).toEqual(data.tall); + + mkdirSync(outDir, { recursive: true }); + const path = resolve(outDir, `${id}.map`); + writeFileSync(path, JSON.stringify(file)); + }); + } + + it('RLE реально сжимает карты', () => { + for (const [id, data] of Object.entries(MAPS)) { + const file = encodeMap(data); + const pairs = file.tiles as [number, number][]; + // Серий меньше, чем тайлов: RLE что-то ужал, несмотря на пятнистость + expect(pairs.length).toBeLessThan(data.tiles.length); + expect(file.encoding).toBe('rle'); + expect(id.length).toBeGreaterThan(0); + } + }); +}); \ No newline at end of file diff --git a/apps/game/tools/pixelart/canvas.mjs b/apps/game/tools/pixelart/canvas.mjs new file mode 100644 index 0000000..9bdb000 --- /dev/null +++ b/apps/game/tools/pixelart/canvas.mjs @@ -0,0 +1,109 @@ +/** + * Мини-канвас с палитрой: пиксель, ромб, шум, ASCII-карты спрайтов. + */ +import { RGBA, TRANSPARENT } from './palette.mjs'; +import { encodePng } from './png.mjs'; + +export class Canvas { + constructor(width, height) { + this.width = width; + this.height = height; + this.data = Buffer.alloc(width * height * 4); + this.data.fill(0); + } + + set(x, y, color, alpha = 255) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + const i = (y * this.width + x) * 4; + if (color === null) { + this.data.fill(0, i, i + 4); + return; + } + const [r, g, b] = RGBA[color] ?? color; + this.data[i] = r; + this.data[i + 1] = g; + this.data[i + 2] = b; + this.data[i + 3] = alpha; + } + + get(x, y) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null; + const i = (y * this.width + x) * 4; + if (this.data[i + 3] === 0) return null; + return [this.data[i], this.data[i + 1], this.data[i + 2], 255]; + } + + toPng() { + return encodePng(this.width, this.height, this.data); + } +} + +/** Детерминированный ГПСЧ (mulberry32). */ +export function rng(seed) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Залить ромб 2:1 по канвасу: base — базовый цвет; свет на верхних гранях, + * тень на нижних. light/dark — ключи палитры или null. + * Центр ромба (cx, cy), ширина w, высота h. + */ +export function fillDiamond(canvas, cx, cy, w, h, base, light = null, dark = null) { + const hw = w / 2; + const hh = h / 2; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const rx = x - hw + 0.5; + const ry = y - hh + 0.5; + if (Math.abs(rx) / hw + Math.abs(ry) / hh <= 1) { + canvas.set(cx - hw + x, cy - hh + y, base); + } + } + } + for (let i = 0; i < hw; i++) { + // Верхний склон: от левого угла к верхней вершине (свет) и от вершины к правому углу. + const leftY = cy - Math.round((i / hw) * hh); + const rightY = cy - hh + Math.round((i / hw) * hh); + if (light) { + canvas.set(cx - hw + i, leftY, light); + canvas.set(cx + i, rightY, light); + } + // Нижний склон: от левого угла вниз к нижней вершине (тень) и от неё к правому углу. + const botLeftY = cy + Math.round((i / hw) * hh); + const botRightY = cy + hh - Math.round((i / hw) * hh); + if (dark) { + canvas.set(cx - hw + i, botLeftY, dark); + canvas.set(cx + i, botRightY, dark); + } + } +} + +/** + * ASCII-карта -> спрайт. Каждый символ — ключ палитры; '.' — прозрачный пиксель. + * Дополнительные символы передаются в extra ('x' -> 'P1' и т.п.). + */ +export function fromAscii(rows, extra = {}) { + const h = rows.length; + const w = Math.max(...rows.map((r) => r.length)); + const canvas = new Canvas(w, h); + for (let y = 0; y < h; y++) { + for (let x = 0; x < rows[y].length; x++) { + const ch = rows[y][x]; + if (ch === '.') { + continue; + } + const key = extra[ch] ?? ch; + if (!RGBA[key]) { + throw new Error(`Неизвестный символ '${ch}' в строке ${y}: ${rows[y]}`); + } + canvas.set(x, y, key); + } + } + return canvas; +} \ No newline at end of file diff --git a/apps/game/tools/pixelart/gen.mjs b/apps/game/tools/pixelart/gen.mjs new file mode 100644 index 0000000..009f156 --- /dev/null +++ b/apps/game/tools/pixelart/gen.mjs @@ -0,0 +1,915 @@ +/** + * Генератор пиксель-арта «Пепельных лугов». + * Запуск: node tools/pixelart/gen.mjs + * Выход: apps/game/assets/tiles/*.png, apps/game/assets/chars/*.png + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Canvas, fillDiamond, fromAscii, rng } from './canvas.mjs'; + +const OUT = fileURLToPath(new URL('../../assets/', import.meta.url)); + +// ---------- тайлы 32x16 ---------- + +/** Ромб с зерном пепла/травы. Без обводки граней: смежные тайлы сливаются в поле. */ +function tileBase(base, light, dark, seed, specks) { + const c = new Canvas(32, 16); + fillDiamond(c, 16, 8, 32, 16, base); + const r = rng(seed); + for (let i = 0; i < specks; i++) { + const x = Math.floor(r() * 32); + const y = Math.floor(r() * 16); + if (c.get(x, y)) { + c.set(x, y, r() < 0.5 ? dark : base); + } + } + return c; +} + +function grassTile() { + const c = tileBase('G1', 'G2', 'G0', 11, 26); + // Сухие стебли: 1-2 пикселя G2H. + const r = rng(77); + for (let i = 0; i < 5; i++) { + const x = 3 + Math.floor(r() * 26); + const y = 3 + Math.floor(r() * 10); + if (c.get(x, y)) { + c.set(x, y, 'G2H'); + } + } + return c; +} + +function pathTile() { + const c = tileBase('P4', 'P5', 'P3', 23, 30); + // Утоптанный камешек и след колеи. + const r = rng(31); + for (let i = 0; i < 4; i++) { + const x = 2 + Math.floor(r() * 28); + const y = 2 + Math.floor(r() * 12); + if (c.get(x, y)) c.set(x, y, 'P2'); + } + return c; +} + +function waterTile() { + const c = tileBase('W1', 'W2', 'W0', 31, 0); + // Рябь: короткие горизонтальные штрихи W2 и глубина W0. + const r = rng(5); + for (let i = 0; i < 5; i++) { + const x = 4 + Math.floor(r() * 22); + const y = 2 + Math.floor(r() * 12); + c.set(x, y, 'W2'); + c.set(x + 1, y, 'W2'); + } + for (let i = 0; i < 3; i++) { + const x = 6 + Math.floor(r() * 20); + const y = 5 + Math.floor(r() * 9); + c.set(x, y, 'W0'); + c.set(x + 1, y, 'W0'); + } + return c; +} + +function ashTile() { + // Пепельный бугор: чуть светлее травы, тоже без обводки. + const c = new Canvas(32, 16); + fillDiamond(c, 16, 8, 32, 16, 'P3'); + const r = rng(9); + for (let i = 0; i < 6; i++) { + const x = 3 + Math.floor(r() * 26); + const y = 4 + Math.floor(r() * 9); + if (c.get(x, y)) c.set(x, y, 'P5'); + } + return c; +} + +function bellflowerTile() { + const c = grassTile(); + // Три цветка: золото 3px + хайлайт. Тёплый цвет — только жизнь. + const flowers = [ + [10, 7], + [21, 9], + [16, 5] + ]; + for (const [x, y] of flowers) { + c.set(x, y - 1, 'B2'); + c.set(x - 1, y, 'B1'); + c.set(x, y, 'B1'); + c.set(x + 1, y, 'B1'); + c.set(x, y + 1, 'G2H'); // стебель + } + return c; +} + +/** Дерево Ржавой рощи 32x48: ржавый ствол, редкая серо-оливковая крона, кабель. */ +function treeTile() { + const c = new Canvas(32, 48); + const r = rng(2026); + + const ellipse = (cx, cy, rx, ry, color) => { + for (let y = -ry; y <= ry; y++) { + for (let x = -rx; x <= rx; x++) { + if ((x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1) { + c.set(cx + x, cy + y, color); + } + } + } + }; + + // Крона: три перекрывающихся «облака» мёртвой листвы. + ellipse(16, 9, 10, 7, 'G0'); + ellipse(9, 14, 6, 5, 'G0'); + ellipse(23, 13, 7, 5, 'G0'); + // Внутренний тон и редкие «живые» пряди. + ellipse(15, 10, 7, 4, 'G1'); + ellipse(10, 14, 3, 2, 'G1'); + ellipse(21, 12, 4, 2, 'G1'); + for (let i = 0; i < 26; i++) { + const x = 6 + Math.floor(r() * 20); + const y = 3 + Math.floor(r() * 12); + if (c.get(x, y) && r() < 0.5) c.set(x, y, 'G2'); + } + // Прорехи (мёртвое дерево): прозрачные проколы в кроне. + for (const [x, y] of [ + [13, 6], + [19, 12], + [8, 13], + [22, 8] + ]) { + c.set(x, y, null); + c.set(x + 1, y, null); + } + + // Ствол: металл с ржавчиной внизу, лёгкий изгиб, корни в стороны. + for (let y = 18; y <= 47; y++) { + const sway = Math.round(Math.sin((y - 18) * 0.09) * 1.5); + const half = y > 40 ? 2 : 1; + for (let dx = -half; dx <= half; dx++) { + c.set(16 + sway + dx, y, y > 38 ? 'R1' : 'M1'); + } + c.set(16 + sway - half - 1, y, y > 38 ? 'R0' : 'M0'); + } + // Корни. + for (let i = 0; i < 4; i++) { + c.set(12 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + c.set(20 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); + } + // Пепел на корнях. + c.set(13, 46, 'P5'); + c.set(19, 46, 'P5'); + + // Кабель через крону и вокруг ствола. + for (let x = 4; x <= 27; x++) { + const y = 16 - Math.round(Math.sin((x - 4) * 0.2) * 1); + if (!c.get(x, y)) c.set(x, y, 'M2'); + else c.set(x, y, 'M2'); + } + for (let y = 22; y <= 44; y += 7) { + c.set(14, y, 'M0'); + c.set(19, y, 'M0'); + } + + return c; +} + +/** Дом Звенца 32x56: стены из вагонных щитов, крыша из платформ. Tall-объект 1.75 юнита. */ +function houseTile() { + const c = new Canvas(32, 56); + const r = rng(777); + + // Стены: вагонные щиты — горизонтальные доски C1 со швами C0. + for (let y = 17; y <= 48; y++) { + const seam = (y - 17) % 6 === 0; + for (let x = 6; x <= 25; x++) { + c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); + } + } + // Вертикальные стойки по краям стен. + for (let y = 17; y <= 48; y++) { + c.set(6, y, 'M1'); + c.set(25, y, 'M1'); + } + + // Крыша-платформа: треугольник из шпал, расширяется книзу. + for (let i = 0; i <= 15; i++) { + const y = 1 + i; + const half = Math.min(13, 1 + Math.floor(i * 0.85)); + for (let dx = -half; dx <= half; dx++) { + c.set(16 + dx, y, i % 4 === 0 ? 'T0' : 'T1'); + } + } + // Свес крыши. + for (let x = 2; x <= 29; x++) { + c.set(x, 18, 'T0'); + } + + // Дверь: тёмный проём с бронзовой ручкой. + for (let y = 32; y <= 48; y++) { + for (let x = 13; x <= 19; x++) c.set(x, y, 'P1'); + } + c.set(18, 40, 'B0'); + + // Окно: тёмный квадрат с металлической рамой. + for (let y = 22; y <= 27; y++) { + for (let x = 9; x <= 13; x++) c.set(x, y, 'P2'); + } + for (let x = 8; x <= 14; x++) { + c.set(x, 21, 'M0'); + c.set(x, 28, 'M0'); + } + for (let y = 21; y <= 28; y++) { + c.set(7, y, 'M0'); + c.set(15, y, 'M0'); + } + c.set(11, 24, 'M1'); // переплёт + + // Фундамент из шпал. + for (let x = 4; x <= 27; x++) { + c.set(x, 49, 'R1'); + c.set(x, 50, 'R0'); + } + + // Пепел у стен. + c.set(3, 52, 'P5'); + c.set(28, 52, 'P5'); + c.set(9, 53, 'P4'); + + return c; +} + +/** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */ +/** Пол интерьера: доски вагонных платформ вдоль изометрии, швы темнее. */ +function floorTile() { + const c = new Canvas(32, 16); + fillDiamond(c, 16, 8, 32, 16, 'T1'); + const r = rng(808); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 32; x++) { + if (!c.get(x, y)) continue; + // Доски «полосами» по экранному Y + случайная щепка. + const seam = y % 5 === 0; + c.set(x, y, seam ? 'T0' : r() < 0.12 ? 'T0' : 'T1'); + } + } + return c; +} + +/** Каменная стена вагонного цеха: блоки C1 со швами C0, тень по низу. */ +function wallTile() { + const c = new Canvas(32, 32); + const r = rng(909); + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const seam = y % 8 === 0 || (x + Math.floor(y / 8) * 8) % 16 === 0; + c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); + } + } + // Нижний ряд темнее (тень у земли). + for (let x = 0; x < 32; x++) { + c.set(x, 30, 'C0'); + c.set(x, 31, 'C0'); + } + return c; +} + +/** Старый телеграфный колодец: каменное жерло, бронзовый обруч — звон = жизнь. */ +function wellTile() { + const c = new Canvas(32, 40); + const r = rng(1010); + + // Тёмное жерло (вода) — верхняя часть, видна внутри кольца. + for (let y = 20; y <= 26; y++) { + const half = Math.floor((26 - y) * 1.8) + 2; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'W0'); + } + // Каменная кладка вокруг жерла (эллипс). + for (let y = 16; y <= 28; y++) { + const half = Math.floor((24 - y) * 1.35) + 3; + for (let dx = -half; dx <= half; dx++) { + const rim = Math.abs(dx) > half - 2; + c.set(16 + dx, y, rim ? (r() < 0.8 ? 'C1' : 'C0') : null === c.get(16 + dx, y) ? 'C0' : c.get(16 + dx, y)); + } + } + // Бронзовый обруч по кромке — тёплый: колодец «звонный». + for (let x = 6; x <= 25; x++) { + c.set(x, 15, x % 4 === 0 ? 'B0' : 'B1'); + c.set(x, 16, 'B0'); + } + // Ворото с бронзовой рукоятью. + for (let y = 4; y <= 15; y++) { + c.set(7, y, 'T1'); + c.set(24, y, 'T1'); + } + for (let x = 7; x <= 24; x++) c.set(x, 5, 'T1'); + c.set(12, 4, 'B1'); + c.set(13, 4, 'B2'); // блик бронзы + return c; +} + +function towerTile() { + const c = new Canvas(32, 80); + const r = rng(1414); + + // Бак: цилиндр из полос металла. + for (let y = 12; y <= 40; y++) { + for (let x = 7; x <= 24; x++) { + c.set(x, y, r() < 0.8 ? 'M1' : 'M0'); + } + // Боковые тени. + c.set(7, y, 'M0'); + c.set(8, y, 'M0'); + c.set(24, y, 'M0'); + } + // Пояса обруча. + for (const y of [14, 22, 30, 38]) { + for (let x = 7; x <= 24; x++) c.set(x, y, 'M2'); + } + // Коническая крыша бака. + for (let i = 0; i <= 6; i++) { + const half = 9 - i; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, 11 - i, 'M2'); + } + // Флюгер-шпиль. + for (let y = 2; y <= 4; y++) c.set(16, y, 'M2'); + c.set(18, 2, 'B1'); // бронзовый флажок — колокол помнит голос + + // Ноги-рельсы. + for (const [lx, rx] of [ + [10, 12], + [19, 21] + ]) { + for (let y = 41; y <= 77; y++) { + c.set(lx, y, 'M2'); + c.set(lx + 1, y, 'M0'); + c.set(rx, y, 'M2'); + c.set(rx - 1, y, 'M0'); + } + } + // Раскосы между ногами. + for (let i = 0; i < 4; i++) { + const y = 46 + i * 8; + for (let dx = 0; dx < 8; dx++) { + c.set(12 + dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); + c.set(19 - dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); + } + } + + // Колокол из рельсов под баком, между ног. + for (let y = 44; y <= 54; y++) { + const half = y < 50 ? 2 : 3; + for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'B0'); + } + c.set(16, 45, 'B2'); // блик + c.set(15, 48, 'B1'); + c.set(16, 55, 'M0'); // язык + + // Пепел у ног. + c.set(8, 79, 'P5'); + c.set(23, 79, 'P5'); + + return c; +} + +// ---------- персонажи 16x24 ---------- + +// Общие цвета: o — контур/глаза (P0), c/C — плащ тень/свет, +// t/T — шляпа тень/свет, s/S — кожа тень/свет, b/B — бронза, p — сапоги (P1). +const HERO_MAP = { o: 'P0', c: 'C0', C: 'C1', t: 'T0', T: 'T1', s: 'S0', S: 'S1', b: 'B0', B: 'B1', p: 'P1', P: 'P2' }; + +const heroDown1 = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '....oSSSSSSo....', + '....oSoSSoSo....', + '....oSSSsSSo....', + '.....oSSSSo.....', + '....oCCCCCCo....', + '...oCCCCCCCCo...', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '..obbBBBBBBbbo..', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '...oCCo..oCCo...', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................' +], HERO_MAP); + +// Второй кадр ходьбы: ноги сошлись (passing), туловище на пиксель ниже — боб походки. +const heroDown2 = fromAscii([ + '................', + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '....oSSSSSSo....', + '....oSoSSoSo....', + '....oSSSsSSo....', + '.....oSSSSo.....', + '....oCCCCCCo....', + '...oCCCCCCCCo...', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '..obbBBBBBBbbo..', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '....oCCoCCo.....', + '....oPo.oPo.....', + '....oPo.oPo.....', + '....oPPoPPo.....', + '.....oPPPo......', + '......ooo.......' +], HERO_MAP); + +// Спина: швы капюшона под шляпой, лампа на поясе (единственный тёплый пиксель). +const heroUpRows = [ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '.....oTttTo.....', + '....oCCCCCCo....', + '...oCCCCCCCCo...', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '..oCcCCCCCCcCo..', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '..obbBBffBBbbo..', + '..oCCCCCCCCCCo..', + '..oCCCCCCCCCCo..', + '...oCCo..oCCo...', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................' +]; + +const heroUp1 = fromAscii(heroUpRows, { ...HERO_MAP, f: 'F0' }); + +// Второй кадр спины: туловище на пиксель ниже — боб походки. +const heroUp2 = fromAscii(['................', ...heroUpRows.slice(0, -1)], { ...HERO_MAP, f: 'F0' }); + +const heroSide1 = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '.....oSSSSSSo...', + '.....oSSoSSo....', + '.....oSSSSSs....', + '......oSSSSo....', + '.....oCCCCCo....', + '....oCCCCCCCo...', + '...oCCCCCCCCCo..', + '...oCCCCCCCCCo..', + '...obbBBBBBBbo..', + '...oCCCCCCCCCo..', + '...oCCCCCCCCCo..', + '....oCCoCCo.....', + '....oPo.oPo.....', + '....oPo.oPo.....', + '...oPPo.oPPo....', + '...oPPo.oPPo....', + '....oo...oo.....', + '................' +], HERO_MAP); + +// Шаг в бок: ноги шире (stride), туловище на пиксель ниже — боб походки. +const heroSide2 = fromAscii([ + '................', + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '..oTttttttttTo..', + '..oooooooooooo..', + '.....oSSSSSSo...', + '.....oSSoSSo....', + '.....oSSSSSs....', + '......oSSSSo....', + '.....oCCCCCo....', + '....oCCCCCCCo...', + '...oCCCCCCCCCo..', + '...oCCCCCCCCCo..', + '...obbBBBBBBbo..', + '...oCCCCCCCCCo..', + '...oCCCCCCCCCo..', + '....oCCoCCo.....', + '...oPo...oPo....', + '...oPo...oPo....', + '..oPPo...oPPo...', + '..oPPo...oPPo...', + '...oo.....oo....' +], HERO_MAP); + +const elderIrwin = fromAscii([ + '................', + '.....oooooo.....', + '....oTTTTTTo....', + '...oTttttttTo...', + '...otttttttto...', + '....ooSSSSoo....', + '....oSoPSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oTTTTo.....', + '....oTssssTo....', + '...oTTsssSTo....', + '...oTtTssTtTo...', + '...oTttTTttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '...oTttttttTo...', + '....oTtTTtTo....', + '....oTtttTo.....', + '....oTttttTo....', + '....oTttttTo....', + '....oTttttTo....', + '....oooooooo....' +], HERO_MAP); + +const traderMila = fromAscii([ + '................', + '.....oooooo.....', + '....oWWWWWWo....', + '...oWwWWWwWo....', + '...oWWWWWWWo....', + '....oWSSSSo.....', + '....oSoPSoSo....', + '....oSsSsSso....', + '.....oSSSSo.....', + '.....oWWWWo.....', + '....oWwWWwWo....', + '...oWWWWWWWWo...', + '...oWWWWWWWWo...', + '...oWWWBWWWWo...', + '...oWWWWWWWWo...', + '....oWWWWWWo....', + '....oWooWoo.....', + '....oPo..oPo....', + '....oPo..oPo....', + '...oPPo..oPPo...', + '...oPPo..oPPo...', + '....oo....oo....', + '................', + '................' +], { o: 'P0', W: 'W3', w: 'S0', S: 'S1', s: 'S0', P: 'P0', B: 'B1' }); + +// ---------- вывод ---------- + +mkdirSync(OUT + 'tiles', { recursive: true }); +mkdirSync(OUT + 'chars', { recursive: true }); + +function save(canvas, rel) { + writeFileSync(OUT + rel, canvas.toPng()); + console.log(` ${rel}`); +} + +console.log('Генерация тайлов:'); +save(grassTile(), 'tiles/grass.png'); +save(pathTile(), 'tiles/path.png'); +save(waterTile(), 'tiles/water.png'); +save(ashTile(), 'tiles/ash.png'); +save(bellflowerTile(), 'tiles/bellflower.png'); +save(treeTile(), 'tiles/tree.png'); +save(houseTile(), 'tiles/house.png'); +save(towerTile(), 'tiles/tower.png'); +save(floorTile(), 'tiles/floor.png'); +save(wallTile(), 'tiles/wall.png'); +save(wellTile(), 'tiles/well.png'); + +console.log('Генерация персонажей:'); +save(heroDown1, 'chars/hero_down_1.png'); +save(heroDown2, 'chars/hero_down_2.png'); +save(heroUp1, 'chars/hero_up_1.png'); +save(heroUp2, 'chars/hero_up_2.png'); +save(heroSide1, 'chars/hero_side_1.png'); +save(heroSide2, 'chars/hero_side_2.png'); +save(elderIrwin, 'chars/elder_irwin.png'); +save(traderMila, 'chars/trader_mila.png'); + +// ---------- безгласный олень (пейзажная фауна) ---------- +// Выцветший: серо-оливковые G*/M2, никаких тёплых пикселей — голос забран. + +const DEER_MAP = { o: 'P0', m: 'M2', G: 'G1', l: 'G2', d: 'P0' }; + +// Кадр 1: стоит (ноги вместе). +const deerStand = fromAscii([ + '................', + '................', + '...o.....o......', + '....o...o.......', + '.....o.o........', + '......omo.......', + '.....ommmo......', + '....oGGGGo......', + '...oGGdGGo......', + '....oGGGo.......', + '.....oGo........', + '..oGGGGGGGGo....', + '.oGGGGGGGGGGo...', + '.oGllGGGlllGo...', + '.oGllGGGlllGo...', + '..ooGGGGGGoo....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...oGo..oGo.....', + '...ooo..ooo.....', + '................', + '................', + '................' +], DEER_MAP); + +// Кадр 2: шаг (ноги врозь). +const deerWalk = fromAscii([ + '................', + '................', + '...o.....o......', + '....o...o.......', + '.....o.o........', + '......omo.......', + '.....ommmo......', + '....oGGGGo......', + '...oGGdGGo......', + '....oGGGo.......', + '.....oGo........', + '..oGGGGGGGGo....', + '.oGGGGGGGGGGo...', + '.oGllGGGlllGo...', + '.oGllGGGlllGo...', + '..ooGGGGGGoo....', + '..oGo....oGo....', + '..oGo....oGo....', + '.oGo......oGo...', + '.oGo......oGo...', + '.ooo......ooo...', + '................', + '................', + '................' +], DEER_MAP); + +save(deerStand, 'chars/fauna_deer_1.png'); +save(deerWalk, 'chars/fauna_deer_2.png'); + +// атлас фауны (кадры 16x24 — та же сетка, что у героя) +const FRAME_W_FAUNA = 16; +const FRAME_H_FAUNA = 24; +const faunaFrames = [deerStand, deerWalk]; +const faunaNames = ['fauna_deer_1', 'fauna_deer_2']; +const faunaSheetW = FRAME_W_FAUNA * faunaFrames.length; +const faunaSheetCanvas = new Canvas(faunaSheetW, FRAME_H_FAUNA); +const faunaJson = {}; +for (let i = 0; i < faunaFrames.length; i++) { + const src = faunaFrames[i]; + for (let y = 0; y < FRAME_H_FAUNA; y++) { + for (let x = 0; x < FRAME_W_FAUNA; x++) { + const rgba = src.get(x, y); + if (rgba) faunaSheetCanvas.set(i * FRAME_W_FAUNA + x, y, rgba); + } + } + faunaJson[faunaNames[i]] = { + frame: { x: i * FRAME_W_FAUNA, y: 0, w: FRAME_W_FAUNA, h: FRAME_H_FAUNA }, + rotated: false, + trimmed: false, + sourceSize: { w: FRAME_W_FAUNA, h: FRAME_H_FAUNA } + }; +} +writeFileSync(OUT + 'chars/fauna_sheet.png', faunaSheetCanvas.toPng()); +writeFileSync( + OUT + 'chars/fauna_sheet.json', + JSON.stringify( + { + frames: faunaJson, + meta: { image: 'fauna_sheet.png', size: { w: faunaSheetW, h: FRAME_H_FAUNA }, scale: 1 } + }, + null, + 2 + ) +); +console.log(` chars/fauna_sheet.png (+ .json): ${faunaSheetW}x${FRAME_H_FAUNA}`); + +// ---------- пепельные сгустки (враги демо-боя) ---------- +// Серые P*/G* тела; тёплое ядро F* — «жизнь» загорается, когда сгусток просыпается. + +const CLUMP_MAP = { o: 'P0', d: 'P2', p: 'P3', q: 'P4', h: 'P5H', f: 'F0', F: 'F1' }; + +// Ползун 14x12: плоский слизень на коротких ножках. +const crawler1 = fromAscii([ + '..............', + '..............', + '...oooooo.....', + '..odppppdo....', + '.odpppppppdo..', + '.dpppffppppdo.', + '.dpppppppppdo.', + '.odpppppppdo..', + '..oddpppddo...', + '..o.o....o.o..', + '..o.o....o.o..', + '..............' +], CLUMP_MAP); + +const crawler2 = fromAscii([ + '..............', + '..............', + '...oooooo.....', + '..odppppdo....', + '.odpppppppdo..', + '.dpppffppppdo.', + '.dpppppppppdo.', + '.odpppppppdo..', + '..oddpppddo...', + '...o.o..o.o...', + '...o.o..o.o...', + '..............' +], CLUMP_MAP); + +// Плевун 14x14: столбик с тёмной пастью. +const spitter1 = fromAscii([ + '....oooooo....', + '...odppppdo...', + '..odppppppdo..', + '..dpppppppppd.', + '..dpppffppppd.', + '..dpppppppppd.', + '..dpoooopppd..', + '..dpppppppppd.', + '...dpppppppd..', + '...odpppppdo..', + '....odpppdo...', + '....o.o.o.o...', + '...o..o..o....', + '..............' +], CLUMP_MAP); + +const spitter2 = fromAscii([ + '....oooooo....', + '...odppppdo...', + '..odppppppdo..', + '..dpppppppppd.', + '..dpppffppppd.', + '..dpppppppppd.', + '..dppoooopppd.', + '..dpppppppppd.', + '...dpppppppd..', + '...odpppppdo..', + '....odpppdo...', + '...o..o..o....', + '....o.o.o.o...', + '..............' +], CLUMP_MAP); + +// Наст 20x20: тяжёлая корка с трещинами; медленно давит. +const heavy1 = fromAscii([ + '.......oooooo.......', + '.....oodppppdoo.....', + '....odppppppppdo....', + '...odppppppppppdo...', + '...dpppddppppppppd..', + '..dppppppddpppppppd.', + '..dppppppppddppffpd.', + '..dpppffppppppppfpd.', + '..dpppffppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..odpppppppppppppdo.', + '..odqpppppppppppdo..', + '...odqqppppqqpdo....', + '....odqpppqqpdo.....', + '.....oddddddddo.....', + '...ooooddddooooo....', + '....................', + '....................' +], CLUMP_MAP); + +const heavy2 = fromAscii([ + '.......oooooo.......', + '.....oodppppdoo.....', + '....odppppppppdo....', + '...odppppppppppdo...', + '...dpppppppddppppd..', + '..dpppddpppppddpppd.', + '..dpppppddpppppffpd.', + '..dpppffpppddpppfpd.', + '..dpppffppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..odpppppppppppppdo.', + '..odqpppppppppppdo..', + '...odqqppppqqpdo....', + '....odqpppqqpdo.....', + '.....oddddddddo.....', + '...ooooddddooooo....', + '....................', + '....................' +], CLUMP_MAP); + +save(crawler1, 'chars/clump_crawler_1.png'); +save(crawler2, 'chars/clump_crawler_2.png'); +save(spitter1, 'chars/clump_spitter_1.png'); +save(spitter2, 'chars/clump_spitter_2.png'); +save(heavy1, 'chars/clump_heavy_1.png'); +save(heavy2, 'chars/clump_heavy_2.png'); + +// атлас сгустков (кадры разной ширины — выравнивание по низу ячейки 20x20) +const clumpFrames = [crawler1, crawler2, spitter1, spitter2, heavy1, heavy2]; +const clumpNames = ['clump_crawler_1', 'clump_crawler_2', 'clump_spitter_1', 'clump_spitter_2', 'clump_heavy_1', 'clump_heavy_2']; +const CLUMP_W = 20; +const CLUMP_H = 20; +const clumpSheetW = CLUMP_W * clumpFrames.length; +const clumpSheet = new Canvas(clumpSheetW, CLUMP_H); +const clumpJson = {}; +for (let i = 0; i < clumpFrames.length; i++) { + const src = clumpFrames[i]; + for (let y = 0; y < src.height; y++) { + for (let x = 0; x < src.width; x++) { + const rgba = src.get(x, y); + if (rgba) { + clumpSheet.set(i * CLUMP_W + x, CLUMP_H - src.height + y, rgba); + } + } + } + clumpJson[clumpNames[i]] = { + frame: { x: i * CLUMP_W, y: 0, w: CLUMP_W, h: CLUMP_H }, + rotated: false, + trimmed: false, + sourceSize: { w: CLUMP_W, h: CLUMP_H } + }; +} +writeFileSync(OUT + 'chars/clumps_sheet.png', clumpSheet.toPng()); +writeFileSync( + OUT + 'chars/clumps_sheet.json', + JSON.stringify( + { + frames: clumpJson, + meta: { image: 'clumps_sheet.png', size: { w: clumpSheetW, h: CLUMP_H }, scale: 1 } + }, + null, + 2 + ) +); +console.log(` chars/clumps_sheet.png (+ .json): ${clumpSheetW}x${CLUMP_H}`); + +// ---------- атлас героя (Spritesheet: один PNG + JSON) ---------- + +const FRAME_W = 16; +const FRAME_H = 24; +const heroFrames = [heroDown1, heroDown2, heroUp1, heroUp2, heroSide1, heroSide2]; +const heroNames = ['hero_down_1', 'hero_down_2', 'hero_up_1', 'hero_up_2', 'hero_side_1', 'hero_side_2']; + +const sheetW = FRAME_W * heroFrames.length; +const sheetCanvas = new Canvas(sheetW, FRAME_H); +const framesJson = {}; +for (let i = 0; i < heroFrames.length; i++) { + const src = heroFrames[i]; + // Построчное копирование: строка атласа шире кадра, цельный set ломает шаг. + for (let y = 0; y < FRAME_H; y++) { + for (let x = 0; x < FRAME_W; x++) { + const rgba = src.get(x, y); + if (rgba) sheetCanvas.set(i * FRAME_W + x, y, rgba); + } + } + framesJson[heroNames[i]] = { + frame: { x: i * FRAME_W, y: 0, w: FRAME_W, h: FRAME_H }, + rotated: false, + trimmed: false, + sourceSize: { w: FRAME_W, h: FRAME_H } + }; +} +writeFileSync(OUT + 'chars/hero_sheet.png', sheetCanvas.toPng()); +writeFileSync( + OUT + 'chars/hero_sheet.json', + JSON.stringify( + { + frames: framesJson, + meta: { image: 'hero_sheet.png', size: { w: sheetW, h: FRAME_H }, scale: 1 } + }, + null, + 2 + ) +); +console.log(` chars/hero_sheet.png (+ .json): ${sheetW}x${FRAME_H}`); + +console.log('Готово: ' + OUT); \ No newline at end of file diff --git a/apps/game/tools/pixelart/palette.mjs b/apps/game/tools/pixelart/palette.mjs new file mode 100644 index 0000000..a0bf80f --- /dev/null +++ b/apps/game/tools/pixelart/palette.mjs @@ -0,0 +1,58 @@ +/** + * Палитра из docs/art-style.md — единственный источник цветов арта. + */ + +export const PALETTE = { + // Пепел + P0: '#17171d', + P1: '#23232b', + P2: '#2f2f38', + P3: '#3e3e48', + P4: '#52525c', + P5: '#6a6a74', + P6: '#86868f', + P5H: '#a3a3ac', + // Мёртвая трава + G0: '#2b3026', + G1: '#3d4436', + G2: '#525b44', + G2H: '#687053', + // Ржавчина и металл + R0: '#3a2822', + R1: '#5c3b2e', + R2: '#82503a', + M0: '#26262e', + M1: '#44454e', + M2: '#61626c', + // Вода + W0: '#22343c', + W1: '#32505c', + W2: '#4a737f', + // Тепло — только жизнь + B0: '#8a6a2e', + B1: '#d99a32', + B2: '#f2cd68', + F0: '#d97f2b', + F1: '#f2b45a', + // Люди и ткань + C0: '#3a4356', + C1: '#4d5a72', + W3: '#8a8f7d', + S0: '#8a6d55', + S1: '#c9a689', + T0: '#5a4a3c', + T1: '#77634f' +}; + +/** Прозрачный пиксель. */ +export const TRANSPARENT = [0, 0, 0, 0]; + +export function hexToRgba(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, 255]; +} + +/** Ключи палитры -> RGBA-значения (кэш). */ +export const RGBA = Object.fromEntries( + Object.entries(PALETTE).map(([k, v]) => [k, hexToRgba(v)]) +); \ No newline at end of file diff --git a/apps/game/tools/pixelart/png.mjs b/apps/game/tools/pixelart/png.mjs new file mode 100644 index 0000000..744dbdf --- /dev/null +++ b/apps/game/tools/pixelart/png.mjs @@ -0,0 +1,61 @@ +/** + * PNG-энкодер без зависимостей: RGBA-пиксели -> PNG-файл (zlib из node:zlib). + * Цвета — ключи палитры из palette.mjs. + */ +import { deflateSync } from 'node:zlib'; + +// CRC32 для чанков PNG. +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) { + c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +/** rgba — Uint8Array длиной w*h*4. Возвращает PNG-буфер. */ +export function encodePng(width, height, rgba) { + // Сырые строки: байт фильтра (0 = None) + строка пикселей. + const raw = Buffer.alloc(height * (width * 4 + 1)); + for (let y = 0; y < height; y++) { + raw[y * (width * 4 + 1)] = 0; + rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)) + ]); +} \ No newline at end of file diff --git a/apps/game/tools/skin-info.mjs b/apps/game/tools/skin-info.mjs new file mode 100644 index 0000000..0139a91 --- /dev/null +++ b/apps/game/tools/skin-info.mjs @@ -0,0 +1,34 @@ +/** Проба: печатает информацию о текущей текстуре спрайта героя из браузера. */ +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 new file mode 100644 index 0000000..24e117e --- /dev/null +++ b/apps/game/tools/skin-probe.mjs @@ -0,0 +1,26 @@ +/** + * Проба дебага спрайта: включаем просмотр кадра героя (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 new file mode 100644 index 0000000..9010aec --- /dev/null +++ b/apps/game/tools/smoke-act1.mjs @@ -0,0 +1,53 @@ +/** + * Полный прогон акта 1 через агентный мост: луга -> Звенец -> квест у Ирвина -> + * (сбор цветов эмулируется var'ом) -> сдача -> кат-сцена с колоколом. + * Запуск: node tools/smoke-act1.mjs [url] [скриншот] + */ +import { openGame } from './agent-lib.mjs'; + +const url = process.argv[2] ?? 'http://localhost:5199/'; +const shot = process.argv[3] ?? '/tmp/rpg_act1.png'; + +const { browser, page, agent, consoleLogs } = await openGame({ url, newGame: true }); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); + +// Усыпить сгустков: путь героя не должен прерываться уроном. +async function sleepAll() { + await page.evaluate(() => { + const g = window.__game; + for (const [, en] of g.scenes.current.combat?.enemies ?? []) en.brain.putToSleep(9999); + }); +} +await sleepAll(); + +// 1) Луга -> тропа в Звенец (26,14) — триггер срабатывает на самом тайле. +console.log('шаг 1: луга -> Звенец'); +if (!(await agent.walkTo(26, 14))) console.log(' ! герой не дошёл до (26,14)'); +await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); +// Фаза «in» fade-перехода: сцена ещё игнорирует клики. +await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); +await sleepAll(); // переход мог вернуть героя в бой + +// 2-3) Клик по Ирвину (12,9): сцена сама подведёт героя и откроет диалог. +console.log('шаг 2: квест у Ирвина'); +await agent.tapTile(12, 9); +if (!(await agent.runDialogue())) console.log(' ! диалог с Ирвином не открылся'); + +// 4) Сбор цветов — эмуляция var'ом (вылазка проверяется smoke-ponds). +console.log('шаг 4: цветы собраны (эмуляция)'); +await agent.command('scene:setVar', { id: 'flowers', value: 3 }); + +// 5) Сдача Ирвину -> кат-сцена. +console.log('шаг 5: сдача -> кат-сцена'); +await agent.tapTile(12, 9); +if (!(await agent.runDialogue())) console.log(' ! диалог сдачи не открылся'); + +// 6) Кат-сцена: крутим шаги, пока активна (мост перематывает без реального ожидания). +await agent.waitFor('s.cutscene == null || !s.cutscene.active', { timeoutTicks: 1200 }); + +const snap = await agent.snapshot(); +console.log('итог:', JSON.stringify({ flags: snap.flags, vars: snap.vars })); +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +if (!snap.flags.includes('quest_bells_done')) console.log(' ! квест не закрыт (quest_bells_done)'); +await browser.close(); \ No newline at end of file diff --git a/apps/game/tools/smoke-ponds.mjs b/apps/game/tools/smoke-ponds.mjs new file mode 100644 index 0000000..91e4e22 --- /dev/null +++ b/apps/game/tools/smoke-ponds.mjs @@ -0,0 +1,33 @@ +/** + * Смоук перехода луга -> Серые пруды: герой идёт через мост к триггеру (2,2). + * Запуск: node tools/smoke-ponds.mjs [url] [скриншот] + */ +import { openGame } from './agent-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 new file mode 100644 index 0000000..6c7b304 --- /dev/null +++ b/apps/game/tools/smoke-quest.mjs @@ -0,0 +1,35 @@ +/** + * Смоук квестовой рамки: новая игра → Звенец → диалог с Ирвином (задание) → + * сумка с журналом (I). Запуск: node tools/smoke-quest.mjs [url] [скриншот] + */ +import { openGame } from './agent-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 new file mode 100644 index 0000000..d518c5e --- /dev/null +++ b/apps/game/tools/smoke-zvenets.mjs @@ -0,0 +1,32 @@ +/** + * Смоук перехода луга -> Звенец: герой идёт через мост к триггеру (26,14). + * Запуск: node tools/smoke-zvenets.mjs [url] [скриншот] + */ +import { openGame } from './agent-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/smoke.mjs b/apps/game/tools/smoke.mjs new file mode 100644 index 0000000..3e2d55e --- /dev/null +++ b/apps/game/tools/smoke.mjs @@ -0,0 +1,22 @@ +/** + * Базовый смоук через агентный мост: новая игра, шаги, снапшот, скриншот. + * Запуск: node tools/smoke.mjs [url] [файл-скриншота] + */ +import { openGame } from './agent-lib.mjs'; + +const url = process.argv[2] ?? 'http://localhost:5199/'; +const shot = process.argv[3] ?? '/tmp/rpg_smoke.png'; + +const { browser, page, agent } = await openGame({ url, newGame: true }); + +// 300 фиксированных шагов без ошибок инвариантов. +await agent.step(300); +const inv = await agent.invariants(); +const errs = inv.filter((i) => i.severity === 'error'); +if (errs.length) console.log(' ! инварианты:', JSON.stringify(errs)); + +const snap = await agent.snapshot(); +console.log('снапшот:', JSON.stringify({ area: snap.area, hero: snap.hero?.tile, hp: snap.hero?.hp })); +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +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 new file mode 100644 index 0000000..8ec0158 --- /dev/null +++ b/apps/game/tools/walk-probe.mjs @@ -0,0 +1,52 @@ +/** + * Проба анимации ходьбы героя: запускаем игру, отправляем героя на запад, + * снимаем серию скриншотов во время движения. + * Запуск: 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/art-style.md b/docs/art-style.md index 2056221..37d2db4 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -6,7 +6,7 @@ ## 1. Палитра (32 цвета) -Движок и генератор (`tools/pixelart/`) используют ровно эти цвета. Имя цвета — тоже +Движок и генератор (`apps/game/tools/pixelart/`) используют ровно эти цвета. Имя цвета — тоже комментарий к настроению. ### Пепел (нейтраль) @@ -94,7 +94,7 @@ ## 4. Ассеты и чеклист -Все PNG — из `apps/game/assets/`, генерируются `tools/pixelart/gen.mjs`. +Все PNG — из `apps/game/assets/`, генерируются `apps/game/tools/pixelart/gen.mjs`. ### Тайлы (32×16, id совпадает с `apps/game/src/data/map.ts`) @@ -127,7 +127,7 @@ ## 5. Что где хранится -- `tools/pixelart/gen.mjs` — генератор: пиксель-карты в ASCII + энкодер PNG (без зависимостей). +- `apps/game/tools/pixelart/gen.mjs` — генератор: пиксель-карты в ASCII + энкодер PNG (без зависимостей). - `apps/game/assets/` — выход генератора, загружается `AssetLoader` по ключу = пути без расширения (`tiles/grass` → `assets/tiles/grass.png`). - После замены сгенерированного арта на нарисованный руками имена файлов и размеры не меняются. \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index a6d0811..8c14ccd 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/локации/враги) | готово | -| tools/agent.mjs + tools/agent-lib.mjs | CLI check/run/snapshot/screenshot/dev; смоуки на мосту | готово | +| apps/game/tools/agent.mjs + apps/game/tools/agent-lib.mjs | CLI check/run/snapshot/screenshot/dev; смоуки на мосту | готово | ## Сюжетная рамка (акт 1, по docs/world.md) @@ -67,9 +67,9 @@ LocationScene (+ InventoryScene); - `systems/` — PlayerController (путь + прямое движение), DialogueSystem, `combat/` (CombatWorld, EnemyBrain, PlayerCombat, CombatViews, HealthBar, stats). -- Инструменты: `tools/pixelart` (PNG-атласы), `tools/audio` (WAV-генератор), - `tools/maps` (карты-файлы), `tools/agent.mjs` + `tools/agent-lib.mjs` + - `tools/checks/` (агентный мост: check/snapshot/сценарии), `tools/smoke*.mjs` +- Инструменты: `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). ## Новое в движке за срез @@ -106,30 +106,30 @@ **Текущий срез — движок для ИИ-агента + геймплей на нём (план утверждён):** - ✅ **Батч 0 — агентный набор инструментов** (готово): мост `window.__agent`, - валидация контента, `tools/agent.mjs`, смоуки на мосту, `docs/llms.txt` + + валидация контента, `apps/game/tools/agent.mjs`, смоуки на мосту, `docs/llms.txt` + `docs/engine/agent.md` + `docs/engine/practices.md`. - ✅ **Батч 1 — единый механизм переходов** (готово): `TransitionDef` (step/click, условия, lockedText), дисарм тайла входа, `returnTo`, сейвы v3, - телепорт-колодцы (Звенец ↔ луга), сценарий `tools/checks/transitions.mjs`. + телепорт-колодцы (Звенец ↔ луга), сценарий `apps/game/tools/checks/transitions.mjs`. - ✅ **Батч 2 — интерьеры + интерактивные объекты** (готово): карты интерьеров (`buildInteriorMap`: дом Ирвина, лавка Милы), двери-клики внутрь и выход `kind: 'return'`, `InteractableDef`/`Interactables` (сундук, очаг, записка, прилавок; реакции по `when`, `once` → флаг `used:`, `gives`), снапшот - `interactables`, сценарий `tools/checks/interact.mjs`. + `interactables`, сценарий `apps/game/tools/checks/interact.mjs`. - ✅ **Батч 3 — взаимодействие с миром шире** (готово): столбы у троп (луга, пруды, Звенец), подбор пепельных мотов (`gives` + `addVar`, вар `motes` — зачаток экономики), верёвка колокола башни (флаг `rang_tower`), ветка повторного диалога Милы по вару (`whenVar motes`), инвариант `interact-used-consistent` в мосте, `walkTo` пропускает узлы-объекты, - сценарий `tools/checks/interact-world.mjs`. + сценарий `apps/game/tools/checks/interact-world.mjs`. - ✅ **Батч 4 — ИИ врагов** (готово): LOS по сетке (`systems/combat/los.ts`, кэш раз в 3 тика), шум мира `CombatWorld.noise` (шаги героя 0.35, удар конусом 1.0, плевок/смерть 0.6; спящих будит только ≥ 0.7), состояния `patrol`/`wary`/`flee`/`return` в `EnemyBrain` (патруль — per-spawn в `AREAS[*].enemies`), бег при отступлении (`run` в `EnemyIntent.move`), инварианты `enemy-state-legal`/`enemy-in-bounds`, команды - `scene:noise`/`scene:damageEnemy`, сценарий `tools/checks/ai.mjs`. -- Принцип среза: каждая геймплейная система имеет сценарий в `tools/checks/`. + `scene:noise`/`scene:damageEnemy`, сценарий `apps/game/tools/checks/ai.mjs`. +- Принцип среза: каждая геймплейная система имеет сценарий в `apps/game/tools/checks/`. **Дальше (запрос игрока, после моста):** diff --git a/docs/engine/README.md b/docs/engine/README.md index a191b73..de9e895 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -17,7 +17,7 @@ | [assets-audio-save.md](assets-audio-save.md) | AssetLoader, атласы, AudioManager, сейвы | | [art-pipeline.md](art-pipeline.md) | Генератор пиксель-арта, палитра, замена на рисованный арт | | [recipes.md](recipes.md) | «Как сделать…»: готовые решения типовых задач | -| [agent.md](agent.md) | Агентный мост: EngineAgent, window.__agent, инварианты, tools/agent.mjs | +| [agent.md](agent.md) | Агентный мост: EngineAgent, window.__agent, инварианты, apps/game/tools/agent.mjs | | [practices.md](practices.md) | Живой документ практик агента «по ситуациям» — пополняется в каждом коммите | ## Принципы diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 39789cc..d6535c7 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -18,8 +18,8 @@ GameAgent.ts регистрирует window.__agent (только DEV), high-level хелперы apps/game/src/data/validate.ts — runtime-валидация контента → Invariant[] -tools/agent-lib.mjs — браузерный клиент (puppeteer, openGame, Checks) -tools/agent.mjs — CLI: check / run / snapshot / screenshot / dev +apps/game/tools/agent-lib.mjs — браузерный клиент (puppeteer, openGame, Checks) +apps/game/tools/agent.mjs — CLI: check / run / snapshot / screenshot / dev ``` Граница: движок не знает ничего об RPG-контенте. `EngineAgent` общается со сценой @@ -74,7 +74,7 @@ ``` - **pred — строка-выражение** над снапшотом (`'s.hero.hp < 3'`): из браузера нельзя - передать функцию. Компилируется `new Function('s', ...)` — источник доверенный (tools/). + передать функцию. Компилируется `new Function('s', ...)` — источник доверенный (apps/game/tools/). - **waitFor крутит шаги сам** и вызывает pred после каждого шага; сцены могут меняться в процессе — обращайся к полям через optional chaining. - Мост **никогда не бросает исключений**: ошибка внутри превращается в слой @@ -127,22 +127,22 @@ Вызывается из `GameAgent.invariants()` (работает и в меню) и из юнит-теста `data/__tests__/validate.test.ts` — битый контент падает сразу в тестах. -## tools/agent.mjs (CLI, JSON по умолчанию) +## apps/game/tools/agent.mjs (CLI, JSON по умолчанию) ```bash -node tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] # полный прогон -node tools/agent.mjs run tools/checks/xxx.mjs # один сценарий -node tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] -node tools/agent.mjs screenshot [--out] [--steps N] # скриншот + хвост консоли -node tools/agent.mjs dev [--port 5199] # dev-сервер для ручной работы +node apps/game/tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] # полный прогон +node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs # один сценарий +node apps/game/tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] +node apps/game/tools/agent.mjs screenshot [--out] [--steps N] # скриншот + хвост консоли +node apps/game/tools/agent.mjs dev [--port 5199] # dev-сервер для ручной работы ``` `check` поднимает dev-сервер сам (и гасит в finally): `typecheck`, `tests`, `maps`, `maps:fresh` (файлы карт равны генераторам), `agent:invariants` (новая игра → 300 -шагов → инварианты чисты). Сценарии геймплея — в `tools/checks/*.mjs`, каркас — -`Checks` из `tools/agent-lib.mjs` (проверка возвращает `false`/кидает исключение +шагов → инварианты чисты). Сценарии геймплея — в `apps/game/tools/checks/*.mjs`, каркас — +`Checks` из `apps/game/tools/agent-lib.mjs` (проверка возвращает `false`/кидает исключение или `c.expect(cond, msg, details)`). **Правило**: каждая геймплейная система обязана иметь сценарий проверки в -`tools/checks/`. Предпочитай `snapshot`/`check` скриншотам: скриншот — для +`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 423e875..e5561cc 100644 --- a/docs/engine/art-pipeline.md +++ b/docs/engine/art-pipeline.md @@ -1,6 +1,6 @@ # Арт-пайплайн: генерация пиксель-арта из кода -Весь арт первой игры генерируется кодом: `tools/pixelart/*.mjs` рисуют спрайты +Весь арт первой игры генерируется кодом: `apps/game/tools/pixelart/*.mjs` рисуют спрайты на собственном Canvas и кодируют PNG без сторонних зависимостей (`node:zlib` + свой CRC32). Позже генератор можно заменить на рисованный арт — движку всё равно, откуда текстуры. @@ -9,15 +9,15 @@ | Файл | Что делает | |---|---| -| `tools/pixelart/palette.mjs` | Палитра из 32 цветов (hex + RGBA) | -| `tools/pixelart/canvas.mjs` | Canvas: set/get пикселя, `fillDiamond` (изо-ромб), `fromAscii`, seeded rng | -| `tools/pixelart/png.mjs` | `encodePng` — сборка PNG вручную | -| `tools/pixelart/gen.mjs` | Генерация всех спрайтов игры → `apps/game/assets/` | +| `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/` | ## Запуск ```bash -npm run art # node tools/pixelart/gen.mjs — перегенерировать все PNG +npm run art # node apps/game/tools/pixelart/gen.mjs — перегенерировать все PNG ``` Выходные файлы кладутся в `apps/game/assets/` и грузятся в игре по ключам diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 9152a7d..9d593dc 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -9,21 +9,21 @@ 1. `npm run typecheck` — быстрый отсев. 2. `npx vitest run <затронутый тест>` — точечный прогон. -3. `node tools/agent.mjs check --pretty` — полный прогон (typecheck + тесты + +3. `node apps/game/tools/agent.mjs check --pretty` — полный прогон (typecheck + тесты + карты + карты-файлы равны генераторам + браузерная проверка инвариантов + - сценарий переходов `tools/checks/transitions.mjs`). -4. Смоук акта 1 (`node tools/smoke-act1.mjs`) — не сломал ли существующий геймплей. + сценарий переходов `apps/game/tools/checks/transitions.mjs`). +4. Смоук акта 1 (`node apps/game/tools/smoke-act1.mjs`) — не сломал ли существующий геймплей. ## Ситуация: добавляю локацию -1. Генератор в `tools/maps/gen.mjs` (детерминированный сид) → `npm run maps`. +1. Генератор в `apps/game/tools/maps/gen.mjs` (детерминированный сид) → `npm run maps`. 2. `AreaId` + `AreaDef` в `data/locations.ts`: spawn/npcs/enemies/transitions. 3. Экспорт через `AREAS`/`areaOf` и загрузка в `BootScene` по `Object.keys(AREAS)`. 4. Валидатор проверит спавн/врагов/переходы автоматически (`agent:invariants`): **инвариант `in-wall` у врага — почти всегда реальный баг данных** (враг поставлен на воду/дерево). Чинить данные, а не валидатор. -5. Сценарий в `tools/checks/`: переход туда и обратно через `walkTo` + `waitFor` - (готовый образец — `tools/checks/transitions.mjs`). +5. Сценарий в `apps/game/tools/checks/`: переход туда и обратно через `walkTo` + `waitFor` + (готовый образец — `apps/game/tools/checks/transitions.mjs`). ## Ситуация: добавляю переход (дверь, портал, колодец) @@ -35,7 +35,7 @@ 3. `npm run maps` при провале «fresh»-теста чинит файлы, **но сначала прочти провал**: расхождение «тайлов N, ожидалось W×H» = индекс за пределами карты в генераторе (разреженный массив дорос, см. граблю ниже). -4. Проверка: `tools/checks/transitions.mjs` — шаг до перехода, клик-переход +4. Проверка: `apps/game/tools/checks/transitions.mjs` — шаг до перехода, клик-переход туда и обратно, снапшот `s.transitions` (триггеры и цели). ## Ситуация: добавляю интерьер / интерактивный объект @@ -51,7 +51,7 @@ реакции по `when` (первая подходящая), `once` поднимает флаг `used:`, эффекты — `setFlags`/`setVar`/`gives`, текст виден в `s.lastToast`. 4. Валидатор сам проверит объекты в границах и переходы; полная проверка — - `tools/checks/interact.mjs` (образец: вход → сундук → used → выход → + `apps/game/tools/checks/interact.mjs` (образец: вход → сундук → used → выход → возврат на тайл клика). 5. `waitFor` моста видит **полный** снапшот игры (`s.flags`, `s.inventory`, `s.interactables`) — предикаты над прогрессом пиши против него, а не @@ -95,7 +95,22 @@ 3. `npm run typecheck` ловит разрывы в обоих пакетах; тесты движка — в Vitest без браузера (Pixi-зависимости — через стабы, см. `GameLoop.manual.test.ts`). -## Ситуация: пишу новый сценарий tools/checks/ +## Ситуация: меняю границу движок / игра + +1. Правила границы проверяет гвард: `npm run guard` + (`apps/game/tools/guards/boundary.mjs`), он же проба `guard:boundary` в + `agent check`. Провал = импорт мимо публичного API движка + (`@rpg/engine` / `@rpg/engine/assets/*`), относительный импорт, + пересекающий границу пакета, или импорт игры из движка. Чинить импорт, + а не гвард. +2. Движок не знает ничего об RPG-контенте; если движку нужна точка расширения + — интерфейс с инъекцией (образцы: `StorageLike`, `SceneAgent`), а не + импорт из `apps/game`. +3. Чистое ядро гварда (`importSpecs`/`checkBoundary`) покрыто тестами + (`guards/__tests__/boundary.test.mjs`) — новый класс нарушений добавляй + как правило + тест. + +## Ситуация: пишу новый сценарий apps/game/tools/checks/ 1. Каркас: `import { startDevServer, openGame, Checks } from '../agent-lib.mjs'`; `export default async function ({ pretty })`; вернуть @@ -119,12 +134,17 @@ от игровых троп, иначе сломаются смоуки акта. 3. Проверка без боёв: `scene:noise`/`scene:damageEnemy`/`scene:teleport` (см. `docs/engine/agent.md`) + состояния из `s.enemies[i].state`. - Пример — `tools/checks/ai.mjs`. + Пример — `apps/game/tools/checks/ai.mjs`. 4. Позицию врага в юнит-тестах двигай руками: мозг чистый, сенсор `pos` — это то, что подал тест (см. `EnemyAI.test.ts`). ## Грабли среды (кратко, подробности в CLAUDE.md) +- **Тулзы живут в `apps/game/tools/` (3 уровня ниже корня)**: скриптам, + спавнящим процессы с путями от корня репо, нужен `ROOT = new URL('../../..', + import.meta.url)` (не `'../..'` — остаток от жизни в корневом `tools/`). + Коварство: `npm run` из `apps/` всё равно сработает (npm ищет package.json + вверх), а вот vitest с корне-относительным путём файла — упадёт «не зелёный». - `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 cbeaad7..9931124 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -7,7 +7,7 @@ Начни с `CLAUDE.md` в корне (команды, архитектура, грабли) и `docs/engine/practices.md` (приёмы по ситуациям). Проверка любого изменения: -`node tools/agent.mjs check --pretty`. +`node apps/game/tools/agent.mjs check --pretty`. ## Движок @@ -22,14 +22,15 @@ - [Ассеты, аудио, сейвы](engine/assets-audio-save.md): AssetLoader, атласы, шины аудио, SaveManager. - [Пайплайн арта](engine/art-pipeline.md): генератор пиксель-арта, палитра, атласы. - [Рецепты](engine/recipes.md): короткие «как сделать X». -- [Агентный мост](engine/agent.md): EngineAgent, window.__agent, waitFor, валидация контента, tools/agent.mjs. +- [Агентный мост](engine/agent.md): EngineAgent, window.__agent, waitFor, валидация контента, apps/game/tools/agent.mjs. ## Инструменты агента - [Практики](engine/practices.md): живой документ приёмов по ситуациям агента; пополняется в каждом коммите. -- `tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). -- `tools/agent-lib.mjs`: openGame, AgentClient, Checks — каркас сценариев. -- `tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. +- `apps/game/tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). +- `apps/game/tools/agent-lib.mjs`: openGame, AgentClient, Checks — каркас сценариев. +- `apps/game/tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. +- `apps/game/tools/guards/boundary.mjs` (`npm run guard`): гвард границы движок/игра — импорты только через публичное API. ## Мир и арт diff --git a/package.json b/package.json index 69c9441..fb6163b 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,15 @@ "scripts": { "dev": "npm run dev --workspace @rpg/game", "build": "npm run build --workspace @rpg/game", - "art": "node tools/pixelart/gen.mjs", - "maps": "vitest run tools/maps/gen.test.ts", + "art": "node apps/game/tools/pixelart/gen.mjs", + "maps": "vitest run apps/game/tools/maps/gen.test.ts", + "guard": "node apps/game/tools/guards/boundary.mjs", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game", - "agent": "node tools/agent.mjs dev", - "agent:check": "node tools/agent.mjs check", - "agent:snapshot": "node tools/agent.mjs snapshot --new-game" + "agent": "node apps/game/tools/agent.mjs dev", + "agent:check": "node apps/game/tools/agent.mjs check", + "agent:snapshot": "node apps/game/tools/agent.mjs snapshot --new-game" }, "devDependencies": { "typescript": "^5.6.0", diff --git a/tools/agent-lib.mjs b/tools/agent-lib.mjs deleted file mode 100644 index bd80af2..0000000 --- a/tools/agent-lib.mjs +++ /dev/null @@ -1,161 +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'; - -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: new URL('..', import.meta.url).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/tools/agent.mjs b/tools/agent.mjs deleted file mode 100644 index 9cb7691..0000000 --- a/tools/agent.mjs +++ /dev/null @@ -1,171 +0,0 @@ -/** - * CLI-диспетчер агентных инструментов. Вывод по умолчанию — JSON - * (машинночитаемый), --pretty — для человека. - * - * node tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty] - * node tools/agent.mjs run [--pretty] - * node tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty] - * node tools/agent.mjs screenshot [--out файл] [--steps N] [--pretty] - * node tools/agent.mjs dev [--port N] - */ -import { spawnSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { parseArgs, startDevServer, openGame, Checks } from './agent-lib.mjs'; - -const args = parseArgs(); -const cmd = args._[0] ?? 'check'; - -/** Один «серверный» шаг проверки: результат + ms + детали (fn может быть async). */ -async function stepResult(name, fn) { - const t0 = Date.now(); - try { - const details = await fn(); - return { name, ok: true, ms: Date.now() - t0, details: details ?? null }; - } catch (err) { - return { name, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) }; - } -} - -/** Существующие .map равны генераторам (gen.test.ts сравнивает сиды с файлами). */ -const mapsFresh = () => stepResult('maps:fresh', () => { - const gen = spawnSync('npx', ['vitest', 'run', 'tools/maps/gen.test.ts'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname }); - if (gen.status !== 0) throw new Error(gen.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'gen.test.ts не зелёный'); - return 'файлы карт равны генераторам'; -}); - -/** Браузерная проверка: новая игра -> N шагов -> инварианты пусты (error). */ -async function agentInvariants() { - const c = new Checks('agent:invariants'); - const server = await startDevServer({ port: args.port ?? 5199 }); - let ctx; - try { - await c.run('новая игра + 300 шагов', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.step(300); - const inv = await ctx.agent.invariants(); - const errs = inv.filter((i) => i.severity === 'error'); - if (errs.length) return errs; - return 'инварианты чисты'; - }); - await c.run('снапшот валиден', async () => { - const s = await ctx.agent.snapshot(); - if (!s.hero || !Array.isArray(s.flags)) return 'снапшот без hero/flags'; - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty: args.pretty }); -} - -/** Сценарий из tools/checks/ как шаг check: exit code -> результат. */ -async function runScenario(name, file) { - return stepResult(name, async () => { - const mod = await import(pathToFileURL(resolve(new URL('..', import.meta.url).pathname, file))); - const code = await mod.default({ args, pretty: args.pretty }); - if (code !== 0) throw new Error(`сценарий ${file} не зелёный (exit ${code})`); - return null; - }); -} - -const commands = { - /** Полный прогон проверок. */ - async check() { - const all = [ - { name: 'typecheck', run: () => stepResult('typecheck', () => { - const r = spawnSync('npm', ['run', 'typecheck'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname }); - if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('error TS')).join('\n')); - return null; - }) }, - { name: 'tests', run: () => stepResult('tests', () => { - const r = spawnSync('npm', ['test'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname }); - if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'тесты не зелёные'); - return null; - }) }, - { name: 'maps', run: () => stepResult('maps', () => { - const r = spawnSync('npm', ['run', 'maps'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname }); - if (r.status !== 0) throw new Error('npm run maps не зелёный'); - return null; - }) }, - { name: 'maps:fresh', run: mapsFresh }, - { name: 'agent:invariants', run: agentInvariants }, - { name: 'transitions', run: () => runScenario('transitions', 'tools/checks/transitions.mjs') }, - { name: 'interact', run: () => runScenario('interact', 'tools/checks/interact.mjs') }, - { name: 'interact-world', run: () => runScenario('interact-world', 'tools/checks/interact-world.mjs') }, - { name: 'ai', run: () => runScenario('ai', 'tools/checks/ai.mjs') } - ]; - const only = args.only ?? all.map((p) => p.name); - const skip = args.skip ?? []; - const report = { name: 'check', ok: true, ms: 0, results: [] }; - const t0 = Date.now(); - for (const probe of all) { - if (!only.includes(probe.name) || skip.includes(probe.name)) continue; - let res; - try { - res = await probe.run(); - } catch (err) { - res = { name: probe.name, ok: false, ms: 0, details: String(err?.message ?? err) }; - } - report.results.push(res); - report.ok = report.ok && res.ok; - } - report.ms = Date.now() - t0; - console.log(args.pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report)); - return report.ok ? 0 : 1; - }, - - /** Один сценарий из tools/checks/. */ - async run() { - const file = args._[1]; - if (!file) throw new Error('укажи путь к сценарию: node tools/agent.mjs run tools/checks/xxx.mjs'); - const mod = await import(pathToFileURL(file).href); - if (typeof mod.default !== 'function') throw new Error(`${file}: нет export default (async ({args}) => exitCode)`); - return mod.default({ args, pretty: args.pretty }); - }, - - /** Снимок снапшота в файл/stdout. */ - async snapshot() { - const server = await startDevServer({ port: args.port ?? 5199 }); - try { - const ctx = await openGame({ url: server.url, newGame: args.newGame ?? false }); - const snap = await ctx.agent.snapshot(); - if (args.steps) await ctx.agent.step(args.steps); - if (args.out) writeFileSync(args.out, JSON.stringify(snap, null, 2)); - console.log(args.out ? `снапшот: ${args.out}` : JSON.stringify(snap, null, 2)); - await ctx.browser.close(); - return 0; - } finally { server.stop(); } - }, - - /** Скриншот + консоль (для визуальных проверок, когда снапшота мало). */ - async screenshot() { - const server = await startDevServer({ port: args.port ?? 5199 }); - try { - const ctx = await openGame({ url: server.url, newGame: args.newGame ?? true }); - if (args.steps) await ctx.agent.step(args.steps); - const out = args.out ?? '/tmp/rpg_agent.png'; - await ctx.agent.screenshot(out); - console.log(JSON.stringify({ screenshot: out, logs: ctx.consoleLogs.slice(-10) })); - await ctx.browser.close(); - return 0; - } finally { server.stop(); } - }, - - /** Dev-сервер + URL для ручной работы (не гасит сервер до Ctrl+C). */ - async dev() { - const server = await startDevServer({ port: args.port ?? 5199 }); - console.log(`dev: ${server.url}`); - process.on('SIGINT', () => { server.stop(); process.exit(0); }); - await new Promise(() => {}); - } -}; - -const fn = commands[cmd]; -if (!fn) { - console.error(`Неизвестная команда «${cmd}». Доступны: ${Object.keys(commands).join(', ')}`); - process.exit(2); -} -process.exit(await fn()); \ No newline at end of file diff --git a/tools/audio/gen.mjs b/tools/audio/gen.mjs deleted file mode 100644 index 8cdaf5e..0000000 --- a/tools/audio/gen.mjs +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Генератор звуков «Пепельных лугов» — WAV из кода, без зависимостей. - * Запуск: node tools/audio/gen.mjs - * Выход: apps/game/assets/audio/sfx/*.wav, apps/game/assets/audio/music/*.wav - * - * Формат: моно, 22050 Гц, 16-бит PCM (хватает для пиксель-арта со звуком, - * файлы вдвое меньше). Меланхоличный пепельный саунд: глухие удары, шум, низкие пады. - */ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -const OUT = fileURLToPath(new URL('../../apps/game/assets/audio/', import.meta.url)); -const RATE = 22050; - -// ---------- WAV-энкодер ---------- - -/** Собрать WAV-файл из Float32 сэмплов (-1..1). */ -function encodeWav(samples) { - const n = samples.length; - const data = new ArrayBuffer(44 + n * 2); - const view = new DataView(data); - const str = (offset, s) => { - for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); - }; - str(0, 'RIFF'); - view.setUint32(4, 36 + n * 2, true); - str(8, 'WAVE'); - str(12, 'fmt '); - view.setUint32(16, 16, true); // размер fmt - view.setUint16(20, 1, true); // PCM - view.setUint16(22, 1, true); // моно - view.setUint32(24, RATE, true); - view.setUint32(28, RATE * 2, true); // байт/сек - view.setUint16(32, 2, true); // блок - view.setUint16(34, 16, true); // бит - str(36, 'data'); - view.setUint32(40, n * 2, true); - for (let i = 0; i < n; i++) { - const s = Math.max(-1, Math.min(1, samples[i])); - view.setInt16(44 + i * 2, Math.round(s * 32767), true); - } - return Buffer.from(data); -} - -// ---------- примитивы ---------- - -/** Детерминированный шум. */ -function makeRng(seed) { - let s = seed >>> 0; - return () => { - s = (s + 0x6d2b79f5) >>> 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ -function synth(seconds, fn) { - const n = Math.floor(seconds * RATE); - const out = new Float32Array(n); - for (let i = 0; i < n; i++) out[i] = fn(i / RATE, i); - return out; -} - -/** Экспоненциальный спад: полный на старте, ~0 к концу. */ -function decay(t, dur, power = 4) { - return Math.pow(Math.max(0, 1 - t / dur), power); -} - -/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ -function bellPartial(freq, stretch, dur, power) { - return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); -} - -/** Полосовой шум через однополюсные фильтры (низ + верх). */ -function bandNoise(seed, lowHz, highHz, dur) { - const r = makeRng(seed); - let l1 = 0; - let l2 = 0; - const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); - const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); - return () => { - const white = r() * 2 - 1; - l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим - l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим - return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); - }; -} - -/** Нормализовать по пику. */ -function normalize(samples, peak = 0.9) { - let max = 0; - for (const s of samples) max = Math.max(max, Math.abs(s)); - if (max === 0) return samples; - const k = peak / max; - return samples.map((s) => s * k); -} - -/** Сшить луп: кроссфейд первых fade-секунд с концом. */ -function loopify(samples, fadeSeconds = 0.5) { - const n = Math.floor(fadeSeconds * RATE); - for (let i = 0; i < n; i++) { - const k = i / n; - samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); - } - return samples.slice(0, samples.length - n); -} - -// ---------- звуки ---------- - -/** Гулкий удар колокольчиком: 4 негармоничных партиала + шумовой клик. */ -function bellHit() { - const dur = 0.35; - const s = synth(dur, (t) => { - const body = - bellPartial(520, 1.0, dur, 5)(t) * 0.5 + - bellPartial(520, 1.52, dur, 6)(t) * 0.3 + - bellPartial(520, 2.38, dur * 0.6, 7)(t) * 0.2 + - bellPartial(520, 3.91, dur * 0.4, 8)(t) * 0.12; - const click = t < 0.008 ? (makeRng(1)() * 2 - 1) * (1 - t / 0.008) * 0.4 : 0; - return body * 0.8 + click; - }); - return normalize(s, 0.7); -} - -/** Низкий резонанс: медленный гул с тремоло — «усыпляет пепел». */ -function bellLow() { - const dur = 0.9; - const s = synth(dur, (t) => { - const trem = 1 + 0.3 * Math.sin(2 * Math.PI * 6 * t); - const body = - Math.sin(2 * Math.PI * 110 * t) * 0.5 + - Math.sin(2 * Math.PI * 165 * t) * 0.25 + - Math.sin(2 * Math.PI * 220.5 * t) * 0.12; - return body * decay(t, dur, 2) * trem; - }); - return normalize(s, 0.6); -} - -/** Шипение: сгусток просыпается. */ -function ashHiss() { - const dur = 0.4; - const noise = bandNoise(11, 900, 4200, dur); - const s = synth(dur, (t) => noise() * Math.min(1, t / 0.08) * decay(t, dur, 2)); - return normalize(s, 0.55); -} - -/** Смерть сгустка: шум с падающим «выдохом». */ -function ashDie() { - const dur = 0.6; - const noise = bandNoise(23, 300, 2400, dur); - const s = synth(dur, (t) => { - const pitch = 1 - 0.7 * (t / dur); // темнеет к концу - return noise() * decay(t, dur, 3) * (0.4 + pitch); - }); - return normalize(s, 0.6); -} - -/** Плевок: синус с падающей высотой + шипящий хвост. */ -function spit() { - const dur = 0.2; - const noise = bandNoise(37, 2000, 6000, dur); - const s = synth(dur, (t) => { - const f = 700 - 350 * (t / dur); - return (Math.sin(2 * Math.PI * f * t) * 0.6 + noise() * 0.4) * decay(t, dur, 3); - }); - return normalize(s, 0.5); -} - -/** Урон герою: глухой низкий удар. */ -function hurt() { - const dur = 0.25; - const noise = bandNoise(41, 80, 400, dur); - const s = synth(dur, (t) => { - const thump = Math.sin(2 * Math.PI * 90 * t) * decay(t, dur, 3); - return thump * 0.8 + noise() * 0.3 * decay(t, dur * 0.5, 2); - }); - return normalize(s, 0.65); -} - -/** Шаг по пеплу: короткий шорох. */ -function step() { - const dur = 0.12; - const noise = bandNoise(53, 300, 1500, dur); - const s = synth(dur, (t) => noise() * decay(t, dur, 2) * 0.5); - return normalize(s, 0.3); -} - -/** Клик UI. */ -function uiClick() { - const dur = 0.06; - const s = synth(dur, (t) => Math.sin(2 * Math.PI * 880 * t) * decay(t, dur, 3) * 0.5); - return normalize(s, 0.4); -} - -/** Колокольчик Милы: высокий, почти хрустальный. */ -function chime() { - const dur = 0.5; - const s = synth(dur, (t) => { - const body = - Math.sin(2 * Math.PI * 1560 * t) * 0.5 + - Math.sin(2 * Math.PI * 1560 * 1.61 * t) * 0.25; - return body * decay(t, dur, 3); - }); - return normalize(s, 0.45); -} - -/** Амбиент лугов: низкий ветер + редкий гул, зациклен. */ -function musicMeadows() { - const dur = 8; - const wind = bandNoise(61, 100, 600, dur); - const r = makeRng(7); - const gusts = []; - for (let i = 0; i < 3; i++) gusts.push({ at: r() * dur, len: 1.5 + r() }); - const s = synth(dur, (t) => { - let v = wind() * 0.25; - // медленное «дыхание» ветра - v *= 0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2 + 1); - for (const g of gusts) { - const d = t - g.at + (t < g.at ? dur : 0); // зациклить порывы - if (d >= 0 && d < g.len) v += Math.sin(2 * Math.PI * 82 * d) * 0.1 * decay(d, g.len, 2); - } - return v; - }); - return loopify(normalize(s, 0.5)); -} - -/** Амбиент прудов: ниже, влажнее, с редкой капелью. */ -function musicPonds() { - const dur = 8; - const wind = bandNoise(71, 60, 400, dur); - const r = makeRng(13); - const drops = []; - for (let i = 0; i < 4; i++) drops.push({ at: r() * dur, f: 900 + r() * 600 }); - const s = synth(dur, (t) => { - let v = wind() * 0.3 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 3)); - for (const d of drops) { - const dt = t - d.at + (t < d.at ? dur : 0); - if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.08 * decay(dt, 0.15, 2); - } - return v; - }); - return loopify(normalize(s, 0.5)); -} - -/** - * Амбиент Звенца: низкий колокольный тон (два удара за цикл) над тихим - * ветром. Колокол башни отлит из рельсов — голос ниже лугового гула. - */ -function musicZvenets() { - const dur = 8; - const wind = bandNoise(81, 80, 500, dur); - const strikes = [ - { at: 1.0, f: 148 }, - { at: 5.0, f: 111 } - ]; - const s = synth(dur, (t) => { - let v = wind() * 0.2 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 0.5)); - for (const s of strikes) { - const dt = t - s.at + (t < s.at ? dur : 0); // зациклить удары - if (dt >= 0 && dt < 2.5) { - v += - (Math.sin(2 * Math.PI * s.f * dt) * 0.22 + - Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.08 + - Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.04) * - decay(dt, 2.5, 3); - } - } - return v; - }); - return loopify(normalize(s, 0.55)); -} - -// ---------- запись ---------- - -const SFX = { - 'sfx/bell_hit': bellHit, - 'sfx/bell_low': bellLow, - 'sfx/ash_hiss': ashHiss, - 'sfx/ash_die': ashDie, - 'sfx/spit': spit, - 'sfx/hurt': hurt, - 'sfx/step': step, - 'sfx/ui_click': uiClick, - 'sfx/chime': chime, - 'music/meadows': musicMeadows, - 'music/ponds': musicPonds, - 'music/zvenets': musicZvenets -}; - -mkdirSync(`${OUT}sfx`, { recursive: true }); -mkdirSync(`${OUT}music`, { recursive: true }); -for (const [name, gen] of Object.entries(SFX)) { - const buf = encodeWav(gen()); - writeFileSync(`${OUT}${name}.wav`, buf); - console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`); -} \ No newline at end of file diff --git a/tools/cam-probe.mjs b/tools/cam-probe.mjs deleted file mode 100644 index e84d7f5..0000000 --- a/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/tools/checks/agent-invariants.mjs b/tools/checks/agent-invariants.mjs deleted file mode 100644 index f421d3a..0000000 --- a/tools/checks/agent-invariants.mjs +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Сценарий agent:invariants — мост жив, мир стабилен. - * Новая игра -> 300 фиксированных шагов -> движковые/сценические/контентные - * инварианты не содержат ошибок. - * Запуск: node tools/agent.mjs run tools/checks/agent-invariants.mjs - */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; - -export default async function ({ pretty }) { - const c = new Checks('agent-invariants'); - const server = await startDevServer(); - let ctx; - try { - await c.run('новая игра через мост', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - const s = await ctx.agent.snapshot(); - c.expect(s.scene === 'location', `сцена не location`, { scene: s.scene, err: s.error }); - return null; - }); - await c.run('300 шагов без ошибок инвариантов', async () => { - await ctx.agent.step(300); - const inv = await ctx.agent.invariants(); - const errs = inv.filter((i) => i.severity === 'error'); - c.expect(errs.length === 0, 'инварианты нарушены', errs); - return null; - }); - await c.run('снапшот содержит героя и флаги', async () => { - const s = await ctx.agent.snapshot(); - c.expect(!!s.hero?.tile, 'нет s.hero.tile', s); - c.expect(Array.isArray(s.flags), 'нет s.flags'); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; -} \ No newline at end of file diff --git a/tools/checks/ai.mjs b/tools/checks/ai.mjs deleted file mode 100644 index 1b6fbe2..0000000 --- a/tools/checks/ai.mjs +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Сценарий ai — ИИ врагов (батч 4). - * 1) тяжеловес с патрулём стартует бодрым в patrol и реально ходит; - * 2) тихий шум (0.35) при далёком герое — настороженность (wary); - * 3) не увидел героя — wary гаснет, возврат к дому и снова patrol; - * 4) герой рядом с LOS — погоня (chase); - * 5) герой далеко — потеря погони (return -> patrol); - * 6) мало hp — отступление бегом (flee), дистанция растёт; - * 7) инварианты (enemy-state-legal, enemy-in-bounds) чисты. - * Запуск: node tools/agent.mjs run tools/checks/ai.mjs - */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; - -const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); -/** Тяжеловес из снапшота. */ -const heavy = (s) => (s.enemies ?? []).find((e) => e.kind === 'heavy'); - -export default async function ({ pretty }) { - const c = new Checks('ai'); - const server = await startDevServer(); - let ctx; - try { - await c.run('патруль: тяжеловес бодр и ходит по маршруту', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - const s0 = await ctx.agent.snapshot(); - const h0 = heavy(s0); - c.expect(h0?.state === 'patrol', 'тяжеловес не в patrol на старте', h0); - // Спавн (6,22), патруль к (4,20): позиция меняется без участия героя. - const w = await ctx.agent.waitFor( - '(s.enemies.find((e) => e.kind === "heavy")?.pos.x ?? 9) < 6', - { timeoutTicks: 600 } - ); - c.expect(w.ok, 'тяжеловес не сдвинулся с места в патруле', w.snapshot.enemies); - return null; - }); - - await c.run('тихий шум настораживает патрульного', async () => { - // Герой далеко (спавн 14,14), шум у маршрута тяжеловеса. - await ctx.agent.command('scene:noise', { x: 5, y: 21, level: 0.35 }); - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "wary"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не насторожился от шума', w.snapshot.enemies); - return null; - }); - - await c.run('настороженность гаснет: wary -> return -> patrol', async () => { - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', - { timeoutTicks: 900 } - ); - c.expect(w.ok, 'тяжеловес не вернулся к патрулю', w.snapshot.enemies); - return null; - }); - - await c.run('герой рядом — погоня', async () => { - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); - await ctx.agent.command('scene:teleport', { x: 5, y: 22 }); - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "chase"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не погнался за героем', w.snapshot.enemies); - return null; - }); - - await c.run('герой далеко — погоня гаснет и враг уходит домой', async () => { - await ctx.agent.command('scene:teleport', { x: 14, y: 14 }); - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', - { timeoutTicks: 900 } - ); - c.expect(w.ok, 'тяжеловес не отступил к дому', w.snapshot.enemies); - return null; - }); - - await c.run('мало hp — отступление бегом', async () => { - await ctx.agent.command('scene:teleport', { x: 6, y: 21 }); - const dmg = await ctx.agent.command('scene:damageEnemy', { id: 'heavy', value: 6 }); - c.expect(dmg === true, 'scene:damageEnemy не нашла тяжеловеса'); - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "flee"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не отступает при низком hp', w.snapshot.enemies); - // Держится подальше: дистанция до неподвижного героя растёт. - const s1 = await ctx.agent.snapshot(); - const d1 = dist(heavy(s1).pos, s1.hero.pos); - await ctx.agent.waitFor('false', { timeoutTicks: 60 }); // 1 сек наблюдения - const s2 = await ctx.agent.snapshot(); - const d2 = dist(heavy(s2).pos, s2.hero.pos); - c.expect(d2 > d1, 'отступающий не отдаляется', { d1, d2 }); - return null; - }); - - await c.run('инварианты ИИ чисты', async () => { - const inv = await ctx.agent.invariants(); - const errors = (inv ?? []).filter((i) => i.severity === 'error'); - c.expect(errors.length === 0, 'инварианты нарушены', errors); - return null; - }); - } finally { - await ctx?.browser?.close().catch(() => {}); - await server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; -} \ No newline at end of file diff --git a/tools/checks/interact-world.mjs b/tools/checks/interact-world.mjs deleted file mode 100644 index 76d3b91..0000000 --- a/tools/checks/interact-world.mjs +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Сценарий interact-world — взаимодействие с миром шире (батч 3). - * 1) столб у тропы: тост с текстом знака; - * 2) подбор мота: предмет в сумке, вар motes растёт, used:; - * 3) второй и третий моты: вар копится (addVar, порядок не важен); - * 4) знак наката у прудов; - * 5) верёвка колокола: флаг rang_tower, повтор — другой текст; - * 6) Мила: знакомство -> повторный диалог с веткой по мотам; - * 7) инвариант interact-used-consistent чист. - * Запуск: node tools/agent.mjs run tools/checks/interact-world.mjs - */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; - -export default async function ({ pretty }) { - const c = new Checks('interact-world'); - const server = await startDevServer(); - let ctx; - try { - await c.run('столб у тропы: тост с текстом знака', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.command('scene:sleepAll'); - // Столб (25,13) у тропы в Звенец — клик издалека: герой подойдёт сам. - await ctx.agent.tapTile(25, 13); - const w = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { - timeoutTicks: 1500 - }); - c.expect(w.ok, 'знак не показал текст', w.snapshot.lastToast); - return null; - }); - await c.run('мот 1: предмет + вар + used', async () => { - await ctx.agent.tapTile(11, 14); - const w = await ctx.agent.waitFor( - '(s.inventory.find((i) => i.id === "mote")?.count ?? 0) === 1 && s.vars.motes === 1', - { timeoutTicks: 1500 } - ); - c.expect(w.ok, 'подбор мота не дал предмет и вар', { - inventory: w.snapshot.inventory, - vars: w.snapshot.vars - }); - const s = await ctx.agent.snapshot(); - const mote = (s.interactables ?? []).find((o) => o.id === 'mote_1'); - c.expect(mote?.used === true, 'мот не помечен used', mote); - return null; - }); - await c.run('моты 2 и 3: вар копится (addVar)', async () => { - await ctx.agent.tapTile(14, 20); - const w2 = await ctx.agent.waitFor('s.vars.motes === 2', { timeoutTicks: 1500 }); - c.expect(w2.ok, 'второй мот не поднял вар до 2', w2.snapshot.vars); - await ctx.agent.tapTile(14, 6); - const w3 = await ctx.agent.waitFor('s.vars.motes === 3', { timeoutTicks: 1500 }); - c.expect(w3.ok, 'третий мот не поднял вар до 3', w3.snapshot.vars); - return null; - }); - await c.run('знак наката у прудов', async () => { - // Тропа на пруды (2,2): step-переход; у берега — столб (3,2). - const w = await ctx.agent.walkTo(2, 2, { timeoutTicks: 3000 }); - c.expect(w, 'не дошёл до тропы на пруды'); - await ctx.agent.waitFor('s.area === "ponds"', { timeoutTicks: 600 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(3, 2); - const ws = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("накатом")', { - timeoutTicks: 900 - }); - c.expect(ws.ok, 'знак наката не показал текст', ws.snapshot.lastToast); - // Обратно на луга (2,2) прудов. - const wb = await ctx.agent.walkTo(2, 2, { timeoutTicks: 3000 }); - c.expect(wb, 'не вернулся к тропе с прудов'); - await ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 600 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - return null; - }); - await c.run('верёвка колокола: флаг + повтор с другим текстом', async () => { - // Звенец через тропу (26,14); верёвка у башни (13,7) — клик издалека. - const w = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - c.expect(w, 'не дошёл до тропы в Звенец'); - await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(13, 7); - const wr = await ctx.agent.waitFor('s.flags.includes("rang_tower")', { - timeoutTicks: 1500 - }); - c.expect(wr.ok, 'звон не поднял rang_tower', wr.snapshot.flags); - const s1 = await ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('ушёл в землю'), 'первый звон — не тот текст', text1); - await ctx.agent.tapTile(13, 7); - await ctx.agent.step(150); - const s2 = await ctx.agent.snapshot(); - const text2 = s2.lastToast?.text ?? ''; - c.expect( - text2.includes('снова') && text2 !== text1, - 'повторный звон не сменил текст', - text2 - ); - return null; - }); - await c.run('Мила: знакомство -> повтор с веткой по мотам', async () => { - // Первое знакомство (trader_first): полотно. - await ctx.agent.tapTile(17, 11); - const d1 = await ctx.agent.runDialogue(900); - c.expect(d1, 'первый диалог с Милой не завершился'); - // Повтор (trader_repeat): при моты >= 1 после первой реплики идёт - // ветка о мота́х — листаем вручную и ловим её текст. - await ctx.agent.tapTile(17, 11); - const o = await ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 900 }); - c.expect(o.ok, 'повторный диалог не открылся'); - await ctx.agent.press('advance'); - const w = await ctx.agent.waitFor( - 's.dialogue != null && (s.dialogue.text ?? "").includes("Моты")', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'в повторном диалоге нет ветки о мота́х', w.snapshot.dialogue); - const d2 = await ctx.agent.runDialogue(600); - c.expect(d2, 'повторный диалог не завершился'); - return null; - }); - await c.run('инвариант: used-флаги и снапшот согласованы', async () => { - const inv = await ctx.agent.invariants(); - const bad = inv.filter((i) => i.severity === 'error'); - c.expect(bad.length === 0, 'ошибки инвариантов', bad); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; -} \ No newline at end of file diff --git a/tools/checks/interact.mjs b/tools/checks/interact.mjs deleted file mode 100644 index 91c822b..0000000 --- a/tools/checks/interact.mjs +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Сценарий interact — интерьеры и интерактивные объекты. - * 1) дверь: клик по дому Ирвина -> интерьер house_elder; - * 2) сундук (once): предмет в сумке, used:, повтор молчит; - * 3) очаг: реакция зависит от флага quest_bells_done; - * 4) записка (once): флаг read_note; - * 5) выход через проём: step kind 'return' -> Звенец, герой на тайле входа; - * 6) лавка Милы: прилавок меняет реакцию с флагом. - * Запуск: node tools/agent.mjs run tools/checks/interact.mjs - */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; - -export default async function ({ pretty }) { - const c = new Checks('interact'); - const server = await startDevServer(); - let ctx; - let entryTile = null; // тайл, с которого герой кликнул дверь (цель kind 'return') - try { - await c.run('дверь: дом Ирвина -> интерьер', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.command('scene:sleepAll'); - // Добираемся до Звенца пешком (луга -> тропа (26,14)). - const w0 = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - c.expect(w0, 'walkTo(26,14) не дошёл'); - const wz = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); - c.expect(wz.ok, 'не в Звенце после перехода', { area: wz.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - // Подводим героя к дому (тайл дома (7,6)) и кликаем по нему. - const wh = await ctx.agent.walkTo(8, 6, { timeoutTicks: 2000 }); - c.expect(wh, 'не дошёл до дома Ирвина'); - entryTile = (await ctx.agent.snapshot()).hero.tile; - await ctx.agent.tapTile(7, 6); - const w = await ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'клик по дому не открыл интерьер', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 5 && s.hero.tile.y === 7, 'герой не у проёма (5,7)', s.hero.tile); - return null; - }); - await c.run('сундук: предмет + used, повтор молчит', async () => { - const before = await ctx.agent.snapshot(); - const had = before.inventory.find((i) => i.id === 'cloth')?.count ?? 0; - // Клик по сундуку (3,3) — герой подходит и открывает. - await ctx.agent.tapTile(3, 3); - const w = await ctx.agent.waitFor( - `(s.inventory.find((i) => i.id === 'cloth')?.count ?? 0) === ${had + 1}`, - { timeoutTicks: 1200 } - ); - c.expect(w.ok, 'сундук не дал полотно', { inventory: w.snapshot.inventory }); - const s = await ctx.agent.snapshot(); - const chest = (s.interactables ?? []).find((o) => o.id === 'chest_elder'); - c.expect(chest?.used === true, 'сундук не помечен used', chest); - c.expect((s.flags ?? []).includes('used:chest_elder'), 'нет флага used:chest_elder'); - // Повтор: тик последнего тоста не меняется. - const t0 = s.lastToast?.tick ?? -1; - await ctx.agent.tapTile(3, 3); - await ctx.agent.step(120); - const s2 = await ctx.agent.snapshot(); - c.expect((s2.lastToast?.tick ?? -1) === t0, 'повторное взаимодействие не молчит', s2.lastToast); - return null; - }); - await c.run('очаг: реакция меняется с флагом', async () => { - await ctx.agent.tapTile(8, 0); - await ctx.agent.step(120); - const s1 = await ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('щепки'), 'реакция очага до флага не та', text1); - await ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); - await ctx.agent.tapTile(8, 0); - await ctx.agent.step(120); - const s2 = await ctx.agent.snapshot(); - c.expect( - (s2.lastToast?.text ?? '').includes('ровнее'), - 'реакция очага после флага не та', - s2.lastToast - ); - return null; - }); - await c.run('записка: флаг read_note', async () => { - await ctx.agent.tapTile(10, 4); - await ctx.agent.step(120); - const s = await ctx.agent.snapshot(); - c.expect((s.flags ?? []).includes('read_note'), 'записка не подняла read_note', s.flags); - return null; - }); - await c.run('выход через проём: возврат в Звенец', async () => { - // Клик по тайлу проёма (5,8) — герой доходит, step-переход kind 'return'. - await ctx.agent.tapTile(5, 8); - const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'проём не вернул в Звенец', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect( - s.hero.tile.x === entryTile.x && s.hero.tile.y === entryTile.y, - 'герой не на тайле, откуда кликнул дверь', - { entry: entryTile, after: s.hero.tile } - ); - return null; - }); - await c.run('лавка Милы: прилавок меняет реакцию', async () => { - // Клик по лавке (21,9) — интерьер; прилавок (5,3). - await ctx.agent.tapTile(21, 9); - const w = await ctx.agent.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'лавка не открылась', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(5, 3); - await ctx.agent.step(120); - const s1 = await ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('соль'), 'реакция прилавка до флага не та', text1); - await ctx.agent.tapTile(5, 3); - await ctx.agent.step(120); - const s2 = await ctx.agent.snapshot(); - c.expect( - (s2.lastToast?.text ?? '').includes('цветы'), - 'прилавок не изменил реакцию после флага', - s2.lastToast - ); - return null; - }); - await c.run('снапшот: интерактивные объекты области', async () => { - const s = await ctx.agent.snapshot(); - const list = s.interactables ?? []; - c.expect(list.some((o) => o.id === 'counter_shop'), 'прилавка нет в снапшоте', list); - c.expect(list.every((o) => typeof o.used === 'boolean'), 'объект без поля used', list); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; -} \ No newline at end of file diff --git a/tools/checks/transitions.mjs b/tools/checks/transitions.mjs deleted file mode 100644 index 47b74a4..0000000 --- a/tools/checks/transitions.mjs +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Сценарий transitions — единый механизм переходов. - * 1) step-переход: пешком в Звенец -> area==='zvenets'. - * 2) клик-переход: клик по колодцу у дома Ирвина -> телепорт на луга (entry (22,21)). - * 3) обратный колодец: клик по луговому колодцу -> возврат в Звенец. - * 4) снапшот перечисляет переходы области с триггерами. - * Запуск: node tools/agent.mjs run tools/checks/transitions.mjs - */ -import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; - -export default async function ({ pretty }) { - const c = new Checks('transitions'); - const server = await startDevServer(); - let ctx; - try { - await c.run('step-переход: луга -> Звенец пешком', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - // Спим врагов, чтобы путь был безопасным; идём к тропе (26,14). - await ctx.agent.command('scene:sleepAll'); - const walked = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - c.expect(walked, 'walkTo(26,14) не дошёл'); - const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); - c.expect(w.ok, 'после тропы не в Звенце', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - return null; - }); - await c.run('клик-переход: колодец Звенца -> луга', async () => { - // Колодец (9,6) у дома Ирвина; entry на лугах — (22,21). - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(9, 6); - const w = await ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'колодец не телепортировал на луга', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 22 && s.hero.tile.y === 21, 'герой не на entry (22,21)', s.hero.tile); - return null; - }); - await c.run('обратный колодец: луга -> Звенец', async () => { - // Луговой колодец (22,20) стоит рядом с entry — кликаем по нему. - await ctx.agent.tapTile(22, 20); - const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'луговой колодец не вернул в Звенец', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 9 && s.hero.tile.y === 7, 'герой не на entry (9,7) Звенца', s.hero.tile); - return null; - }); - await c.run('снапшот: переходы области с триггерами', async () => { - const s = await ctx.agent.snapshot(); - const wells = (s.transitions ?? []).filter((t) => t.trigger === 'click'); - c.expect(wells.length >= 1, 'нет click-переходов в снапшоте', s.transitions); - c.expect( - (s.transitions ?? []).every((t) => typeof t.to === 'string' && t.to.length > 0), - 'переход без цели', - s.transitions - ); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; -} \ No newline at end of file diff --git a/tools/far-test.mjs b/tools/far-test.mjs deleted file mode 100644 index 56beb2e..0000000 --- a/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/tools/font-probe.mjs b/tools/font-probe.mjs deleted file mode 100644 index 3a173c1..0000000 --- a/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/tools/maps/gen.test.ts b/tools/maps/gen.test.ts deleted file mode 100644 index be6b719..0000000 --- a/tools/maps/gen.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import { encodeMap, parseMap } from '../../packages/engine/src/map/mapFormat'; -import { - buildMeadowsMap, - buildPondsMap, - buildZvenetsMap, - buildHouseElderMap, - buildShopMap -} from '../../apps/game/src/data/map'; - -/** - * Генератор карт-файлов: `npm run maps` (vitest run tools/maps/gen.test.ts). - * Карты строятся из data/map.ts (единый источник истины), пакуются в RLE - * через движковый encodeMap и пишутся в apps/game/assets/maps/*.map. - * Здесь же проверяется обратная распаковка: parseMap должен вернуть карту - * попиксельно равную исходной. - */ - -const here = dirname(fileURLToPath(import.meta.url)); -const outDir = resolve(here, '../../apps/game/assets/maps'); - -const MAPS: Record> = { - meadows: buildMeadowsMap(), - ponds: buildPondsMap(), - zvenets: buildZvenetsMap(), - house_elder: buildHouseElderMap(), - shop: buildShopMap() -}; - -describe('карты-файлы на диске равны генераторам (fresh)', () => { - for (const [id, data] of Object.entries(MAPS)) { - it(`${id}.map не разъехался с генератором (npm run maps лечит)`, () => { - const path = resolve(outDir, `${id}.map`); - const onDisk = JSON.parse(readFileSync(path, 'utf8')); - expect(onDisk).toEqual(encodeMap(data)); - }); - } -}); - -describe('генерация карт-файлов (rpg-map, RLE)', () => { - for (const [id, data] of Object.entries(MAPS)) { - it(`${id}: encode -> файл -> parse восстанавливает карту`, () => { - const file = encodeMap(data); - const back = parseMap(JSON.parse(JSON.stringify(file))); - - expect(back.width).toBe(data.width); - expect(back.height).toBe(data.height); - expect(back.blocked).toEqual(data.blocked); - expect(back.tiles).toEqual(data.tiles); - expect(back.tall).toEqual(data.tall); - - mkdirSync(outDir, { recursive: true }); - const path = resolve(outDir, `${id}.map`); - writeFileSync(path, JSON.stringify(file)); - }); - } - - it('RLE реально сжимает карты', () => { - for (const [id, data] of Object.entries(MAPS)) { - const file = encodeMap(data); - const pairs = file.tiles as [number, number][]; - // Серий меньше, чем тайлов: RLE что-то ужал, несмотря на пятнистость - expect(pairs.length).toBeLessThan(data.tiles.length); - expect(file.encoding).toBe('rle'); - expect(id.length).toBeGreaterThan(0); - } - }); -}); \ No newline at end of file diff --git a/tools/pixelart/canvas.mjs b/tools/pixelart/canvas.mjs deleted file mode 100644 index 9bdb000..0000000 --- a/tools/pixelart/canvas.mjs +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Мини-канвас с палитрой: пиксель, ромб, шум, ASCII-карты спрайтов. - */ -import { RGBA, TRANSPARENT } from './palette.mjs'; -import { encodePng } from './png.mjs'; - -export class Canvas { - constructor(width, height) { - this.width = width; - this.height = height; - this.data = Buffer.alloc(width * height * 4); - this.data.fill(0); - } - - set(x, y, color, alpha = 255) { - if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; - const i = (y * this.width + x) * 4; - if (color === null) { - this.data.fill(0, i, i + 4); - return; - } - const [r, g, b] = RGBA[color] ?? color; - this.data[i] = r; - this.data[i + 1] = g; - this.data[i + 2] = b; - this.data[i + 3] = alpha; - } - - get(x, y) { - if (x < 0 || y < 0 || x >= this.width || y >= this.height) return null; - const i = (y * this.width + x) * 4; - if (this.data[i + 3] === 0) return null; - return [this.data[i], this.data[i + 1], this.data[i + 2], 255]; - } - - toPng() { - return encodePng(this.width, this.height, this.data); - } -} - -/** Детерминированный ГПСЧ (mulberry32). */ -export function rng(seed) { - return () => { - seed |= 0; - seed = (seed + 0x6d2b79f5) | 0; - let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** - * Залить ромб 2:1 по канвасу: base — базовый цвет; свет на верхних гранях, - * тень на нижних. light/dark — ключи палитры или null. - * Центр ромба (cx, cy), ширина w, высота h. - */ -export function fillDiamond(canvas, cx, cy, w, h, base, light = null, dark = null) { - const hw = w / 2; - const hh = h / 2; - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const rx = x - hw + 0.5; - const ry = y - hh + 0.5; - if (Math.abs(rx) / hw + Math.abs(ry) / hh <= 1) { - canvas.set(cx - hw + x, cy - hh + y, base); - } - } - } - for (let i = 0; i < hw; i++) { - // Верхний склон: от левого угла к верхней вершине (свет) и от вершины к правому углу. - const leftY = cy - Math.round((i / hw) * hh); - const rightY = cy - hh + Math.round((i / hw) * hh); - if (light) { - canvas.set(cx - hw + i, leftY, light); - canvas.set(cx + i, rightY, light); - } - // Нижний склон: от левого угла вниз к нижней вершине (тень) и от неё к правому углу. - const botLeftY = cy + Math.round((i / hw) * hh); - const botRightY = cy + hh - Math.round((i / hw) * hh); - if (dark) { - canvas.set(cx - hw + i, botLeftY, dark); - canvas.set(cx + i, botRightY, dark); - } - } -} - -/** - * ASCII-карта -> спрайт. Каждый символ — ключ палитры; '.' — прозрачный пиксель. - * Дополнительные символы передаются в extra ('x' -> 'P1' и т.п.). - */ -export function fromAscii(rows, extra = {}) { - const h = rows.length; - const w = Math.max(...rows.map((r) => r.length)); - const canvas = new Canvas(w, h); - for (let y = 0; y < h; y++) { - for (let x = 0; x < rows[y].length; x++) { - const ch = rows[y][x]; - if (ch === '.') { - continue; - } - const key = extra[ch] ?? ch; - if (!RGBA[key]) { - throw new Error(`Неизвестный символ '${ch}' в строке ${y}: ${rows[y]}`); - } - canvas.set(x, y, key); - } - } - return canvas; -} \ No newline at end of file diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs deleted file mode 100644 index 5b068f4..0000000 --- a/tools/pixelart/gen.mjs +++ /dev/null @@ -1,915 +0,0 @@ -/** - * Генератор пиксель-арта «Пепельных лугов». - * Запуск: node tools/pixelart/gen.mjs - * Выход: apps/game/assets/tiles/*.png, apps/game/assets/chars/*.png - */ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { Canvas, fillDiamond, fromAscii, rng } from './canvas.mjs'; - -const OUT = fileURLToPath(new URL('../../apps/game/assets/', import.meta.url)); - -// ---------- тайлы 32x16 ---------- - -/** Ромб с зерном пепла/травы. Без обводки граней: смежные тайлы сливаются в поле. */ -function tileBase(base, light, dark, seed, specks) { - const c = new Canvas(32, 16); - fillDiamond(c, 16, 8, 32, 16, base); - const r = rng(seed); - for (let i = 0; i < specks; i++) { - const x = Math.floor(r() * 32); - const y = Math.floor(r() * 16); - if (c.get(x, y)) { - c.set(x, y, r() < 0.5 ? dark : base); - } - } - return c; -} - -function grassTile() { - const c = tileBase('G1', 'G2', 'G0', 11, 26); - // Сухие стебли: 1-2 пикселя G2H. - const r = rng(77); - for (let i = 0; i < 5; i++) { - const x = 3 + Math.floor(r() * 26); - const y = 3 + Math.floor(r() * 10); - if (c.get(x, y)) { - c.set(x, y, 'G2H'); - } - } - return c; -} - -function pathTile() { - const c = tileBase('P4', 'P5', 'P3', 23, 30); - // Утоптанный камешек и след колеи. - const r = rng(31); - for (let i = 0; i < 4; i++) { - const x = 2 + Math.floor(r() * 28); - const y = 2 + Math.floor(r() * 12); - if (c.get(x, y)) c.set(x, y, 'P2'); - } - return c; -} - -function waterTile() { - const c = tileBase('W1', 'W2', 'W0', 31, 0); - // Рябь: короткие горизонтальные штрихи W2 и глубина W0. - const r = rng(5); - for (let i = 0; i < 5; i++) { - const x = 4 + Math.floor(r() * 22); - const y = 2 + Math.floor(r() * 12); - c.set(x, y, 'W2'); - c.set(x + 1, y, 'W2'); - } - for (let i = 0; i < 3; i++) { - const x = 6 + Math.floor(r() * 20); - const y = 5 + Math.floor(r() * 9); - c.set(x, y, 'W0'); - c.set(x + 1, y, 'W0'); - } - return c; -} - -function ashTile() { - // Пепельный бугор: чуть светлее травы, тоже без обводки. - const c = new Canvas(32, 16); - fillDiamond(c, 16, 8, 32, 16, 'P3'); - const r = rng(9); - for (let i = 0; i < 6; i++) { - const x = 3 + Math.floor(r() * 26); - const y = 4 + Math.floor(r() * 9); - if (c.get(x, y)) c.set(x, y, 'P5'); - } - return c; -} - -function bellflowerTile() { - const c = grassTile(); - // Три цветка: золото 3px + хайлайт. Тёплый цвет — только жизнь. - const flowers = [ - [10, 7], - [21, 9], - [16, 5] - ]; - for (const [x, y] of flowers) { - c.set(x, y - 1, 'B2'); - c.set(x - 1, y, 'B1'); - c.set(x, y, 'B1'); - c.set(x + 1, y, 'B1'); - c.set(x, y + 1, 'G2H'); // стебель - } - return c; -} - -/** Дерево Ржавой рощи 32x48: ржавый ствол, редкая серо-оливковая крона, кабель. */ -function treeTile() { - const c = new Canvas(32, 48); - const r = rng(2026); - - const ellipse = (cx, cy, rx, ry, color) => { - for (let y = -ry; y <= ry; y++) { - for (let x = -rx; x <= rx; x++) { - if ((x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1) { - c.set(cx + x, cy + y, color); - } - } - } - }; - - // Крона: три перекрывающихся «облака» мёртвой листвы. - ellipse(16, 9, 10, 7, 'G0'); - ellipse(9, 14, 6, 5, 'G0'); - ellipse(23, 13, 7, 5, 'G0'); - // Внутренний тон и редкие «живые» пряди. - ellipse(15, 10, 7, 4, 'G1'); - ellipse(10, 14, 3, 2, 'G1'); - ellipse(21, 12, 4, 2, 'G1'); - for (let i = 0; i < 26; i++) { - const x = 6 + Math.floor(r() * 20); - const y = 3 + Math.floor(r() * 12); - if (c.get(x, y) && r() < 0.5) c.set(x, y, 'G2'); - } - // Прорехи (мёртвое дерево): прозрачные проколы в кроне. - for (const [x, y] of [ - [13, 6], - [19, 12], - [8, 13], - [22, 8] - ]) { - c.set(x, y, null); - c.set(x + 1, y, null); - } - - // Ствол: металл с ржавчиной внизу, лёгкий изгиб, корни в стороны. - for (let y = 18; y <= 47; y++) { - const sway = Math.round(Math.sin((y - 18) * 0.09) * 1.5); - const half = y > 40 ? 2 : 1; - for (let dx = -half; dx <= half; dx++) { - c.set(16 + sway + dx, y, y > 38 ? 'R1' : 'M1'); - } - c.set(16 + sway - half - 1, y, y > 38 ? 'R0' : 'M0'); - } - // Корни. - for (let i = 0; i < 4; i++) { - c.set(12 - i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); - c.set(20 + i, 46 - Math.floor(i / 2), i > 1 ? 'R0' : 'M0'); - } - // Пепел на корнях. - c.set(13, 46, 'P5'); - c.set(19, 46, 'P5'); - - // Кабель через крону и вокруг ствола. - for (let x = 4; x <= 27; x++) { - const y = 16 - Math.round(Math.sin((x - 4) * 0.2) * 1); - if (!c.get(x, y)) c.set(x, y, 'M2'); - else c.set(x, y, 'M2'); - } - for (let y = 22; y <= 44; y += 7) { - c.set(14, y, 'M0'); - c.set(19, y, 'M0'); - } - - return c; -} - -/** Дом Звенца 32x56: стены из вагонных щитов, крыша из платформ. Tall-объект 1.75 юнита. */ -function houseTile() { - const c = new Canvas(32, 56); - const r = rng(777); - - // Стены: вагонные щиты — горизонтальные доски C1 со швами C0. - for (let y = 17; y <= 48; y++) { - const seam = (y - 17) % 6 === 0; - for (let x = 6; x <= 25; x++) { - c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); - } - } - // Вертикальные стойки по краям стен. - for (let y = 17; y <= 48; y++) { - c.set(6, y, 'M1'); - c.set(25, y, 'M1'); - } - - // Крыша-платформа: треугольник из шпал, расширяется книзу. - for (let i = 0; i <= 15; i++) { - const y = 1 + i; - const half = Math.min(13, 1 + Math.floor(i * 0.85)); - for (let dx = -half; dx <= half; dx++) { - c.set(16 + dx, y, i % 4 === 0 ? 'T0' : 'T1'); - } - } - // Свес крыши. - for (let x = 2; x <= 29; x++) { - c.set(x, 18, 'T0'); - } - - // Дверь: тёмный проём с бронзовой ручкой. - for (let y = 32; y <= 48; y++) { - for (let x = 13; x <= 19; x++) c.set(x, y, 'P1'); - } - c.set(18, 40, 'B0'); - - // Окно: тёмный квадрат с металлической рамой. - for (let y = 22; y <= 27; y++) { - for (let x = 9; x <= 13; x++) c.set(x, y, 'P2'); - } - for (let x = 8; x <= 14; x++) { - c.set(x, 21, 'M0'); - c.set(x, 28, 'M0'); - } - for (let y = 21; y <= 28; y++) { - c.set(7, y, 'M0'); - c.set(15, y, 'M0'); - } - c.set(11, 24, 'M1'); // переплёт - - // Фундамент из шпал. - for (let x = 4; x <= 27; x++) { - c.set(x, 49, 'R1'); - c.set(x, 50, 'R0'); - } - - // Пепел у стен. - c.set(3, 52, 'P5'); - c.set(28, 52, 'P5'); - c.set(9, 53, 'P4'); - - return c; -} - -/** Башня Звенца 32x80: водонапорная башня с колоколом из рельсов. Tall-объект 2.5 юнита. */ -/** Пол интерьера: доски вагонных платформ вдоль изометрии, швы темнее. */ -function floorTile() { - const c = new Canvas(32, 16); - fillDiamond(c, 16, 8, 32, 16, 'T1'); - const r = rng(808); - for (let y = 0; y < 16; y++) { - for (let x = 0; x < 32; x++) { - if (!c.get(x, y)) continue; - // Доски «полосами» по экранному Y + случайная щепка. - const seam = y % 5 === 0; - c.set(x, y, seam ? 'T0' : r() < 0.12 ? 'T0' : 'T1'); - } - } - return c; -} - -/** Каменная стена вагонного цеха: блоки C1 со швами C0, тень по низу. */ -function wallTile() { - const c = new Canvas(32, 32); - const r = rng(909); - for (let y = 0; y < 32; y++) { - for (let x = 0; x < 32; x++) { - const seam = y % 8 === 0 || (x + Math.floor(y / 8) * 8) % 16 === 0; - c.set(x, y, seam ? 'C0' : r() < 0.85 ? 'C1' : 'C0'); - } - } - // Нижний ряд темнее (тень у земли). - for (let x = 0; x < 32; x++) { - c.set(x, 30, 'C0'); - c.set(x, 31, 'C0'); - } - return c; -} - -/** Старый телеграфный колодец: каменное жерло, бронзовый обруч — звон = жизнь. */ -function wellTile() { - const c = new Canvas(32, 40); - const r = rng(1010); - - // Тёмное жерло (вода) — верхняя часть, видна внутри кольца. - for (let y = 20; y <= 26; y++) { - const half = Math.floor((26 - y) * 1.8) + 2; - for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'W0'); - } - // Каменная кладка вокруг жерла (эллипс). - for (let y = 16; y <= 28; y++) { - const half = Math.floor((24 - y) * 1.35) + 3; - for (let dx = -half; dx <= half; dx++) { - const rim = Math.abs(dx) > half - 2; - c.set(16 + dx, y, rim ? (r() < 0.8 ? 'C1' : 'C0') : null === c.get(16 + dx, y) ? 'C0' : c.get(16 + dx, y)); - } - } - // Бронзовый обруч по кромке — тёплый: колодец «звонный». - for (let x = 6; x <= 25; x++) { - c.set(x, 15, x % 4 === 0 ? 'B0' : 'B1'); - c.set(x, 16, 'B0'); - } - // Ворото с бронзовой рукоятью. - for (let y = 4; y <= 15; y++) { - c.set(7, y, 'T1'); - c.set(24, y, 'T1'); - } - for (let x = 7; x <= 24; x++) c.set(x, 5, 'T1'); - c.set(12, 4, 'B1'); - c.set(13, 4, 'B2'); // блик бронзы - return c; -} - -function towerTile() { - const c = new Canvas(32, 80); - const r = rng(1414); - - // Бак: цилиндр из полос металла. - for (let y = 12; y <= 40; y++) { - for (let x = 7; x <= 24; x++) { - c.set(x, y, r() < 0.8 ? 'M1' : 'M0'); - } - // Боковые тени. - c.set(7, y, 'M0'); - c.set(8, y, 'M0'); - c.set(24, y, 'M0'); - } - // Пояса обруча. - for (const y of [14, 22, 30, 38]) { - for (let x = 7; x <= 24; x++) c.set(x, y, 'M2'); - } - // Коническая крыша бака. - for (let i = 0; i <= 6; i++) { - const half = 9 - i; - for (let dx = -half; dx <= half; dx++) c.set(16 + dx, 11 - i, 'M2'); - } - // Флюгер-шпиль. - for (let y = 2; y <= 4; y++) c.set(16, y, 'M2'); - c.set(18, 2, 'B1'); // бронзовый флажок — колокол помнит голос - - // Ноги-рельсы. - for (const [lx, rx] of [ - [10, 12], - [19, 21] - ]) { - for (let y = 41; y <= 77; y++) { - c.set(lx, y, 'M2'); - c.set(lx + 1, y, 'M0'); - c.set(rx, y, 'M2'); - c.set(rx - 1, y, 'M0'); - } - } - // Раскосы между ногами. - for (let i = 0; i < 4; i++) { - const y = 46 + i * 8; - for (let dx = 0; dx < 8; dx++) { - c.set(12 + dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); - c.set(19 - dx, y + dx, i % 2 === 0 ? 'M1' : 'M2'); - } - } - - // Колокол из рельсов под баком, между ног. - for (let y = 44; y <= 54; y++) { - const half = y < 50 ? 2 : 3; - for (let dx = -half; dx <= half; dx++) c.set(16 + dx, y, 'B0'); - } - c.set(16, 45, 'B2'); // блик - c.set(15, 48, 'B1'); - c.set(16, 55, 'M0'); // язык - - // Пепел у ног. - c.set(8, 79, 'P5'); - c.set(23, 79, 'P5'); - - return c; -} - -// ---------- персонажи 16x24 ---------- - -// Общие цвета: o — контур/глаза (P0), c/C — плащ тень/свет, -// t/T — шляпа тень/свет, s/S — кожа тень/свет, b/B — бронза, p — сапоги (P1). -const HERO_MAP = { o: 'P0', c: 'C0', C: 'C1', t: 'T0', T: 'T1', s: 'S0', S: 'S1', b: 'B0', B: 'B1', p: 'P1', P: 'P2' }; - -const heroDown1 = fromAscii([ - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '....oSSSSSSo....', - '....oSoSSoSo....', - '....oSSSsSSo....', - '.....oSSSSo.....', - '....oCCCCCCo....', - '...oCCCCCCCCo...', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '..obbBBBBBBbbo..', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '...oCCo..oCCo...', - '....oPo..oPo....', - '....oPo..oPo....', - '...oPPo..oPPo...', - '...oPPo..oPPo...', - '....oo....oo....', - '................' -], HERO_MAP); - -// Второй кадр ходьбы: ноги сошлись (passing), туловище на пиксель ниже — боб походки. -const heroDown2 = fromAscii([ - '................', - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '....oSSSSSSo....', - '....oSoSSoSo....', - '....oSSSsSSo....', - '.....oSSSSo.....', - '....oCCCCCCo....', - '...oCCCCCCCCo...', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '..obbBBBBBBbbo..', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '....oCCoCCo.....', - '....oPo.oPo.....', - '....oPo.oPo.....', - '....oPPoPPo.....', - '.....oPPPo......', - '......ooo.......' -], HERO_MAP); - -// Спина: швы капюшона под шляпой, лампа на поясе (единственный тёплый пиксель). -const heroUpRows = [ - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '.....oTttTo.....', - '....oCCCCCCo....', - '...oCCCCCCCCo...', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '..oCcCCCCCCcCo..', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '..obbBBffBBbbo..', - '..oCCCCCCCCCCo..', - '..oCCCCCCCCCCo..', - '...oCCo..oCCo...', - '....oPo..oPo....', - '....oPo..oPo....', - '...oPPo..oPPo...', - '...oPPo..oPPo...', - '....oo....oo....', - '................' -]; - -const heroUp1 = fromAscii(heroUpRows, { ...HERO_MAP, f: 'F0' }); - -// Второй кадр спины: туловище на пиксель ниже — боб походки. -const heroUp2 = fromAscii(['................', ...heroUpRows.slice(0, -1)], { ...HERO_MAP, f: 'F0' }); - -const heroSide1 = fromAscii([ - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '.....oSSSSSSo...', - '.....oSSoSSo....', - '.....oSSSSSs....', - '......oSSSSo....', - '.....oCCCCCo....', - '....oCCCCCCCo...', - '...oCCCCCCCCCo..', - '...oCCCCCCCCCo..', - '...obbBBBBBBbo..', - '...oCCCCCCCCCo..', - '...oCCCCCCCCCo..', - '....oCCoCCo.....', - '....oPo.oPo.....', - '....oPo.oPo.....', - '...oPPo.oPPo....', - '...oPPo.oPPo....', - '....oo...oo.....', - '................' -], HERO_MAP); - -// Шаг в бок: ноги шире (stride), туловище на пиксель ниже — боб походки. -const heroSide2 = fromAscii([ - '................', - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '..oTttttttttTo..', - '..oooooooooooo..', - '.....oSSSSSSo...', - '.....oSSoSSo....', - '.....oSSSSSs....', - '......oSSSSo....', - '.....oCCCCCo....', - '....oCCCCCCCo...', - '...oCCCCCCCCCo..', - '...oCCCCCCCCCo..', - '...obbBBBBBBbo..', - '...oCCCCCCCCCo..', - '...oCCCCCCCCCo..', - '....oCCoCCo.....', - '...oPo...oPo....', - '...oPo...oPo....', - '..oPPo...oPPo...', - '..oPPo...oPPo...', - '...oo.....oo....' -], HERO_MAP); - -const elderIrwin = fromAscii([ - '................', - '.....oooooo.....', - '....oTTTTTTo....', - '...oTttttttTo...', - '...otttttttto...', - '....ooSSSSoo....', - '....oSoPSoSo....', - '....oSsSsSso....', - '.....oSSSSo.....', - '.....oTTTTo.....', - '....oTssssTo....', - '...oTTsssSTo....', - '...oTtTssTtTo...', - '...oTttTTttTo...', - '...oTttttttTo...', - '...oTttttttTo...', - '...oTttttttTo...', - '...oTttttttTo...', - '....oTtTTtTo....', - '....oTtttTo.....', - '....oTttttTo....', - '....oTttttTo....', - '....oTttttTo....', - '....oooooooo....' -], HERO_MAP); - -const traderMila = fromAscii([ - '................', - '.....oooooo.....', - '....oWWWWWWo....', - '...oWwWWWwWo....', - '...oWWWWWWWo....', - '....oWSSSSo.....', - '....oSoPSoSo....', - '....oSsSsSso....', - '.....oSSSSo.....', - '.....oWWWWo.....', - '....oWwWWwWo....', - '...oWWWWWWWWo...', - '...oWWWWWWWWo...', - '...oWWWBWWWWo...', - '...oWWWWWWWWo...', - '....oWWWWWWo....', - '....oWooWoo.....', - '....oPo..oPo....', - '....oPo..oPo....', - '...oPPo..oPPo...', - '...oPPo..oPPo...', - '....oo....oo....', - '................', - '................' -], { o: 'P0', W: 'W3', w: 'S0', S: 'S1', s: 'S0', P: 'P0', B: 'B1' }); - -// ---------- вывод ---------- - -mkdirSync(OUT + 'tiles', { recursive: true }); -mkdirSync(OUT + 'chars', { recursive: true }); - -function save(canvas, rel) { - writeFileSync(OUT + rel, canvas.toPng()); - console.log(` ${rel}`); -} - -console.log('Генерация тайлов:'); -save(grassTile(), 'tiles/grass.png'); -save(pathTile(), 'tiles/path.png'); -save(waterTile(), 'tiles/water.png'); -save(ashTile(), 'tiles/ash.png'); -save(bellflowerTile(), 'tiles/bellflower.png'); -save(treeTile(), 'tiles/tree.png'); -save(houseTile(), 'tiles/house.png'); -save(towerTile(), 'tiles/tower.png'); -save(floorTile(), 'tiles/floor.png'); -save(wallTile(), 'tiles/wall.png'); -save(wellTile(), 'tiles/well.png'); - -console.log('Генерация персонажей:'); -save(heroDown1, 'chars/hero_down_1.png'); -save(heroDown2, 'chars/hero_down_2.png'); -save(heroUp1, 'chars/hero_up_1.png'); -save(heroUp2, 'chars/hero_up_2.png'); -save(heroSide1, 'chars/hero_side_1.png'); -save(heroSide2, 'chars/hero_side_2.png'); -save(elderIrwin, 'chars/elder_irwin.png'); -save(traderMila, 'chars/trader_mila.png'); - -// ---------- безгласный олень (пейзажная фауна) ---------- -// Выцветший: серо-оливковые G*/M2, никаких тёплых пикселей — голос забран. - -const DEER_MAP = { o: 'P0', m: 'M2', G: 'G1', l: 'G2', d: 'P0' }; - -// Кадр 1: стоит (ноги вместе). -const deerStand = fromAscii([ - '................', - '................', - '...o.....o......', - '....o...o.......', - '.....o.o........', - '......omo.......', - '.....ommmo......', - '....oGGGGo......', - '...oGGdGGo......', - '....oGGGo.......', - '.....oGo........', - '..oGGGGGGGGo....', - '.oGGGGGGGGGGo...', - '.oGllGGGlllGo...', - '.oGllGGGlllGo...', - '..ooGGGGGGoo....', - '...oGo..oGo.....', - '...oGo..oGo.....', - '...oGo..oGo.....', - '...oGo..oGo.....', - '...ooo..ooo.....', - '................', - '................', - '................' -], DEER_MAP); - -// Кадр 2: шаг (ноги врозь). -const deerWalk = fromAscii([ - '................', - '................', - '...o.....o......', - '....o...o.......', - '.....o.o........', - '......omo.......', - '.....ommmo......', - '....oGGGGo......', - '...oGGdGGo......', - '....oGGGo.......', - '.....oGo........', - '..oGGGGGGGGo....', - '.oGGGGGGGGGGo...', - '.oGllGGGlllGo...', - '.oGllGGGlllGo...', - '..ooGGGGGGoo....', - '..oGo....oGo....', - '..oGo....oGo....', - '.oGo......oGo...', - '.oGo......oGo...', - '.ooo......ooo...', - '................', - '................', - '................' -], DEER_MAP); - -save(deerStand, 'chars/fauna_deer_1.png'); -save(deerWalk, 'chars/fauna_deer_2.png'); - -// атлас фауны (кадры 16x24 — та же сетка, что у героя) -const FRAME_W_FAUNA = 16; -const FRAME_H_FAUNA = 24; -const faunaFrames = [deerStand, deerWalk]; -const faunaNames = ['fauna_deer_1', 'fauna_deer_2']; -const faunaSheetW = FRAME_W_FAUNA * faunaFrames.length; -const faunaSheetCanvas = new Canvas(faunaSheetW, FRAME_H_FAUNA); -const faunaJson = {}; -for (let i = 0; i < faunaFrames.length; i++) { - const src = faunaFrames[i]; - for (let y = 0; y < FRAME_H_FAUNA; y++) { - for (let x = 0; x < FRAME_W_FAUNA; x++) { - const rgba = src.get(x, y); - if (rgba) faunaSheetCanvas.set(i * FRAME_W_FAUNA + x, y, rgba); - } - } - faunaJson[faunaNames[i]] = { - frame: { x: i * FRAME_W_FAUNA, y: 0, w: FRAME_W_FAUNA, h: FRAME_H_FAUNA }, - rotated: false, - trimmed: false, - sourceSize: { w: FRAME_W_FAUNA, h: FRAME_H_FAUNA } - }; -} -writeFileSync(OUT + 'chars/fauna_sheet.png', faunaSheetCanvas.toPng()); -writeFileSync( - OUT + 'chars/fauna_sheet.json', - JSON.stringify( - { - frames: faunaJson, - meta: { image: 'fauna_sheet.png', size: { w: faunaSheetW, h: FRAME_H_FAUNA }, scale: 1 } - }, - null, - 2 - ) -); -console.log(` chars/fauna_sheet.png (+ .json): ${faunaSheetW}x${FRAME_H_FAUNA}`); - -// ---------- пепельные сгустки (враги демо-боя) ---------- -// Серые P*/G* тела; тёплое ядро F* — «жизнь» загорается, когда сгусток просыпается. - -const CLUMP_MAP = { o: 'P0', d: 'P2', p: 'P3', q: 'P4', h: 'P5H', f: 'F0', F: 'F1' }; - -// Ползун 14x12: плоский слизень на коротких ножках. -const crawler1 = fromAscii([ - '..............', - '..............', - '...oooooo.....', - '..odppppdo....', - '.odpppppppdo..', - '.dpppffppppdo.', - '.dpppppppppdo.', - '.odpppppppdo..', - '..oddpppddo...', - '..o.o....o.o..', - '..o.o....o.o..', - '..............' -], CLUMP_MAP); - -const crawler2 = fromAscii([ - '..............', - '..............', - '...oooooo.....', - '..odppppdo....', - '.odpppppppdo..', - '.dpppffppppdo.', - '.dpppppppppdo.', - '.odpppppppdo..', - '..oddpppddo...', - '...o.o..o.o...', - '...o.o..o.o...', - '..............' -], CLUMP_MAP); - -// Плевун 14x14: столбик с тёмной пастью. -const spitter1 = fromAscii([ - '....oooooo....', - '...odppppdo...', - '..odppppppdo..', - '..dpppppppppd.', - '..dpppffppppd.', - '..dpppppppppd.', - '..dpoooopppd..', - '..dpppppppppd.', - '...dpppppppd..', - '...odpppppdo..', - '....odpppdo...', - '....o.o.o.o...', - '...o..o..o....', - '..............' -], CLUMP_MAP); - -const spitter2 = fromAscii([ - '....oooooo....', - '...odppppdo...', - '..odppppppdo..', - '..dpppppppppd.', - '..dpppffppppd.', - '..dpppppppppd.', - '..dppoooopppd.', - '..dpppppppppd.', - '...dpppppppd..', - '...odpppppdo..', - '....odpppdo...', - '...o..o..o....', - '....o.o.o.o...', - '..............' -], CLUMP_MAP); - -// Наст 20x20: тяжёлая корка с трещинами; медленно давит. -const heavy1 = fromAscii([ - '.......oooooo.......', - '.....oodppppdoo.....', - '....odppppppppdo....', - '...odppppppppppdo...', - '...dpppddppppppppd..', - '..dppppppddpppppppd.', - '..dppppppppddppffpd.', - '..dpppffppppppppfpd.', - '..dpppffppppppppppd.', - '..dpppppppppppppppd.', - '..dpppppppppppppppd.', - '..dpppppppppppppppd.', - '..odpppppppppppppdo.', - '..odqpppppppppppdo..', - '...odqqppppqqpdo....', - '....odqpppqqpdo.....', - '.....oddddddddo.....', - '...ooooddddooooo....', - '....................', - '....................' -], CLUMP_MAP); - -const heavy2 = fromAscii([ - '.......oooooo.......', - '.....oodppppdoo.....', - '....odppppppppdo....', - '...odppppppppppdo...', - '...dpppppppddppppd..', - '..dpppddpppppddpppd.', - '..dpppppddpppppffpd.', - '..dpppffpppddpppfpd.', - '..dpppffppppppppppd.', - '..dpppppppppppppppd.', - '..dpppppppppppppppd.', - '..dpppppppppppppppd.', - '..odpppppppppppppdo.', - '..odqpppppppppppdo..', - '...odqqppppqqpdo....', - '....odqpppqqpdo.....', - '.....oddddddddo.....', - '...ooooddddooooo....', - '....................', - '....................' -], CLUMP_MAP); - -save(crawler1, 'chars/clump_crawler_1.png'); -save(crawler2, 'chars/clump_crawler_2.png'); -save(spitter1, 'chars/clump_spitter_1.png'); -save(spitter2, 'chars/clump_spitter_2.png'); -save(heavy1, 'chars/clump_heavy_1.png'); -save(heavy2, 'chars/clump_heavy_2.png'); - -// атлас сгустков (кадры разной ширины — выравнивание по низу ячейки 20x20) -const clumpFrames = [crawler1, crawler2, spitter1, spitter2, heavy1, heavy2]; -const clumpNames = ['clump_crawler_1', 'clump_crawler_2', 'clump_spitter_1', 'clump_spitter_2', 'clump_heavy_1', 'clump_heavy_2']; -const CLUMP_W = 20; -const CLUMP_H = 20; -const clumpSheetW = CLUMP_W * clumpFrames.length; -const clumpSheet = new Canvas(clumpSheetW, CLUMP_H); -const clumpJson = {}; -for (let i = 0; i < clumpFrames.length; i++) { - const src = clumpFrames[i]; - for (let y = 0; y < src.height; y++) { - for (let x = 0; x < src.width; x++) { - const rgba = src.get(x, y); - if (rgba) { - clumpSheet.set(i * CLUMP_W + x, CLUMP_H - src.height + y, rgba); - } - } - } - clumpJson[clumpNames[i]] = { - frame: { x: i * CLUMP_W, y: 0, w: CLUMP_W, h: CLUMP_H }, - rotated: false, - trimmed: false, - sourceSize: { w: CLUMP_W, h: CLUMP_H } - }; -} -writeFileSync(OUT + 'chars/clumps_sheet.png', clumpSheet.toPng()); -writeFileSync( - OUT + 'chars/clumps_sheet.json', - JSON.stringify( - { - frames: clumpJson, - meta: { image: 'clumps_sheet.png', size: { w: clumpSheetW, h: CLUMP_H }, scale: 1 } - }, - null, - 2 - ) -); -console.log(` chars/clumps_sheet.png (+ .json): ${clumpSheetW}x${CLUMP_H}`); - -// ---------- атлас героя (Spritesheet: один PNG + JSON) ---------- - -const FRAME_W = 16; -const FRAME_H = 24; -const heroFrames = [heroDown1, heroDown2, heroUp1, heroUp2, heroSide1, heroSide2]; -const heroNames = ['hero_down_1', 'hero_down_2', 'hero_up_1', 'hero_up_2', 'hero_side_1', 'hero_side_2']; - -const sheetW = FRAME_W * heroFrames.length; -const sheetCanvas = new Canvas(sheetW, FRAME_H); -const framesJson = {}; -for (let i = 0; i < heroFrames.length; i++) { - const src = heroFrames[i]; - // Построчное копирование: строка атласа шире кадра, цельный set ломает шаг. - for (let y = 0; y < FRAME_H; y++) { - for (let x = 0; x < FRAME_W; x++) { - const rgba = src.get(x, y); - if (rgba) sheetCanvas.set(i * FRAME_W + x, y, rgba); - } - } - framesJson[heroNames[i]] = { - frame: { x: i * FRAME_W, y: 0, w: FRAME_W, h: FRAME_H }, - rotated: false, - trimmed: false, - sourceSize: { w: FRAME_W, h: FRAME_H } - }; -} -writeFileSync(OUT + 'chars/hero_sheet.png', sheetCanvas.toPng()); -writeFileSync( - OUT + 'chars/hero_sheet.json', - JSON.stringify( - { - frames: framesJson, - meta: { image: 'hero_sheet.png', size: { w: sheetW, h: FRAME_H }, scale: 1 } - }, - null, - 2 - ) -); -console.log(` chars/hero_sheet.png (+ .json): ${sheetW}x${FRAME_H}`); - -console.log('Готово: ' + OUT); \ No newline at end of file diff --git a/tools/pixelart/palette.mjs b/tools/pixelart/palette.mjs deleted file mode 100644 index a0bf80f..0000000 --- a/tools/pixelart/palette.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Палитра из docs/art-style.md — единственный источник цветов арта. - */ - -export const PALETTE = { - // Пепел - P0: '#17171d', - P1: '#23232b', - P2: '#2f2f38', - P3: '#3e3e48', - P4: '#52525c', - P5: '#6a6a74', - P6: '#86868f', - P5H: '#a3a3ac', - // Мёртвая трава - G0: '#2b3026', - G1: '#3d4436', - G2: '#525b44', - G2H: '#687053', - // Ржавчина и металл - R0: '#3a2822', - R1: '#5c3b2e', - R2: '#82503a', - M0: '#26262e', - M1: '#44454e', - M2: '#61626c', - // Вода - W0: '#22343c', - W1: '#32505c', - W2: '#4a737f', - // Тепло — только жизнь - B0: '#8a6a2e', - B1: '#d99a32', - B2: '#f2cd68', - F0: '#d97f2b', - F1: '#f2b45a', - // Люди и ткань - C0: '#3a4356', - C1: '#4d5a72', - W3: '#8a8f7d', - S0: '#8a6d55', - S1: '#c9a689', - T0: '#5a4a3c', - T1: '#77634f' -}; - -/** Прозрачный пиксель. */ -export const TRANSPARENT = [0, 0, 0, 0]; - -export function hexToRgba(hex) { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, 255]; -} - -/** Ключи палитры -> RGBA-значения (кэш). */ -export const RGBA = Object.fromEntries( - Object.entries(PALETTE).map(([k, v]) => [k, hexToRgba(v)]) -); \ No newline at end of file diff --git a/tools/pixelart/png.mjs b/tools/pixelart/png.mjs deleted file mode 100644 index 744dbdf..0000000 --- a/tools/pixelart/png.mjs +++ /dev/null @@ -1,61 +0,0 @@ -/** - * PNG-энкодер без зависимостей: RGBA-пиксели -> PNG-файл (zlib из node:zlib). - * Цвета — ключи палитры из palette.mjs. - */ -import { deflateSync } from 'node:zlib'; - -// CRC32 для чанков PNG. -const CRC_TABLE = (() => { - const table = new Uint32Array(256); - for (let n = 0; n < 256; n++) { - let c = n; - for (let k = 0; k < 8; k++) { - c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - table[n] = c >>> 0; - } - return table; -})(); - -function crc32(buf) { - let c = 0xffffffff; - for (const b of buf) { - c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); - } - return (c ^ 0xffffffff) >>> 0; -} - -function chunk(type, data) { - const len = Buffer.alloc(4); - len.writeUInt32BE(data.length); - const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(body)); - return Buffer.concat([len, body, crc]); -} - -/** rgba — Uint8Array длиной w*h*4. Возвращает PNG-буфер. */ -export function encodePng(width, height, rgba) { - // Сырые строки: байт фильтра (0 = None) + строка пикселей. - const raw = Buffer.alloc(height * (width * 4 + 1)); - for (let y = 0; y < height; y++) { - raw[y * (width * 4 + 1)] = 0; - rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); - } - - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; // bit depth - ihdr[9] = 6; // RGBA - ihdr[10] = 0; - ihdr[11] = 0; - ihdr[12] = 0; - - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw, { level: 9 })), - chunk('IEND', Buffer.alloc(0)) - ]); -} \ No newline at end of file diff --git a/tools/skin-info.mjs b/tools/skin-info.mjs deleted file mode 100644 index 0139a91..0000000 --- a/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/tools/skin-probe.mjs b/tools/skin-probe.mjs deleted file mode 100644 index 24e117e..0000000 --- a/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/tools/smoke-act1.mjs b/tools/smoke-act1.mjs deleted file mode 100644 index 9010aec..0000000 --- a/tools/smoke-act1.mjs +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Полный прогон акта 1 через агентный мост: луга -> Звенец -> квест у Ирвина -> - * (сбор цветов эмулируется var'ом) -> сдача -> кат-сцена с колоколом. - * Запуск: node tools/smoke-act1.mjs [url] [скриншот] - */ -import { openGame } from './agent-lib.mjs'; - -const url = process.argv[2] ?? 'http://localhost:5199/'; -const shot = process.argv[3] ?? '/tmp/rpg_act1.png'; - -const { browser, page, agent, consoleLogs } = await openGame({ url, newGame: true }); -page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); - -// Усыпить сгустков: путь героя не должен прерываться уроном. -async function sleepAll() { - await page.evaluate(() => { - const g = window.__game; - for (const [, en] of g.scenes.current.combat?.enemies ?? []) en.brain.putToSleep(9999); - }); -} -await sleepAll(); - -// 1) Луга -> тропа в Звенец (26,14) — триггер срабатывает на самом тайле. -console.log('шаг 1: луга -> Звенец'); -if (!(await agent.walkTo(26, 14))) console.log(' ! герой не дошёл до (26,14)'); -await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 300 }); -// Фаза «in» fade-перехода: сцена ещё игнорирует клики. -await agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); -await sleepAll(); // переход мог вернуть героя в бой - -// 2-3) Клик по Ирвину (12,9): сцена сама подведёт героя и откроет диалог. -console.log('шаг 2: квест у Ирвина'); -await agent.tapTile(12, 9); -if (!(await agent.runDialogue())) console.log(' ! диалог с Ирвином не открылся'); - -// 4) Сбор цветов — эмуляция var'ом (вылазка проверяется smoke-ponds). -console.log('шаг 4: цветы собраны (эмуляция)'); -await agent.command('scene:setVar', { id: 'flowers', value: 3 }); - -// 5) Сдача Ирвину -> кат-сцена. -console.log('шаг 5: сдача -> кат-сцена'); -await agent.tapTile(12, 9); -if (!(await agent.runDialogue())) console.log(' ! диалог сдачи не открылся'); - -// 6) Кат-сцена: крутим шаги, пока активна (мост перематывает без реального ожидания). -await agent.waitFor('s.cutscene == null || !s.cutscene.active', { timeoutTicks: 1200 }); - -const snap = await agent.snapshot(); -console.log('итог:', JSON.stringify({ flags: snap.flags, vars: snap.vars })); -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -if (!snap.flags.includes('quest_bells_done')) console.log(' ! квест не закрыт (quest_bells_done)'); -await browser.close(); \ No newline at end of file diff --git a/tools/smoke-ponds.mjs b/tools/smoke-ponds.mjs deleted file mode 100644 index 91e4e22..0000000 --- a/tools/smoke-ponds.mjs +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Смоук перехода луга -> Серые пруды: герой идёт через мост к триггеру (2,2). - * Запуск: node tools/smoke-ponds.mjs [url] [скриншот] - */ -import { openGame } from './agent-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/tools/smoke-quest.mjs b/tools/smoke-quest.mjs deleted file mode 100644 index 6c7b304..0000000 --- a/tools/smoke-quest.mjs +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Смоук квестовой рамки: новая игра → Звенец → диалог с Ирвином (задание) → - * сумка с журналом (I). Запуск: node tools/smoke-quest.mjs [url] [скриншот] - */ -import { openGame } from './agent-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/tools/smoke-zvenets.mjs b/tools/smoke-zvenets.mjs deleted file mode 100644 index d518c5e..0000000 --- a/tools/smoke-zvenets.mjs +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Смоук перехода луга -> Звенец: герой идёт через мост к триггеру (26,14). - * Запуск: node tools/smoke-zvenets.mjs [url] [скриншот] - */ -import { openGame } from './agent-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/tools/smoke.mjs b/tools/smoke.mjs deleted file mode 100644 index 3e2d55e..0000000 --- a/tools/smoke.mjs +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Базовый смоук через агентный мост: новая игра, шаги, снапшот, скриншот. - * Запуск: node tools/smoke.mjs [url] [файл-скриншота] - */ -import { openGame } from './agent-lib.mjs'; - -const url = process.argv[2] ?? 'http://localhost:5199/'; -const shot = process.argv[3] ?? '/tmp/rpg_smoke.png'; - -const { browser, page, agent } = await openGame({ url, newGame: true }); - -// 300 фиксированных шагов без ошибок инвариантов. -await agent.step(300); -const inv = await agent.invariants(); -const errs = inv.filter((i) => i.severity === 'error'); -if (errs.length) console.log(' ! инварианты:', JSON.stringify(errs)); - -const snap = await agent.snapshot(); -console.log('снапшот:', JSON.stringify({ area: snap.area, hero: snap.hero?.tile, hp: snap.hero?.hp })); -await page.screenshot({ path: shot }); -console.log(`Скриншот: ${shot}`); -await browser.close(); \ No newline at end of file diff --git a/tools/walk-probe.mjs b/tools/walk-probe.mjs deleted file mode 100644 index 8ec0158..0000000 --- a/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