diff --git a/apps/game/assets/audio/ambience/wind.wav b/apps/game/assets/audio/ambience/wind.wav new file mode 100644 index 0000000..330a1f7 --- /dev/null +++ b/apps/game/assets/audio/ambience/wind.wav Binary files differ diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts index 8c12a8c..72ac17e 100644 --- a/apps/game/src/data/audio.ts +++ b/apps/game/src/data/audio.ts @@ -14,8 +14,13 @@ /** Амбиент-лупы областей. */ export const AMBIENCE_KEYS = ['ambience/meadows', 'ambience/ponds', 'ambience/zvenets'] as const; -/** Локальные амбиент-слои (вода, гул, дома). */ -export const AMBIENCE_LAYER_KEYS = ['ambience/ponds_water', 'ambience/tower_hum', 'ambience/houses'] as const; +/** Локальные амбиент-слои (вода, гул, дома) и глобальные (ветер). */ +export const AMBIENCE_LAYER_KEYS = [ + 'ambience/ponds_water', + 'ambience/tower_hum', + 'ambience/houses', + 'ambience/wind' +] as const; /** Все аудио-ключи игры (валидация контента; префетч только файловых). */ export const AUDIO_KEYS: readonly string[] = [ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 4c9e2ae..e362cd6 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -54,9 +54,9 @@ tiles?: number[]; /** Точка-источник в тайлах (центр башни и т.п.). */ at?: { x: number; y: number }; - /** Радиус слышимости в юнитах (за ним слой молчит). */ - radius: number; - /** Громкость вплотную (по умолчанию 0.5). */ + /** Радиус слышимости в юнитах (за ним слой молчит); нет at/tiles — глобальный слой. */ + radius?: number; + /** Громкость (для глобального — константа; по умолчанию 0.5). */ volume?: number; } @@ -125,6 +125,8 @@ ], atmosphere: 'ash', interactables: INTERACTABLES.meadows, + // Глобальный ветер: порывы свистят выше базового амбиента лугов. + ambienceLayers: [{ key: 'ambience/wind', volume: 0.3 }], // Безгласные олени у берегов прудов — подходят к звону. fauna: [ { x: 9, y: 18 }, @@ -150,7 +152,11 @@ ], atmosphere: 'fog', // Вода прудов слышна у берега — тихий плеск поверх ветрового амбиента. - ambienceLayers: [{ key: 'ambience/ponds_water', tiles: [TILES.WATER], radius: 6, volume: 0.6 }], + // Глобальный ветер + вода у берегов (громкость — от дистанции). + ambienceLayers: [ + { key: 'ambience/wind', volume: 0.3 }, + { key: 'ambience/ponds_water', tiles: [TILES.WATER], radius: 5, volume: 0.35 } + ], // Свежий накат — низины у берегов: без вощёного полотна здесь не дышать. hazards: [ { diff --git a/apps/game/src/data/sfxSpecs.ts b/apps/game/src/data/sfxSpecs.ts index 0406e63..c13c70f 100644 --- a/apps/game/src/data/sfxSpecs.ts +++ b/apps/game/src/data/sfxSpecs.ts @@ -73,6 +73,11 @@ 'sfx/step_floor': { kind: 'hit', dur: 0.1, low: 150, high: 800, seed: 256, power: 3, w: 0.4, peak: 0.3, layers: [{ freq: 140, w: 0.5 }] + }, + // Дальний шорох пепла («мир живой»): медленный вздох шума, панорама/вариации + // задаёт тик сцены (LocationScene.updateAmbient). + 'sfx/rustle': { + kind: 'scrape', dur: 0.5, low: 400, high: 1800, seed: 263, power: 1.2, attack: 0.15, peak: 0.25 } }; diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index ac339ce..e9af4ee 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -10,6 +10,7 @@ Texture, IsometricTileMap, RATE, + createRng, renderSpec, renderMusicStereo, worldToScreen, @@ -113,6 +114,9 @@ private stemPending = false; /** Room tone интерьера (тихий гул на шине ambience). */ private roomtoneHandle: MusicHandle | null = null; + /** Дикий мир: редкие дальние шорохи пепла (rng сцены — детерминирован). */ + private ambientRng = createRng(20260908); + private rustleIn = 6; /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */ private cutscene = new CutsceneRunner(); /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */ @@ -474,6 +478,7 @@ this.map.update(dt); // анимированные тайлы (вода) — no-op без анимаций this.worldAudio.update(dt); this.updateAudioLayers(); + this.updateAmbient(dt); this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { @@ -778,21 +783,27 @@ /** * Тик амбиент-слоёв: громкость каждого — от дистанции героя до ближайшего - * источника (тайлы или точка). Пустой набор — setLayers сам снимет лишние. + * источника (тайлы или точка); глобальный слой (без источников) — константа. + * Пустой набор — setLayers сам снимет лишние. */ 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 volume = (layer.volume ?? 0.5) * Math.max(0, 1 - d / layer.radius); + 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 ?? []) { @@ -804,12 +815,23 @@ 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 { + */private updateMusic(dt: number): void { let threat = 0; for (const en of this.combat.enemies.values()) { const st = en.brain.state; diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index d9c09fe..d39ddcc 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -151,6 +151,7 @@ if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) { this.onStep?.(); // ступили на новый тайл this.advanceWaypoint(); + this.syncView(); // вью на точном прибытии (иначе кадр отстаёт) return; } this.faceMovement(); diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs index d2efb31..05ebdef 100644 --- a/apps/game/tools/audio/gen.mjs +++ b/apps/game/tools/audio/gen.mjs @@ -296,6 +296,24 @@ return loopify(normalize(s, 0.5)); } +/** Слой «ветер» (глобальный): свистящая высокая полоса с редкими порывами. */ +function layerWind() { + const dur = 8; + const air = bandNoise(175, 300, 1400, dur); + const r = makeRng(47); + const gusts = []; + for (let i = 0; i < 2; i++) gusts.push({ at: r() * dur, len: 2 + r() }); + const s = synth(dur, (t) => { + let v = air() * 0.18 * (0.55 + 0.45 * Math.sin(2 * Math.PI * t / dur + 1.5)); + for (const g of gusts) { + const dt = t - g.at + (t < g.at ? dur : 0); // зациклить порывы + if (dt >= 0 && dt < g.len) v += air() * 0.22 * decay(dt, g.len, 1.5); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + // ---------- запись ---------- const AMBIENCE = { @@ -304,7 +322,8 @@ 'ambience/zvenets': ambienceZvenets, 'ambience/ponds_water': layerPondsWater, 'ambience/tower_hum': layerTowerHum, - 'ambience/houses': layerHouses + 'ambience/houses': layerHouses, + 'ambience/wind': layerWind }; // Записываем WAV только при запуске как CLI, а не при импорте (парити-тест // импортирует формулы bellHit/whoosh, чтобы сверить их со спек-синтезом). diff --git a/docs/engine/anim.md b/docs/engine/anim.md index baa964e..b5c0343 100644 --- a/docs/engine/anim.md +++ b/docs/engine/anim.md @@ -96,9 +96,12 @@ основания сохраняет контакт); `pulse`/`blink` — для свечения и акцентов. `SpriteMotion` снимает базовые `position/rotation/alpha/scale` в конструкторе — -позиционируйте view **до** его создания. `params` живые: меняйте на лету -(колокол качается только пока звонят). `detach()` возвращает исходные -значения. Фаза из `seed` — детерминированная (никакого `Math.random`). +позиционируйте view **до** его создания. Важно: позиция/поворот перезаписываются +каждый тик только когда есть `bob`/`sway` — view с чистым `pulse`/`blink` +(светлячок, мигающий герой) можно двигать извне, оживитель позицию не тронет. +`params` живые: меняйте на лету (колокол качается только пока звонят). +`detach()` возвращает исходные значения (только тех полей, которые трогал). +Фаза из `seed` — детерминированная (никакого `Math.random`). ## Анимированные тайлы: setTileAnimation diff --git a/docs/engine/practices.md b/docs/engine/practices.md index ad1f492..8c31fdd 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -200,7 +200,12 @@ Событийные звуки боя/фауны — подпиской в `AudioSystem.attach()` на `combat:*` / `fauna:startle` (симуляция чиста от аудио). Лупы областей — `AreaDef.ambience`, локальные - слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`. + слои (вода, гул) — `AreaDef.ambienceLayers` + тик `worldAudio.setLayers`; + слой без `at`/`tiles` — глобальный (ветер `ambience/wind`: громкость + константой, радиус не нужен). «Мир живой» — редкие дальние шорохи + `sfx/rustle` из тика сцены (`LocationScene.updateAmbient`: rng сцены, + случайные pan/rate/volume; перезапуск 10–30 с). Птиц в мире НЕТ — лор + (docs/world.md: «птицы молчат»), звук пернатых — слом мира. Полифонию ограничивает движок (`maxVoices`, воровство тихих голосов) — пер-ключевой интервал в `AudioSystem` нужен только против «пулемёта» одного ключа. Агентный доступ — op `scene:synthesize`, образец — diff --git a/packages/engine/src/anim/SpriteMotion.ts b/packages/engine/src/anim/SpriteMotion.ts index c0b8bee..eaa5b8d 100644 --- a/packages/engine/src/anim/SpriteMotion.ts +++ b/packages/engine/src/anim/SpriteMotion.ts @@ -1,7 +1,9 @@ /** * Pixi-адаптер оживителей: применяет выборку sampleMotion к view (position - * относительно базовой, rotation, alpha, scale). База снимается в конструкторе - * — не позиционируйте view после создания. Тикается через engine.fx. + * относительно базовой при bob, rotation при sway, alpha/scale при + * pulse/blink). Базы снимаются в конструкторе; позиция/поворот + * перезаписываются только когда есть bob/sway — view с чистым blink/pulse + * можно двигать извне. Тикается через engine.fx. */ import type { Container } from 'pixi.js'; import type { Updatable } from '../core/Updatables'; @@ -44,20 +46,26 @@ update(dt: number): void { this.t += dt; const s = sampleMotion(this.params, this.t); - this.view.x = this.baseX; - this.view.y = this.baseY + s.y; - this.view.rotation = s.angle; - this.view.alpha = this.baseAlpha * s.alpha; - this.view.scale.set(this.baseScaleX * s.scale, this.baseScaleY * s.scale); + // Позицию/поворот трогаем только если они «живые» (bob/sway): view с + // чистым blink/pulse имеет право двигаться извне (герой, враги). + if (this.params.bob) { + this.view.x = this.baseX; + this.view.y = this.baseY + s.y; + } + if (this.params.sway) this.view.rotation = s.angle; + if (this.params.blink || this.params.pulse) this.view.alpha = this.baseAlpha * s.alpha; + if (this.params.pulse) this.view.scale.set(this.baseScaleX * s.scale, this.baseScaleY * s.scale); } /** Вернуть исходные position/rotation/alpha/scale (при выходе из сцены). */ detach(): void { - this.view.x = this.baseX; - this.view.y = this.baseY; - this.view.rotation = 0; - this.view.alpha = this.baseAlpha; - this.view.scale.set(this.baseScaleX, this.baseScaleY); + if (this.params.bob) { + this.view.x = this.baseX; + this.view.y = this.baseY; + } + if (this.params.sway) this.view.rotation = 0; + if (this.params.blink || this.params.pulse) this.view.alpha = this.baseAlpha; + if (this.params.pulse) this.view.scale.set(this.baseScaleX, this.baseScaleY); } /** Самозачистка из engine.fx: view разрушен — реестр отпустит запись. */ diff --git a/packages/engine/src/anim/__tests__/SpriteMotion.test.ts b/packages/engine/src/anim/__tests__/SpriteMotion.test.ts new file mode 100644 index 0000000..20a89ba --- /dev/null +++ b/packages/engine/src/anim/__tests__/SpriteMotion.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { SpriteMotion } from '../SpriteMotion'; + +/** Минимальный стенд под интерфейс Container, который трогает SpriteMotion. */ +function fakeView(x = 10, y = 20) { + const v = { + x, + y, + rotation: 0, + alpha: 1, + scale: { _sx: 1, _sy: 1, set(sx: number, sy: number) { this._sx = sx; this._sy = sy; }, + get x() { return this._sx; }, get y() { return this._sy; } }, + destroyed: false + }; + return v; +} + +describe('SpriteMotion', () => { + it('blink не трогает позицию: движущийся view сохраняет внешнее перемещение', () => { + const v = fakeView(5, 7); + const m = new SpriteMotion(v as never, { blink: { period: 0.25, duty: 1 } }); + v.x = 100; // извне (синк вью героя) + v.y = 50; + m.update(0.1); + m.update(0.1); + expect(v.x).toBe(100); + expect(v.y).toBe(50); + }); + + it('bob смещает Y относительно базы, X держит; detach возвращает базу', () => { + const v = fakeView(10, 20); + const m = new SpriteMotion(v as never, { bob: { amplitude: 4, period: 1 } }); + m.update(0.25); // четверть периода — максимальное смещение + expect(v.x).toBe(10); + expect(v.y).not.toBe(20); + m.detach(); + expect(v.x).toBe(10); + expect(v.y).toBe(20); + }); + + it('pulse меняет alpha и scale, но не позицию', () => { + const v = fakeView(); + const m = new SpriteMotion(v as never, { pulse: { min: 0.5, max: 1, period: 1 } }); + m.update(0.25); + expect(v.alpha).not.toBe(1); + expect(v.x).toBe(10); + expect(v.y).toBe(20); + }); +}); \ No newline at end of file