diff --git a/apps/game/assets/audio/music/meadows.wav b/apps/game/assets/audio/music/meadows.wav new file mode 100644 index 0000000..3b958b8 --- /dev/null +++ b/apps/game/assets/audio/music/meadows.wav Binary files differ diff --git a/apps/game/assets/audio/music/ponds.wav b/apps/game/assets/audio/music/ponds.wav new file mode 100644 index 0000000..f18cbe8 --- /dev/null +++ b/apps/game/assets/audio/music/ponds.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/ash_die.wav b/apps/game/assets/audio/sfx/ash_die.wav new file mode 100644 index 0000000..fe7f847 --- /dev/null +++ b/apps/game/assets/audio/sfx/ash_die.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/ash_hiss.wav b/apps/game/assets/audio/sfx/ash_hiss.wav new file mode 100644 index 0000000..f0cf5cf --- /dev/null +++ b/apps/game/assets/audio/sfx/ash_hiss.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/bell_hit.wav b/apps/game/assets/audio/sfx/bell_hit.wav new file mode 100644 index 0000000..c64de60 --- /dev/null +++ b/apps/game/assets/audio/sfx/bell_hit.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/bell_low.wav b/apps/game/assets/audio/sfx/bell_low.wav new file mode 100644 index 0000000..12728b3 --- /dev/null +++ b/apps/game/assets/audio/sfx/bell_low.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/chime.wav b/apps/game/assets/audio/sfx/chime.wav new file mode 100644 index 0000000..d988c38 --- /dev/null +++ b/apps/game/assets/audio/sfx/chime.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/hurt.wav b/apps/game/assets/audio/sfx/hurt.wav new file mode 100644 index 0000000..a25aaf7 --- /dev/null +++ b/apps/game/assets/audio/sfx/hurt.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/spit.wav b/apps/game/assets/audio/sfx/spit.wav new file mode 100644 index 0000000..1a7a6ee --- /dev/null +++ b/apps/game/assets/audio/sfx/spit.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/step.wav b/apps/game/assets/audio/sfx/step.wav new file mode 100644 index 0000000..c5e3023 --- /dev/null +++ b/apps/game/assets/audio/sfx/step.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/ui_click.wav b/apps/game/assets/audio/sfx/ui_click.wav new file mode 100644 index 0000000..1638053 --- /dev/null +++ b/apps/game/assets/audio/sfx/ui_click.wav Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 4e23825..97249b3 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -2,6 +2,7 @@ Engine, SaveManager, AssetLoader, + AudioManager, GameState, Settings, type Renderer, @@ -21,6 +22,8 @@ readonly saves: SaveManager; readonly assets: AssetLoader; + /** Звуки и музыка (WAV из tools/audio/gen.mjs). */ + readonly audio = new AudioManager((key) => `${import.meta.env.BASE_URL}audio/${key}.wav`); /** Флаги и переменные прохождения. */ readonly state = new GameState(); /** Настройки игрока (громкости и т.п.) — вне сейвов. */ @@ -38,6 +41,21 @@ 'chars/trader_mila' ]; + /** Ключи аудио (WAV): sfx + амбиент-лупы. */ + static readonly AUDIO_KEYS = [ + 'sfx/bell_hit', + 'sfx/bell_low', + 'sfx/ash_hiss', + 'sfx/ash_die', + 'sfx/spit', + 'sfx/hurt', + 'sfx/step', + 'sfx/ui_click', + 'sfx/chime', + 'music/meadows', + 'music/ponds' + ]; + constructor(readonly engine: Engine) { this.saves = new SaveManager(window.localStorage); // Ключи без расширения -> .png; ключи .json (атласы) резолвятся как есть. @@ -45,6 +63,16 @@ key.endsWith('.json') ? `${import.meta.env.BASE_URL}${key}` : `${import.meta.env.BASE_URL}${key}.png` ); this.settings.load(); + // Настройки громкости -> шины аудио (работает и до unlock — запомнится). + this.settings.onChange((s) => { + this.audio.setBusVolume('master', s.master); + this.audio.setBusVolume('music', s.music); + this.audio.setBusVolume('sfx', s.sfx); + }); + // Применить текущие значения сразу. + this.audio.setBusVolume('master', this.settings.data.master); + this.audio.setBusVolume('music', this.settings.data.music); + this.audio.setBusVolume('sfx', this.settings.data.sfx); } /** URL пиксельного шрифта (VT323 идёт вместе с движком, OFL). */ @@ -65,4 +93,14 @@ get worldRoot() { return this.engine.renderer.worldRoot; } + + /** Музыка с кроссфейдом; повторный вызов с тем же треком не перезапускает его. */ + playMusic(key: string, volume = 0.7): void { + if (this.musicKey === key) return; + this.musicKey = key; + void this.audio.playMusic(key, { fade: 1.5, volume }); + } + + /** Текущий трек (защита от перезапуска того же ключа). */ + private musicKey: string | null = null; } \ No newline at end of file diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index d5da936..71a9d92 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -20,19 +20,33 @@ advance: ['Space', 'Enter'], menu: ['Escape'], up: ['KeyW', 'ArrowUp'], - down: ['KeyS', 'ArrowDown'] + down: ['KeyS', 'ArrowDown'], + left: ['KeyA', 'ArrowLeft'], + right: ['KeyD', 'ArrowRight'] }); engine.input.bindGamepad({ advance: [0], // A menu: [9], // Start up: [12], - down: [13] + down: [13], + left: [14], + right: [15] }); // Пиксельный шрифт движка (VT323): до первого текста; фолбэк monospace безопасен. void ensurePixelFont(Game.FONT_URL); const game = new Game(engine); + + // Аудио разрешено только после жеста пользователя: разблокируем на первом вводе. + const unlockAudio = (): void => { + void game.audio.unlock(); + window.removeEventListener('pointerdown', unlockAudio); + window.removeEventListener('keydown', unlockAudio); + }; + window.addEventListener('pointerdown', unlockAudio); + window.addEventListener('keydown', unlockAudio); + await engine.scenes.push(new BootScene(game)); await engine.start(); } diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index af2ea31..b63c4f3 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -39,6 +39,13 @@ }) .then(async () => await this.game.assets.loadAtlas('chars/hero_sheet.json')) .then(() => ensurePixelFont(Game.FONT_URL)) + // Звуки: декодируем заранее, чтобы первый play не тормозил. + .then(async () => { + for (const key of Game.AUDIO_KEYS) { + await this.game.audio.load(key); + await this.game.audio.preload(key); + } + }) .then(() => { this.progress = 1; void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index f6ca127..08add29 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -44,7 +44,9 @@ const startTile = save?.pos ?? { x: 14, y: 14 }; if (save?.state) this.game.state.load(save.state); - this.player = new PlayerController(this.map, this.heroTextures(), startTile); + this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { + void this.game.audio.play('sfx/step', 0.35); + }); // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). this.actors.add(this.player.view, startTile.x, startTile.y); @@ -60,6 +62,7 @@ margin: 8 }); this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id); + this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5); // Пепел над лугами: медленные серые точки в воздухе. this.ash = new ParticleEmitter({ @@ -96,7 +99,10 @@ this.game.renderer.uiRoot.addChild(this.hint); } - enter(): void {} + enter(): void { + // Амбиент локации (кроссфейд; тот же трек не перезапускается). + this.game.playMusic('music/meadows'); + } exit(): void { this.ash.clear(); diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index ec7cfab..2b4771b 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -1,6 +1,7 @@ import { Container, MenuList, PixelText, type GameStateData, type Scene } from '@rpg/engine'; import type { Game } from '../Game'; import { LocationScene } from './LocationScene'; +import { SettingsScene } from './SettingsScene'; /** * Главное меню: название, «новая игра», «продолжить» (если есть сейв). @@ -34,13 +35,23 @@ const items = [ { label: 'Новая игра', - onSelect: () => this.start(null) + onSelect: () => this.game.audio.play('sfx/ui_click').then(() => this.start(null)) + }, + { + label: 'Настройки', + onSelect: () => { + void this.game.audio.play('sfx/ui_click'); + void this.game.scenes.push( + new SettingsScene(this.game, () => void this.game.scenes.pop()) + ); + } } ]; if (this.game.saves.has('autosave')) { items.unshift({ label: 'Продолжить', onSelect: () => { + void this.game.audio.play('sfx/ui_click'); const save = this.game.saves.load('autosave'); this.start(save); } diff --git a/apps/game/src/scenes/SettingsScene.ts b/apps/game/src/scenes/SettingsScene.ts new file mode 100644 index 0000000..64c48b9 --- /dev/null +++ b/apps/game/src/scenes/SettingsScene.ts @@ -0,0 +1,100 @@ +import { + Container, + MenuList, + Panel, + PixelText, + type Scene, + type SettingsData +} from '@rpg/engine'; +import type { Game } from '../Game'; + +/** + * Настройки: громкости шин аудио. Панель поверх вызывающей сцены (push/pop), + * изменения сразу применяются через settings.onChange -> AudioManager. + */ +export class SettingsScene implements Scene { + private view = new Container(); + private menu: MenuList; + + /** Куда вернуться (закрыть панель). */ + constructor( + private game: Game, + private onBack: () => void + ) { + const panel = new Panel({ width: 220, height: 130 }); + panel.position.set((480 - 220) / 2, (270 - 130) / 2); + + const title = new PixelText({ text: 'НАСТРОЙКИ', size: 14, color: 0xd8c79a }); + title.anchor.set(0.5); + title.position.set(110, 14); + panel.addChild(title); + + this.menu = new MenuList({ width: 180, height: 14, gap: 4, size: 9 }); + this.menu.position.set(20, 36); + panel.addChild(this.menu); + + this.view.addChild(panel); + this.game.renderer.uiRoot.addChild(this.view); + this.rebuild(); + } + + enter(): void {} + + exit(): void { + this.view.destroy({ children: true }); + } + + render(): void {} + + update(_dt: number): void { + if (this.game.scenes.transitioning) return; + const input = this.game.engine.input; + if (input.isActionJustPressed('menu')) { + this.back(); + return; + } + if (!this.menu) return; + if (input.isActionJustPressed('up')) this.menu.moveCursor(-1); + if (input.isActionJustPressed('down')) this.menu.moveCursor(1); + // влево/вправо меняют громкость выбранной строки, Enter — на 10% вверх + const step = input.isActionJustPressed('advance') ? 0.1 : 0; + const dir = input.isActionJustPressed('right') ? 0.1 : input.isActionJustPressed('left') ? -0.1 : step; + if (dir !== 0) this.adjust(this.menu.index, dir); + } + + private rebuild(): void { + const s = this.game.settings.data; + this.menu.setItems([ + { label: this.volumeLabel('Общая', s.master), onSelect: () => this.adjust(0, 0.1) }, + { label: this.volumeLabel('Музыка', s.music), onSelect: () => this.adjust(1, 0.1) }, + { label: this.volumeLabel('Звуки', s.sfx), onSelect: () => this.adjust(2, 0.1) }, + { label: 'Назад', onSelect: () => this.back() } + ]); + } + + private volumeLabel(name: string, v: number): string { + const bars = Math.round(v * 10); + return `${name} ${'▮'.repeat(bars)}${'▯'.repeat(10 - bars)}`; + } + + private adjust(index: number, delta: number): void { + if (index < 0 || index > 2) { + this.back(); + return; + } + const s = this.game.settings.data; + const key = (['master', 'music', 'sfx'] as const)[index]; + const value = Math.round(Math.min(1, Math.max(0, s[key] + delta)) * 10) / 10; + this.game.settings.update({ [key]: value }); + if (delta > 0) void this.game.audio.play('sfx/ui_click'); + this.rebuild(); + } + + private back(): void { + void this.game.audio.play('sfx/ui_click'); + this.onBack(); + } +} + +/** Тип громкости в SettingsData (для adjust). */ +export type VolumeKey = keyof Pick; \ No newline at end of file diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts index fe55ef3..1e1d917 100644 --- a/apps/game/src/systems/DialogueSystem.ts +++ b/apps/game/src/systems/DialogueSystem.ts @@ -17,6 +17,8 @@ /** Сюжетное событие «диалог завершён» — для триггеров/квестов. */ onDialogueFinished: ((id: string) => void) | null = null; + /** Звук на каждую новую реплику (колокольчик). */ + onLineShown: (() => void) | null = null; /** id стартованного диалога (для onFinish). */ private currentId: string | null = null; @@ -29,7 +31,10 @@ this.runner = new DialogueRunner(state); this.runner.setView({ - show: ({ speaker, text, choices }) => this.box.show({ speaker, text, choices }), + show: ({ speaker, text, choices }) => { + this.box.show({ speaker, text, choices }); + this.onLineShown?.(); + }, hide: () => this.box.hide() }); } diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index bf84b27..b94562d 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -43,7 +43,8 @@ constructor( private map: IsometricTileMap, textures: HeroTextures, - startTile: { x: number; y: number } + startTile: { x: number; y: number }, + private onStep?: () => void ) { this.textures = textures; @@ -58,6 +59,25 @@ this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y)); } + /** Текущее направление взгляда (для боя: куда бьёт конус). */ + get dir(): 'down' | 'up' | 'left' | 'right' { + return this.facing; + } + + /** Вектор направления взгляда в экранных координатах. */ + get dirVector(): Vec2 { + switch (this.facing) { + case 'up': + return { x: 0, y: -1 }; + case 'left': + return { x: -1, y: 0 }; + case 'right': + return { x: 1, y: 0 }; + default: + return { x: 0, y: 1 }; + } + } + /** Кликом по миру выбран тайл — строим путь A* и начинаем движение. */ onWorldClick(worldX: number, worldY: number): void { const tile = screenToIsoExact( @@ -79,6 +99,7 @@ if (!this.waypoint) return; this.pos = moveTo(this.pos, this.waypoint, this.speed * dt); if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) { + this.onStep?.(); // ступили на новый тайл this.advanceWaypoint(); return; } diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index 14379bb..868d7b0 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -52,6 +52,16 @@ } /** + * Декодировать заранее (после unlock, например в сцене загрузки), + * чтобы первый play не тратил время на fetch+decode. + */ + async preload(key: string): Promise { + const ctx = await this.ensureContext(); + if (!ctx) return; + await this.decode(key); + } + + /** * Проиграть sfx (если звук не загружен — тихо ничего не делает). * volume — относительная громкость в шине sfx. */ diff --git a/tools/audio/gen.mjs b/tools/audio/gen.mjs new file mode 100644 index 0000000..b12ee5a --- /dev/null +++ b/tools/audio/gen.mjs @@ -0,0 +1,269 @@ +/** + * Генератор звуков «Пепельных лугов» — WAV из кода, без зависимостей. + * Запуск: node tools/audio/gen.mjs + * Выход: apps/game/assets/audio/sfx/*.wav, apps/game/assets/audio/music/*.wav + * + * Формат: моно, 22050 Гц, 16-бит PCM (хватает для пиксель-арта со звуком, + * файлы вдвое меньше). Меланхоличный пепельный саунд: глухие удары, шум, низкие пады. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const OUT = fileURLToPath(new URL('../../apps/game/assets/audio/', import.meta.url)); +const RATE = 22050; + +// ---------- WAV-энкодер ---------- + +/** Собрать WAV-файл из Float32 сэмплов (-1..1). */ +function encodeWav(samples) { + const n = samples.length; + const data = new ArrayBuffer(44 + n * 2); + const view = new DataView(data); + const str = (offset, s) => { + for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); + }; + str(0, 'RIFF'); + view.setUint32(4, 36 + n * 2, true); + str(8, 'WAVE'); + str(12, 'fmt '); + view.setUint32(16, 16, true); // размер fmt + view.setUint16(20, 1, true); // PCM + view.setUint16(22, 1, true); // моно + view.setUint32(24, RATE, true); + view.setUint32(28, RATE * 2, true); // байт/сек + view.setUint16(32, 2, true); // блок + view.setUint16(34, 16, true); // бит + str(36, 'data'); + view.setUint32(40, n * 2, true); + for (let i = 0; i < n; i++) { + const s = Math.max(-1, Math.min(1, samples[i])); + view.setInt16(44 + i * 2, Math.round(s * 32767), true); + } + return Buffer.from(data); +} + +// ---------- примитивы ---------- + +/** Детерминированный шум. */ +function makeRng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Синтез: секунды -> Float32[]. fn(t, i) -> сэмпл. */ +function synth(seconds, fn) { + const n = Math.floor(seconds * RATE); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = fn(i / RATE, i); + return out; +} + +/** Экспоненциальный спад: полный на старте, ~0 к концу. */ +function decay(t, dur, power = 4) { + return Math.pow(Math.max(0, 1 - t / dur), power); +} + +/** Колокольный партиал: негармоничный (частота * растяжка), быстрый спад. */ +function bellPartial(freq, stretch, dur, power) { + return (t) => Math.sin(2 * Math.PI * freq * stretch * t) * decay(t, dur, power); +} + +/** Полосовой шум через однополюсные фильтры (низ + верх). */ +function bandNoise(seed, lowHz, highHz, dur) { + const r = makeRng(seed); + let l1 = 0; + let l2 = 0; + const aLow = 1 - Math.exp(-2 * Math.PI * lowHz / RATE); + const aHigh = 1 - Math.exp(-2 * Math.PI * highHz / RATE); + return () => { + const white = r() * 2 - 1; + l1 += aHigh * (white - l1); // фильтр верхов: выше highHz глушим + l2 += aLow * (l1 - l2); // фильтр низов: ниже lowHz глушим + return l2 - l1 < -1 ? -1 : Math.max(-1, Math.min(1, l1 - l2)); + }; +} + +/** Нормализовать по пику. */ +function normalize(samples, peak = 0.9) { + let max = 0; + for (const s of samples) max = Math.max(max, Math.abs(s)); + if (max === 0) return samples; + const k = peak / max; + return samples.map((s) => s * k); +} + +/** Сшить луп: кроссфейд первых fade-секунд с концом. */ +function loopify(samples, fadeSeconds = 0.5) { + const n = Math.floor(fadeSeconds * RATE); + for (let i = 0; i < n; i++) { + const k = i / n; + samples[i] = samples[i] * k + samples[samples.length - n + i] * (1 - k); + } + return samples.slice(0, samples.length - n); +} + +// ---------- звуки ---------- + +/** Гулкий удар колокольчиком: 4 негармоничных партиала + шумовой клик. */ +function bellHit() { + const dur = 0.35; + const s = synth(dur, (t) => { + const body = + bellPartial(520, 1.0, dur, 5)(t) * 0.5 + + bellPartial(520, 1.52, dur, 6)(t) * 0.3 + + bellPartial(520, 2.38, dur * 0.6, 7)(t) * 0.2 + + bellPartial(520, 3.91, dur * 0.4, 8)(t) * 0.12; + const click = t < 0.008 ? (makeRng(1)() * 2 - 1) * (1 - t / 0.008) * 0.4 : 0; + return body * 0.8 + click; + }); + return normalize(s, 0.7); +} + +/** Низкий резонанс: медленный гул с тремоло — «усыпляет пепел». */ +function bellLow() { + const dur = 0.9; + const s = synth(dur, (t) => { + const trem = 1 + 0.3 * Math.sin(2 * Math.PI * 6 * t); + const body = + Math.sin(2 * Math.PI * 110 * t) * 0.5 + + Math.sin(2 * Math.PI * 165 * t) * 0.25 + + Math.sin(2 * Math.PI * 220.5 * t) * 0.12; + return body * decay(t, dur, 2) * trem; + }); + return normalize(s, 0.6); +} + +/** Шипение: сгусток просыпается. */ +function ashHiss() { + const dur = 0.4; + const noise = bandNoise(11, 900, 4200, dur); + const s = synth(dur, (t) => noise() * Math.min(1, t / 0.08) * decay(t, dur, 2)); + return normalize(s, 0.55); +} + +/** Смерть сгустка: шум с падающим «выдохом». */ +function ashDie() { + const dur = 0.6; + const noise = bandNoise(23, 300, 2400, dur); + const s = synth(dur, (t) => { + const pitch = 1 - 0.7 * (t / dur); // темнеет к концу + return noise() * decay(t, dur, 3) * (0.4 + pitch); + }); + return normalize(s, 0.6); +} + +/** Плевок: синус с падающей высотой + шипящий хвост. */ +function spit() { + const dur = 0.2; + const noise = bandNoise(37, 2000, 6000, dur); + const s = synth(dur, (t) => { + const f = 700 - 350 * (t / dur); + return (Math.sin(2 * Math.PI * f * t) * 0.6 + noise() * 0.4) * decay(t, dur, 3); + }); + return normalize(s, 0.5); +} + +/** Урон герою: глухой низкий удар. */ +function hurt() { + const dur = 0.25; + const noise = bandNoise(41, 80, 400, dur); + const s = synth(dur, (t) => { + const thump = Math.sin(2 * Math.PI * 90 * t) * decay(t, dur, 3); + return thump * 0.8 + noise() * 0.3 * decay(t, dur * 0.5, 2); + }); + return normalize(s, 0.65); +} + +/** Шаг по пеплу: короткий шорох. */ +function step() { + const dur = 0.12; + const noise = bandNoise(53, 300, 1500, dur); + const s = synth(dur, (t) => noise() * decay(t, dur, 2) * 0.5); + return normalize(s, 0.3); +} + +/** Клик UI. */ +function uiClick() { + const dur = 0.06; + const s = synth(dur, (t) => Math.sin(2 * Math.PI * 880 * t) * decay(t, dur, 3) * 0.5); + return normalize(s, 0.4); +} + +/** Колокольчик Милы: высокий, почти хрустальный. */ +function chime() { + const dur = 0.5; + const s = synth(dur, (t) => { + const body = + Math.sin(2 * Math.PI * 1560 * t) * 0.5 + + Math.sin(2 * Math.PI * 1560 * 1.61 * t) * 0.25; + return body * decay(t, dur, 3); + }); + return normalize(s, 0.45); +} + +/** Амбиент лугов: низкий ветер + редкий гул, зациклен. */ +function musicMeadows() { + const dur = 8; + const wind = bandNoise(61, 100, 600, dur); + const r = makeRng(7); + const gusts = []; + for (let i = 0; i < 3; i++) gusts.push({ at: r() * dur, len: 1.5 + r() }); + const s = synth(dur, (t) => { + let v = wind() * 0.25; + // медленное «дыхание» ветра + v *= 0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2 + 1); + for (const g of gusts) { + const d = t - g.at + (t < g.at ? dur : 0); // зациклить порывы + if (d >= 0 && d < g.len) v += Math.sin(2 * Math.PI * 82 * d) * 0.1 * decay(d, g.len, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** Амбиент прудов: ниже, влажнее, с редкой капелью. */ +function musicPonds() { + const dur = 8; + const wind = bandNoise(71, 60, 400, dur); + const r = makeRng(13); + const drops = []; + for (let i = 0; i < 4; i++) drops.push({ at: r() * dur, f: 900 + r() * 600 }); + const s = synth(dur, (t) => { + let v = wind() * 0.3 * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur + 3)); + for (const d of drops) { + const dt = t - d.at + (t < d.at ? dur : 0); + if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.08 * decay(dt, 0.15, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +// ---------- запись ---------- + +const SFX = { + 'sfx/bell_hit': bellHit, + 'sfx/bell_low': bellLow, + 'sfx/ash_hiss': ashHiss, + 'sfx/ash_die': ashDie, + 'sfx/spit': spit, + 'sfx/hurt': hurt, + 'sfx/step': step, + 'sfx/ui_click': uiClick, + 'sfx/chime': chime, + 'music/meadows': musicMeadows, + 'music/ponds': musicPonds +}; + +mkdirSync(`${OUT}sfx`, { recursive: true }); +mkdirSync(`${OUT}music`, { recursive: true }); +for (const [name, gen] of Object.entries(SFX)) { + const buf = encodeWav(gen()); + writeFileSync(`${OUT}${name}.wav`, buf); + console.log(`${name}.wav ${(buf.length / 1024).toFixed(1)} КБ`); +} \ No newline at end of file