diff --git a/CLAUDE.md b/CLAUDE.md index 5fab270..fd68a12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ node apps/game/tools/aiart/tryon.mjs # примерка AI-сборки в игре до promote (скриншот /tmp/rpg_tryon.png) npm run dialogues # визуальный редактор графов диалогов (порт 5199) npm run audio # перегенерация WAV (apps/game/tools/audio/gen.mjs) -npm run maps # перегенерация карт-файлов (apps/game/tools/maps + encodeMap) +npm run maps # перезапись карт-файлов (RUN_MAPS_GEN=1; обычный npm test только сверяет fresh) npm run agent:check # полный прогон проверок через агентный мост (JSON) node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs # один сценарий проверки node apps/game/tools/agent.mjs snapshot --new-game # снапшот игры (JSON) diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index e912f1c..a33b121 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -138,6 +138,10 @@ private worldAudio: AudioSystem; /** off() подписок worldAudio (снять в exit). */ private audioOff: () => void; + /** off() подписок на шину событий боя (снять в exit). */ + private eventOffs: (() => void)[] = []; + /** Сцена вышла (exit): гасим хвосты асинхронных заводок (roomtone). */ + private exited = false; /** Tension-стем (враги wary): буфер + хендл + сглаженная громкость. */ private stemHandle: MusicHandle | null = null; private stemBuf: AudioBuffer | null = null; @@ -302,7 +306,8 @@ ); // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. - this.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { + this.eventOffs.push( + this.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { const en = this.combat.enemies.get(entity); if (!en) return; this.combatViews.flashEnemy(entity); @@ -319,7 +324,8 @@ radius: unitsToPx(1.5), spec: { attack: 0.02, decay: 0.2 } }); - }); + }) + ); // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя). this.fauna = new FaunaSystem( @@ -334,16 +340,18 @@ ); // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона // (через слой эффектов: тик и зачистка — сами; раньше эмиттер не тикался). - this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { - const s = worldToScreen(origin.x, origin.y); - this.fx.burst(6, { x: s.x, y: s.y - 8 }, emberPreset(0x8a8a96, { - blend: 'normal', - fadeIn: 0, - lifetime: [1.2, 2.2], - velocity: { x: [-6, 6], y: [-14, -8] }, - seed: ((origin.x * 13 + origin.y) | 0) || 1 - })); - }); + this.eventOffs.push( + this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { + const s = worldToScreen(origin.x, origin.y); + this.fx.burst(6, { x: s.x, y: s.y - 8 }, emberPreset(0x8a8a96, { + blend: 'normal', + fadeIn: 0, + lifetime: [1.2, 2.2], + velocity: { x: [-6, 6], y: [-14, -8] }, + seed: ((origin.x * 13 + origin.y) | 0) || 1 + })); + }) + ); const savedHp = this.game.state.getNumber(VARS.hp) || PLAYER_COMBAT.maxHp; this.playerCombat = new PlayerCombat(savedHp); @@ -556,15 +564,26 @@ if (!spec) return; const buf = this.game.audio.createBuffer(renderSpec(spec)); if (!buf) return; // до unlock контекста нет — тон появится при следующем входе + const key = this.area.id; void this.game.audio - .playLoopBuffer(`roomtone/${this.area.id}`, buf, { bus: 'ambience', fade: 2, volume: 0.6 }) + .playLoopBuffer(`roomtone/${key}`, buf, { bus: 'ambience', fade: 2, volume: 0.6 }) .then((h) => { + // Сцена уже вышла, пока луп заводился, — гасим без хвостов. + if (this.exited) { + h?.stop(1); + return; + } this.roomtoneHandle = h; }); } exit(): void { + this.exited = true; + for (const off of this.eventOffs) off(); + this.eventOffs = []; this.audioOff(); + this.worldAudio.setLayers([]); // лупы локальных слоёв: иначе звучат поверх следующей сцены + this.dialogue.destroy(); this.roomtoneHandle?.stop(1); this.roomtoneHandle = null; this.stemHandle?.stop(1); @@ -583,6 +602,8 @@ this.lightView.destroy({ children: true }); this.fog.destroy(); this.healthBar.destroy({ children: true }); + this.moteLabel.destroy({ children: true }); + this.chargeRing.destroy(); // в worldRoot — не вычищается с this.world this.hint.destroy({ children: true }); this.locationLabel.destroy({ children: true }); this.joystick.destroy({ children: true }); diff --git a/apps/game/src/scenes/SaveSlotsScene.ts b/apps/game/src/scenes/SaveSlotsScene.ts index 4e54918..d153d74 100644 --- a/apps/game/src/scenes/SaveSlotsScene.ts +++ b/apps/game/src/scenes/SaveSlotsScene.ts @@ -1,6 +1,7 @@ import { playSfx } from '../data/sfxSpecs'; import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; import { Game } from '../Game'; +import { areaOf } from '../data/locations'; import { SAVE_SLOTS, canDelete, slotTitle } from '../data/saves'; import type { SaveData } from './saveData'; @@ -98,7 +99,8 @@ } private areaName(areaId: string | undefined): string { - return areaId === 'ponds' ? 'пруды' : areaId === 'zvenets' ? 'Звенец' : 'луга'; + // Имя из реестра областей: интерьеры раньше показывались как «луга». + return areaId ? areaOf(areaId).name : 'луга'; } /** Удалить сейв в выбранной строке (именованные слоты; autosave защищён). */ diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts index c75b6f3..52f5003 100644 --- a/apps/game/src/systems/DialogueSystem.ts +++ b/apps/game/src/systems/DialogueSystem.ts @@ -50,6 +50,11 @@ this.sink = sink; } + /** Уничтожить панель диалога (exit сцены — иначе копится в uiRoot). */ + destroy(): void { + this.box.view.destroy({ children: true }); + } + get active(): boolean { return this.runner.active; } diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index eaa4aa1..f06780c 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -12,7 +12,7 @@ import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { parseArgs, startDevServer, openGame, Checks } from './lib.mjs'; +import { parseArgs, startDevServer, openGame } from './lib.mjs'; /** Корень репозитория (тулзы живут в apps/game/tools, скрипты npm — в корне). */ const ROOT = new URL('../../..', import.meta.url); @@ -38,31 +38,8 @@ 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 }); -} +/** Браузерная проверка invariants — сценарий checks/agent-invariants.mjs (единый источник). */ +const agentInvariants = () => runScenario('agent:invariants', 'apps/game/tools/checks/agent-invariants.mjs'); /** Сценарий из apps/game/tools/checks/ как шаг check: exit code -> результат. */ async function runScenario(name, file) { @@ -93,11 +70,6 @@ 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') }, diff --git a/apps/game/tools/maps/gen.test.ts b/apps/game/tools/maps/gen.test.ts index 3f51f72..96c58af 100644 --- a/apps/game/tools/maps/gen.test.ts +++ b/apps/game/tools/maps/gen.test.ts @@ -12,15 +12,15 @@ } 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 должен вернуть карту - * попиксельно равную исходной. + * Карты-файлы: fresh-тест (диск == генератор) и round-trip RLE — read-only + * и идут в каждом `npm test`. Запись файлов — только при RUN_MAPS_GEN=1 + * (`npm run maps`): обычный прогон тестов не трогает рабочее дерево. */ const here = dirname(fileURLToPath(import.meta.url)); const outDir = resolve(here, '../../assets/maps'); +const gen = !!process.env.RUN_MAPS_GEN; +const d = gen ? describe : describe.skip; const MAPS: Record> = { meadows: buildMeadowsMap(), @@ -40,21 +40,16 @@ } }); -describe('генерация карт-файлов (rpg-map, RLE)', () => { +describe('rpg-map, RLE (round-trip, read-only)', () => { for (const [id, data] of Object.entries(MAPS)) { - it(`${id}: encode -> файл -> parse восстанавливает карту`, () => { - const file = encodeMap(data); - const back = parseMap(JSON.parse(JSON.stringify(file))); + it(`${id}: encode -> parse восстанавливает карту`, () => { + const back = parseMap(JSON.parse(JSON.stringify(encodeMap(data)))); 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)); }); } @@ -68,4 +63,15 @@ expect(id.length).toBeGreaterThan(0); } }); +}); + +d('запись карт-файлов (RUN_MAPS_GEN=1 / npm run maps)', () => { + for (const [id, data] of Object.entries(MAPS)) { + it(`${id}: пишет assets/maps/${id}.map`, () => { + const file = encodeMap(data); + mkdirSync(outDir, { recursive: true }); + writeFileSync(resolve(outDir, `${id}.map`), JSON.stringify(file)); + expect(true).toBe(true); + }); + } }); \ No newline at end of file diff --git a/package.json b/package.json index 577099d..6ccb616 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "aiart": "node apps/game/tools/aiart/atlas.mjs", "art:lint": "node apps/game/tools/aiart/lint-art.mjs $(find apps/game/assets -name '*.png')", "audio": "node apps/game/tools/audio/gen.mjs", - "maps": "vitest run apps/game/tools/maps/gen.test.ts", + "maps": "RUN_MAPS_GEN=1 vitest run apps/game/tools/maps/gen.test.ts", "dialogues:dry": "vite-node apps/game/tools/dialogues/dry-run.ts", "dialogues": "node apps/game/tools/dialogue-editor/server.mjs", "guard": "node apps/game/tools/guards/boundary.mjs", diff --git a/packages/engine/src/input/InputManager.ts b/packages/engine/src/input/InputManager.ts index 921e62c..5e0900c 100644 --- a/packages/engine/src/input/InputManager.ts +++ b/packages/engine/src/input/InputManager.ts @@ -69,7 +69,7 @@ canvas.addEventListener('pointerdown', this.onPointerDown); canvas.addEventListener('pointerup', this.onPointerUp); canvas.addEventListener('pointermove', this.onPointerMove); - canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + canvas.addEventListener('contextmenu', this.onContextMenu); } /** Маппинг: действие -> список кодов клавиш (e.code). */ @@ -214,6 +214,7 @@ this.canvas.removeEventListener('pointerdown', this.onPointerDown); this.canvas.removeEventListener('pointerup', this.onPointerUp); this.canvas.removeEventListener('pointermove', this.onPointerMove); + this.canvas.removeEventListener('contextmenu', this.onContextMenu); } // --- инъекция ввода (агентный мост / тесты) --- @@ -320,4 +321,8 @@ this.pointer.y = y; this.handlers.onPointerMove?.(this.pointer); }; + + private onContextMenu = (e: Event): void => { + e.preventDefault(); + }; } \ No newline at end of file diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 582a176..43a6538 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -97,6 +97,9 @@ interface CellSprites { ground?: Sprite; object?: Sprite; + /** Плейсхолдеры без текстуры (чистятся вместе с ячейкой при перерисовке). */ + groundPlaceholder?: Container; + objectPlaceholder?: Container; /** id тайла, по которому создан спрайт (для регистрации в анимациях). */ groundId?: number; objectId?: number; @@ -351,6 +354,8 @@ const old = this.cells.get(key); if (old?.ground) { this.unregisterAnim(old.ground); old.ground.destroy(); } if (old?.object) { this.unregisterAnim(old.object); old.object.destroy(); } + old?.groundPlaceholder?.destroy(); + old?.objectPlaceholder?.destroy(); const fresh: CellSprites = {}; this.cells.set(key, fresh); @@ -377,6 +382,7 @@ g.poly([p.x, p.y, p.x + hw, p.y + hh, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]); g.fill(this.blockedSet.has(id) ? 0x4a3b2a : 0x2d5a27); this.groundLayer.addChild(g); + fresh.groundPlaceholder = g; } // Высокий объект: спрайт с якорем в центре ромба, иначе колонна. @@ -391,7 +397,9 @@ fresh.object = s; this.registerAnim(s, id); } else { - this.objectsLayer.addChild(this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height)); + const ph = this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height); + this.objectsLayer.addChild(ph); + fresh.objectPlaceholder = ph; } } }