diff --git a/docs/roadmap.md b/docs/roadmap.md index 69bbc76..805a9e8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,6 +15,7 @@ Build a small complete tactical RPG loop: - Vite, TypeScript, and Phaser project foundation - Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio - Cinematic prologue pages with high-resolution story backgrounds, character portraits, and scenario data +- File-based original BGM loops, local generated Korean narration/voice lines, and automatic music ducking during speech - First battle scene with a 12x12 map, terrain colors, unit placement, and movement range preview - Flow verification script from title to first battle diff --git a/package.json b/package.json index 83e82a0..836a77d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc && vite build", + "generate:audio": "node scripts/generate-bgm.mjs && node scripts/generate-voice-assets.mjs", "preview": "vite preview --host 0.0.0.0", "verify:flow": "node scripts/verify-flow.mjs" }, diff --git a/scripts/generate-bgm.mjs b/scripts/generate-bgm.mjs new file mode 100644 index 0000000..95a8320 --- /dev/null +++ b/scripts/generate-bgm.mjs @@ -0,0 +1,227 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const sampleRate = 22050; +const duration = 32; +const outputDir = 'src/assets/audio/bgm'; + +mkdirSync(outputDir, { recursive: true }); + +const tracks = [ + { + name: 'title-theme', + root: 196, + tempo: 72, + drum: 0.34, + melody: [0, 2, 4, 7, 9, 7, 4, 2], + bass: [0, 0, -5, -3], + color: 'warm' + }, + { + name: 'story-dark', + root: 146.83, + tempo: 58, + drum: 0.22, + melody: [0, -3, 0, 2, -5, -3, 0, -7], + bass: [0, -7, -5, -3], + color: 'dark' + }, + { + name: 'oath-theme', + root: 220, + tempo: 66, + drum: 0.24, + melody: [0, 4, 7, 9, 12, 9, 7, 4], + bass: [0, -5, -3, -7], + color: 'gold' + }, + { + name: 'militia-theme', + root: 174.61, + tempo: 82, + drum: 0.42, + melody: [0, 2, 5, 7, 9, 7, 5, 2], + bass: [0, -5, 0, -3], + color: 'earth' + }, + { + name: 'battle-prep', + root: 164.81, + tempo: 94, + drum: 0.5, + melody: [0, 3, 5, 7, 10, 7, 5, 3], + bass: [0, 0, -2, -5], + color: 'steel' + } +]; + +for (const track of tracks) { + const buffer = new Float32Array(sampleRate * duration); + buildTrack(buffer, track); + normalize(buffer, 0.86); + writeFileSync(join(outputDir, `${track.name}.wav`), encodeWav(buffer)); +} + +function buildTrack(buffer, track) { + const beat = 60 / track.tempo; + const bar = beat * 4; + + for (let t = 0; t < duration; t += bar) { + for (let i = 0; i < 4; i += 1) { + const bassFreq = note(track.root, track.bass[i % track.bass.length] - 12); + addTone(buffer, t + i * beat, beat * 1.9, bassFreq, 0.075, 'soft'); + addTone(buffer, t + i * beat, beat * 1.9, bassFreq * 2, 0.025, 'soft'); + } + } + + for (let t = 0; t < duration; t += beat * 2) { + const step = Math.floor(t / (beat * 2)) % track.melody.length; + const freq = note(track.root, track.melody[step]); + addPluck(buffer, t + 0.04, beat * 1.65, freq, 0.12, track.color); + addPluck(buffer, t + beat * 0.95, beat * 0.9, freq * 1.5, 0.045, track.color); + } + + for (let t = 0; t < duration; t += beat) { + if (Math.round(t / beat) % 4 === 0) { + addDrum(buffer, t, 0.26, track.drum); + } else if (track.drum > 0.3 && Math.round(t / beat) % 2 === 0) { + addDrum(buffer, t, 0.16, track.drum * 0.45); + } + } + + for (let t = 0; t < duration; t += bar * 2) { + addNoiseWash(buffer, t, bar * 1.8, track.color === 'dark' ? 0.012 : 0.007); + } + + for (let i = 0; i < buffer.length; i += 1) { + const seconds = i / sampleRate; + const edge = Math.min(1, seconds / 0.6, (duration - seconds) / 0.6); + buffer[i] *= Math.max(0, edge); + } +} + +function note(root, semitone) { + return root * 2 ** (semitone / 12); +} + +function addTone(buffer, start, length, freq, gain, shape) { + const startIndex = Math.floor(start * sampleRate); + const count = Math.floor(length * sampleRate); + + for (let i = 0; i < count; i += 1) { + const index = startIndex + i; + if (index >= buffer.length) { + break; + } + + const t = i / sampleRate; + const env = Math.sin(Math.PI * Math.min(1, i / count)); + const wave = Math.sin(2 * Math.PI * freq * t) + Math.sin(2 * Math.PI * freq * 2 * t) * 0.18; + buffer[index] += wave * gain * env * (shape === 'soft' ? 0.78 : 1); + } +} + +function addPluck(buffer, start, length, freq, gain, color) { + const startIndex = Math.floor(start * sampleRate); + const count = Math.floor(length * sampleRate); + const brightness = color === 'gold' ? 0.36 : color === 'steel' ? 0.42 : 0.28; + + for (let i = 0; i < count; i += 1) { + const index = startIndex + i; + if (index >= buffer.length) { + break; + } + + const t = i / sampleRate; + const env = Math.exp(-t * 4.8) * Math.min(1, t / 0.02); + const wave = + Math.sin(2 * Math.PI * freq * t) + + Math.sin(2 * Math.PI * freq * 2 * t) * brightness + + Math.sin(2 * Math.PI * freq * 3 * t) * 0.14; + buffer[index] += wave * gain * env; + } +} + +function addDrum(buffer, start, length, gain) { + const startIndex = Math.floor(start * sampleRate); + const count = Math.floor(length * sampleRate); + let seed = 1337 + startIndex; + + for (let i = 0; i < count; i += 1) { + const index = startIndex + i; + if (index >= buffer.length) { + break; + } + + const t = i / sampleRate; + const env = Math.exp(-t * 15); + const freq = 96 - t * 120; + seed = (seed * 1664525 + 1013904223) >>> 0; + const noise = ((seed / 0xffffffff) * 2 - 1) * 0.18; + const wave = Math.sin(2 * Math.PI * Math.max(42, freq) * t) + noise; + buffer[index] += wave * gain * env; + } +} + +function addNoiseWash(buffer, start, length, gain) { + const startIndex = Math.floor(start * sampleRate); + const count = Math.floor(length * sampleRate); + let seed = 4219 + startIndex; + let previous = 0; + + for (let i = 0; i < count; i += 1) { + const index = startIndex + i; + if (index >= buffer.length) { + break; + } + + seed = (seed * 1103515245 + 12345) >>> 0; + const raw = (seed / 0xffffffff) * 2 - 1; + previous = previous * 0.98 + raw * 0.02; + const env = Math.sin(Math.PI * i / count); + buffer[index] += previous * gain * env; + } +} + +function normalize(buffer, target) { + let peak = 0; + for (const value of buffer) { + peak = Math.max(peak, Math.abs(value)); + } + + if (peak === 0) { + return; + } + + const scale = target / peak; + for (let i = 0; i < buffer.length; i += 1) { + buffer[i] *= scale; + } +} + +function encodeWav(buffer) { + const bytesPerSample = 2; + const dataSize = buffer.length * bytesPerSample; + const wav = Buffer.alloc(44 + dataSize); + + wav.write('RIFF', 0); + wav.writeUInt32LE(36 + dataSize, 4); + wav.write('WAVE', 8); + wav.write('fmt ', 12); + wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); + wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(sampleRate, 24); + wav.writeUInt32LE(sampleRate * bytesPerSample, 28); + wav.writeUInt16LE(bytesPerSample, 32); + wav.writeUInt16LE(16, 34); + wav.write('data', 36); + wav.writeUInt32LE(dataSize, 40); + + for (let i = 0; i < buffer.length; i += 1) { + const value = Math.max(-1, Math.min(1, buffer[i])); + wav.writeInt16LE(Math.round(value * 32767), 44 + i * bytesPerSample); + } + + return wav; +} diff --git a/scripts/generate-voice-assets.mjs b/scripts/generate-voice-assets.mjs new file mode 100644 index 0000000..430c3e1 --- /dev/null +++ b/scripts/generate-voice-assets.mjs @@ -0,0 +1,127 @@ +import { mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const outputDir = resolve('src/assets/audio/voice/prologue'); + +mkdirSync(outputDir, { recursive: true }); + +const voiceName = 'Microsoft Heami Desktop'; + +const lines = [ + { + name: 'yellow-turban-chaos', + role: 'narrator', + rate: -2, + volume: 86, + text: '중평 원년, 황건의 깃발이 각지에서 일어섰다. 관군은 흩어지고, 마을마다 불안한 소문이 번져갔다.' + }, + { + name: 'liu-bei-resolve', + role: 'liuBei', + rate: -1, + volume: 92, + text: '천실의 백성이 이토록 고통받는데, 어찌 초개처럼 그늘에 숨어 있을 수 있겠는가.' + }, + { + name: 'zhang-fei-joins', + role: 'zhangFei', + rate: 1, + volume: 100, + text: '좋소! 나라를 바로잡고 백성을 구한다면, 내 재산과 힘을 모두 아끼지 않겠소.' + }, + { + name: 'guan-yu-joins', + role: 'guanYu', + rate: -3, + volume: 95, + text: '서로의 뜻을 믿고 의로운 길을 함께한다면, 천 리라도 멀다 하지 않겠습니다.' + }, + { + name: 'oath-narration', + role: 'narrator', + rate: -2, + volume: 88, + text: '복사꽃 향기가 흩날리는 동산에서 세 사람은 하늘과 땅 앞에 고했다.' + }, + { + name: 'oath-liu-bei', + role: 'liuBei', + rate: -1, + volume: 94, + text: '우리는 태어난 날은 달라도, 오늘부터 뜻을 함께한다. 백성을 구하고 천하를 바로 세우는 길에서 물러서지 않겠다.' + }, + { + name: 'oath-brothers', + role: 'guanYu', + rate: -3, + volume: 96, + text: '의리를 저버리지 않고, 형제를 저버리지 않겠습니다.' + }, + { + name: 'militia-rises', + role: 'zhangFei', + rate: 1, + volume: 100, + text: '창을 들 사람은 앞으로 나오시오! 오늘 모인 이름 없는 무리가 내일의 방패가 될 것이오!' + }, + { + name: 'first-sortie', + role: 'liuBei', + rate: -1, + volume: 94, + text: '우리는 아직 작다. 그러나 오늘 한 마을을 지켜낸다면, 그 뜻은 천하로 번져갈 것이다.' + }, + { + name: 'battle-briefing', + role: 'narrator', + rate: -2, + volume: 88, + text: '의용군은 마을 입구로 향했다. 첫 전투가 곧 시작된다.' + } +]; + +for (const line of lines) { + const output = resolve(outputDir, `${line.name}.wav`); + generateVoice({ ...line, output }); +} + +function generateVoice(item) { + const payload = Buffer.from(JSON.stringify({ ...item, voiceName }), 'utf8').toString('base64'); + const powershell = ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Add-Type -AssemblyName System.Speech +$payload = '${payload}' +$json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) +$item = $json | ConvertFrom-Json +$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer +try { + try { + $synth.SelectVoice($item.voiceName) + } catch { + $installed = $synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name } + if ($installed.Count -gt 0) { + $synth.SelectVoice($installed[0]) + } + } + $synth.Rate = [int]$item.rate + $synth.Volume = [int]$item.volume + $synth.SetOutputToWaveFile($item.output) + $synth.Speak($item.text) +} finally { + $synth.Dispose() +} +`; + + const encodedCommand = Buffer.from(powershell, 'utf16le').toString('base64'); + const result = spawnSync( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodedCommand], + { stdio: 'inherit' } + ); + + if (result.status !== 0) { + throw new Error(`Failed to generate voice asset: ${item.name}`); + } +} diff --git a/src/assets/audio/bgm/battle-prep.wav b/src/assets/audio/bgm/battle-prep.wav new file mode 100644 index 0000000..e5cf201 Binary files /dev/null and b/src/assets/audio/bgm/battle-prep.wav differ diff --git a/src/assets/audio/bgm/militia-theme.wav b/src/assets/audio/bgm/militia-theme.wav new file mode 100644 index 0000000..b2acdf1 Binary files /dev/null and b/src/assets/audio/bgm/militia-theme.wav differ diff --git a/src/assets/audio/bgm/oath-theme.wav b/src/assets/audio/bgm/oath-theme.wav new file mode 100644 index 0000000..b3abc29 Binary files /dev/null and b/src/assets/audio/bgm/oath-theme.wav differ diff --git a/src/assets/audio/bgm/story-dark.wav b/src/assets/audio/bgm/story-dark.wav new file mode 100644 index 0000000..c48e7d3 Binary files /dev/null and b/src/assets/audio/bgm/story-dark.wav differ diff --git a/src/assets/audio/bgm/title-theme.wav b/src/assets/audio/bgm/title-theme.wav new file mode 100644 index 0000000..4b1456c Binary files /dev/null and b/src/assets/audio/bgm/title-theme.wav differ diff --git a/src/assets/audio/voice/prologue/battle-briefing.wav b/src/assets/audio/voice/prologue/battle-briefing.wav new file mode 100644 index 0000000..7cf1e5c Binary files /dev/null and b/src/assets/audio/voice/prologue/battle-briefing.wav differ diff --git a/src/assets/audio/voice/prologue/first-sortie.wav b/src/assets/audio/voice/prologue/first-sortie.wav new file mode 100644 index 0000000..a897440 Binary files /dev/null and b/src/assets/audio/voice/prologue/first-sortie.wav differ diff --git a/src/assets/audio/voice/prologue/guan-yu-joins.wav b/src/assets/audio/voice/prologue/guan-yu-joins.wav new file mode 100644 index 0000000..8b13ec6 Binary files /dev/null and b/src/assets/audio/voice/prologue/guan-yu-joins.wav differ diff --git a/src/assets/audio/voice/prologue/liu-bei-resolve.wav b/src/assets/audio/voice/prologue/liu-bei-resolve.wav new file mode 100644 index 0000000..b76dc70 Binary files /dev/null and b/src/assets/audio/voice/prologue/liu-bei-resolve.wav differ diff --git a/src/assets/audio/voice/prologue/militia-rises.wav b/src/assets/audio/voice/prologue/militia-rises.wav new file mode 100644 index 0000000..952dfa6 Binary files /dev/null and b/src/assets/audio/voice/prologue/militia-rises.wav differ diff --git a/src/assets/audio/voice/prologue/oath-brothers.wav b/src/assets/audio/voice/prologue/oath-brothers.wav new file mode 100644 index 0000000..92ea777 Binary files /dev/null and b/src/assets/audio/voice/prologue/oath-brothers.wav differ diff --git a/src/assets/audio/voice/prologue/oath-liu-bei.wav b/src/assets/audio/voice/prologue/oath-liu-bei.wav new file mode 100644 index 0000000..3df5802 Binary files /dev/null and b/src/assets/audio/voice/prologue/oath-liu-bei.wav differ diff --git a/src/assets/audio/voice/prologue/oath-narration.wav b/src/assets/audio/voice/prologue/oath-narration.wav new file mode 100644 index 0000000..2199a55 Binary files /dev/null and b/src/assets/audio/voice/prologue/oath-narration.wav differ diff --git a/src/assets/audio/voice/prologue/yellow-turban-chaos.wav b/src/assets/audio/voice/prologue/yellow-turban-chaos.wav new file mode 100644 index 0000000..6cd689c Binary files /dev/null and b/src/assets/audio/voice/prologue/yellow-turban-chaos.wav differ diff --git a/src/assets/audio/voice/prologue/zhang-fei-joins.wav b/src/assets/audio/voice/prologue/zhang-fei-joins.wav new file mode 100644 index 0000000..46f21a0 Binary files /dev/null and b/src/assets/audio/voice/prologue/zhang-fei-joins.wav differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index f8165b5..048fc3e 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -1,42 +1,82 @@ +type AudioTrackMap = Record; +export type VoiceRole = 'narrator' | 'liuBei' | 'guanYu' | 'zhangFei'; + +type PitchSafeAudio = HTMLAudioElement & { + preservesPitch?: boolean; + mozPreservesPitch?: boolean; + webkitPreservesPitch?: boolean; +}; + +const rolePlayback: Record = { + narrator: { rate: 0.98, volume: 0.9 }, + liuBei: { rate: 0.96, volume: 0.92 }, + guanYu: { rate: 0.84, volume: 0.95 }, + zhangFei: { rate: 1.05, volume: 1 } +}; + class SoundDirector { private context?: AudioContext; private master?: GainNode; - private musicGain?: GainNode; private started = false; private muted = false; - private sequenceTimer?: number; + private musicTracks: AudioTrackMap = {}; + private voiceTracks: AudioTrackMap = {}; + private music?: HTMLAudioElement; + private voice?: HTMLAudioElement; + private currentMusicKey?: string; + private pendingMusicKey?: string; + private musicFadeTimer?: number; + private musicRampTimer?: number; + private readonly musicVolume = 0.46; + private readonly duckedMusicVolume = 0.18; + + registerMusicTracks(tracks: AudioTrackMap) { + this.musicTracks = tracks; + } + + registerVoiceTracks(tracks: AudioTrackMap) { + this.voiceTracks = tracks; + } start() { if (this.started) { + this.resume(); + if (this.pendingMusicKey) { + this.playMusic(this.pendingMusicKey); + } return; } const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext; - if (!AudioContextCtor) { - return; + if (AudioContextCtor) { + this.context = new AudioContextCtor(); + this.master = this.context.createGain(); + this.master.gain.value = this.muted ? 0 : 0.34; + this.master.connect(this.context.destination); } - this.context = new AudioContextCtor(); - this.master = this.context.createGain(); - this.master.gain.value = this.muted ? 0 : 0.34; - this.master.connect(this.context.destination); - - this.musicGain = this.context.createGain(); - this.musicGain.gain.value = 0.0; - this.musicGain.connect(this.master); this.started = true; - - this.createAmbientBed(); - this.schedulePlucks(); - this.musicGain.gain.linearRampToValueAtTime(0.72, this.context.currentTime + 2.4); + this.resume(); + if (this.pendingMusicKey) { + this.playMusic(this.pendingMusicKey); + } } resume() { void this.context?.resume(); + if (this.started && this.music) { + void this.music.play().catch(() => undefined); + } } setMuted(muted: boolean) { this.muted = muted; + if (this.music) { + this.music.muted = muted; + } + if (this.voice) { + this.voice.muted = muted; + } if (!this.context || !this.master) { return; @@ -59,60 +99,110 @@ class SoundDirector { window.setTimeout(() => this.playTone(495, 0.1, 0.08, 'triangle'), 65); } - private createAmbientBed() { - if (!this.context || !this.musicGain) { + playMusic(key?: string) { + if (!key) { return; } - const base = this.context.createOscillator(); - const fifth = this.context.createOscillator(); - const filter = this.context.createBiquadFilter(); - const bedGain = this.context.createGain(); + this.pendingMusicKey = key; + if (!this.started) { + return; + } - base.type = 'sine'; - fifth.type = 'sine'; - base.frequency.value = 110; - fifth.frequency.value = 165; - filter.type = 'lowpass'; - filter.frequency.value = 520; - bedGain.gain.value = 0.08; + const src = this.musicTracks[key]; + if (!src) { + return; + } - base.connect(filter); - fifth.connect(filter); - filter.connect(bedGain); - bedGain.connect(this.musicGain); - base.start(); - fifth.start(); + if (this.currentMusicKey === key && this.music) { + this.music.muted = this.muted; + void this.music.play().catch(() => undefined); + return; + } + + const previous = this.music; + const next = new Audio(src); + next.loop = true; + next.preload = 'auto'; + next.muted = this.muted; + next.volume = 0; + + this.music = next; + this.currentMusicKey = key; + void next.play().catch(() => undefined); + this.fadeMusic(previous, next, this.voice ? this.duckedMusicVolume : this.musicVolume); } - private schedulePlucks() { - if (!this.context) { + playVoice(key?: string, role: VoiceRole = 'narrator') { + if (!key || !this.started) { + this.stopVoice(); + return; + } + this.stopVoice(false); + + const src = this.voiceTracks[key]; + if (!src) { return; } - const notes = [220, 247, 294, 330, 392, 330, 294, 247]; - let index = 0; - this.sequenceTimer = window.setInterval(() => { - this.playTone(notes[index % notes.length], 0.18, 0.09, 'triangle', true); - index += 1; - }, 1800); + const profile = rolePlayback[role] ?? rolePlayback.narrator; + const voice = new Audio(src) as PitchSafeAudio; + voice.preload = 'auto'; + voice.muted = this.muted; + voice.volume = profile.volume; + voice.playbackRate = profile.rate; + voice.preservesPitch = false; + voice.mozPreservesPitch = false; + voice.webkitPreservesPitch = false; + voice.addEventListener('ended', () => { + if (this.voice === voice) { + this.voice = undefined; + this.duckMusic(false); + } + }); + voice.addEventListener('error', () => { + if (this.voice === voice) { + this.voice = undefined; + this.duckMusic(false); + } + }); + + this.voice = voice; + this.duckMusic(true); + void voice.play().catch(() => { + if (this.voice === voice) { + this.voice = undefined; + this.duckMusic(false); + } + }); + } + + stopVoice(restoreMusic = true) { + if (!this.voice) { + return; + } + + this.voice.pause(); + this.voice.currentTime = 0; + this.voice = undefined; + if (restoreMusic) { + this.duckMusic(false); + } } private playTone( frequency: number, duration: number, gainValue: number, - type: OscillatorType, - routeToMusic = false + type: OscillatorType ) { - if (!this.context) { + if (!this.context || this.muted) { return; } const oscillator = this.context.createOscillator(); const gain = this.context.createGain(); const now = this.context.currentTime; - const destination = routeToMusic && this.musicGain ? this.musicGain : this.master; oscillator.type = type; oscillator.frequency.value = frequency; @@ -121,10 +211,73 @@ class SoundDirector { gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); oscillator.connect(gain); - gain.connect(destination ?? this.context.destination); + gain.connect(this.master ?? this.context.destination); oscillator.start(now); oscillator.stop(now + duration + 0.04); } + + private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) { + if (this.musicFadeTimer) { + window.clearInterval(this.musicFadeTimer); + } + + const duration = 900; + const startedAt = performance.now(); + const previousStartVolume = previous?.volume ?? 0; + + this.musicFadeTimer = window.setInterval(() => { + const progress = Math.min(1, (performance.now() - startedAt) / duration); + next.volume = targetVolume * progress; + + if (previous) { + previous.volume = previousStartVolume * (1 - progress); + } + + if (progress >= 1) { + if (this.musicFadeTimer) { + window.clearInterval(this.musicFadeTimer); + this.musicFadeTimer = undefined; + } + if (previous) { + previous.pause(); + previous.src = ''; + } + } + }, 32); + } + + private duckMusic(ducked: boolean) { + if (!this.music) { + return; + } + + if (ducked && this.musicFadeTimer) { + window.clearInterval(this.musicFadeTimer); + this.musicFadeTimer = undefined; + } + + if (this.musicRampTimer) { + window.clearInterval(this.musicRampTimer); + } + + const from = this.music.volume; + const to = ducked ? this.duckedMusicVolume : this.musicVolume; + const duration = 260; + const startedAt = performance.now(); + + this.musicRampTimer = window.setInterval(() => { + if (!this.music) { + return; + } + + const progress = Math.min(1, (performance.now() - startedAt) / duration); + this.music.volume = from + (to - from) * progress; + if (progress >= 1 && this.musicRampTimer) { + window.clearInterval(this.musicRampTimer); + this.musicRampTimer = undefined; + } + }, 32); + } } declare global { diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts new file mode 100644 index 0000000..ac864ff --- /dev/null +++ b/src/game/audio/audioAssets.ts @@ -0,0 +1,39 @@ +import battlePrepUrl from '../../assets/audio/bgm/battle-prep.wav'; +import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.wav'; +import oathThemeUrl from '../../assets/audio/bgm/oath-theme.wav'; +import storyDarkUrl from '../../assets/audio/bgm/story-dark.wav'; +import titleThemeUrl from '../../assets/audio/bgm/title-theme.wav'; +import battleBriefingVoiceUrl from '../../assets/audio/voice/prologue/battle-briefing.wav'; +import firstSortieVoiceUrl from '../../assets/audio/voice/prologue/first-sortie.wav'; +import guanYuJoinsVoiceUrl from '../../assets/audio/voice/prologue/guan-yu-joins.wav'; +import liuBeiResolveVoiceUrl from '../../assets/audio/voice/prologue/liu-bei-resolve.wav'; +import militiaRisesVoiceUrl from '../../assets/audio/voice/prologue/militia-rises.wav'; +import oathBrothersVoiceUrl from '../../assets/audio/voice/prologue/oath-brothers.wav'; +import oathLiuBeiVoiceUrl from '../../assets/audio/voice/prologue/oath-liu-bei.wav'; +import oathNarrationVoiceUrl from '../../assets/audio/voice/prologue/oath-narration.wav'; +import yellowTurbanChaosVoiceUrl from '../../assets/audio/voice/prologue/yellow-turban-chaos.wav'; +import zhangFeiJoinsVoiceUrl from '../../assets/audio/voice/prologue/zhang-fei-joins.wav'; + +export const musicTracks = { + 'title-theme': titleThemeUrl, + 'story-dark': storyDarkUrl, + 'oath-theme': oathThemeUrl, + 'militia-theme': militiaThemeUrl, + 'battle-prep': battlePrepUrl +} as const; + +export const voiceTracks = { + 'yellow-turban-chaos': yellowTurbanChaosVoiceUrl, + 'liu-bei-resolve': liuBeiResolveVoiceUrl, + 'zhang-fei-joins': zhangFeiJoinsVoiceUrl, + 'guan-yu-joins': guanYuJoinsVoiceUrl, + 'oath-narration': oathNarrationVoiceUrl, + 'oath-liu-bei': oathLiuBeiVoiceUrl, + 'oath-brothers': oathBrothersVoiceUrl, + 'militia-rises': militiaRisesVoiceUrl, + 'first-sortie': firstSortieVoiceUrl, + 'battle-briefing': battleBriefingVoiceUrl +} as const; + +export type MusicTrackKey = keyof typeof musicTracks; +export type VoiceTrackKey = keyof typeof voiceTracks; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index ac39cb3..5db25f9 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1,4 +1,5 @@ export type PortraitKey = 'liuBei' | 'guanYu' | 'zhangFei'; +export type VoiceRole = 'narrator' | PortraitKey; export type StoryPage = { id: string; @@ -6,6 +7,9 @@ export type StoryPage = { background: string; speaker?: string; portrait?: PortraitKey; + bgm?: string; + voice?: string; + voiceRole?: VoiceRole; text: string; }; @@ -33,12 +37,18 @@ export type BattleMap = { export const prologuePages: StoryPage[] = [ { id: 'yellow-turban-chaos', + bgm: 'story-dark', + voice: 'yellow-turban-chaos', + voiceRole: 'narrator', chapter: '난세의 시작', background: 'story-chaos', text: '중평 원년, 황건의 깃발이 각지에서 일어났다. 관군은 흩어지고, 마을마다 피난 행렬이 이어졌다.' }, { id: 'liu-bei-resolve', + bgm: 'story-dark', + voice: 'liu-bei-resolve', + voiceRole: 'liuBei', chapter: '탁현의 방', background: 'story-liu-bei', speaker: '유비', @@ -47,6 +57,9 @@ export const prologuePages: StoryPage[] = [ }, { id: 'zhang-fei-joins', + bgm: 'story-dark', + voice: 'zhang-fei-joins', + voiceRole: 'zhangFei', chapter: '뜻이 모이다', background: 'story-three-heroes', speaker: '장비', @@ -55,6 +68,9 @@ export const prologuePages: StoryPage[] = [ }, { id: 'guan-yu-joins', + bgm: 'story-dark', + voice: 'guan-yu-joins', + voiceRole: 'guanYu', chapter: '뜻이 모이다', background: 'story-three-heroes', speaker: '관우', @@ -63,12 +79,18 @@ export const prologuePages: StoryPage[] = [ }, { id: 'oath-narration', + bgm: 'oath-theme', + voice: 'oath-narration', + voiceRole: 'narrator', chapter: '도원결의', background: 'title-taoyuan', text: '복숭아꽃이 흩날리는 동산에서 세 사람은 하늘과 땅에 고했다.' }, { id: 'oath-liu-bei', + bgm: 'oath-theme', + voice: 'oath-liu-bei', + voiceRole: 'liuBei', chapter: '도원결의', background: 'title-taoyuan', speaker: '유비', @@ -77,6 +99,9 @@ export const prologuePages: StoryPage[] = [ }, { id: 'oath-brothers', + bgm: 'oath-theme', + voice: 'oath-brothers', + voiceRole: 'guanYu', chapter: '도원결의', background: 'title-taoyuan', speaker: '관우', @@ -85,6 +110,9 @@ export const prologuePages: StoryPage[] = [ }, { id: 'militia-rises', + bgm: 'militia-theme', + voice: 'militia-rises', + voiceRole: 'zhangFei', chapter: '의용군 모집', background: 'story-militia', speaker: '장비', @@ -93,6 +121,9 @@ export const prologuePages: StoryPage[] = [ }, { id: 'first-sortie', + bgm: 'battle-prep', + voice: 'first-sortie', + voiceRole: 'liuBei', chapter: '첫 출진', background: 'story-sortie', speaker: '유비', @@ -101,6 +132,9 @@ export const prologuePages: StoryPage[] = [ }, { id: 'battle-briefing', + bgm: 'battle-prep', + voice: 'battle-briefing', + voiceRole: 'narrator', chapter: '황건적 토벌', background: 'story-sortie', text: '의용군은 마을 어귀로 향했다. 첫 싸움은 곧 시작된다.' diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index cb8cb48..746cf0d 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import { soundDirector } from '../audio/SoundDirector'; import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData } from '../data/scenario'; import { palette } from '../ui/palette'; @@ -24,6 +25,8 @@ export class BattleScene extends Phaser.Scene { create() { const { width, height } = this.scale; + soundDirector.stopVoice(); + soundDirector.playMusic('battle-prep'); this.add.rectangle(0, 0, width, height, 0x0f1418).setOrigin(0); this.drawMap(); this.drawUnits(); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index c18bdc2..8756eae 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import { soundDirector } from '../audio/SoundDirector'; import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario'; import { palette } from '../ui/palette'; @@ -114,6 +115,8 @@ export class StoryScene extends Phaser.Scene { } private applyPage(page: StoryPage) { + soundDirector.playMusic(page.bgm); + soundDirector.playVoice(page.voice, page.voiceRole ?? page.portrait ?? 'narrator'); this.applyBackground(page.background); this.chapterText?.setText(page.chapter); diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 48cb983..1cfa91f 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -17,8 +17,15 @@ export class TitleScene extends Phaser.Scene { this.drawTitleMark(width, height); this.drawMenu(width, height); - this.input.once('pointerdown', () => soundDirector.start()); - this.input.keyboard?.once('keydown', () => soundDirector.start()); + soundDirector.playMusic('title-theme'); + const unlockAudio = () => { + soundDirector.start(); + soundDirector.resume(); + soundDirector.playMusic('title-theme'); + }; + + this.input.once('pointerdown', unlockAudio); + this.input.keyboard?.once('keydown', unlockAudio); this.input.keyboard?.once('keydown-ENTER', () => this.startGame()); } @@ -275,6 +282,8 @@ export class TitleScene extends Phaser.Scene { } private startGame() { + soundDirector.start(); + soundDirector.resume(); soundDirector.playSelect(); this.scene.start('StoryScene'); } diff --git a/src/main.ts b/src/main.ts index 4e59c4c..1600ecf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,6 @@ import Phaser from 'phaser'; +import { musicTracks, voiceTracks } from './game/audio/audioAssets'; +import { soundDirector } from './game/audio/SoundDirector'; import { BattleScene } from './game/scenes/BattleScene'; import { BootScene } from './game/scenes/BootScene'; import { StoryScene } from './game/scenes/StoryScene'; @@ -24,6 +26,9 @@ const config: Phaser.Types.Core.GameConfig = { scene: [BootScene, TitleScene, StoryScene, BattleScene] }; +soundDirector.registerMusicTracks(musicTracks); +soundDirector.registerVoiceTracks(voiceTracks); + const game = new Phaser.Game(config); if (import.meta.env.DEV) {