diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 4980d59..b8b9042 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -43,6 +43,148 @@ /** Допустимые kind спек-синтеза (как в движке, строками — спек идёт по мосту). */ const SOUND_KINDS: readonly string[] = ['hit', 'chime', 'scrape', 'hum', 'tone']; +/** Аргументы whitelist-команд моста (все опциональны; типы проверяют обработчики). */ +interface CommandArgs { + x?: number; + y?: number; + level?: number; + id?: string; + value?: number | string | boolean; + flag?: string; + index?: number; + hours?: number; + text?: string; + from?: { x?: number; y?: number }; + to?: { x?: number; y?: number }; + key?: string; + volume?: number; + spec?: Partial; +} + +/** Обработчик команды моста: deps сцены + разобранные аргументы. */ +type CommandHandler = (d: SceneAgentDeps, a: CommandArgs) => JsonValue; + +/** Аргументы-тайлы x/y заданы числами (общая проверка тайловых команд). */ +function hasXY(a: CommandArgs): a is CommandArgs & { x: number; y: number } { + return typeof a.x === 'number' && typeof a.y === 'number'; +} + +/** Таблица whitelist-команд (name → обработчик); неизвестное имя — null в agentCommand. */ +const COMMANDS: Record = { + 'scene:sleepAll': (d) => { + for (const [, en] of d.combat.enemies) en.brain.putToSleep(9999); + return true; + }, + 'scene:noise': (d, a) => { + // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих. + if (!hasXY(a)) return null; + d.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1); + return true; + }, + 'scene:damageEnemy': (d, a) => { + // Урон живому сгустку (проверки отступления); id — фильтр по виду. + if (typeof a.value !== 'number') return null; + for (const [e, en] of d.combat.enemies) { + if (en.brain.dead) continue; + if (typeof a.id === 'string' && en.kind.id !== a.id) continue; + d.combat.damageEnemy(e, en, a.value, d.player.position); + return true; + } + return false; + }, + 'scene:damagePlayer': (d, a) => { + // Урон герою полным боевым путём (CombatWorld.deps → onPlayerDamaged): + // вспышка, шейк, виньетка при низком hp. + if (typeof a.value !== 'number') return null; + d.damagePlayer(a.value); + return true; + }, + 'scene:give': (d, a) => { + if (typeof a.id !== 'string') return null; + d.game.inventory.add(a.id); + return true; + }, + 'scene:setVar': (d, a) => { + if (typeof a.id !== 'string') return null; + d.game.state.setVar(a.id, a.value ?? 0); + return true; + }, + 'scene:setFlag': (d, a) => { + if (typeof a.flag !== 'string') return null; + d.game.state.setFlag(a.flag); + return true; + }, + 'scene:setTime': (d, a) => { + // Перемотка времени суток (часы → минуты) — детерминизм проверок света. + if (typeof a.hours !== 'number') return null; + d.game.clock.set(a.hours * 60); + return true; + }, + 'scene:teleport': (d, a) => { + if (!hasXY(a)) return null; + d.player.teleportTo({ x: a.x, y: a.y }); + d.followCamera(true); + return true; + }, + 'scene:route': (d, a) => { + if (!hasXY(a)) return null; + // Маршрут как у героя: тайлы NPC заняты, иначе мост ведёт сквозь тело. + return findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false) ?? null; + }, + 'scene:walkable': (d, a) => { + // Проходим ли тайл (юниты моста — тайлы; стены, вода и footprint пропов блокируют). + if (!hasXY(a)) return null; + return d.map.isWalkable(Math.floor(a.x), Math.floor(a.y)); + }, + 'scene:raycast': (d, a) => { + // Прямая видимость между тайлами: true — чисто, false — стена/дом на отрезке. + const f = a.from; + const t = a.to; + if (!f || !t || typeof f.x !== 'number' || typeof f.y !== 'number' || typeof t.x !== 'number' || typeof t.y !== 'number') { + return null; + } + return hasLineOfSight(tileToWorld(f.x, f.y), tileToWorld(t.x, t.y), d.combat.opaque); + }, + 'scene:pickChoice': (d, a) => { + if (typeof a.index !== 'number') return null; + d.dialogue.pickChoice(a.index); + return true; + }, + 'scene:pickChoiceByText': (d, a) => { + // Выбор по тексту реплики — сценариям не надо знать порядок вариантов. + if (typeof a.text !== 'string') return null; + return d.dialogue.pickChoiceByText(a.text); + }, + 'scene:skipCutscene': (d) => { + if (!d.cutscene.active) return false; + while (d.cutscene.active) d.cutscene.update(0.5); + return true; + }, + 'scene:synthesize': (d, a) => { + // Звук по описанию (спек → playSpec): агент без слуха описывает + // звук параметрами; факт запуска виден в DEV-логе аудио. + const s = a.spec; + if (!s || !SOUND_KINDS.includes(s.kind as SoundKind) || typeof s.dur !== 'number') { + return null; + } + const spec: SoundSpec = { kind: s.kind as SoundKind, dur: s.dur }; + if (typeof s.low === 'number') spec.low = s.low; + if (typeof s.high === 'number') spec.high = s.high; + if (typeof s.tone === 'number') spec.tone = s.tone; + if (typeof s.freq === 'number') spec.freq = s.freq; + if (typeof s.power === 'number') spec.power = s.power; + if (typeof s.seed === 'number') spec.seed = s.seed; + if (typeof s.peak === 'number') spec.peak = s.peak; + if (typeof s.loop === 'boolean') spec.loop = s.loop; + void d.game.audio.playSpec( + typeof a.key === 'string' ? a.key : 'agent/synth', + spec, + typeof a.volume === 'number' ? a.volume : 0.6 + ); + return true; + } +}; + /** * Агентный фасад сцены локации: слой снапшота, инварианты, whitelist-команды. * Вынесен из LocationScene (SceneAgent), чтобы сцена оставалась оркестратором @@ -236,132 +378,8 @@ /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ agentCommand(name: string, args?: JsonValue): JsonValue { - const d = this.deps; - const a = (args ?? {}) as { - x?: number; - y?: number; - level?: number; - id?: string; - value?: number | string | boolean; - flag?: string; - index?: number; - hours?: number; - text?: string; - from?: { x?: number; y?: number }; - to?: { x?: number; y?: number }; - key?: string; - volume?: number; - spec?: Partial; - }; - switch (name) { - case 'scene:sleepAll': - for (const [, en] of d.combat.enemies) en.brain.putToSleep(9999); - return true; - case 'scene:noise': { - // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих. - if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; - d.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1); - return true; - } - case 'scene:damageEnemy': { - // Урон живому сгустку (проверки отступления); id — фильтр по виду. - if (typeof a.value !== 'number') return null; - for (const [e, en] of d.combat.enemies) { - if (en.brain.dead) continue; - if (typeof a.id === 'string' && en.kind.id !== a.id) continue; - d.combat.damageEnemy(e, en, a.value, d.player.position); - return true; - } - return false; - } - case 'scene:damagePlayer': { - // Урон герою полным боевым путём (CombatWorld.deps → onPlayerDamaged): - // вспышка, шейк, виньетка при низком hp. - if (typeof a.value !== 'number') return null; - d.damagePlayer(a.value); - return true; - } - case 'scene:give': - if (typeof a.id !== 'string') return null; - d.game.inventory.add(a.id); - return true; - case 'scene:setVar': - if (typeof a.id !== 'string') return null; - d.game.state.setVar(a.id, a.value ?? 0); - return true; - case 'scene:setFlag': - if (typeof a.flag !== 'string') return null; - d.game.state.setFlag(a.flag); - return true; - case 'scene:setTime': - // Перемотка времени суток (часы → минуты) — детерминизм проверок света. - if (typeof a.hours !== 'number') return null; - d.game.clock.set(a.hours * 60); - return true; - case 'scene:teleport': { - if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; - d.player.teleportTo({ x: a.x, y: a.y }); - d.followCamera(true); - return true; - } - case 'scene:route': { - if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; - // Маршрут как у героя: тайлы NPC заняты, иначе мост ведёт сквозь тело. - const path = findPath(d.walkGrid(), d.player.currentTile(), { x: a.x, y: a.y }, false); - return path ?? null; - } - case 'scene:walkable': { - // Проходим ли тайл (юниты моста — тайлы; стены, вода и footprint пропов блокируют). - if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; - return d.map.isWalkable(Math.floor(a.x), Math.floor(a.y)); - } - case 'scene:raycast': { - // Прямая видимость между тайлами: true — чисто, false — стена/дом на отрезке. - const f = a.from as { x?: number; y?: number } | undefined; - const t = a.to as { x?: number; y?: number } | undefined; - if (!f || !t || typeof f.x !== 'number' || typeof f.y !== 'number' || typeof t.x !== 'number' || typeof t.y !== 'number') { - return null; - } - return hasLineOfSight(tileToWorld(f.x, f.y), tileToWorld(t.x, t.y), d.combat.opaque); - } - case 'scene:pickChoice': - if (typeof a.index !== 'number') return null; - d.dialogue.pickChoice(a.index); - return true; - case 'scene:pickChoiceByText': - // Выбор по тексту реплики — сценариям не надо знать порядок вариантов. - if (typeof a.text !== 'string') return null; - return d.dialogue.pickChoiceByText(a.text); - case 'scene:skipCutscene': { - if (!d.cutscene.active) return false; - while (d.cutscene.active) d.cutscene.update(0.5); - return true; - } - case 'scene:synthesize': { - // Звук по описанию (спек → playSpec): агент без слуха описывает - // звук параметрами; факт запуска виден в DEV-логе аудио. - const s = a.spec as Partial | undefined; - if (!s || !SOUND_KINDS.includes(s.kind as SoundKind) || typeof s.dur !== 'number') { - return null; - } - const spec: SoundSpec = { kind: s.kind as SoundKind, dur: s.dur }; - if (typeof s.low === 'number') spec.low = s.low; - if (typeof s.high === 'number') spec.high = s.high; - if (typeof s.tone === 'number') spec.tone = s.tone; - if (typeof s.freq === 'number') spec.freq = s.freq; - if (typeof s.power === 'number') spec.power = s.power; - if (typeof s.seed === 'number') spec.seed = s.seed; - if (typeof s.peak === 'number') spec.peak = s.peak; - if (typeof s.loop === 'boolean') spec.loop = s.loop; - void d.game.audio.playSpec( - typeof a.key === 'string' ? a.key : 'agent/synth', - spec, - typeof a.volume === 'number' ? a.volume : 0.6 - ); - return true; - } - default: - return null; - } + const handler = COMMANDS[name]; + if (!handler) return null; + return handler(this.deps, (args ?? {}) as CommandArgs); } } \ No newline at end of file diff --git a/apps/game/src/data/__tests__/quests.test.ts b/apps/game/src/data/__tests__/quests.test.ts index 157dc77..6cce484 100644 --- a/apps/game/src/data/__tests__/quests.test.ts +++ b/apps/game/src/data/__tests__/quests.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { GameState, Inventory } from '@rpg/engine'; -import { QUESTS, QUEST_FLOWERS, activeStage, inventoryItems, questDialogueFor, questDone, questLog, questTaken } from '../quests'; +import { QUESTS, QUEST_FLOWERS, activeStage, questDialogueFor, questLog } from '../quests'; import { applyDialogueEffect, type DialogueEffectSink } from '../../systems/DialogueEffects'; import { DIALOGUE_CUSTOM } from '../effects'; @@ -9,14 +9,12 @@ describe('квест «Три цветка» — стадии', () => { it('до знакомства квест не взят, стадий нет', () => { const s = new GameState(); - expect(questTaken(s, bells)).toBe(false); expect(questLog(s)).toEqual([]); }); it('после взятия активна стадия сбора; диалог сдачи не готов', () => { const s = new GameState(); s.setFlag('quest_bells_taken'); - expect(questTaken(s, bells)).toBe(true); expect(activeStage(s, bells)?.dialogue).toBe('elder_hand_in'); expect(questDialogueFor(s, 'elder')).toBeNull(); // цветов мало expect(questLog(s)).toEqual([ @@ -32,7 +30,6 @@ expect(questLog(s)).toEqual([ { done: false, text: `Собрать лунные колокольчики у Серых прудов (3/3)` } ]); - expect(questDone(s, bells)).toBe(false); }); it('после сдачи — эпилог с Милой и финальная строка', () => { @@ -40,7 +37,6 @@ s.setFlag('quest_bells_taken'); s.setVar('flowers', QUEST_FLOWERS); s.setFlag('quest_bells_done'); - expect(questDone(s, bells)).toBe(true); expect(questDialogueFor(s, 'trader')).toBe('trader_after'); expect(questDialogueFor(s, 'elder')).toBeNull(); // Ирвин — обычный repeat expect(questLog(s)).toEqual([ @@ -87,17 +83,6 @@ const s = new GameState(); s.setFlag('quest_bells_taken'); s.setFlag('quest_bells_done'); - expect(questDone(s, bells)).toBe(true); expect(activeStage(s, bells)?.npc).toBe('trader'); }); -}); - -describe('inventoryItems из сумки', () => { - it('имя + множитель, пусто — заглушка', () => { - const inv = new Inventory(); - expect(inventoryItems(inv)).toEqual(['— пусто —']); - inv.add('cloth'); - inv.add('bellflower', 2); - expect(inventoryItems(inv)).toEqual(['Вощёное полотно', `Лунный колокольчик ×2`]); - }); }); \ No newline at end of file diff --git a/apps/game/src/data/__tests__/validate.test.ts b/apps/game/src/data/__tests__/validate.test.ts index 7d57443..dc3b778 100644 --- a/apps/game/src/data/__tests__/validate.test.ts +++ b/apps/game/src/data/__tests__/validate.test.ts @@ -2,13 +2,11 @@ import type { DialogueGraph, TileMapData } from '@rpg/engine'; import { TILES, buildMeadowsMap, buildPondsMap, buildZvenetsMap } from '../map'; import { AREAS } from '../locations'; -import { INTERACTABLES } from '../interactables'; -import { AUDIO_KEYS } from '../audio'; import { ROOM_TONES } from '../sfxSpecs'; import { NPCS } from '../npcs'; import { DIALOGUES } from '../dialogues'; import { FLAGS } from '../ids'; -import { validateContent, validateDialogue, validateLocations, validateNpcs, validateReferences } from '../validate'; +import { validateContent, validateDialogue, validateAudio, validateLocations, validateNpcs, validateReferences } from '../validate'; /** Карта целиком из проходимой травы (или с одиночной стеной). */ function flatMap(w: number, h: number, wall?: { x: number; y: number }): TileMapData { @@ -123,28 +121,124 @@ expect(inv.some((i) => i.id === 'in-wall' && i.message.includes('spawn'))).toBe(true); }); }); -describe('аудио-ключи контента', () => { - it('ambience областей и sound интерактивов есть в AUDIO_KEYS', () => { - const keys = new Set(AUDIO_KEYS); - for (const area of Object.values(AREAS)) { - if (area.ambience) { - expect(keys.has(area.ambience), `${area.id}: амбиент ${area.ambience} не в AUDIO_KEYS`).toBe(true); - } - } - for (const list of Object.values(INTERACTABLES)) { - for (const def of list) { - for (const r of def.responses) { - if (r.sound) { - expect(keys.has(r.sound), `${def.id}: звук ${r.sound} не в AUDIO_KEYS`).toBe(true); - } - } - } +describe('validateAudio — аудио-ключи контента', () => { + it('неизвестный ambience области — error', () => { + const backup = AREAS.meadows.ambience; + (AREAS.meadows as { ambience?: string }).ambience = 'ambience/tundra'; + try { + const inv = validateAudio(); + expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('ambience/tundra'))).toBe(true); + } finally { + (AREAS.meadows as { ambience?: string }).ambience = backup; } }); - it('room tone интерьеров — ключи ROOM_TONES есть среди областей (id → спек)', () => { - for (const key of Object.keys(ROOM_TONES)) { - expect(AREAS[key as keyof typeof AREAS], `room tone ${key} — нет такой области`).toBeTruthy(); + it('sfx-ключ реакции вне спек-реестра — error', () => { + const area = AREAS.meadows; + const backup = area.interactables; + (area as { interactables?: unknown }).interactables = [ + { id: 'x', kind: 'signpost', tile: { x: 1, y: 1 }, responses: [{ sound: 'sfx/nats' }] } + ]; + try { + const inv = validateAudio(); + expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('sfx/nats'))).toBe(true); + } finally { + (area as { interactables?: unknown }).interactables = backup; + } + }); + + it('roomtone с ключом-не-областью — error; реальные контент чист', () => { + (ROOM_TONES as Record).cave = { kind: 'hum', tone: 40, dur: 4, power: 2, peak: 0.2 }; + try { + const inv = validateAudio(); + expect(inv.some((i) => i.id === 'audio-key' && i.message.includes('cave'))).toBe(true); + } finally { + delete (ROOM_TONES as Record).cave; + } + expect(validateAudio().filter((i) => i.severity === 'error')).toEqual([]); + }); +}); + +describe('validateReferences — requiresItem и return-правило интерьеров', () => { + it('переход с неизвестным requiresItem — error', () => { + const t = AREAS.ponds.transitions[0]!; + const backup = t.requiresItem; + (t as { requiresItem?: string }).requiresItem = 'rope'; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'item-unknown' && i.message.includes('rope'))).toBe(true); + } finally { + (t as { requiresItem?: string }).requiresItem = backup; + } + }); + + it('накат с неизвестным requiresItem — error', () => { + const h = AREAS.ponds.hazards![0]!; + const backup = h.requiresItem; + (h as { requiresItem?: string }).requiresItem = 'mask'; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'item-unknown' && i.message.includes('mask'))).toBe(true); + } finally { + (h as { requiresItem?: string }).requiresItem = backup; + } + }); + + it('неизвестный whenFlag источника света — error', () => { + const s = AREAS.zvenets.lighting!.sources![0]!; + const backup = s.whenFlag; + (s as { whenFlag?: string }).whenFlag = 'evening_yes'; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('evening_yes'))).toBe(true); + } finally { + (s as { whenFlag?: string }).whenFlag = backup; + } + }); + + it('интерьер без return-перехода — error', () => { + const area = AREAS.shop; + const backup = area.transitions; + (area as { transitions: typeof backup }).transitions = []; + try { + const inv = validateReferences(); + expect(inv.some((i) => i.id === 'interior-return' && i.where?.includes('shop'))).toBe(true); + } finally { + (area as { transitions: typeof backup }).transitions = backup; + } + }); +}); + +describe('validateLocations — патрули и источники света', () => { + it('точка патруля вне карты — out-of-bounds', () => { + const e = AREAS.meadows.enemies[2]!; + const backup = e.patrol; + (e as { patrol?: typeof backup }).patrol = { points: [{ x: 19, y: 6 }, { x: 50, y: 0 }], pauseSec: 1 }; + try { + const inv = validateLocations(realMaps()); + expect(inv.some((i) => i.id === 'out-of-bounds' && i.message.includes('патруль#1'))).toBe(true); + } finally { + (e as { patrol?: typeof backup }).patrol = backup; + } + }); + + it('дубль id источника света и источник без at/tiles — error', () => { + const area = AREAS.shop; + const backup = area.lighting; + (area as { lighting?: typeof backup }).lighting = { + ambient: 0x5a5e70, + sources: [ + { id: 'a', at: { x: 1.5, y: 1.5 } }, + { id: 'a', at: { x: 2.5, y: 2.5 } }, + { id: 'b' } + ] + }; + try { + const inv = validateLocations(new Map([['shop', flatMap(10, 10)]])); + expect(inv.filter((i) => i.id === 'light-id').length).toBe(1); + expect(inv.some((i) => i.id === 'light-source-empty' && i.message.includes('b'))).toBe(true); + } finally { + (area as { lighting?: typeof backup }).lighting = backup; } }); }); diff --git a/apps/game/src/data/assetSets.ts b/apps/game/src/data/assetSets.ts new file mode 100644 index 0000000..bfffea8 --- /dev/null +++ b/apps/game/src/data/assetSets.ts @@ -0,0 +1,52 @@ +import type { AssetLoader, Texture } from '@rpg/engine'; +import { DECOR, TILES } from './map'; +import { ENEMY_KINDS, type EnemyKindId } from './enemies'; +import type { HeroTextures } from '../systems/PlayerController'; + +/** + * Наборы текстур сцены из загруженных ассетов: соответствие «данные ↔ вьюхи» + * живёт здесь, а не в LocationScene. Имена файлов фиксированы арт-библией. + */ + +/** Текстуры тайлов из загруженных ассетов (id -> Texture) — для IsometricTileMap. */ +export function tileTextures(a: AssetLoader): Map { + return new Map([ + [TILES.GRASS, a.texture('tiles/grass')], + [TILES.GRASS_V1, a.texture('tiles/grass_2')], + [TILES.GRASS_V2, a.texture('tiles/grass_3')], + [TILES.PATH, a.texture('tiles/path')], + [TILES.PATH_V1, a.texture('tiles/path_2')], + [TILES.WATER, a.texture('tiles/water_1')], + [TILES.ASH, a.texture('tiles/ash')], + [TILES.ASH_V1, a.texture('tiles/ash_2')], + [DECOR.TUFT, a.texture('tiles/decor_tuft')], + [DECOR.STONE, a.texture('tiles/decor_stone')], + [DECOR.BALD, a.texture('tiles/decor_bald')], + [TILES.BELLFLOWER, a.texture('tiles/bellflower')], + [TILES.TREE, a.texture('tiles/tree')], + [TILES.TOWER, a.texture('tiles/tower')], + [TILES.HOUSE, a.texture('tiles/house')], + [TILES.FLOOR, a.texture('tiles/floor')], + [TILES.WALL, a.texture('tiles/wall')], + [TILES.WELL, a.texture('tiles/well')] + ]); +} + +/** Кадры сгустков из атласа chars/clumps_sheet: префиксы — из EnemyKindDef.frames. */ +export function enemyTextures(a: AssetLoader): Map { + const out = new Map(); + for (const kind of Object.values(ENEMY_KINDS)) { + const prefix = kind.frames[0]!.replace(/_\d+$/, ''); // clump_crawler_1 -> clump_crawler + out.set(kind.id, a.frames('chars/clumps_sheet.json', prefix) as [Texture, Texture]); + } + return out; +} + +/** Кадры героя из атласа chars/hero_sheet (именованные анимации генератора). */ +export function heroTextures(a: AssetLoader): HeroTextures { + return { + down: a.animation('chars/hero_sheet.json', 'hero_walk_down') as [Texture, Texture], + up: a.animation('chars/hero_sheet.json', 'hero_walk_up') as [Texture, Texture], + side: a.animation('chars/hero_sheet.json', 'hero_walk_side') as [Texture, Texture] + }; +} \ No newline at end of file diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 3008d09..229a0d2 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -114,6 +114,8 @@ ambienceLayers?: AmbienceLayerDef[]; /** Зоны наката (необязательно). */ hazards?: HazardDef[]; + /** Сборный тайл (лунный колокольчик): клик по нему — сбор. Нет — в области не собирается. */ + harvestTile?: number; /** Спавны пейзажных оленей (тайлы). */ fauna?: { x: number; y: number }[]; } @@ -236,6 +238,8 @@ } ], interactables: INTERACTABLES.ponds, + // Лунные колокольчики собираются только у прудов (квест «Три цветка»). + harvestTile: TILES.BELLFLOWER, // Один олень на северо-западном берегу. fauna: [{ x: 4, y: 6 }] }, diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts index 003e813..960b921 100644 --- a/apps/game/src/data/quests.ts +++ b/apps/game/src/data/quests.ts @@ -1,5 +1,4 @@ -import { evalConditions, type DialogueConditions, type DialogueWorld, type GameState, type Inventory } from '@rpg/engine'; -import { itemName } from './items'; +import { evalConditions, type DialogueConditions, type DialogueWorld, type GameState } from '@rpg/engine'; import { FLAGS, VARS, type FlagId, type VarId } from './ids'; /** @@ -14,6 +13,23 @@ /** Квест «Три цветка» (docs/world.md, акт 1): собрать и посадить колокольчики. */ export const QUEST_FLOWERS = 3; +/** Тайлы посадки поляны у тропы (Звенец): collectFlowers расползается от них. */ +export const QUEST_PLANT_TILES: readonly (readonly [number, number])[] = [ + [15, 13], + [16, 14], + [15, 15] +]; + +/** Башня Звенца: тайл (камера кат-сцены сдачи идёт сюда). */ +export const QUEST_TOWER_TILE = { x: 14, y: 7 }; + +/** Зелень у подножия башни: визуальный финал акта 1. */ +export const QUEST_TOWER_GREEN: readonly (readonly [number, number])[] = [ + [13, 8], + [15, 8], + [14, 9] +]; + /** Одна стадия квеста. */ export interface QuestStage { /** Чей диалог ведёт стадию (id из NpcDef). */ @@ -70,17 +86,6 @@ } ]; -/** Квест взят: стоит doneFlag первой стадии. */ -export function questTaken(state: GameState, quest: QuestDef): boolean { - const first = quest.stages[0]!; - return first.doneFlag !== undefined && state.hasFlag(first.doneFlag); -} - -/** Квест завершён: все завершающие флаги стоят. */ -export function questDone(state: GameState, quest: QuestDef): boolean { - return quest.stages.every((s) => s.doneFlag === undefined || state.hasFlag(s.doneFlag)); -} - /** Активная стадия: первая незавершённая (эпилог без doneFlag — тоже стадия). */ export function activeStage(state: GameState, quest: QuestDef): QuestStage | null { for (const stage of quest.stages) { @@ -129,13 +134,4 @@ }); } return entries; -} - -/** Строки сумки из инвентаря: имя + ×N (у одиночных — без множителя). */ -export function inventoryItems(inventory: Inventory): string[] { - const items = inventory.all.map((slot) => - slot.count > 1 ? `${itemName(slot.id)} ×${slot.count}` : itemName(slot.id) - ); - if (items.length === 0) items.push('— пусто —'); - return items; } \ No newline at end of file diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 7891609..dde283b 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -14,6 +14,10 @@ import { QUESTS } from './quests'; import { FLAGS, VARS } from './ids'; import { ITEMS } from './items'; +import { TILES } from './map'; +import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS } from './audio'; +import { THEMES } from './music'; +import { ROOM_TONES, SPEC_SFX } from './sfxSpecs'; import { DIALOGUE_CUSTOM } from './effects'; import { checkGraph, type GraphRefs } from './dialogueRules'; @@ -81,7 +85,39 @@ checkBounds(`враг#${i}`, e.tile, map.width, map.height, where), checkWalkable(`враг#${i}`, e.tile, grid, where) )); + // Патруль: точки в границах и проходимы (иначе враг врежется в стену). + (e.patrol?.points ?? []).forEach((p, j) => { + out.push(...mergeInvariants( + checkBounds(`враг#${i} патруль#${j}`, p, map.width, map.height, where), + checkWalkable(`враг#${i} патруль#${j}`, p, grid, where) + )); + }); }); + // Зоны наката: тайлы в границах карты. + for (const [i, h] of (loc.hazards ?? []).entries()) { + h.tiles.forEach((t, j) => { + out.push(...mergeInvariants(checkBounds(`накат#${i} тайл#${j}`, t, map.width, map.height, where))); + }); + } + // Источники света: центр в границах (дробные at — содержащий тайл), + // id тайлов-светильников существуют; дубли id в одной области — ошибка. + const lightIds = new Set(); + for (const [i, s] of (loc.lighting?.sources ?? []).entries()) { + const lw = `${where}/light#${i}`; + if (lightIds.has(s.id)) { + out.push({ id: 'light-id', severity: 'error', message: `источник света «${s.id}» уже есть в области`, where: lw }); + } + lightIds.add(s.id); + if (s.at) out.push(...mergeInvariants(checkBounds(`light#${i} at`, { x: Math.floor(s.at.x), y: Math.floor(s.at.y) }, map.width, map.height, lw))); + for (const tileId of s.tiles ?? []) { + if (!(Object.values(TILES) as number[]).includes(tileId)) { + out.push({ id: 'light-tile', severity: 'error', message: `light#${i}: тайл ${tileId} не из TILES`, where: lw }); + } + } + if (!s.at && !(s.tiles && s.tiles.length > 0)) { + out.push({ id: 'light-source-empty', severity: 'error', message: `light#${i} «${s.id}»: ни at, ни tiles — источник никогда не включится`, where: lw }); + } + } for (const obj of loc.interactables ?? []) { out.push(...mergeInvariants(checkBounds(`объект ${obj.id}`, obj.tile, map.width, map.height, where))); } @@ -147,8 +183,31 @@ } for (const area of Object.values(AREAS)) { const where = `${WHERE}/area/${area.id}`; - for (const t of area.transitions) { - if (t.requiresFlag !== undefined) checkFlag(t.requiresFlag, where, `переход ${t.tile.x},${t.tile.y}`); + for (const [i, t] of area.transitions.entries()) { + const tw = `переход#${i} (${t.tile.x},${t.tile.y})`; + if (t.requiresFlag !== undefined) checkFlag(t.requiresFlag, where, tw); + if (t.requiresItem !== undefined && !(t.requiresItem in ITEMS)) { + out.push({ id: 'item-unknown', severity: 'error', message: `${tw}: requiresItem «${t.requiresItem}» вне реестра ITEMS`, where }); + } + } + // Условные источники света: опечатка во флаге = свет никогда не включится. + for (const [i, s] of (area.lighting?.sources ?? []).entries()) { + const lw = `${where}/light#${i}`; + if (s.whenFlag !== undefined) checkFlag(s.whenFlag, lw, 'whenFlag'); + if (s.notFlag !== undefined) checkFlag(s.notFlag, lw, 'notFlag'); + } + // Низины: полотно без реестра предметов = маска никогда не сработает. + for (const [i, h] of (area.hazards ?? []).entries()) { + if (!(h.requiresItem in ITEMS)) { + out.push({ id: 'item-unknown', severity: 'error', message: `накат#${i} «${h.name}»: requiresItem «${h.requiresItem}» вне реестра ITEMS`, where }); + } + } + // Интерьеры (atmosphere 'none') выходят только назад: ровно один return-переход. + if (area.atmosphere === 'none') { + const returns = area.transitions.filter((t) => t.target.kind === 'return').length; + if (returns !== 1) { + out.push({ id: 'interior-return', severity: 'error', message: `интерьер должен иметь ровно один kind:'return' переход (сейчас ${returns})`, where }); + } } for (const obj of area.interactables ?? []) { const w = `${where}/interactable/${obj.id}`; @@ -214,6 +273,54 @@ return out; } +/** + * Аудио-ключи контента: опечатка в ключе = тишина в рантайме (никто не упадёт). + * Лупы/темы/слои — по реестрам audio.ts и music.ts; sfx реакций и реплик — по + * спек-реестру; roomtone/* — ключ обязан быть id существующей области. + */ +export function validateAudio(): Invariant[] { + const out: Invariant[] = []; + const sfx = new Set(Object.keys(SPEC_SFX)); + for (const area of Object.values(AREAS)) { + const where = `${WHERE}/area/${area.id}`; + if (area.ambience !== undefined && !AMBIENCE_KEYS.includes(area.ambience as never)) { + out.push({ id: 'audio-key', severity: 'error', message: `ambience «${area.ambience}» вне AMBIENCE_KEYS`, where }); + } + if (area.theme !== undefined && !(area.theme in THEMES)) { + out.push({ id: 'audio-key', severity: 'error', message: `theme «${area.theme}» вне THEMES`, where }); + } + (area.ambienceLayers ?? []).forEach((layer, i) => { + if (!AMBIENCE_LAYER_KEYS.includes(layer.key as never)) { + out.push({ id: 'audio-key', severity: 'error', message: `ambienceLayer#${i} «${layer.key}» вне AMBIENCE_LAYER_KEYS`, where }); + } + }); + for (const obj of area.interactables ?? []) { + for (const [i, r] of obj.responses.entries()) { + if (r.sound !== undefined && !sfx.has(r.sound)) { + out.push({ id: 'audio-key', severity: 'error', message: `${obj.id} response#${i}: sfx «${r.sound}» вне спек-реестра`, where: `${where}/${obj.id}` }); + } + } + } + } + for (const [id, graph] of Object.entries(DIALOGUES)) { + for (const [nodeId, node] of Object.entries(graph.nodes)) { + for (const n of [node, ...(node.choices ?? [])]) { + for (const op of n.do ?? []) { + if (op.kind === 'sound' && op.id !== undefined && !sfx.has(op.id)) { + out.push({ id: 'audio-key', severity: 'error', message: `узел «${nodeId}»: sfx «${op.id}» вне спек-реестра`, where: `${WHERE}/dialogue/${id}` }); + } + } + } + } + } + for (const key of Object.keys(ROOM_TONES)) { + if (!(key in AREAS)) { + out.push({ id: 'audio-key', severity: 'error', message: `roomtone «${key}»: области не существует`, where: `${WHERE}/roomtone` }); + } + } + return out; +} + /** Весь контент разом (для снапшота моста и тестов). */ export function validateContent(maps: Map): Invariant[] { return mergeInvariants( @@ -221,6 +328,7 @@ validateNpcs(maps), validateLocations(maps), validateEnemies(), + validateAudio(), validateReferences() ); } \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 26992f6..c04ee92 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -3,33 +3,25 @@ Graphics, IsoDepthLayer, Lighting, - ParticleEmitter, PixelText, Sprite, - SpriteAnimator, SpriteMotion, Texture, IsometricTileMap, - RATE, - createRng, FxLayer, emberPreset, readSeconds, - renderSpec, - renderMusicStereo, worldToScreen, worldNorm, - worldToTile, - screenToWorld, tileToWorld, unitsToPx, DebugOverlay, SpriteDebugView, VirtualJoystick, + CutsceneRunner, type Camera, type Entity, type Invariant, - type MusicHandle, type JsonValue, type Scene, type SnapshotLayer, @@ -40,31 +32,21 @@ import { MenuScene } from './MenuScene'; import { SAVE_VERSION, type SaveData } from './saveData'; import { InventoryScene } from './InventoryScene'; -import { TILES, DECOR, TALL_OBJECTS } from '../data/map'; -import { playSfx, ROOM_TONES } from '../data/sfxSpecs'; -import { - areaOf, - type AmbienceLayerDef, - type AreaDef, - type AreaId, - type HazardDef, - type TransitionDef -} from '../data/locations'; -import { AudioSystem, type AudioLayerDef } from '../systems/AudioSystem'; +import { TILES } from '../data/map'; +import { playSfx } from '../data/sfxSpecs'; +import { areaOf, type AreaDef, type AreaId, type TransitionDef } from '../data/locations'; +import { AudioSystem } from '../systems/AudioSystem'; import type { StepSurface } from '../systems/StepVariants'; -import { InteractionRouter } from '../systems/ClickRouting'; +import { InteractionRouter, resolvePixelTile } from '../systems/ClickRouting'; import { SceneAgentView } from '../agent/SceneAgentView'; import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; -import { QUEST_FLOWERS, questDialogueFor } from '../data/quests'; +import { questDialogueFor } from '../data/quests'; import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects'; import type { ItemId } from '../data/items'; -import { TENSION_STEM } from '../data/music'; import { FLAGS, VARS } from '../data/ids'; -import type { EnemyKindId } from '../data/enemies'; -import { PlayerController, type HeroTextures } from '../systems/PlayerController'; +import { PlayerController } from '../systems/PlayerController'; import { FaunaSystem } from '../systems/fauna/FaunaSystem'; -import { CutsceneRunner } from '@rpg/engine'; import { DialogueSystem } from '../systems/DialogueSystem'; import { CombatWorld } from '../systems/combat/CombatWorld'; import { CombatViews } from '../systems/combat/CombatViews'; @@ -74,18 +56,19 @@ import { Interactables } from '../systems/Interactables'; import { GameLighting, MAX_LIGHTS, type HeroLampDef } from '../systems/Lighting'; import { SceneObjects } from '../systems/SceneObjects'; -import type { InteractableDef } from '../data/interactables'; +import { LocationAudio } from '../systems/LocationAudio'; +import { InteractableViews } from '../systems/InteractableViews'; +import { Atmosphere } from '../systems/Atmosphere'; +import { FlowerQuest } from '../systems/quest/FlowerQuest'; +import { enemyTextures, heroTextures, tileTextures } from '../data/assetSets'; /** Лампа героя: тёплый, тихий, слегка мерцает (сумерки мира). */ const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 }; -/** Виньетка: цель при низком hp / в накате без маски и фейд перехода. */ -const VIGNETTE_LOW_HP = 0.3; -const VIGNETTE_HAZARD = 0.35; -const VIGNETTE_FADE = 0.5; - /** * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. + * Сцена — оркестратор вьюх: аудио/атмосфера/квест/вьюхи объектов живут + * своими подсистемами (systems/), а здесь только их сборка и тик. */ export class LocationScene implements Scene { private world = new Container(); @@ -97,10 +80,14 @@ private npcs: { def: NpcDef; view: Container; body: Sprite }[] = []; private actors = new IsoDepthLayer(); private hint: Container; - /** Атмосферные частицы (в интерьерах их нет). */ - private ash: ParticleEmitter | null; - /** Тёплые мотыльки над лугами (светящиеся, дрейф по синусу). */ - private moths: ParticleEmitter | null; + /** Аудио локации: слои/roomtone/шорохи/музыка угрозы. */ + private locationAudio: LocationAudio; + /** Атмосфера: пепел/мотыли/дымка, зоны наката, виньетка. */ + private atmosphere: Atmosphere; + /** Квест «Три цветка»: сбор/посадка/кат-сцена сдачи. */ + private flowers: FlowerQuest; + /** Вьюхи интерактивных объектов и NPC (фабрики + растворение). */ + private interactViews: InteractableViews; /** Мигание при неуязвимости — процедурный blink (duty живой). */ private heroMotion: SpriteMotion; private locationLabel: PixelText; @@ -126,9 +113,6 @@ private interactables: Interactables; /** Объекты сцены на движковом реестре (кто на тайле — для кликов/коллизий). */ private objects: SceneObjects; - private interactViews: { def: InteractableDef; view: Container; body: Graphics | Sprite; label: PixelText }[] = []; - /** Оверлей дымки (зоны наката). Виден только в низинах. */ - private fog: Graphics; /** Освещение: движковый слой (lightRoot) + игровая обвязка. */ private lightView: Lighting; private lighting: GameLighting; @@ -140,25 +124,8 @@ 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; - private stemLevel = 0; - /** Стем заводится (промис не вернулся) — не заводить второй. */ - private stemPending = false; - /** Room tone интерьера (тихий гул на шине ambience). */ - private roomtoneHandle: MusicHandle | null = null; - /** Дикий мир: редкие дальние шорохи пепла (rng сцены — детерминирован). */ - private ambientRng = createRng(20260908); - private rustleIn = 6; /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */ private cutscene = new CutsceneRunner(); - /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */ - private inHazard: HazardDef | null = null; - /** Текущая зона наката перекрыта полотном (для виньетки). */ - private hazardMasked = false; /** Тач-джойстик (активен только для касаний). */ private joystick: VirtualJoystick; private debug: DebugOverlay; @@ -181,7 +148,7 @@ ) { this.camera = game.engine.camera; const data = this.game.mapFiles.get(area.id)!; - this.map = new IsometricTileMap(data, this.tileTextures()); + this.map = new IsometricTileMap(data, tileTextures(this.game.assets)); // Живая вода: один общий таймлайн на все клетки с id WATER (2 кадра). this.map.setTileAnimation( TILES.WATER, @@ -198,7 +165,6 @@ .moveTo(0, -8) .lineTo(16, 0) .lineTo(0, 8) - .lineTo(-16, 0) .closePath() .stroke({ color: 0x8a6d3a, width: 1 }); glow.position.set(p.x, p.y); @@ -220,6 +186,10 @@ // Крупные объекты карты (дома) — в общий depth-слой по footprint'у. for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h); this.game.renderer.worldRoot.addChild(this.world); + // Слой мировых эффектов (раньше вьюх объектов: consume растворяет через него). + this.fx = new FxLayer(); + this.world.addChild(this.fx); + this.game.engine.fx.add(this.fx); // Единый реестр объектов сцены: NPC, интерактивы, пропы (враги/фауна // добавляются позже своими системами). this.objects = new SceneObjects(area, this.map); @@ -236,7 +206,7 @@ getListener: () => this.player.position }); this.audioOff = this.worldAudio.attach(); - this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { + this.player = new PlayerController(this.map, heroTextures(this.game.assets), startTile, () => { this.worldAudio.playStep(this.stepSurface(this.player.currentTile()), this.player.position, 0.35); // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. this.combat.noise(this.player.position, 0.35); @@ -252,8 +222,13 @@ // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). this.actors.add(this.player.view, startTile.x, startTile.y); + this.interactViews = new InteractableViews({ + assets: this.game.assets, + addFx: (u) => this.game.engine.fx.add(u), + fx: this.fx + }); for (const def of area.npcs) { - const made = this.makeNpcView(def); + const made = this.interactViews.createNpcView(def, (tx, ty) => this.tileCenter(tx, ty)); this.actors.add(made.view, def.tile.x, def.tile.y); this.npcs.push({ def, view: made.view, body: made.body }); } @@ -264,12 +239,11 @@ give: (id, n) => this.game.inventory.add(id, n), playSound: (k) => this.worldAudio.playAt(k, this.player.position, 0.6), showToast: (t) => this.showToast(t), - onConsumed: (def) => this.consumeView(def) + onConsumed: (def) => this.interactViews.consume(def, this.tileCenter, (s) => this.combatViews.hitBurst(s)) }); for (const def of area.interactables ?? []) { - const made = this.makeInteractableView(def); + const made = this.interactViews.createInteractableView(def, (tx, ty) => this.tileCenter(tx, ty)); this.actors.add(made.view, def.tile.x, def.tile.y); - this.interactViews.push({ def, view: made.view, body: made.body, label: made.label }); } // --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки --- @@ -290,17 +264,14 @@ for (const s of area.enemies) { this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y), s.patrol ? { patrol: s.patrol } : undefined); } - // Слои эффектов: мировые поверх мира, UI (тосты) поверх uiRoot. - this.fx = new FxLayer(); - this.world.addChild(this.fx); - this.game.engine.fx.add(this.fx); + // Слой UI-эффектов (тосты) поверх uiRoot. this.uiFx = new FxLayer(); this.game.renderer.uiRoot.addChild(this.uiFx); this.game.engine.fx.add(this.uiFx); this.combatViews = new CombatViews( this.combat, this.actors, - this.enemyTextures(), + enemyTextures(this.game.assets), this.world, this.fx ); @@ -386,6 +357,17 @@ } }); + // Квест «Три цветка»: сбор/посадка/кат-сцена — свои изменения тайлов. + this.flowers = new FlowerQuest( + this.game, + this.map, + this.layerTilePos, + (tx, ty) => this.tileCenter(tx, ty), + (s) => this.combatViews.hitBurst(s), + (t) => this.showToast(t), + this.cutscene + ); + // Маршрутизация клика/переходов/отложенных взаимодействий. this.router = new InteractionRouter({ game: this.game, @@ -402,7 +384,7 @@ showToast: (t) => this.showToast(t), playUiClick: () => playSfx(this.game.audio, 'sfx/ui_click', 0.4), talkTo: (d) => this.talkTo(d), - collectFlower: (x, y) => this.collectFlower(x, y), + collectFlower: (x, y) => this.flowers.collectFlower(x, y), useTransition: (d) => this.useTransition(d), attackTarget: (from, dir) => this.attackTarget(from, dir) } @@ -423,68 +405,38 @@ dialogue: this.dialogue, cutscene: this.cutscene, lastToast: () => this.lastToast, - inHazard: () => this.inHazard, + inHazard: () => this.atmosphere.inHazard, damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from ?? this.player.position), lighting: () => this.lighting, followCamera: (snap) => this.updateCameraFollow(snap) }); - // Атмосфера локации: пепел на лугах, туман над прудами, в интерьере — ничего. - this.ash = - area.atmosphere === 'none' - ? null - : area.atmosphere === 'fog' - ? new ParticleEmitter({ - color: 0x7a7a88, - rate: 6, - lifetime: [5, 10], - velocity: { x: [-3, 3], y: [-1, 1] }, - size: 2, - spawnArea: { width: 560, height: 340 }, - seed: 20260906 - }) - : new ParticleEmitter({ - color: 0x666677, - rate: 5, - lifetime: [4, 9], - velocity: { x: [-9, -3], y: [-2, 2] }, - size: 1, - spawnArea: { width: 520, height: 300 }, - seed: 20260905 - }); - if (this.ash) { - this.ash.position.set(240, 120); - this.game.renderer.worldRoot.addChild(this.ash); - // Пепел тикается реестром эффектов (engine.fx) — без ручного update. - this.game.engine.fx.add(this.ash); - } + // Атмосфера локации: пепел/мотыли/дымка, зоны наката, виньетка. + this.atmosphere = new Atmosphere({ + game: this.game, + area, + worldRoot: this.game.renderer.worldRoot, + uiRoot: this.game.renderer.uiRoot, + addFx: (u) => this.game.engine.fx.add(u), + player: this.player, + heroHp: () => this.playerCombat.hp, + vignette: { + level: () => this.lighting.vignetteLevel, + set: (level, fade) => this.lighting.setVignette(level, fade) + }, + showToast: (t) => this.showToast(t) + }); - // Тёплые мотыльки над лугами: светятся (add), дышат по синусу, в интерьере их нет. - this.moths = - area.atmosphere === 'ash' - ? new ParticleEmitter({ - colors: [0xf0d878, 0xd8b050], - blend: 'add', - rate: 2, - lifetime: [3, 6], - velocity: { x: [-4, 4], y: [-2, 2] }, - size: 1, - wobble: 8, - fadeIn: 0.6, - spawnArea: { width: 520, height: 300 }, - seed: 20260907 - }) - : null; - if (this.moths) { - this.moths.position.set(240, 120); - this.game.renderer.worldRoot.addChild(this.moths); - this.game.engine.fx.add(this.moths); - } - - // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще). - this.fog = new Graphics().rect(0, 0, Game.VIRTUAL_W, Game.VIRTUAL_H).fill(0x5a6a78); - this.fog.alpha = 0; - this.game.renderer.uiRoot.addChildAt(this.fog, 0); + // Аудио локации: roomtone, слои, шорохи, музыка угрозы. + this.locationAudio = new LocationAudio({ + audio: this.game.audio, + worldAudio: this.worldAudio, + area, + layerTilePos: this.layerTilePos, + heroPos: () => this.player.position, + playTheme: (key) => this.game.playTheme(key), + threat: () => this.combatThreat() + }); // Освещение: ambient области + статические источники (очаг, окна, лампа). this.lightView = new Lighting({ @@ -550,7 +502,7 @@ // Амбиент локации (кроссфейд; тот же ключ не перезапускается, '' — тишина). this.game.playAmbience(this.area.ambience ?? ''); this.game.playTheme(this.area.theme ?? ''); - this.startRoomTone(); + this.locationAudio.startRoomTone(); // Первая подсказка сбора: как брать лунные колокольчики (один раз). if (this.area.id === 'ponds' && !this.game.state.hasFlag(FLAGS.hint_bells)) { this.game.state.setFlag(FLAGS.hint_bells); @@ -558,49 +510,21 @@ } } - /** Room tone интерьера (см. ROOM_TONES): тихий луп на шине ambience. */ - private startRoomTone(): void { - const spec = ROOM_TONES[this.area.id]; - 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/${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.locationAudio.exit(); this.dialogue.destroy(); - this.roomtoneHandle?.stop(1); - this.roomtoneHandle = null; - this.stemHandle?.stop(1); - this.stemHandle = null; - this.stemBuf = null; - this.stemPending = false; this.fauna.exit(); this.combatViews.exit(); - this.ash?.clear(); this.fx.destroy({ children: true }); this.uiFx.destroy({ children: true }); this.world.destroy({ children: true }); - this.ash?.destroy(); - this.moths?.destroy(); + this.atmosphere.destroy(); this.lighting.destroy(); this.lightView.destroy({ children: true }); - this.fog.destroy(); this.healthBar.destroy({ children: true }); this.moteLabel.destroy({ children: true }); this.chargeRing.destroy(); // в worldRoot — не вычищается с this.world @@ -614,12 +538,12 @@ update(dt: number): void { this.map.update(dt); // анимированные тайлы (вода) — no-op без анимаций this.worldAudio.update(dt); - this.updateAudioLayers(); - this.updateAmbient(dt); + this.locationAudio.updateLayers(); + this.locationAudio.updateAmbient(dt); this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам - this.updateVignette(); + this.atmosphere.updateVignette(); this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { @@ -682,7 +606,7 @@ this.combat.update(dt); this.combatViews.sync(dt); this.playerCombat.update(dt); - this.updateMusic(dt); + this.locationAudio.updateMusic(dt); this.router.updateTarget(dt); this.updateChargeRing(); this.healthBar.setHp(this.playerCombat.hp); @@ -703,12 +627,12 @@ this.lastMotes = motes; this.moteLabel.text = motes > 0 ? `Пепельные моты: ${motes}` : ''; } - this.updateHazard(tile); + this.atmosphere.updateHazard(tile); this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); this.router.checkTransitions(tile); // Подписи объектов: видны вблизи, пока объект не использован (once). - for (const iv of this.interactViews) { + for (const iv of this.interactViews.items) { iv.label.visible = this.router.inInteractRange(iv.def.tile.x, iv.def.tile.y) && !(iv.def.once && this.interactables.isUsed(iv.def.id)); @@ -723,64 +647,6 @@ if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); } - /** Виньетка: низкий hp или накат без маски — края экрана темнеют. */ - private updateVignette(): void { - const target = Math.max( - this.playerCombat.hp <= 2 ? VIGNETTE_LOW_HP : 0, - this.inHazard !== null && !this.hazardMasked ? VIGNETTE_HAZARD : 0 - ); - if (this.lighting.vignetteLevel !== target) this.lighting.setVignette(target, VIGNETTE_FADE); - } - - /** - * Зона наката под ногами: без маски герой еле идёт и рискует голосом, - * звон здесь «проверяет воздух» — пепел на секунду видно волной. - */ - private updateHazard(tile: { x: number; y: number }): void { - const hazard = - (this.area.hazards ?? []).find((h) => - h.tiles.some((t) => t.x === tile.x && t.y === tile.y) - ) ?? null; - const masked = hazard !== null && this.game.inventory.has(hazard.requiresItem); - this.player.speedMul = hazard !== null && !masked ? 0.5 : 1; - - // Вход в низину: предупреждение один раз за вход. - if (hazard !== null && this.inHazard === null) { - this.showToast( - masked ? `${hazard.name}: полотно держит — но пепел у самых губ.` : `${hazard.name}! Дышать поверх — потерять голос.` - ); - void this.game.audio.play('sfx/ash_hiss', 0.5); - } - this.inHazard = hazard; - this.hazardMasked = masked; - - // Дымка: плотная без маски, лёгкая с ней, вне низин её нет. - this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28; - } - - /** Куда ведёт переход: область + точка входа ('return' — откуда вошли). */ - private targetEntry(def: TransitionDef): { area: AreaDef; entry: { x: number; y: number } } { - const t = def.target; - if (t.kind === 'area') return { area: areaOf(t.area), entry: t.entry }; - return { - area: areaOf(this.returnTo?.area), - entry: this.returnTo?.entry ?? this.area.spawn - }; - } - - /** Выполнить переход: новая сцена с fade (длительность — из определения). */ - private useTransition(def: TransitionDef): void { - const next = this.targetEntry(def); - playSfx(this.game.audio, 'sfx/whoosh', 0.5); - void this.game.scenes.replace( - new LocationScene(this.game, null, next.area, next.entry, { - area: this.area.id, - entry: this.player.currentTile() - }), - { duration: def.duration ?? 0.4 } - ); - } - render(): void {} // ---------- бой ---------- @@ -807,7 +673,7 @@ this.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } }); this.game.state.setVar(VARS.resonances, this.game.state.getNumber(VARS.resonances) + (slept > 0 ? 1 : 0)); // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. - if (this.inHazard !== null) { + if (this.atmosphere.inHazard !== null) { this.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 }); this.combatViews.hitBurst({ x: s.x + 14, y: s.y + 6 }); void this.game.audio.play('sfx/ash_hiss', 0.7); @@ -826,6 +692,17 @@ return false; } + /** Уровень угрозы 0..1 по состояниям врагов (музыка: бой/настороженность). */ + private combatThreat(): number { + let threat = 0; + for (const en of this.combat.enemies.values()) { + const st = en.brain.state; + if (st === 'chase' || st === 'windup' || st === 'attack' || st === 'hurt') threat = 1; + else if (st === 'wary') threat = Math.max(threat, 0.4); + } + return threat; + } + /** Индикатор заряда резонанса под героем. */ private updateChargeRing(): void { this.chargeRing.clear(); @@ -890,66 +767,16 @@ else this.camera.follow(p.x, p.y); } - /** Текстуры тайлов из загруженных ассетов (id -> Texture). */ - private tileTextures(): Map { - const a = this.game.assets; - return new Map([ - [TILES.GRASS, a.texture('tiles/grass')], - [TILES.GRASS_V1, a.texture('tiles/grass_2')], - [TILES.GRASS_V2, a.texture('tiles/grass_3')], - [TILES.PATH, a.texture('tiles/path')], - [TILES.PATH_V1, a.texture('tiles/path_2')], - [TILES.WATER, a.texture('tiles/water_1')], - [TILES.ASH, a.texture('tiles/ash')], - [TILES.ASH_V1, a.texture('tiles/ash_2')], - [DECOR.TUFT, a.texture('tiles/decor_tuft')], - [DECOR.STONE, a.texture('tiles/decor_stone')], - [DECOR.BALD, a.texture('tiles/decor_bald')], - [TILES.BELLFLOWER, a.texture('tiles/bellflower')], - [TILES.TREE, a.texture('tiles/tree')], - [TILES.TOWER, a.texture('tiles/tower')], - [TILES.HOUSE, a.texture('tiles/house')], - [TILES.FLOOR, a.texture('tiles/floor')], - [TILES.WALL, a.texture('tiles/wall')], - [TILES.WELL, a.texture('tiles/well')] - ]); - } - - /** Кадры сгустков из атласа chars/clumps_sheet. */ - private enemyTextures(): Map { - const frames = (prefix: string) => - this.game.assets.frames('chars/clumps_sheet.json', prefix) as [Texture, Texture]; - return new Map([ - ['crawler', frames('clump_crawler')], - ['spitter', frames('clump_spitter')], - ['heavy', frames('clump_heavy')] - ]); - } - - /** Кадры героя из атласа chars/hero_sheet (именованные анимации генератора). */ - private heroTextures(): HeroTextures { - const a = this.game.assets; - return { - down: a.animation('chars/hero_sheet.json', 'hero_walk_down') as [Texture, Texture], - up: a.animation('chars/hero_sheet.json', 'hero_walk_up') as [Texture, Texture], - side: a.animation('chars/hero_sheet.json', 'hero_walk_side') as [Texture, Texture] - }; - } - /** Экранные координаты центра ромба тайла (с Origin карты). */ private tileCenter(tx: number, ty: number): { x: number; y: number } { const u = tileToWorld(tx, ty); return worldToScreen(u.x, u.y); } - /** id тайла карты (для кликов по сборным объектам). */ - private tileId(x: number, y: number): number { - return this.map.data.tiles[y * this.map.data.width + x]; - } - /** Поверхность под ногами (трава/пепел/вода/пол) — для вариативных шагов. */ private stepSurface(tile: Vec2): StepSurface { - const id = this.tileId(tile.x, tile.y); + const d = this.map.data; + const id = d.tiles[tile.y * d.width + tile.x]!; if (id === TILES.WATER) return 'water'; if (id === TILES.ASH || id === TILES.HOUSE || id === TILES.TOWER) return 'ash'; if (id === TILES.FLOOR || id === TILES.PATH) return 'floor'; @@ -957,113 +784,26 @@ } /** - * Тик амбиент-слоёв: громкость каждого — от дистанции героя до ближайшего - * источника (тайлы или точка); глобальный слой (без источников) — константа. - * Пустой набор — setLayers сам снимет лишние. + * Тайл клика «по телу объекта» (RouterDeps.pixelTile): чистый резолвер + * resolvePixelTile с телами вьюх NPC/интерактивов и кликабельностью базы + * высокого тайла (двери-переходы, объекты). */ - private updateAudioLayers(): void { - const p = this.player.position; - const defs: AudioLayerDef[] = []; - for (const layer of this.area.ambienceLayers ?? []) { - const d = this.layerDistance(layer, p); - const k = layer.radius === undefined ? 1 : Math.max(0, 1 - d / layer.radius); - const volume = (layer.volume ?? 0.5) * k; - if (volume > 0.01) defs.push({ key: layer.key, volume, fade: 0.5 }); - } - this.worldAudio.setLayers(defs); - } - - /** - * Дистанция до ближайшего источника слоя (юниты; тайл — центр ромба). - * Слой без at и tiles — глобальный (ветер): источников нет, дистанция 0. - */ - private layerDistance(layer: AmbienceLayerDef, p: Vec2): number { - if (!layer.at && !(layer.tiles && layer.tiles.length > 0)) return 0; - if (layer.at) return Math.hypot(p.x - (layer.at.x + 0.5), p.y - (layer.at.y + 0.5)); - let best = Infinity; - for (const id of layer.tiles ?? []) { - for (const tile of this.layerTilePos.get(id) ?? []) { - const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); - if (d < best) best = d; - } - } - return best; - } - - /** Редкие дальние шорохи пепла: панорама и высота — случайные (rng сцены). */ - private updateAmbient(dt: number): void { - this.rustleIn -= dt; - if (this.rustleIn > 0) return; - this.rustleIn = 10 + this.ambientRng.next() * 20; - playSfx(this.game.audio, 'sfx/rustle', { - volume: 0.25 + this.ambientRng.next() * 0.15, - pan: (this.ambientRng.next() * 2 - 1) * 0.7, - rate: 0.85 + this.ambientRng.next() * 0.4 - }); - } - - /** - * Динамическая музыка по состояниям врагов: погоня (chase/бой) — боевая - * тема вместо темы области; настороженность (wary) — тихий tension-стем - * под темой (громкость по угрозе, сглаженная); спокойствие — тема области. - */private updateMusic(dt: number): void { - let threat = 0; - for (const en of this.combat.enemies.values()) { - const st = en.brain.state; - if (st === 'chase' || st === 'windup' || st === 'attack' || st === 'hurt') threat = 1; - else if (st === 'wary') threat = Math.max(threat, 0.4); - } - // Боевая тема вытесняет тему области; обратно — как только погоня кончилась. - this.game.playTheme(threat >= 1 ? 'combat' : (this.area.theme ?? '')); - - // Стем: тихо в бою (тема боя сама громкая), громче при wary, ноль в тишине. - const target = threat >= 1 ? 0.15 : threat > 0 ? 0.6 * threat : 0; - if (!this.stemHandle && !this.stemPending && target > 0) { - this.stemBuf ??= this.game.audio.createBuffer(renderMusicStereo(TENSION_STEM), RATE, 2); - if (this.stemBuf) { - this.stemPending = true; - void this.game.audio - .playLoopBuffer('music/tension', this.stemBuf, { fade: 1, volume: 0 }) - .then((h) => { - this.stemPending = false; - if (!h) return; - // Сцена сменилась, пока луп заводился, — гасим без хвостов. - if (!this.stemBuf) h.stop(1); - else this.stemHandle = h; - }); - } - } - // Громкость ведём плавно (dt-подход), stop в exit. - this.stemLevel += (target - this.stemLevel) * Math.min(1, dt * 2); - this.stemHandle?.setVolume(this.stemLevel * 0.8); - } - - /** Смена тайла с поддержанием индекса layerTilePos (свет по тайлам живой). */ - private setTileTracked(x: number, y: number, id: number): void { - const prev = this.tileId(x, y); - this.map.setTile(x, y, id); - const from = this.layerTilePos.get(prev); - if (from) { - const i = from.findIndex((p) => p.x === x && p.y === y); - if (i >= 0) from.splice(i, 1); - if (from.length === 0) this.layerTilePos.delete(prev); - } - const list = this.layerTilePos.get(id) ?? []; - list.push({ x, y }); - this.layerTilePos.set(id, list); - } - - /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */ - private collectFlower(x: number, y: number): void { - this.setTileTracked(x, y, TILES.GRASS); - this.game.inventory.add('bellflower'); - const n = this.game.state.getNumber(VARS.flowers) + 1; - this.game.state.setVar(VARS.flowers, n); - this.game.engine.events.emit('quest:flower', { n }); - playSfx(this.game.audio, 'sfx/bell_hit', 0.6); - const c = this.tileCenter(x, y); - this.combatViews.hitBurst(c); - this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); + private pixelTile(px: number, py: number): { x: number; y: number } | null { + const bodies = [ + ...this.npcs.map((n) => ({ tile: n.def.tile, body: n.body })), + ...this.interactViews.items.map((iv) => ({ tile: iv.def.tile, body: iv.body })) + ].map(({ tile, body }) => ({ tile, bounds: body.getBounds() })); + return resolvePixelTile( + px, + py, + this.game.renderer.worldRoot.position, + bodies, + this.map.data, + (tile) => + this.area.transitions.some( + (tr) => tr.tile.x === tile.x && tr.tile.y === tile.y && tr.trigger === 'click' + ) || this.interactables.defAt(tile.x, tile.y) !== null + ); } /** Предикаты мира для условий (сумка героя). */ @@ -1085,80 +825,10 @@ // Сюжетные custom-эффекты графа — только когда реплики доскажены. if (this.pendingCustom.has(DIALOGUE_CUSTOM.plant_flowers)) { this.pendingCustom.delete(DIALOGUE_CUSTOM.plant_flowers); - this.startHandInCutscene(); + this.flowers.startHandInCutscene(); } } - /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */ - private plantFlowers(): void { - const left = this.game.state.getNumber(VARS.flowers) - QUEST_FLOWERS; - this.game.state.setVar(VARS.flowers, Math.max(0, left)); - const planted: [number, number][] = [ - [15, 13], - [16, 14], - [15, 15] - ]; - for (const [tx, ty] of planted) { - this.setTileTracked(tx, ty, TILES.BELLFLOWER); - this.combatViews.hitBurst(this.tileCenter(tx, ty)); - } - // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»). - for (const [tx, ty] of planted) { - for (const [dx, dy] of [ - [1, 0], - [-1, 0], - [0, 1], - [0, -1] - ]) { - const id = this.tileId(tx + dx, ty + dy); - if (id === TILES.GRASS || id === TILES.ASH) this.setTileTracked(tx + dx, ty + dy, TILES.BELLFLOWER); - } - } - void this.game.audio.play('sfx/bell_low', 0.8); - this.showToast('Цветы в земле. Поляна гудит.'); - } - - /** Кат-сцена сдачи: посадка, камера к башне, удар колокола, зелень — финал акта 1. */ - private startHandInCutscene(): void { - const tower = tileToWorld(14, 7); // башня Звенца (юниты) - const s = worldToScreen(tower.x, tower.y); - this.cutscene.play([ - // Пересев: поляна гудит, герой стоит. - { kind: 'call', fn: () => this.plantFlowers(), seconds: 0.8 }, - // Камера уходит к башне. - { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: this.game.camera }, - // Удар колокола: низкий звон, тряска, кольцо резонанса. - { - kind: 'burst', - fn: () => { - void this.game.audio.play('sfx/bell_low', 1); - this.game.camera.addShake(2.5, 0.4); - this.combatViews.resonanceRing(s); - }, - seconds: 1 - }, - // Зелень поднимается у подножия башни (визуальный финал акта). - { - kind: 'burst', - fn: () => { - for (const [tx, ty] of [ - [13, 8], - [15, 8], - [14, 9] - ]) { - if (this.tileId(tx, ty) === TILES.GRASS || this.tileId(tx, ty) === TILES.ASH) { - this.setTileTracked(tx, ty, TILES.BELLFLOWER); - this.combatViews.hitBurst(this.tileCenter(tx, ty)); - } - } - }, - seconds: 1.2 - }, - // Тост-финал акта 1. - { kind: 'call', fn: () => this.showToast('Пепел отступил у тропы. Машина дышит тише... но дышит.') } - ]); - } - /** Всплывающая подсказка: появляется, держится, растворяется вверх (FxLayer). */ private showToast(text: string): void { this.lastToast = { text, tick: this.game.engine.tickCount }; @@ -1175,144 +845,27 @@ }); } - /** - * Одноразовый объект поднят (sink.onConsumed): вьюха растворяется, из - * тайла вылетают пеплинки — состояние мира меняется на глазах. - */ - private consumeView(def: InteractableDef): void { - const iv = this.interactViews.find((v) => v.def.id === def.id); - if (!iv) return; - this.interactViews = this.interactViews.filter((v) => v !== iv); - const w = tileToWorld(def.tile.x, def.tile.y); - this.combatViews.hitBurst(worldToScreen(w.x, w.y)); - // Вьюха тает и удаляется сама (пресет; removeChild+destroy внутри). - this.fx.fade(iv.view, { duration: 0.5 }); + /** Куда ведёт переход: область + точка входа ('return' — откуда вошли). */ + private targetEntry(def: TransitionDef): { area: AreaDef; entry: { x: number; y: number } } { + const t = def.target; + if (t.kind === 'area') return { area: areaOf(t.area), entry: t.entry }; + return { + area: areaOf(this.returnTo?.area), + entry: this.returnTo?.entry ?? this.area.spawn + }; } - /** - * Тайл клика «по телу объекта» (RouterDeps.pixelTile): bbox вьюх NPC и - * интерактивов + экранное тело высокого тайла (дом, дерево, башня). - * Экранно объект стоит выше своего ромба-пола, и клик по спрайту - * «проваливается под объект» — отдаём его базовому тайлу. - */ - private pixelTile(px: number, py: number): { x: number; y: number } | null { - // Тела вьюх (без подписей — они тянутся на тайл выше): границы в - // глобальных виртуальных px (рендер всегда 480×270). - for (const n of this.npcs) { - const b = n.body.getBounds(); - if (px >= b.x && px <= b.x + b.width && py >= b.y && py <= b.y + b.height) return n.def.tile; - } - for (const iv of this.interactViews) { - const b = iv.body.getBounds(); - if (px >= b.x && px <= b.x + b.width && py >= b.y && py <= b.y + b.height) return iv.def.tile; - } - // Высокий тайл: тело стоит на (tx,ty), а занимает на экране (tx-1,ty-1). - // Ремап — только если на базовом тайле есть что кликать (дверь, объект): - // иначе клик по «телу дерева» уводил бы героя к непроходимому тайлу. - const off = this.game.renderer.worldRoot.position; - const p = screenToWorld(px - off.x, py - off.y); - const t = worldToTile(p.x, p.y, this.map.data.width, this.map.data.height); - if (!t || t.x + 1 >= this.map.data.width || t.y + 1 >= this.map.data.height) return null; - const base = { x: t.x + 1, y: t.y + 1 }; - const above = this.map.data.tiles[(t.y + 1) * this.map.data.width + (t.x + 1)]!; - if (!TALL_OBJECTS[above]) return null; - const clickable = - this.area.transitions.some( - (tr) => tr.tile.x === base.x && tr.tile.y === base.y && tr.trigger === 'click' - ) || this.interactables.defAt(base.x, base.y); - return clickable ? base : null; - } - - /** Вьюха интерактивного объекта: спрайт из def.view или плейсхолдер + подпись. */ - private makeInteractableView(def: InteractableDef): { - view: Container; - body: Graphics | Sprite; - label: PixelText; - } { - const view = new Container(); - const p = this.tileCenter(def.tile.x, def.tile.y); - view.position.set(p.x, p.y); - let g: Graphics | Sprite; - if (def.view?.tex) { - // Настоящий арт из def.view: якорь низом в центр ромба (или задан). - const sprite = new Sprite(this.game.assets.texture(def.view.tex)); - sprite.anchor.set(0.5, def.view.anchorY ?? 1); - g = sprite; - } else { - // Плейсхолдер-ящик по роду (пока нет арта конкретного объекта). - g = new Graphics(); - switch (def.kind) { - case 'signpost': // столбик с доской - g.rect(-1, -10, 2, 10).fill(0x4a3b2a); - g.rect(-6, -14, 12, 5).fill(0x5c4a33); - g.rect(-6, -14, 12, 1).fill(0x8a6d3a); - break; - case 'lever': // рычаг: основание и наклонная рукоять - g.rect(-3, -2, 6, 2).fill(0x4a3b2a); - g.rect(0, -8, 2, 7).fill(0x8a6d3a); - g.rect(-2, -10, 4, 2).fill(0x8a6d3a); - break; - case 'pickup': // пепельная насыпь, из которой теплится мот - g.rect(-6, -2, 12, 3).fill(0x3a3a3a); - g.rect(-4, -4, 8, 2).fill(0x4a4a4a); - g.rect(-2, -5, 4, 1).fill(0x5a5a5a); - break; - default: // контейнер/предмет осмотра: ящик с бронзовой кромкой - g.rect(-5, -12, 10, 12).fill(0x4a3b2a); - g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо - } - } - view.addChild(g); - // У мота — тёплая искра поверх насыпи: заметный пульс видно издалека. - if (def.kind === 'pickup') { - const spark = new Graphics(); - spark.rect(-1, -1, 3, 3).fill(0xf0d878); - spark.position.set(0, -5); - view.addChild(spark); - this.game.engine.fx.add( - new SpriteMotion(spark, { pulse: { min: 0.3, max: 1, period: 1.1 } }, { - seed: def.tile.x * 31 + def.tile.y - }) - ); - } - const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a }); - label.anchor.set(0.5, 1); - label.position.set(0, -16); - label.visible = false; - view.addChild(label); - return { view, body: g, label }; - } - - private makeNpcView(def: NpcDef): { view: Container; body: Sprite } { - const view = new Container(); - const p = this.tileCenter(def.tile.x, def.tile.y); - view.position.set(p.x, p.y); - - const sprite = new Sprite(); - sprite.anchor.set(0.5, 1); // ноги в центре ромба - // idle-дыхание кадрами из атласа (по умолчанию — общий лист NPC). - const idle = this.game.assets.animation( - def.atlas ?? 'chars/npcs_sheet.json', - `${def.sprite}_idle` + /** Выполнить переход: новая сцена с fade (длительность — из определения). */ + private useTransition(def: TransitionDef): void { + const next = this.targetEntry(def); + playSfx(this.game.audio, 'sfx/whoosh', 0.5); + void this.game.scenes.replace( + new LocationScene(this.game, null, next.area, next.entry, { + area: this.area.id, + entry: this.player.currentTile() + }), + { duration: def.duration ?? 0.4 } ); - sprite.texture = idle[0]!; - const animator = new SpriteAnimator( - sprite, - { idle: { frames: idle, fps: 1.2 } }, - 'idle', - { randomPhase: def.tile.y * 64 + def.tile.x } - ); - this.game.engine.fx.add(animator); - view.addChild(sprite); - - // Маркер «с ним можно говорить»: восклицательный штрих над головой - // (высота — от реального кадра атласа, не от захардкоженной). - const frameH = idle[0]!.height; - const marker = new Graphics(); - marker.rect(-1, -(frameH + 3), 2, 4).fill(0xf0d878); - marker.rect(-1, -(frameH - 3), 2, 2).fill(0xf0d878); - view.addChild(marker); - return { view, body: sprite }; } private saveAndExit(): void { @@ -1334,4 +887,4 @@ ); void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); } -} +} \ No newline at end of file diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index 6f279dd..2e5ff13 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -15,8 +15,6 @@ import { normalizeSave, type SaveData } from './saveData'; import { START_MINUTES } from '../systems/Clock'; -export { SAVE_VERSION, normalizeSave, type ReturnToData, type SaveData } from './saveData'; - /** * Главное меню: название, «новая игра», «продолжить» (если есть сейв). * Список на движковом MenuList: мышь и клавиатура/геймпад. diff --git a/apps/game/src/scenes/SettingsScene.ts b/apps/game/src/scenes/SettingsScene.ts index 512f9bb..4d5e6f0 100644 --- a/apps/game/src/scenes/SettingsScene.ts +++ b/apps/game/src/scenes/SettingsScene.ts @@ -1,5 +1,5 @@ import { playSfx } from '../data/sfxSpecs'; -import { MenuList, MenuSceneBase, Panel, PixelText, type SettingsData } from '@rpg/engine'; +import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; import { Game } from '../Game'; /** @@ -82,7 +82,4 @@ playSfx(this.game.audio, 'sfx/ui_click'); this.onBack(); } -} - -/** Тип громкости в SettingsData (для adjust). */ -export type VolumeKey = keyof Pick; \ No newline at end of file +} \ No newline at end of file diff --git a/apps/game/src/systems/Atmosphere.ts b/apps/game/src/systems/Atmosphere.ts new file mode 100644 index 0000000..c36a151 --- /dev/null +++ b/apps/game/src/systems/Atmosphere.ts @@ -0,0 +1,145 @@ +import { Container, Graphics, ParticleEmitter, type Updatable } from '@rpg/engine'; +import { Game } from '../Game'; +import type { AreaDef, HazardDef } from '../data/locations'; +import type { PlayerController } from './PlayerController'; + +/** + * Атмосфера локации: пепел/мотыли (частицы мира), дымка зон наката (UI-слой), + * механика низин (замедление без маски, тост при входе) и виньетка опасности. + */ + +/** Виньетка: цель при низком hp / в накате без маски и фейд перехода. */ +const VIGNETTE_LOW_HP = 0.3; +const VIGNETTE_HAZARD = 0.35; +const VIGNETTE_FADE = 0.5; + +export interface AtmosphereDeps { + game: Game; + area: AreaDef; + /** Слои: частицы — в мир, дымка — под UI (index 0 uiRoot). */ + worldRoot: Container; + uiRoot: Container; + /** Реестр тикающихся (engine.fx). */ + addFx(u: Updatable): void; + player: PlayerController; + /** Текущий hp героя (виньетка при низком). */ + heroHp: () => number; + /** Виньетка движкового света: уровень + цель с фейдом. */ + vignette: { level(): number; set(intensity: number, fadeSec: number): void }; + showToast(text: string): void; +} + +export class Atmosphere { + /** Пепел на лугах/деревне (в интерьерах null). */ + readonly ash: ParticleEmitter | null; + /** Тёплые мотыльки над лугами (светящиеся, дрейф по синусу). */ + readonly moths: ParticleEmitter | null; + /** Оверлей дымки (зоны наката). Виден только в низинах. */ + readonly fog: Graphics; + /** Зона наката, в которой герой был в прошлом кадре (тост при входе). */ + inHazard: HazardDef | null = null; + /** Текущая зона наката перекрыта полотном (для виньетки). */ + hazardMasked = false; + + constructor(private deps: AtmosphereDeps) { + const area = deps.area; + // Пепел на лугах, туман над прудами, в интерьере — ничего. + this.ash = + area.atmosphere === 'none' + ? null + : area.atmosphere === 'fog' + ? new ParticleEmitter({ + color: 0x7a7a88, + rate: 6, + lifetime: [5, 10], + velocity: { x: [-3, 3], y: [-1, 1] }, + size: 2, + spawnArea: { width: 560, height: 340 }, + seed: 20260906 + }) + : new ParticleEmitter({ + color: 0x666677, + rate: 5, + lifetime: [4, 9], + velocity: { x: [-9, -3], y: [-2, 2] }, + size: 1, + spawnArea: { width: 520, height: 300 }, + seed: 20260905 + }); + if (this.ash) { + this.ash.position.set(240, 120); + deps.worldRoot.addChild(this.ash); + // Пепел тикается реестром эффектов (engine.fx) — без ручного update. + deps.addFx(this.ash); + } + + // Тёплые мотыльки над лугами: светятся (add), дышат по синусу, в интерьере их нет. + this.moths = + area.atmosphere === 'ash' + ? new ParticleEmitter({ + colors: [0xf0d878, 0xd8b050], + blend: 'add', + rate: 2, + lifetime: [3, 6], + velocity: { x: [-4, 4], y: [-2, 2] }, + size: 1, + wobble: 8, + fadeIn: 0.6, + spawnArea: { width: 520, height: 300 }, + seed: 20260907 + }) + : null; + if (this.moths) { + this.moths.position.set(240, 120); + deps.worldRoot.addChild(this.moths); + deps.addFx(this.moths); + } + + // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще). + this.fog = new Graphics().rect(0, 0, Game.VIRTUAL_W, Game.VIRTUAL_H).fill(0x5a6a78); + this.fog.alpha = 0; + deps.uiRoot.addChildAt(this.fog, 0); + } + + /** Зона наката под ногами: без маски герой еле идёт (тост при входе). */ + updateHazard(tile: { x: number; y: number }): void { + const { game, area, player } = this.deps; + const hazard = + (area.hazards ?? []).find((h) => + h.tiles.some((t) => t.x === tile.x && t.y === tile.y) + ) ?? null; + const masked = hazard !== null && game.inventory.has(hazard.requiresItem); + player.speedMul = hazard !== null && !masked ? 0.5 : 1; + + // Вход в низину: предупреждение один раз за вход. + if (hazard !== null && this.inHazard === null) { + this.deps.showToast( + masked ? `${hazard.name}: полотно держит — но пепел у самых губ.` : `${hazard.name}! Дышать поверх — потерять голос.` + ); + void game.audio.play('sfx/ash_hiss', 0.5); + } + this.inHazard = hazard; + this.hazardMasked = masked; + + // Дымка: плотная без маски, лёгкая с ней, вне низин её нет. + this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28; + } + + /** Виньетка: низкий hp или накат без маски — края экрана темнеют. */ + updateVignette(): void { + const target = Math.max( + this.deps.heroHp() <= 2 ? VIGNETTE_LOW_HP : 0, + this.inHazard !== null && !this.hazardMasked ? VIGNETTE_HAZARD : 0 + ); + if (this.deps.vignette.level() !== target) this.deps.vignette.set(target, VIGNETTE_FADE); + } + + /** Погасить/уничтожить свои вьюхи (exit сцены; engine.fx дочистит сам). */ + destroy(): void { + this.ash?.clear(); + this.moths?.clear(); + this.ash?.destroy(); + this.moths?.destroy(); + this.fog.destroy(); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/ClickRouting.ts b/apps/game/src/systems/ClickRouting.ts index 7b0633e..d3da199 100644 --- a/apps/game/src/systems/ClickRouting.ts +++ b/apps/game/src/systems/ClickRouting.ts @@ -8,9 +8,10 @@ tileToWorld, type Entity, type IsometricTileMap, + type TileMapData, type Vec2 } from '@rpg/engine'; -import { TILES } from '../data/map'; +import { TALL_OBJECTS } from '../data/map'; import type { AreaDef, TransitionDef } from '../data/locations'; import { resolveTransition, type TransitionCtx, type TransitionPick } from '../data/transitions'; import type { NpcDef } from '../data/npcs'; @@ -83,6 +84,53 @@ return { kind: 'move' }; } +/** Тело вьюхи для клика «по объекту»: тайл объекта + экранный bbox. */ +export interface PixelBody { + tile: { x: number; y: number }; + bounds: { x: number; y: number; width: number; height: number }; +} + +/** + * Указатель (виртуальные px) -> точка мира (юниты): учёт смещения worldRoot. + * Общий хелпер клик-роутинга и пиксельного тайла. + */ +export function pointerToWorld(px: number, py: number, offset: Vec2): Vec2 { + return screenToWorld(px - offset.x, py - offset.y); +} + +/** + * Тайл клика «по телу объекта» (RouterDeps.pixelTile): bbox вьюх NPC и + * интерактивов + экранное тело высокого тайла (дом, дерево, башня). + * Экранно объект стоит выше своего ромба-пола, и клик по спрайту + * «проваливается под объект» — отдаём его базовому тайлу. Чистая функция + * (без Pixi) — тестируется в Vitest. + */ +export function resolvePixelTile( + px: number, + py: number, + offset: Vec2, + bodies: PixelBody[], + data: TileMapData, + isClickable: (tile: { x: number; y: number }) => boolean +): { x: number; y: number } | null { + // Тела вьюх (без подписей — они тянутся на тайл выше): границы в + // глобальных виртуальных px (рендер всегда 480×270). + for (const b of bodies) { + const { x, y, width, height } = b.bounds; + if (px >= x && px <= x + width && py >= y && py <= y + height) return b.tile; + } + // Высокий тайл: тело стоит на (tx,ty), а занимает на экране (tx-1,ty-1). + // Ремап — только если на базовом тайле есть что кликать (дверь, объект): + // иначе клик по «телу дерева» уводил бы героя к непроходимому тайлу. + const p = pointerToWorld(px, py, offset); + const t = worldToTile(p.x, p.y, data.width, data.height); + if (!t || t.x + 1 >= data.width || t.y + 1 >= data.height) return null; + const base = { x: t.x + 1, y: t.y + 1 }; + const above = data.tiles[(t.y + 1) * data.width + (t.x + 1)]!; + if (!TALL_OBJECTS[above]) return null; + return isClickable(base) ? base : null; +} + /** Отложенное взаимодействие: сработает, когда герой подойдёт и остановится. */ export type PendingInteraction = | { kind: 'talk'; def: NpcDef } @@ -150,9 +198,8 @@ /** Клик по миру (виртуальные px указателя). */ handleWorldClick(px: number, py: number): void { - const off = this.deps.worldRootOffset(); // Координаты указателя (виртуальные px) -> мировые юниты (учёт камеры). - const p = screenToWorld(px - off.x, py - off.y); + const p = pointerToWorld(px, py, this.deps.worldRootOffset()); const clicked = worldToTile(p.x, p.y, this.deps.map.data.width, this.deps.map.data.height); const probe = (tile: { x: number; y: number } | null) => ({ world: p, @@ -343,11 +390,12 @@ return this.deps.objects.npcAt(x, y); } - /** Лунный колокольчик: сборный тайл, только в прудах (id области). */ + /** Сборный тайл области (лунный колокольчик); нет harvestTile — не собирается. */ private isFlower(x: number, y: number): boolean { - if (this.deps.area.id !== 'ponds') return false; + const harvest = this.deps.area.harvestTile; + if (harvest === undefined) return false; const d = this.deps.map.data; - return d.tiles[y * d.width + x] === TILES.BELLFLOWER; + return d.tiles[y * d.width + x] === harvest; } /** Живой враг под точкой мира (реестр: тело в радиусе + 0.15 клик-запаса). */ diff --git a/apps/game/src/systems/InteractableViews.ts b/apps/game/src/systems/InteractableViews.ts new file mode 100644 index 0000000..87c31f7 --- /dev/null +++ b/apps/game/src/systems/InteractableViews.ts @@ -0,0 +1,140 @@ +import { Container, FxLayer, Graphics, PixelText, Sprite, SpriteAnimator, SpriteMotion, type AssetLoader, type Updatable } from '@rpg/engine'; +import type { InteractableDef } from '../data/interactables'; +import type { NpcDef } from '../data/npcs'; + +/** + * Вьюхи NPC и интерактивных объектов: фабрики спрайтов/плейсхолдеров с + * подписями + растворение использованных объектов. Сцена только вызывает + * фабрики и держит слои глубины. + */ + +/** Вьюха интерактивного объекта + её части (тело — для кликов, подпись — близость). */ +export interface InteractableView { + def: InteractableDef; + view: Container; + body: Graphics | Sprite; + label: PixelText; +} + +export interface InteractableViewDeps { + assets: AssetLoader; + /** Реестр тикающихся (engine.fx): пульс мота и idle-анимация NPC. */ + addFx(u: Updatable): void; + /** Слой мировых эффектов: растворение использованной вьюхи. */ + fx: FxLayer; +} + +/** Экранный центр ромба тайла (worldToScreen ∘ tileToWorld). */ +export type TileCenter = (tx: number, ty: number) => { x: number; y: number }; + +export class InteractableViews { + /** Созданные вьюхи интерактивов (подписи, клики по телу, растворение). */ + readonly items: InteractableView[] = []; + + constructor(private deps: InteractableViewDeps) {} + + /** Вьюха интерактивного объекта: спрайт из def.view или плейсхолдер + подпись. */ + createInteractableView(def: InteractableDef, center: TileCenter): InteractableView { + const view = new Container(); + const p = center(def.tile.x, def.tile.y); + view.position.set(p.x, p.y); + let g: Graphics | Sprite; + if (def.view?.tex) { + // Настоящий арт из def.view: якорь низом в центр ромба (или задан). + const sprite = new Sprite(this.deps.assets.texture(def.view.tex)); + sprite.anchor.set(0.5, def.view.anchorY ?? 1); + g = sprite; + } else { + // Плейсхолдер-ящик по роду (пока нет арта конкретного объекта). + g = new Graphics(); + switch (def.kind) { + case 'signpost': // столбик с доской + g.rect(-1, -10, 2, 10).fill(0x4a3b2a); + g.rect(-6, -14, 12, 5).fill(0x5c4a33); + g.rect(-6, -14, 12, 1).fill(0x8a6d3a); + break; + case 'lever': // рычаг: основание и наклонная рукоять + g.rect(-3, -2, 6, 2).fill(0x4a3b2a); + g.rect(0, -8, 2, 7).fill(0x8a6d3a); + g.rect(-2, -10, 4, 2).fill(0x8a6d3a); + break; + case 'pickup': // пепельная насыпь, из которой теплится мот + g.rect(-6, -2, 12, 3).fill(0x3a3a3a); + g.rect(-4, -4, 8, 2).fill(0x4a4a4a); + g.rect(-2, -5, 4, 1).fill(0x5a5a5a); + break; + default: // контейнер/предмет осмотра: ящик с бронзовой кромкой + g.rect(-5, -12, 10, 12).fill(0x4a3b2a); + g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо + } + } + view.addChild(g); + // У мота — тёплая искра поверх насыпи: заметный пульс видно издалека. + if (def.kind === 'pickup') { + const spark = new Graphics(); + spark.rect(-1, -1, 3, 3).fill(0xf0d878); + spark.position.set(0, -5); + view.addChild(spark); + this.deps.addFx( + new SpriteMotion(spark, { pulse: { min: 0.3, max: 1, period: 1.1 } }, { + seed: def.tile.x * 31 + def.tile.y + }) + ); + } + const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a }); + label.anchor.set(0.5, 1); + label.position.set(0, -16); + label.visible = false; + view.addChild(label); + const made = { def, view, body: g, label }; + this.items.push(made); + return made; + } + + /** + * Одноразовый объект поднят (sink.onConsumed): вьюха растворяется, из + * тайла вылетают пеплинки — состояние мира меняется на глазах. + */ + consume(def: InteractableDef, center: TileCenter, burst: (s: { x: number; y: number }) => void): void { + const iv = this.items.find((v) => v.def.id === def.id); + if (!iv) return; + this.items.splice(this.items.indexOf(iv), 1); + const w = center(def.tile.x, def.tile.y); + burst(w); + // Вьюха тает и удаляется сама (пресет; removeChild+destroy внутри). + this.deps.fx.fade(iv.view, { duration: 0.5 }); + } + + /** NPC: спрайт с idle-дыханием кадрами атласа + маркер «с ним можно говорить». */ + createNpcView(def: NpcDef, center: TileCenter): { view: Container; body: Sprite } { + const view = new Container(); + const p = center(def.tile.x, def.tile.y); + view.position.set(p.x, p.y); + + const sprite = new Sprite(); + sprite.anchor.set(0.5, 1); // ноги в центре ромба + // idle-дыхание кадрами из атласа (по умолчанию — общий лист NPC). + const idle = this.deps.assets.animation( + def.atlas ?? 'chars/npcs_sheet.json', + `${def.sprite}_idle` + ); + sprite.texture = idle[0]!; + const animator = new SpriteAnimator( + sprite, + { idle: { frames: idle, fps: 1.2 } }, + 'idle', + { randomPhase: def.tile.y * 64 + def.tile.x } + ); + this.deps.addFx(animator); + view.addChild(sprite); + + // Маркер «с ним можно говорить»: восклицательный штрих над головой + // (высота — от реального кадра атласа, не от захардкоженной). + const frameH = idle[0]!.height; + const marker = new Graphics(); + marker.rect(-1, -(frameH + 3), 2, 4).fill(0xf0d878); + marker.rect(-1, -(frameH - 3), 2, 2).fill(0xf0d878); + view.addChild(marker); + return { view, body: sprite }; + } +} \ No newline at end of file diff --git a/apps/game/src/systems/Interactables.ts b/apps/game/src/systems/Interactables.ts index 7f1a8d7..cc76734 100644 --- a/apps/game/src/systems/Interactables.ts +++ b/apps/game/src/systems/Interactables.ts @@ -34,10 +34,6 @@ private sink: InteractSink ) {} - all(): InteractableDef[] { - return this.defs; - } - /** Объект на тайле (или null). */ defAt(x: number, y: number): InteractableDef | null { return this.defs.find((d) => d.tile.x === x && d.tile.y === y) ?? null; diff --git a/apps/game/src/systems/Lighting.ts b/apps/game/src/systems/Lighting.ts index 88aad03..b193c6d 100644 --- a/apps/game/src/systems/Lighting.ts +++ b/apps/game/src/systems/Lighting.ts @@ -1,8 +1,7 @@ /** * Игровая обвязка освещения: статика из LightingDef области + лампа героя, * проекция в экранные px каждый тик (camera.toScreen), условные источники - * по флагам и времени суток. Runtime API (setAmbient/addLight) — база - * day/night и эффектов. + * по флагам и времени суток. Runtime API (setAmbient) — база day/night. */ import { @@ -81,8 +80,6 @@ export class GameLighting { private readonly deps: GameLightingDeps; - /** Runtime-источники поверх статики области (эффекты, скрипты). */ - private readonly extra: LightSourceDef[] = []; /** Активные id на прошлом тике — чтобы снимать погасшие (условия по флагам). */ private activeIds = new Set(); /** Активные id тёмных пятен на прошлом тике (ауры хазардов). */ @@ -127,16 +124,6 @@ this.deps.lighting.pulseAmbient(args); } - /** Динамически добавить источник (позиция обязательна — в тайлах). */ - addLight(def: LightSourceDef & { at: { x: number; y: number } }): void { - this.extra.push(def); - } - - removeLight(id: string): void { - const i = this.extra.findIndex((d) => d.id === id); - if (i >= 0) this.extra.splice(i, 1); - } - /** Тик: ночная цель ambient, источники, тёмные пятна хазардов. */ update(): void { if (this.destroyed) return; @@ -236,7 +223,7 @@ private activeDefs(): LightSourceDef[] { const out: LightSourceDef[] = []; - const all = [...(this.deps.area.lighting?.sources ?? []), ...this.extra]; + const all = this.deps.area.lighting?.sources ?? []; const night = this.nightFactor; for (const def of all) { if (def.whenFlag && !this.deps.hasFlag(def.whenFlag)) continue; diff --git a/apps/game/src/systems/LocationAudio.ts b/apps/game/src/systems/LocationAudio.ts new file mode 100644 index 0000000..0fe59b4 --- /dev/null +++ b/apps/game/src/systems/LocationAudio.ts @@ -0,0 +1,155 @@ +import { createRng, RATE, renderMusicStereo, renderSpec, type AudioManager, type MusicHandle, type Vec2 } from '@rpg/engine'; +import { AudioSystem, type AudioLayerDef } from './AudioSystem'; +import { playSfx, ROOM_TONES } from '../data/sfxSpecs'; +import { TENSION_STEM } from '../data/music'; +import type { AmbienceLayerDef, AreaDef } from '../data/locations'; + +/** + * Аудио-подсистема локации: позиционные слои (громкость от дистанции до + * источника), room tone интерьеров, редкие шорохи пепла и динамическая + * музыка угрозы (tension-стем под темой области). Сцена кормит её тиком + * и уровнем угрозы боевого мира; свои лупы гасит сама в exit(). + */ + +export interface LocationAudioDeps { + audio: AudioManager; + /** Позиционный звук мира (шаги, звуки боя) — тикается сценой отдельно. */ + worldAudio: AudioSystem; + area: AreaDef; + /** Позиции тайлов-источников амбиент-слоёв по id (собирает сцена). */ + layerTilePos: Map; + /** Слушатель — герой (мировые юниты). */ + heroPos: () => Vec2; + /** Тема области/боя (Game.playTheme: кроссфейд, '' — тишина). */ + playTheme: (key: string) => void; + /** Уровень угрозы боевого мира 0..1 (погоня — 1, wary — 0.4). */ + threat: () => number; +} + +export class LocationAudio { + /** Tension-стем (враги wary): буфер + хендл + сглаженная громкость. */ + private stemHandle: MusicHandle | null = null; + private stemBuf: AudioBuffer | null = null; + private stemLevel = 0; + /** Стем заводится (промис не вернулся) — не заводить второй. */ + private stemPending = false; + /** Room tone интерьера (тихий гул на шине ambience). */ + private roomtoneHandle: MusicHandle | null = null; + /** Дикий мир: редкие дальние шорохи пепла (rng детерминирован). */ + private ambientRng = createRng(20260908); + private rustleIn = 6; + /** exit() вызван: гасим хвосты асинхронных заводок (roomtone/стем). */ + private exited = false; + + constructor(private deps: LocationAudioDeps) {} + + /** Room tone интерьера (см. ROOM_TONES): тихий луп на шине ambience. */ + startRoomTone(): void { + const spec = ROOM_TONES[this.deps.area.id]; + if (!spec) return; + const buf = this.deps.audio.createBuffer(renderSpec(spec)); + if (!buf) return; // до unlock контекста нет — тон появится при следующем входе + const key = this.deps.area.id; + void this.deps.audio + .playLoopBuffer(`roomtone/${key}`, buf, { bus: 'ambience', fade: 2, volume: 0.6 }) + .then((h) => { + // Сцена уже вышла, пока луп заводился, — гасим без хвостов. + if (this.exited) { + h?.stop(1); + return; + } + this.roomtoneHandle = h; + }); + } + + /** Погасить свои лупы (exit сцены) — без хвостов поверх следующей сцены. */ + exit(): void { + this.exited = true; + this.roomtoneHandle?.stop(1); + this.roomtoneHandle = null; + this.stemHandle?.stop(1); + this.stemHandle = null; + this.stemBuf = null; + this.stemPending = false; + this.stemLevel = 0; + } + + /** + * Тик амбиент-слоёв: громкость каждого — от дистанции героя до ближайшего + * источника (тайлы или точка); глобальный слой (без источников) — константа. + * Пустой набор — setLayers сам снимет лишние. + */ + updateLayers(): void { + const p = this.deps.heroPos(); + const defs: AudioLayerDef[] = []; + for (const layer of this.deps.area.ambienceLayers ?? []) { + const d = this.layerDistance(layer, p); + const k = layer.radius === undefined ? 1 : Math.max(0, 1 - d / layer.radius); + const volume = (layer.volume ?? 0.5) * k; + if (volume > 0.01) defs.push({ key: layer.key, volume, fade: 0.5 }); + } + this.deps.worldAudio.setLayers(defs); + } + + /** + * Дистанция до ближайшего источника слоя (юниты; тайл — центр ромба). + * Слой без at и tiles — глобальный (ветер): источников нет, дистанция 0. + */ + private layerDistance(layer: AmbienceLayerDef, p: Vec2): number { + if (!layer.at && !(layer.tiles && layer.tiles.length > 0)) return 0; + if (layer.at) return Math.hypot(p.x - (layer.at.x + 0.5), p.y - (layer.at.y + 0.5)); + let best = Infinity; + for (const id of layer.tiles ?? []) { + for (const tile of this.deps.layerTilePos.get(id) ?? []) { + const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); + if (d < best) best = d; + } + } + return best; + } + + /** Редкие дальние шорохи пепла: панорама и высота — случайные (rng сцены). */ + updateAmbient(dt: number): void { + this.rustleIn -= dt; + if (this.rustleIn > 0) return; + this.rustleIn = 10 + this.ambientRng.next() * 20; + playSfx(this.deps.audio, 'sfx/rustle', { + volume: 0.25 + this.ambientRng.next() * 0.15, + pan: (this.ambientRng.next() * 2 - 1) * 0.7, + rate: 0.85 + this.ambientRng.next() * 0.4 + }); + } + + /** + * Динамическая музыка по уровню угрозы: погоня — боевая тема вместо темы + * области; настороженность — тихий tension-стем под темой (громкость + * по угрозе, сглаженная); спокойствие — тема области. Сцена зовёт после + * combat.update — угроза свежая. + */ + updateMusic(dt: number): void { + const threat = this.deps.threat(); + // Боевая тема вытесняет тему области; обратно — как только погоня кончилась. + this.deps.playTheme(threat >= 1 ? 'combat' : (this.deps.area.theme ?? '')); + + // Стем: тихо в бою (тема боя сама громкая), громче при wary, ноль в тишине. + const target = threat >= 1 ? 0.15 : threat > 0 ? 0.6 * threat : 0; + if (!this.stemHandle && !this.stemPending && target > 0) { + this.stemBuf ??= this.deps.audio.createBuffer(renderMusicStereo(TENSION_STEM), RATE, 2); + if (this.stemBuf) { + this.stemPending = true; + void this.deps.audio + .playLoopBuffer('music/tension', this.stemBuf, { fade: 1, volume: 0 }) + .then((h) => { + this.stemPending = false; + if (!h) return; + // Сцена сменилась, пока луп заводился, — гасим без хвостов. + if (!this.stemBuf) h.stop(1); + else this.stemHandle = h; + }); + } + } + // Громкость ведём плавно (dt-подход), stop в exit. + this.stemLevel += (target - this.stemLevel) * Math.min(1, dt * 2); + this.stemHandle?.setVolume(this.stemLevel * 0.8); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index d9c4d89..536e77b 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -23,6 +23,9 @@ /** Максимальный радиус тел, из которых выталкивается герой (NPC — 0.35, запас). */ const BODY_PROBE = 0.5; +/** Порог прибытия в waypoint (юниты): меньше шага любого кадра — на глаз незаметно. */ +const ARRIVE_EPS = 1e-4; + /** Кадры героя по направлениям. side смотрит влево; вправо — флип. */ export interface HeroTextures { down: [Texture, Texture]; @@ -149,7 +152,8 @@ return; } this.pos = moveTowardsW(this.pos, this.waypoint, this.speed * this.speedMul * dt); - if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) { + if (this.arrivedAt(this.waypoint)) { + this.pos = { x: this.waypoint.x, y: this.waypoint.y }; // снап: вью и тайлы без дрейфа this.onStep?.(); // ступили на новый тайл this.advanceWaypoint(); this.syncView(); // вью на точном прибытии (иначе кадр отстаёт) @@ -258,6 +262,11 @@ ); } + /** Прибытие с eps: moveTowardsW обещает точность, но расталкивание тел сдвигает. */ + private arrivedAt(t: Vec2): boolean { + return Math.abs(this.pos.x - t.x) < ARRIVE_EPS && Math.abs(this.pos.y - t.y) < ARRIVE_EPS; + } + /** Сменить направление взгляда (кадры + флип спрайта). */ private setFacing(facing: Facing): void { if (facing === this.facing) return; diff --git a/apps/game/src/systems/SceneObjects.ts b/apps/game/src/systems/SceneObjects.ts index 076b26f..767cf1c 100644 --- a/apps/game/src/systems/SceneObjects.ts +++ b/apps/game/src/systems/SceneObjects.ts @@ -1,4 +1,4 @@ -import { SceneRegistry, tileToWorld, type Grid, type IsometricTileMap, type SceneObject } from '@rpg/engine'; +import { SceneRegistry, tileToWorld, type Grid, type IsometricTileMap } from '@rpg/engine'; import type { AreaDef } from '../data/locations'; import type { NpcDef } from '../data/npcs'; import type { InteractableDef } from '../data/interactables'; @@ -57,11 +57,6 @@ return (this.registry.at(x, y, 'interactable')?.ref as InteractableDef | undefined) ?? null; } - /** Все объекты рода (подписи, вьюхи, проверки). */ - byKind(kind: string): readonly SceneObject[] { - return this.registry.byKind(kind); - } - /** Ключ тайла (как в реестре; NPC не двигаются — кэш навсегда). */ private npcTiles: Set | null = null; diff --git a/apps/game/src/systems/__tests__/ClickRouting.test.ts b/apps/game/src/systems/__tests__/ClickRouting.test.ts index 36c7661..bc208fa 100644 --- a/apps/game/src/systems/__tests__/ClickRouting.test.ts +++ b/apps/game/src/systems/__tests__/ClickRouting.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { Vec2 } from '@rpg/engine'; -import { resolveClick, type ClickProbe } from '../ClickRouting'; +import { worldToScreen, type Vec2 } from '@rpg/engine'; +import { pointerToWorld, resolveClick, resolvePixelTile, type ClickProbe } from '../ClickRouting'; +import { TILES } from '../../data/map'; import type { NpcDef } from '../../data/npcs'; import type { InteractableDef } from '../../data/interactables'; import type { TransitionDef } from '../../data/locations'; @@ -103,4 +104,61 @@ expect(resolveClick(probe({ clicked: null }))).toEqual({ kind: 'move' }); expect(resolveClick(probe({ clicked: { x: 9, y: 9 } }))).toEqual({ kind: 'move' }); }); +}); + +/** Карта 4×4: весь — трава, базовый тайл (3,3) — высокое дерево. */ +function mapData(treeAt: { x: number; y: number } | null = { x: 3, y: 3 }) { + const tiles = new Array(16).fill(TILES.GRASS); + if (treeAt) tiles[treeAt.y * 4 + treeAt.x] = TILES.TREE; + return { width: 4, height: 4, tiles, blocked: [] as number[] }; +} + +describe('pointerToWorld — указатель в виртуальных px -> юниты', () => { + it('учёт смещения worldRoot и round-trip с worldToScreen', () => { + const offset = { x: 16, y: 20 }; + const p = pointerToWorld(48 + offset.x, 32 + offset.y, offset); + expect(p.x).toBeCloseTo(3.5); + expect(p.y).toBeCloseTo(0.5); + const s = worldToScreen(p.x, p.y); + expect(s.x + offset.x).toBeCloseTo(48 + offset.x); + expect(s.y + offset.y).toBeCloseTo(32 + offset.y); + }); +}); + +describe('resolvePixelTile — тайл клика «по телу объекта»', () => { + const offset = { x: 10, y: 10 }; + + it('клик в bbox тела вьюхи — тайл тела (приоритет над тайлом под ним)', () => { + const bodies = [{ tile: { x: 1, y: 2 }, bounds: { x: 100, y: 50, width: 24, height: 30 } }]; + const t = resolvePixelTile(110 + offset.x, 60 + offset.y, offset, bodies, mapData(), () => false); + expect(t).toEqual({ x: 1, y: 2 }); + }); + + it('мимо тел: высокий тайл с кликабельным базовым — ремап на (t+1, t+1)', () => { + // Указатель (0,40) при offset 0 -> тайл (2,2); тело дерева экранно на (3,3). + const t = resolvePixelTile(0, 40, { x: 0, y: 0 }, [], mapData(), () => true); + expect(t).toEqual({ x: 3, y: 3 }); + }); + + it('высокий тайл с некликабельным базовым — null (не уводить героя к дереву)', () => { + const t = resolvePixelTile(0, 40, { x: 0, y: 0 }, [], mapData(), () => false); + expect(t).toBeNull(); + }); + + it('не высокий тайл — null, даже если базовый «кликабелен»', () => { + const t = resolvePixelTile(0, 40, { x: 0, y: 0 }, [], mapData(null), () => true); + expect(t).toBeNull(); + }); + + it('клик у правого края карты (t+1 за границей) — null', () => { + // Указатель (48,32) при offset {x:0,y:0} -> тайл (3,0), t.x+1 = 4 = width. + const t = resolvePixelTile(48, 32, { x: 0, y: 0 }, [], mapData(), () => true); + expect(t).toBeNull(); + }); + + it('клик за картой — null', () => { + // (0, 300) -> мир (18.75, 18.75), далеко за 4×4. + const t = resolvePixelTile(0, 300, { x: 0, y: 0 }, [], mapData(), () => true); + expect(t).toBeNull(); + }); }); \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/lighting.test.ts b/apps/game/src/systems/__tests__/lighting.test.ts index f8d437c..c2d9afe 100644 --- a/apps/game/src/systems/__tests__/lighting.test.ts +++ b/apps/game/src/systems/__tests__/lighting.test.ts @@ -208,17 +208,6 @@ }); describe('GameLighting: runtime API и снапшот', () => { - it('addLight/removeLight поверх статики', () => { - const { gl, engineLighting } = makeGameLighting(fakeArea()); - gl.addLight({ id: 'fx', at: { x: 3, y: 3 }, flicker: 0 }); - gl.update(); - expect(engineLighting.hasLight('fx')).toBe(true); - gl.removeLight('fx'); - gl.update(); - expect(engineLighting.hasLight('fx')).toBe(false); - gl.destroy(); - }); - it('setAmbient пробрасывается в движковый слой с лерпом', () => { const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xffffff })); gl.setAmbient(0x000000, 1); diff --git a/apps/game/src/systems/combat/HealthBar.ts b/apps/game/src/systems/combat/HealthBar.ts index 6075f40..58bf33e 100644 --- a/apps/game/src/systems/combat/HealthBar.ts +++ b/apps/game/src/systems/combat/HealthBar.ts @@ -6,6 +6,8 @@ */ export class HealthBar extends Container { private hearts: Graphics[] = []; + /** Прошлое значение hp — не перерисовываем при том же (тик идёт каждый кадр). */ + private lastHp: number = PLAYER_COMBAT.maxHp; constructor() { super(); @@ -20,6 +22,8 @@ /** Обновить по текущему HP. */ setHp(hp: number): void { + if (hp === this.lastHp) return; + this.lastHp = hp; this.draw(hp); } diff --git a/apps/game/src/systems/fauna/FaunaSystem.ts b/apps/game/src/systems/fauna/FaunaSystem.ts index 8d05ac8..bfe0a3c 100644 --- a/apps/game/src/systems/fauna/FaunaSystem.ts +++ b/apps/game/src/systems/fauna/FaunaSystem.ts @@ -162,7 +162,9 @@ return; } e.pos = moveTowardsW(e.pos, e.dest, speed * dt); - if (e.pos.x === e.dest.x && e.pos.y === e.dest.y) { + // Прибытие с eps: расталкивание тел могло сместить существо. + if (Math.abs(e.pos.x - e.dest.x) < 1e-4 && Math.abs(e.pos.y - e.dest.y) < 1e-4) { + e.pos = { x: e.dest.x, y: e.dest.y }; // снап — тайл без дрейфа e.dest = null; e.machine.change(back); } diff --git a/apps/game/src/systems/quest/FlowerQuest.ts b/apps/game/src/systems/quest/FlowerQuest.ts new file mode 100644 index 0000000..19b0666 --- /dev/null +++ b/apps/game/src/systems/quest/FlowerQuest.ts @@ -0,0 +1,122 @@ +import { CutsceneRunner, IsometricTileMap, tileToWorld, worldToScreen, type Vec2 } from '@rpg/engine'; +import { Game } from '../../Game'; +import { VARS } from '../../data/ids'; +import { TILES } from '../../data/map'; +import { QUEST_FLOWERS, QUEST_PLANT_TILES, QUEST_TOWER_GREEN, QUEST_TOWER_TILE } from '../../data/quests'; +import { playSfx } from '../../data/sfxSpecs'; + +/** + * Квест «Три цветка» (docs/world.md, акт 1): сбор лунных колокольчиков, + * посадка поляны у тропы и кат-сцена сдачи (колокол башни, зелень). + * Изменения тайлов идут через map.setTile с поддержанием индекса + * layerTilePos — свет по тайлам-источникам остаётся живым. + */ +export class FlowerQuest { + constructor( + private game: Game, + private map: IsometricTileMap, + /** Позиции тайлов-источников по id (свет/слои): меняем при setTile. */ + private layerTilePos: Map, + /** Экранный центр ромба тайла (с Origin карты). */ + private tileCenter: (tx: number, ty: number) => { x: number; y: number }, + /** Всплеск пеплинок в точке (CombatViews.hitBurst). */ + private burst: (s: { x: number; y: number }) => void, + private showToast: (text: string) => void, + private cutscene: CutsceneRunner + ) {} + + /** id тайла карты. */ + tileId(x: number, y: number): number { + return this.map.data.tiles[y * this.map.data.width + x]; + } + + /** Смена тайла с поддержанием индекса layerTilePos (свет по тайлам живой). */ + setTileTracked(x: number, y: number, id: number): void { + const prev = this.tileId(x, y); + this.map.setTile(x, y, id); + const from = this.layerTilePos.get(prev); + if (from) { + const i = from.findIndex((p) => p.x === x && p.y === y); + if (i >= 0) from.splice(i, 1); + if (from.length === 0) this.layerTilePos.delete(prev); + } + const list = this.layerTilePos.get(id) ?? []; + list.push({ x, y }); + this.layerTilePos.set(id, list); + } + + /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */ + collectFlower(x: number, y: number): void { + this.setTileTracked(x, y, TILES.GRASS); + this.game.inventory.add('bellflower'); + const n = this.game.state.getNumber(VARS.flowers) + 1; + this.game.state.setVar(VARS.flowers, n); + this.game.engine.events.emit('quest:flower', { n }); + playSfx(this.game.audio, 'sfx/bell_hit', 0.6); + this.burst(this.tileCenter(x, y)); + this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); + } + + /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */ + plantFlowers(): void { + const left = this.game.state.getNumber(VARS.flowers) - QUEST_FLOWERS; + this.game.state.setVar(VARS.flowers, Math.max(0, left)); + for (const [tx, ty] of QUEST_PLANT_TILES) { + this.setTileTracked(tx, ty, TILES.BELLFLOWER); + this.burst(this.tileCenter(tx, ty)); + } + // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»). + for (const [tx, ty] of QUEST_PLANT_TILES) { + for (const [dx, dy] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1] + ]) { + const id = this.tileId(tx + dx, ty + dy); + if (id === TILES.GRASS || id === TILES.ASH) this.setTileTracked(tx + dx, ty + dy, TILES.BELLFLOWER); + } + } + void this.game.audio.play('sfx/bell_low', 0.8); + this.showToast('Цветы в земле. Поляна гудит.'); + } + + /** Кат-сцена сдачи: посадка, камера к башне, удар колокола, зелень — финал акта 1. */ + startHandInCutscene(): void { + // Башня Звенца: тайл в данных, камере нужны юниты. + const tower = tileToWorld(QUEST_TOWER_TILE.x, QUEST_TOWER_TILE.y); + const s = worldToScreen(tower.x, tower.y); + this.cutscene.play([ + // Пересев: поляна гудит, герой стоит. + { kind: 'call', fn: () => this.plantFlowers(), seconds: 0.8 }, + // Камера уходит к башне. + { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: this.game.camera }, + // Удар колокола: низкий звон, тряска, кольцо резонанса. + { + kind: 'burst', + fn: () => { + void this.game.audio.play('sfx/bell_low', 1); + this.game.camera.addShake(2.5, 0.4); + this.burst(s); + }, + seconds: 1 + }, + // Зелень поднимается у подножия башни (визуальный финал акта). + { + kind: 'burst', + fn: () => { + for (const [tx, ty] of QUEST_TOWER_GREEN) { + const id = this.tileId(tx, ty); + if (id === TILES.GRASS || id === TILES.ASH) { + this.setTileTracked(tx, ty, TILES.BELLFLOWER); + this.burst(this.tileCenter(tx, ty)); + } + } + }, + seconds: 1.2 + }, + // Тост-финал акта 1. + { kind: 'call', fn: () => this.showToast('Пепел отступил у тропы. Машина дышит тише... но дышит.') } + ]); + } +} \ No newline at end of file