diff --git a/docs/audio-sources.md b/docs/audio-sources.md index d3049a0..38005a0 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -14,6 +14,21 @@ License reference: https://pixabay.com/service/license-summary/ | `src/assets/audio/bgm/militia-theme.mp3` | Orchestra, Heroic, Warrior music | NR-Music | https://pixabay.com/music/adventure-short-heroic-orchestral-loop-541095/ | | `src/assets/audio/bgm/battle-prep.mp3` | Epic orchestral, War, Orchestral music | Sonican | https://pixabay.com/music/main-title-epic-for-war-orchestral-loop-364200/ | +## Project-original Camp Soundscapes + +The following files are generated deterministically by `scripts/generate-camp-audio.mjs`. They use only synthesized oscillators and seeded procedural noise; no external samples or copyrighted recordings are included. + +| Project file | Role | +| --- | --- | +| `src/assets/audio/bgm/camp-rally.mp3` | Yellow Turban and anti-Dong Zhuo camp resolve theme | +| `src/assets/audio/bgm/camp-wayfarer.mp3` | Xuzhou, wandering, and Wolong camp theme | +| `src/assets/audio/bgm/camp-grand-strategy.mp3` | Red Cliffs through Yiling grand-strategy camp theme | +| `src/assets/audio/bgm/camp-frontier.mp3` | Nanzhong and Northern Expeditions frontier camp theme | +| `src/assets/audio/ambience/campfire-ambience.mp3` | Campfire bed for early and wandering camps | +| `src/assets/audio/ambience/river-night-ambience.mp3` | River-night bed for Red Cliffs, Jingzhou, and Yiling camps | +| `src/assets/audio/ambience/mountain-wind-ambience.mp3` | Mountain-wind bed for Wolong, Hanzhong, and Northern camps | +| `src/assets/audio/ambience/nanzhong-night-ambience.mp3` | Forest-night bed for Nanzhong camps | + ## Sound Effects | Project file | Pixabay title | Creator | Source | diff --git a/package.json b/package.json index 43cacdd..81bf52a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "debug": "vite --host 0.0.0.0 --debug", "build": "tsc && vite build", "generate:audio": "node scripts/generate-bgm.mjs", + "generate:camp-audio": "node scripts/generate-camp-audio.mjs", "generate:sfx": "node scripts/generate-sfx.mjs", "audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs", "audit:visual-assets": "node scripts/audit-visual-asset-quality.mjs", @@ -21,6 +22,8 @@ "verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", "verify:camp-skins": "node scripts/verify-camp-skin-data.mjs", + "verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs", + "verify:sound-director": "node scripts/verify-sound-director.mjs", "verify:campaign-recruits": "node scripts/verify-campaign-recruit-data.mjs", "verify:equipment-catalog": "node scripts/verify-equipment-catalog-data.mjs", "verify:inventory-rewards": "node scripts/verify-inventory-reward-data.mjs", diff --git a/scripts/generate-camp-audio.mjs b/scripts/generate-camp-audio.mjs new file mode 100644 index 0000000..bf58dec --- /dev/null +++ b/scripts/generate-camp-audio.mjs @@ -0,0 +1,746 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve, sep } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const sampleRate = 44100; +const channels = 2; +const loopTailSeconds = 2; +const bgmOutputDir = join('src', 'assets', 'audio', 'bgm'); +const ambienceOutputDir = join('src', 'assets', 'audio', 'ambience'); +const ffmpeg = process.env.FFMPEG_PATH?.trim() || 'ffmpeg'; + +const musicTracks = [ + { + name: 'camp-rally', + title: 'Camp Rally', + tempo: 76, + bars: 16, + root: 50, + progression: [[0, 7, 12], [5, 12, 16], [7, 14, 19], [2, 9, 14]], + melody: [12, 14, 16, 19, 21, 19, 16, 14, 12, 14, 19, 21, 19, 16, 14, 12], + mood: 'rally', + stringGain: 0.02, + lowGain: 0.024, + pluckGain: 0.014, + leadGain: 0.018, + drumGain: 0.025, + pluckBeats: [0.12, 0.62, 1.12, 1.62, 2.12, 2.62, 3.12, 3.62], + drumBeats: [0, 2, 2.5] + }, + { + name: 'camp-wayfarer', + title: 'Camp Wayfarer', + tempo: 64, + bars: 16, + root: 45, + progression: [[0, 7, 10], [-2, 5, 10], [-5, 2, 7], [0, 5, 10]], + melody: [12, 15, 17, 19, 17, 15, 12, 10, 7, 10, 12, 15, 12, 10, 7, 5], + mood: 'wayfarer', + stringGain: 0.017, + lowGain: 0.016, + pluckGain: 0.01, + leadGain: 0.014, + drumGain: 0.012, + pluckBeats: [0.18, 2.18], + drumBeats: [] + }, + { + name: 'camp-grand-strategy', + title: 'Camp Grand Strategy', + tempo: 70, + bars: 16, + root: 43, + progression: [[0, 7, 12], [5, 12, 17], [2, 9, 14], [-2, 5, 10]], + melody: [12, 14, 19, 21, 19, 16, 14, 12, 14, 16, 19, 23, 21, 19, 16, 14], + mood: 'grand', + stringGain: 0.022, + lowGain: 0.025, + pluckGain: 0.015, + leadGain: 0.019, + drumGain: 0.028, + pluckBeats: [0.12, 0.78, 1.45, 2.12, 2.78, 3.45], + drumBeats: [0, 2, 3.5] + }, + { + name: 'camp-frontier', + title: 'Camp Frontier', + tempo: 66, + bars: 16, + root: 47, + progression: [[0, 7, 10], [-2, 5, 10], [3, 10, 15], [0, 5, 12]], + melody: [12, 15, 17, 19, 22, 19, 17, 15, 12, 10, 12, 15, 17, 15, 12, 10], + mood: 'frontier', + stringGain: 0.018, + lowGain: 0.017, + pluckGain: 0.013, + leadGain: 0.016, + drumGain: 0.02, + pluckBeats: [0.18, 0.92, 1.68, 2.42, 3.18], + drumBeats: [0, 1.5, 3] + } +]; + +const ambienceTracks = [ + { name: 'campfire-ambience', title: 'Campfire Ambience', duration: 24, seed: 0x31a9f27d, kind: 'campfire' }, + { name: 'river-night-ambience', title: 'River Night Ambience', duration: 32, seed: 0x6c4d813b, kind: 'river' }, + { name: 'mountain-wind-ambience', title: 'Mountain Wind Ambience', duration: 36, seed: 0x52f0a7c1, kind: 'wind' }, + { name: 'nanzhong-night-ambience', title: 'Nanzhong Night Ambience', duration: 32, seed: 0x18bd4e63, kind: 'nanzhong' } +]; + +assertFfmpeg(); +mkdirSync(bgmOutputDir, { recursive: true }); +mkdirSync(ambienceOutputDir, { recursive: true }); + +const temporaryRoot = resolve(tmpdir()); +const temporaryDir = mkdtempSync(join(temporaryRoot, 'heros-camp-audio-')); + +try { + for (const track of musicTracks) { + const render = buildMusicTrack(track); + prepareMaster(render.buffer, 68, 0.62, true); + const loopBuffer = wrapTailIntoStart(render.buffer, render.loopDuration, loopTailSeconds); + renderMp3(loopBuffer, track, bgmOutputDir, 192, -16, -1.5, 'music'); + } + + for (const track of ambienceTracks) { + const render = buildAmbienceTrack(track); + prepareMaster(render.buffer, 45, 0.54, false); + const loopBuffer = wrapTailIntoStart(render.buffer, render.loopDuration, loopTailSeconds); + renderMp3(loopBuffer, track, ambienceOutputDir, 128, -24, -3, 'ambience'); + } +} finally { + const resolvedTemporaryDir = resolve(temporaryDir); + const safeTemporaryPrefix = `${temporaryRoot}${sep}`; + if (resolvedTemporaryDir.startsWith(safeTemporaryPrefix) && basename(resolvedTemporaryDir).startsWith('heros-camp-audio-')) { + rmSync(resolvedTemporaryDir, { recursive: true, force: true }); + } +} + +function buildMusicTrack(track) { + const beat = 60 / track.tempo; + const barDuration = beat * 4; + const loopDuration = barDuration * track.bars; + const renderBuffer = createBuffer(loopDuration + loopTailSeconds); + + for (let barIndex = 0; barIndex < track.bars; barIndex += 1) { + const start = barIndex * barDuration; + const chord = track.progression[barIndex % track.progression.length]; + const dynamics = musicDynamics(track.mood, barIndex, track.bars); + + addStringBed(renderBuffer, start, barDuration + 0.9, track, chord, dynamics); + addLowPulse(renderBuffer, start, beat, track, chord, dynamics); + addPluckedFigure(renderBuffer, start, beat, track, chord, barIndex, dynamics); + addLeadNote(renderBuffer, start, beat, track, barIndex, dynamics); + addDrumPattern(renderBuffer, start, beat, track, barIndex, dynamics); + if ((track.mood === 'rally' || track.mood === 'grand') && (barIndex + 1) % 8 === 0) { + addGong(renderBuffer, start + beat * 3.35, track.mood === 'grand' ? 0.012 : 0.01); + } + if (track.mood === 'wayfarer' && (barIndex === 3 || barIndex === 11)) { + addBell(renderBuffer, start + beat * 3.1, 0.008); + } + } + + return { buffer: renderBuffer, loopDuration }; +} + +function musicDynamics(mood, barIndex, bars) { + if (mood === 'grand') { + const rise = Math.min(1, barIndex / Math.max(1, bars - 5)); + const release = barIndex >= bars - 4 ? (bars - barIndex) / 4 : 1; + return (0.84 + rise * 0.16) * Math.max(0.88, release); + } + return 0.88 + Math.sin((barIndex / bars) * Math.PI) * 0.12; +} + +function addStringBed(buffer, start, duration, track, chord, dynamics) { + const notes = [ + { midi: track.root + chord[0] + 12, pan: -0.28, gain: 0.78 }, + { midi: track.root + chord[1] + 12, pan: 0.28, gain: 0.66 }, + { midi: track.root + chord[2] + 12, pan: -0.02, gain: 0.54 }, + { midi: track.root + chord[1] + 24, pan: 0.18, gain: 0.23 } + ]; + + notes.forEach((note, index) => addTone(buffer, { + start: start + index * 0.04, + duration, + frequency: midiToFrequency(note.midi), + gain: track.stringGain * note.gain * dynamics, + pan: note.pan, + attack: track.mood === 'grand' ? 0.8 : 1.15, + release: 1.2, + wave: stringWave + })); +} + +function addLowPulse(buffer, start, beat, track, chord, dynamics) { + const pulses = track.mood === 'wayfarer' ? [0] : [0, 2]; + pulses.forEach((beatOffset) => addTone(buffer, { + start: start + beat * beatOffset, + duration: beat * (track.mood === 'wayfarer' ? 2.4 : 1.45), + frequency: midiToFrequency(Math.max(38, track.root + chord[0])), + gain: track.lowGain * dynamics, + pan: -0.08, + attack: 0.06, + release: 0.62, + wave: celloWave + })); +} + +function addPluckedFigure(buffer, start, beat, track, chord, barIndex, dynamics) { + track.pluckBeats.forEach((beatOffset, index) => { + const melodicOffset = track.melody[(barIndex + index) % track.melody.length]; + const chordLift = chord[index % chord.length] % 5; + addTone(buffer, { + start: start + beat * beatOffset, + duration: beat * 0.66, + frequency: midiToFrequency(track.root + melodicOffset + 12 + chordLift), + gain: track.pluckGain * dynamics, + pan: index % 2 === 0 ? -0.36 : 0.36, + attack: 0.008, + release: 0.2, + wave: pluckWave, + exponentialDecay: track.mood === 'wayfarer' ? 5.2 : 6.3 + }); + }); +} + +function addLeadNote(buffer, start, beat, track, barIndex, dynamics) { + const noteOffset = track.melody[barIndex % track.melody.length]; + const leadStart = track.mood === 'wayfarer' ? 1.78 : track.mood === 'frontier' ? 1.35 : 1.18; + const leadDuration = track.mood === 'wayfarer' ? beat * 1.65 : beat * 1.2; + addTone(buffer, { + start: start + beat * leadStart, + duration: leadDuration, + frequency: midiToFrequency(track.root + noteOffset + 12), + gain: track.leadGain * dynamics, + pan: track.mood === 'frontier' ? -0.08 : 0.12, + attack: 0.12, + release: 0.48, + wave: reedWave + }); +} + +function addDrumPattern(buffer, start, beat, track, barIndex, dynamics) { + const beats = track.mood === 'wayfarer' && (barIndex === 7 || barIndex === 15) + ? [0] + : track.drumBeats; + beats.forEach((beatOffset, index) => addTone(buffer, { + start: start + beat * beatOffset, + duration: beat * 0.48, + frequency: index === 0 ? 88 : track.mood === 'frontier' ? 112 : 122, + gain: track.drumGain * dynamics * (index === 0 ? 1 : 0.68), + pan: index % 2 === 0 ? -0.04 : 0.04, + attack: 0.004, + release: 0.12, + wave: drumWave, + exponentialDecay: 8.8 + })); + + if (track.mood === 'frontier') { + addTone(buffer, { + start, + duration: beat * 0.7, + frequency: 66, + gain: 0.026 * dynamics, + pan: 0, + attack: 0.004, + release: 0.18, + wave: drumWave, + exponentialDecay: 7.4 + }); + } +} + +function addGong(buffer, start, gain) { + [ + { ratio: 1, amount: 0.72, pan: -0.05 }, + { ratio: 1.48, amount: 0.42, pan: 0.08 }, + { ratio: 2.07, amount: 0.24, pan: 0.2 } + ].forEach(({ ratio, amount, pan }) => addTone(buffer, { + start, + duration: 2.2, + frequency: 164 * ratio, + gain: gain * amount, + pan, + attack: 0.008, + release: 0.7, + wave: gongWave, + exponentialDecay: 1.7 + })); +} + +function addBell(buffer, start, gain) { + [1, 2.02, 3.94].forEach((ratio, index) => addTone(buffer, { + start, + duration: 2.1, + frequency: 520 * ratio, + gain: gain / (index + 1), + pan: index % 2 === 0 ? -0.18 : 0.18, + attack: 0.004, + release: 0.6, + wave: sineWave, + exponentialDecay: 2.4 + })); +} + +function buildAmbienceTrack(track) { + const renderBuffer = createBuffer(track.duration + loopTailSeconds); + if (track.kind === 'campfire') { + synthesizeCampfire(renderBuffer, track); + } else if (track.kind === 'river') { + synthesizeRiverNight(renderBuffer, track); + } else if (track.kind === 'wind') { + synthesizeMountainWind(renderBuffer, track); + } else { + synthesizeNanzhongNight(renderBuffer, track); + } + return { buffer: renderBuffer, loopDuration: track.duration }; +} + +function synthesizeCampfire(buffer, track) { + const random = createRandom(track.seed); + let leftState = 0; + let rightState = 0; + const alpha = onePoleAlpha(2100); + for (let index = 0; index < buffer.left.length; index += 1) { + leftState += alpha * ((random() * 2 - 1) - leftState); + rightState += alpha * ((random() * 2 - 1) - rightState); + const t = index / sampleRate; + const breathe = 0.7 + Math.sin(2 * Math.PI * 0.19 * t) * 0.16 + Math.sin(2 * Math.PI * 0.43 * t) * 0.08; + buffer.left[index] += leftState * 0.035 * breathe + Math.sin(2 * Math.PI * 78 * t) * 0.003; + buffer.right[index] += rightState * 0.033 * breathe + Math.sin(2 * Math.PI * 81 * t) * 0.0025; + } + + const crackleCount = Math.floor((track.duration + loopTailSeconds) * 3.1); + for (let event = 0; event < crackleCount; event += 1) { + const start = random() * (track.duration + loopTailSeconds - 0.08); + addNoiseBurst(buffer, random, start, 0.018 + random() * 0.045, 0.07 + random() * 0.1, random() * 1.4 - 0.7, 44 + random() * 34); + } +} + +function synthesizeRiverNight(buffer, track) { + const random = createRandom(track.seed); + let lowLeft = 0; + let lowRight = 0; + let previousLeft = 0; + let previousRight = 0; + const lowAlpha = onePoleAlpha(2200); + const highAlpha = highPassAlpha(90); + for (let index = 0; index < buffer.left.length; index += 1) { + const t = index / sampleRate; + const inputLeft = random() * 2 - 1; + const inputRight = random() * 2 - 1; + lowLeft += lowAlpha * (inputLeft - lowLeft); + lowRight += lowAlpha * (inputRight - lowRight); + const highLeft = highAlpha * ((buffer.left[index - 1] ?? 0) + lowLeft - previousLeft); + const highRight = highAlpha * ((buffer.right[index - 1] ?? 0) + lowRight - previousRight); + previousLeft = lowLeft; + previousRight = lowRight; + const swell = 0.72 + Math.sin(2 * Math.PI * 0.07 * t) * 0.18 + Math.sin(2 * Math.PI * 0.13 * t + 1.1) * 0.09; + buffer.left[index] = highLeft * 0.055 * swell; + buffer.right[index] = highRight * 0.055 * (1.46 - swell * 0.56); + } + + for (let event = 0; event < 12; event += 1) { + const start = random() * (track.duration + loopTailSeconds - 0.5); + addTone(buffer, { + start, + duration: 0.24 + random() * 0.18, + frequency: 820 + random() * 620, + gain: 0.008 + random() * 0.006, + pan: random() * 1.4 - 0.7, + attack: 0.006, + release: 0.16, + wave: dropletWave, + exponentialDecay: 7.2 + }); + } +} + +function synthesizeMountainWind(buffer, track) { + const random = createRandom(track.seed); + let lowLeft = 0; + let lowRight = 0; + let highLeft = 0; + let highRight = 0; + let previousLowLeft = 0; + let previousLowRight = 0; + const lowAlpha = onePoleAlpha(1400); + const highAlpha = highPassAlpha(180); + for (let index = 0; index < buffer.left.length; index += 1) { + const t = index / sampleRate; + lowLeft += lowAlpha * ((random() * 2 - 1) - lowLeft); + lowRight += lowAlpha * ((random() * 2 - 1) - lowRight); + highLeft = highAlpha * (highLeft + lowLeft - previousLowLeft); + highRight = highAlpha * (highRight + lowRight - previousLowRight); + previousLowLeft = lowLeft; + previousLowRight = lowRight; + const gust = 0.56 + Math.sin(2 * Math.PI * 0.035 * t) * 0.2 + Math.sin(2 * Math.PI * 0.08 * t + 1.7) * 0.16 + Math.sin(2 * Math.PI * 0.17 * t) * 0.06; + const whistle = Math.sin(2 * Math.PI * (510 + Math.sin(2 * Math.PI * 0.041 * t) * 90) * t) * Math.max(0, gust - 0.65) * 0.004; + buffer.left[index] = highLeft * 0.05 * gust + whistle; + buffer.right[index] = highRight * 0.048 * (gust * 0.92 + 0.08) + whistle * 0.72; + } +} + +function synthesizeNanzhongNight(buffer, track) { + const random = createRandom(track.seed); + let forestLeft = 0; + let forestRight = 0; + const alpha = onePoleAlpha(1250); + for (let index = 0; index < buffer.left.length; index += 1) { + const t = index / sampleRate; + forestLeft += alpha * ((random() * 2 - 1) - forestLeft); + forestRight += alpha * ((random() * 2 - 1) - forestRight); + const pulse = 0.68 + Math.sin(2 * Math.PI * 0.09 * t) * 0.16; + buffer.left[index] = forestLeft * 0.038 * pulse; + buffer.right[index] = forestRight * 0.036 * (1.24 - pulse * 0.36); + } + + for (let cluster = 0; cluster < 24; cluster += 1) { + const clusterStart = random() * (track.duration + loopTailSeconds - 0.8); + const chirps = 2 + Math.floor(random() * 4); + for (let chirp = 0; chirp < chirps; chirp += 1) { + addTone(buffer, { + start: clusterStart + chirp * (0.055 + random() * 0.045), + duration: 0.045 + random() * 0.04, + frequency: 2800 + random() * 2400, + gain: 0.006 + random() * 0.005, + pan: random() * 1.6 - 0.8, + attack: 0.003, + release: 0.025, + wave: chirpWave, + exponentialDecay: 18 + }); + } + } + + for (let frog = 0; frog < 9; frog += 1) { + addTone(buffer, { + start: random() * (track.duration + loopTailSeconds - 0.6), + duration: 0.28 + random() * 0.24, + frequency: 140 + random() * 70, + gain: 0.008, + pan: random() * 1.2 - 0.6, + attack: 0.02, + release: 0.18, + wave: frogWave, + exponentialDecay: 4.8 + }); + } +} + +function addNoiseBurst(buffer, random, start, duration, gain, pan, decay) { + const startIndex = Math.floor(start * sampleRate); + const count = Math.floor(duration * sampleRate); + const leftGain = Math.cos((pan + 1) * Math.PI * 0.25); + const rightGain = Math.sin((pan + 1) * Math.PI * 0.25); + for (let offset = 0; offset < count; offset += 1) { + const index = startIndex + offset; + if (index >= buffer.left.length) { + break; + } + const t = offset / sampleRate; + const sample = (random() * 2 - 1) * gain * Math.exp(-t * decay); + buffer.left[index] += sample * leftGain; + buffer.right[index] += sample * rightGain; + } +} + +function createBuffer(duration) { + const length = Math.ceil(duration * sampleRate); + return { left: new Float32Array(length), right: new Float32Array(length) }; +} + +function wrapTailIntoStart(buffer, loopDuration, tailSeconds) { + const loopLength = Math.floor(loopDuration * sampleRate); + const tailLength = Math.min(Math.floor(tailSeconds * sampleRate), buffer.left.length - loopLength, loopLength); + const output = { left: buffer.left.slice(0, loopLength), right: buffer.right.slice(0, loopLength) }; + for (let index = 0; index < tailLength; index += 1) { + const progress = index / Math.max(1, tailLength - 1); + const tailGain = Math.cos(progress * Math.PI * 0.5); + const startGain = Math.sin(progress * Math.PI * 0.5); + output.left[index] = buffer.left[loopLength + index] * tailGain + output.left[index] * startGain; + output.right[index] = buffer.right[loopLength + index] * tailGain + output.right[index] * startGain; + } + return output; +} + +function addTone(buffer, options) { + const startIndex = Math.max(0, Math.floor(options.start * sampleRate)); + const count = Math.max(1, Math.floor(options.duration * sampleRate)); + const leftGain = Math.cos((options.pan + 1) * Math.PI * 0.25); + const rightGain = Math.sin((options.pan + 1) * Math.PI * 0.25); + for (let offset = 0; offset < count; offset += 1) { + const index = startIndex + offset; + if (index >= buffer.left.length) { + break; + } + const t = offset / sampleRate; + const envelopeValue = options.exponentialDecay + ? Math.min(1, t / Math.max(0.001, options.attack)) * Math.exp(-t * options.exponentialDecay) + : envelope(t, options.duration, options.attack, options.release); + const phase = 2 * Math.PI * options.frequency * t; + const sample = options.wave(phase, t) * options.gain * envelopeValue; + buffer.left[index] += sample * leftGain; + buffer.right[index] += sample * rightGain; + } +} + +function envelope(t, duration, attack, release) { + const attackLevel = Math.sin(Math.min(1, t / Math.max(0.001, attack)) * Math.PI * 0.5); + const releaseLevel = Math.min(1, Math.max(0, duration - t) / Math.max(0.001, release)); + return Math.min(attackLevel, releaseLevel); +} + +function stringWave(phase, t) { + const bow = 1 + Math.sin(2 * Math.PI * 0.72 * t) * 0.018; + return Math.sin(phase * bow) * 0.7 + Math.sin(phase * 2) * 0.13 + Math.sin(phase * 3) * 0.045; +} + +function celloWave(phase) { + return Math.sin(phase) * 0.78 + Math.sin(phase * 2) * 0.09 + Math.sin(phase * 0.5) * 0.04; +} + +function reedWave(phase, t) { + const vibrato = Math.sin(2 * Math.PI * 4.5 * t) * 0.006; + return Math.sin(phase + vibrato) * 0.86 + Math.sin(phase * 2) * 0.045; +} + +function pluckWave(phase) { + return Math.sin(phase) * 0.82 + Math.sin(phase * 2) * 0.12 + Math.sin(phase * 3) * 0.035; +} + +function drumWave(phase, t) { + const pitchDrop = 1 - Math.min(1, t * 5.8) * 0.48; + return Math.sin(phase * pitchDrop) * 0.92 + Math.sin(phase * 2.02) * 0.06; +} + +function gongWave(phase) { + return Math.sin(phase) * 0.76 + Math.sin(phase * 1.48) * 0.17 + Math.sin(phase * 2.07) * 0.09; +} + +function sineWave(phase) { + return Math.sin(phase); +} + +function dropletWave(phase, t) { + return Math.sin(phase * (1 + t * 0.22)) * 0.88 + Math.sin(phase * 2.2) * 0.08; +} + +function chirpWave(phase, t) { + return Math.sin(phase * (1 + t * 4.8)); +} + +function frogWave(phase, t) { + return Math.sin(phase) * 0.72 + Math.sin(phase * 0.5) * 0.18 + Math.sin(phase * (1.5 + t * 0.4)) * 0.08; +} + +function midiToFrequency(midi) { + return 440 * 2 ** ((midi - 69) / 12); +} + +function onePoleAlpha(cutoff) { + return 1 - Math.exp((-2 * Math.PI * cutoff) / sampleRate); +} + +function highPassAlpha(cutoff) { + const rc = 1 / (2 * Math.PI * cutoff); + const dt = 1 / sampleRate; + return rc / (rc + dt); +} + +function createRandom(seed) { + let state = seed >>> 0; + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return (state >>> 0) / 0x100000000; + }; +} + +function prepareMaster(buffer, highPassCutoff, targetPeak, room) { + highPass(buffer.left, highPassCutoff); + highPass(buffer.right, highPassCutoff); + if (room) { + applyRoom(buffer); + } + normalize(buffer, targetPeak); + softLimit(buffer); +} + +function highPass(samples, cutoff) { + const alpha = highPassAlpha(cutoff); + let previousOutput = 0; + let previousInput = samples[0] ?? 0; + for (let index = 0; index < samples.length; index += 1) { + const input = samples[index]; + const output = alpha * (previousOutput + input - previousInput); + samples[index] = output; + previousOutput = output; + previousInput = input; + } +} + +function applyRoom(buffer) { + const originalLeft = Float32Array.from(buffer.left); + const originalRight = Float32Array.from(buffer.right); + [ + { delay: 0.075, gain: 0.07 }, + { delay: 0.132, gain: 0.045 } + ].forEach(({ delay, gain }) => { + const samples = Math.floor(sampleRate * delay); + for (let index = samples; index < buffer.left.length; index += 1) { + buffer.left[index] += originalRight[index - samples] * gain; + buffer.right[index] += originalLeft[index - samples] * gain; + } + }); +} + +function normalize(buffer, targetPeak) { + let peak = 0; + for (let index = 0; index < buffer.left.length; index += 1) { + peak = Math.max(peak, Math.abs(buffer.left[index]), Math.abs(buffer.right[index])); + } + if (peak <= 0) { + return; + } + const scale = targetPeak / peak; + for (let index = 0; index < buffer.left.length; index += 1) { + buffer.left[index] *= scale; + buffer.right[index] *= scale; + } +} + +function softLimit(buffer) { + const divisor = Math.tanh(1.04); + for (let index = 0; index < buffer.left.length; index += 1) { + buffer.left[index] = Math.tanh(buffer.left[index] * 1.04) / divisor; + buffer.right[index] = Math.tanh(buffer.right[index] * 1.04) / divisor; + } +} + +function renderMp3(buffer, track, outputDir, bitrateKbps, loudness, truePeak, kind) { + const wavPath = join(temporaryDir, `${track.name}.wav`); + const outputPath = join(outputDir, `${track.name}.mp3`); + writeFileSync(wavPath, encodeWav(buffer)); + const result = spawnSync(ffmpeg, [ + '-y', '-hide_banner', '-loglevel', 'error', + '-i', wavPath, + '-af', `loudnorm=I=${loudness}:LRA=6:TP=${truePeak}`, + '-c:a', 'libmp3lame', + '-b:a', `${bitrateKbps}k`, + '-ar', String(sampleRate), + '-ac', String(channels), + '-write_xing', '1', + '-metadata', `title=${track.title}`, + '-metadata', 'artist=Heros Web Procedural Ensemble', + '-metadata', 'comment=Project-original deterministic synthesis; no sampled source audio.', + outputPath + ], { encoding: 'utf8' }); + + if (result.status !== 0) { + throw new Error(`ffmpeg failed for ${track.name}: ${result.stderr || result.stdout}`); + } + const seam = inspectEncodedLoopSeam(outputPath, track.name, kind); + console.log( + `Generated ${outputPath} (seam ${seam.boundaryJump.toFixed(4)} / ${seam.absoluteLimit.toFixed(4)}, ` + + `peak ${(seam.peakRatio * 100).toFixed(1)}%)` + ); +} + +function inspectEncodedLoopSeam(outputPath, trackName, kind) { + const result = spawnSync(ffmpeg, [ + '-hide_banner', '-loglevel', 'error', + '-i', outputPath, + '-f', 'f32le', + '-c:a', 'pcm_f32le', + '-ar', String(sampleRate), + '-ac', String(channels), + 'pipe:1' + ], { maxBuffer: 96 * 1024 * 1024 }); + + if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) { + throw new Error(`ffmpeg could not decode ${trackName} for loop seam verification: ${result.stderr?.toString() || ''}`); + } + + const bytesPerFrame = channels * 4; + const frameCount = Math.floor(result.stdout.length / bytesPerFrame); + if (frameCount < 2) { + throw new Error(`${trackName} did not decode enough samples for loop seam verification.`); + } + + const sample = (frame, channel) => result.stdout.readFloatLE(frame * bytesPerFrame + channel * 4); + let signalPeak = 0; + let deltaSquaredSum = 0; + let deltaCount = 0; + for (let channel = 0; channel < channels; channel += 1) { + signalPeak = Math.max(signalPeak, Math.abs(sample(0, channel))); + } + for (let frame = 1; frame < frameCount; frame += 1) { + for (let channel = 0; channel < channels; channel += 1) { + const current = sample(frame, channel); + const delta = current - sample(frame - 1, channel); + signalPeak = Math.max(signalPeak, Math.abs(current)); + deltaSquaredSum += delta * delta; + deltaCount += 1; + } + } + + let boundaryJump = 0; + for (let channel = 0; channel < channels; channel += 1) { + boundaryJump = Math.max(boundaryJump, Math.abs(sample(0, channel) - sample(frameCount - 1, channel))); + } + const deltaRms = Math.sqrt(deltaSquaredSum / Math.max(1, deltaCount)); + const absoluteLimit = kind === 'music' + ? Math.min(0.04, Math.max(0.02, deltaRms * 4)) + : Math.min(0.12, Math.max(0.04, deltaRms * 4)); + const peakRatioLimit = kind === 'music' ? 0.1 : 0.75; + const peakRatio = boundaryJump / Math.max(signalPeak, Number.EPSILON); + if ( + !Number.isFinite(boundaryJump) || + boundaryJump > absoluteLimit || + peakRatio > peakRatioLimit + ) { + throw new Error( + `${trackName} loop seam is too abrupt after MP3 encoding: jump ${boundaryJump.toFixed(6)}, ` + + `limit ${absoluteLimit.toFixed(6)}, peak ratio ${(peakRatio * 100).toFixed(2)}%.` + ); + } + + return { boundaryJump, absoluteLimit, peakRatio }; +} + +function encodeWav(buffer) { + const bytesPerSample = 2; + const frameCount = buffer.left.length; + const dataSize = frameCount * channels * 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(channels, 22); + wav.writeUInt32LE(sampleRate, 24); + wav.writeUInt32LE(sampleRate * channels * bytesPerSample, 28); + wav.writeUInt16LE(channels * bytesPerSample, 32); + wav.writeUInt16LE(16, 34); + wav.write('data', 36); + wav.writeUInt32LE(dataSize, 40); + for (let index = 0; index < frameCount; index += 1) { + const offset = 44 + index * channels * bytesPerSample; + wav.writeInt16LE(Math.round(Math.max(-1, Math.min(1, buffer.left[index])) * 32767), offset); + wav.writeInt16LE(Math.round(Math.max(-1, Math.min(1, buffer.right[index])) * 32767), offset + bytesPerSample); + } + return wav; +} + +function assertFfmpeg() { + const result = spawnSync(ffmpeg, ['-version'], { encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(`ffmpeg is required to generate camp audio. Set FFMPEG_PATH when it is not on PATH. ${result.stderr || ''}`); + } +} diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs index cb8e28e..b0f4e55 100644 --- a/scripts/verify-audio-asset-data.mjs +++ b/scripts/verify-audio-asset-data.mjs @@ -19,16 +19,17 @@ const server = await createServer({ }); try { - const { musicTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); + const { musicTracks, ambienceTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const errors = []; validateTrackMap(errors, 'music', musicTracks, 'bgm'); + validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience'); validateTrackMap(errors, 'effect', effectTracks, 'sfx'); - validateAudioDirectoryCoverage(errors, musicTracks, effectTracks); + validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks); validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks); - validateLiteralAudioCalls(errors, musicTracks, effectTracks); + validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks); if (errors.length) { console.error(`Audio asset data verification failed with ${errors.length} issue(s):`); @@ -39,7 +40,8 @@ try { process.exitCode = 1; } else { console.log( - `Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(effectTracks).length} effect tracks, ` + + `Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` + + `${Object.keys(effectTracks).length} effect tracks, ` + `${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.` ); } @@ -64,8 +66,9 @@ function validateTrackMap(errors, label, tracks, expectedFolder) { }); } -function validateAudioDirectoryCoverage(errors, musicTracks, effectTracks) { +function validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks) { const registeredMusicPaths = new Set(Object.values(musicTracks).map(assetUrlToPath).filter(Boolean)); + const registeredAmbiencePaths = new Set(Object.values(ambienceTracks).map(assetUrlToPath).filter(Boolean)); const registeredEffectPaths = new Set(Object.values(effectTracks).map(assetUrlToPath).filter(Boolean)); listAudioFiles(join(audioRoot, 'bgm')).forEach((path) => { @@ -73,6 +76,11 @@ function validateAudioDirectoryCoverage(errors, musicTracks, effectTracks) { errors.push(`BGM file "${path}" exists but is not registered in musicTracks`); } }); + listAudioFiles(join(audioRoot, 'ambience')).forEach((path) => { + if (!registeredAmbiencePaths.has(path)) { + errors.push(`Ambience file "${path}" exists but is not registered in ambienceTracks`); + } + }); listAudioFiles(join(audioRoot, 'sfx')).forEach((path) => { if (!registeredEffectPaths.has(path)) { errors.push(`SFX file "${path}" exists but is not registered in effectTracks`); @@ -89,8 +97,9 @@ function validateStoryBgmReferences(errors, scenarioModule, battleScenarios, mus }); } -function validateLiteralAudioCalls(errors, musicTracks, effectTracks) { +function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks) { const musicKeys = new Set(Object.keys(musicTracks)); + const ambienceKeys = new Set(Object.keys(ambienceTracks)); const effectKeys = new Set(Object.keys(effectTracks)); sourceFilesToScan.forEach((path) => { @@ -100,6 +109,11 @@ function validateLiteralAudioCalls(errors, musicTracks, effectTracks) { errors.push(`${path}:${lineForIndex(source, index)}: playMusic references unknown track "${key}"`); } }); + collectLiteralCallKeys(source, 'playAmbience').forEach(({ key, index }) => { + if (!ambienceKeys.has(key)) { + errors.push(`${path}:${lineForIndex(source, index)}: playAmbience references unknown track "${key}"`); + } + }); collectLiteralCallKeys(source, 'playEffect').forEach(({ key, index }) => { if (!effectKeys.has(key)) { errors.push(`${path}:${lineForIndex(source, index)}: playEffect references unknown track "${key}"`); diff --git a/scripts/verify-camp-soundscape-data.mjs b/scripts/verify-camp-soundscape-data.mjs new file mode 100644 index 0000000..ef17951 --- /dev/null +++ b/scripts/verify-camp-soundscape-data.mjs @@ -0,0 +1,297 @@ +import { createServer } from 'vite'; +import { createHash } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; +import { sep } from 'node:path'; + +const expectedBattleArcs = [ + { skinId: 'yellow-turban', firstBattleOrdinal: 1, lastBattleOrdinal: 4 }, + { skinId: 'anti-dong', firstBattleOrdinal: 5, lastBattleOrdinal: 6 }, + { skinId: 'xuzhou', firstBattleOrdinal: 7, lastBattleOrdinal: 9 }, + { skinId: 'wandering', firstBattleOrdinal: 10, lastBattleOrdinal: 15 }, + { skinId: 'wolong', firstBattleOrdinal: 16, lastBattleOrdinal: 18 }, + { skinId: 'red-cliffs', firstBattleOrdinal: 19, lastBattleOrdinal: 22 }, + { skinId: 'jing-yi', firstBattleOrdinal: 23, lastBattleOrdinal: 34 }, + { skinId: 'hanzhong-shuhan', firstBattleOrdinal: 35, lastBattleOrdinal: 37 }, + { skinId: 'jingzhou-crisis', firstBattleOrdinal: 38, lastBattleOrdinal: 44 }, + { skinId: 'yiling-baidi', firstBattleOrdinal: 45, lastBattleOrdinal: 46 }, + { skinId: 'nanzhong', firstBattleOrdinal: 47, lastBattleOrdinal: 54 }, + { skinId: 'northern', firstBattleOrdinal: 55, lastBattleOrdinal: 66 } +]; + +const expectedMusicGroups = { + rally: 'camp-rally', + wayfarer: 'camp-wayfarer', + 'grand-strategy': 'camp-grand-strategy', + frontier: 'camp-frontier' +}; + +const expectedAmbiences = { + campfire: 'campfire-ambience', + 'river-night': 'river-night-ambience', + 'mountain-wind': 'mountain-wind-ambience', + 'nanzhong-night': 'nanzhong-night-ambience' +}; + +const expectedMapping = { + 'yellow-turban': { musicGroupId: 'rally', ambienceId: 'campfire' }, + 'anti-dong': { musicGroupId: 'rally', ambienceId: 'campfire' }, + xuzhou: { musicGroupId: 'wayfarer', ambienceId: 'campfire' }, + wandering: { musicGroupId: 'wayfarer', ambienceId: 'campfire' }, + wolong: { musicGroupId: 'wayfarer', ambienceId: 'mountain-wind' }, + 'red-cliffs': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' }, + 'jing-yi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' }, + 'hanzhong-shuhan': { musicGroupId: 'grand-strategy', ambienceId: 'mountain-wind' }, + 'jingzhou-crisis': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' }, + 'yiling-baidi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' }, + nanzhong: { musicGroupId: 'frontier', ambienceId: 'nanzhong-night' }, + northern: { musicGroupId: 'frontier', ambienceId: 'mountain-wind' } +}; + +const expectedSpecialSteps = { + 'hanzhong-king-camp': 'hanzhong-shuhan', + 'shu-han-foundation-camp': 'hanzhong-shuhan', + 'baidi-entrustment-camp': 'yiling-baidi', + 'northern-campaign-prep-camp': 'northern', + 'ending-complete': 'northern' +}; + +const errors = []; +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { campMusicGroups, campAmbienceDefinitions, campSoundscapeMapping, getCampSoundscape } = + await server.ssrLoadModule('/src/game/data/campSoundscapes.ts'); + const { selectCampSkin } = await server.ssrLoadModule('/src/game/data/campSkins.ts'); + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); + + const expectedSkinIds = Object.keys(expectedMapping); + const musicEntries = Object.entries(campMusicGroups); + const ambienceEntries = Object.entries(campAmbienceDefinitions); + const mappingEntries = Object.entries(campSoundscapeMapping); + const battleIds = Object.keys(battleScenarios); + + assertEqual(expectedSkinIds.length, 12, 'expected camp skin count'); + assertEqual(mappingEntries.length, 12, 'camp soundscape mapping count'); + assertEqual(musicEntries.length, 4, 'camp music group count'); + assertEqual(ambienceEntries.length, 4, 'camp ambience definition count'); + assertEqual(battleIds.length, 66, 'battle count used by camp soundscapes'); + + validateDefinitions(musicEntries, expectedMusicGroups, musicTracks, 'music group', 0.05, 0.4); + validateDefinitions(ambienceEntries, expectedAmbiences, ambienceTracks, 'ambience', 0.03, 0.3); + validateCampAudioAssets( + musicEntries.map(([, definition]) => ({ key: definition.trackKey, url: musicTracks[definition.trackKey], kind: 'music' })), + ambienceEntries.map(([, definition]) => ({ key: definition.trackKey, url: ambienceTracks[definition.trackKey], kind: 'ambience' })) + ); + + expectedSkinIds.forEach((skinId) => { + const actualMapping = campSoundscapeMapping[skinId]; + const expected = expectedMapping[skinId]; + assert(Boolean(actualMapping), `${skinId}: missing soundscape mapping`); + if (!actualMapping) { + return; + } + + assertEqual(actualMapping.musicGroupId, expected.musicGroupId, `${skinId}: music group`); + assertEqual(actualMapping.ambienceId, expected.ambienceId, `${skinId}: ambience`); + assert(Boolean(campMusicGroups[actualMapping.musicGroupId]), `${skinId}: unknown music group`); + assert(Boolean(campAmbienceDefinitions[actualMapping.ambienceId]), `${skinId}: unknown ambience`); + + const soundscape = getCampSoundscape(skinId); + assertEqual(soundscape.skinId, skinId, `${skinId}: resolved skin id`); + assertEqual(soundscape.music, campMusicGroups[expected.musicGroupId], `${skinId}: resolved music definition`); + assertEqual(soundscape.ambience, campAmbienceDefinitions[expected.ambienceId], `${skinId}: resolved ambience definition`); + }); + + mappingEntries.forEach(([skinId]) => { + assert(expectedSkinIds.includes(skinId), `unexpected camp soundscape mapping "${skinId}"`); + }); + + battleIds.forEach((battleId, index) => { + const battleOrdinal = index + 1; + const expectedSkinId = expectedSkinForOrdinal(battleOrdinal); + const selection = selectCampSkin({ nextBattleId: battleId }); + assertEqual(selection.skin?.id, expectedSkinId, `${battleId}: selected camp skin`); + assertSelectionSoundscape(selection, `${battleId}: battle ${battleOrdinal}`, getCampSoundscape); + }); + + Object.entries(expectedSpecialSteps).forEach(([campaignStep, expectedSkinId]) => { + const selection = selectCampSkin({ + campaignStep, + nextBattleId: battleIds[0], + currentBattleId: battleIds.at(-1) + }); + assertEqual(selection.source, 'special-step', `${campaignStep}: selection source`); + assertEqual(selection.skin?.id, expectedSkinId, `${campaignStep}: selected camp skin`); + assertSelectionSoundscape(selection, `${campaignStep}: special override`, getCampSoundscape); + }); + + if (errors.length > 0) { + throw new Error(`Camp soundscape data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); + } + + console.log( + `Verified ${mappingEntries.length} camp soundscape mappings, ${musicEntries.length} music groups, ` + + `${ambienceEntries.length} ambiences, ${battleIds.length} battle selections, and ` + + `${Object.keys(expectedSpecialSteps).length} special-step overrides.` + ); +} finally { + await server.close(); +} + +function validateDefinitions(entries, expectedDefinitions, registeredTracks, context, minimumVolume, maximumVolume) { + const expectedIds = Object.keys(expectedDefinitions); + const registeredKeys = new Set(Object.keys(registeredTracks)); + + entries.forEach(([id, definition]) => { + assert(expectedIds.includes(id), `unexpected camp ${context} "${id}"`); + assertEqual(definition.id, id, `${context} ${id}: id`); + assertEqual(definition.trackKey, expectedDefinitions[id], `${context} ${id}: track key`); + assertNonEmptyString(definition.label, `${context} ${id}: label`); + assert( + Number.isFinite(definition.volume) && + definition.volume >= minimumVolume && + definition.volume <= maximumVolume, + `${context} ${id}: volume must be between ${minimumVolume} and ${maximumVolume}` + ); + assert(registeredKeys.has(definition.trackKey), `${context} ${id}: unregistered track "${definition.trackKey}"`); + assertNonEmptyString(registeredTracks[definition.trackKey], `${context} ${id}: registered track URL`); + }); + + expectedIds.forEach((id) => { + assert(Boolean(entries.find(([actualId]) => actualId === id)), `missing camp ${context} "${id}"`); + }); + assertEqual(new Set(entries.map(([, definition]) => definition.trackKey)).size, entries.length, `${context} track keys`); +} + +function validateCampAudioAssets(musicAssets, ambienceAssets) { + const assets = [...musicAssets, ...ambienceAssets]; + const hashes = new Set(); + let totalBytes = 0; + + assets.forEach((asset) => { + const path = assetUrlToPath(asset.url); + assert(Boolean(path), `${asset.kind} ${asset.key}: URL does not point at src/assets/audio`); + if (!path) { + return; + } + + let bytes; + let size; + try { + bytes = readFileSync(path); + size = statSync(path).size; + } catch (error) { + errors.push(`${asset.kind} ${asset.key}: cannot read ${path}: ${error.message}`); + return; + } + + totalBytes += size; + const hash = createHash('sha256').update(bytes).digest('hex'); + assert(!hashes.has(hash), `${asset.kind} ${asset.key}: audio content duplicates another camp asset`); + hashes.add(hash); + + const metadata = readMp3Metadata(bytes); + assert(Boolean(metadata), `${asset.kind} ${asset.key}: missing readable MPEG-1 Layer III frame`); + if (!metadata) { + return; + } + + assertEqual(metadata.sampleRate, 44100, `${asset.kind} ${asset.key}: sample rate`); + assertEqual(metadata.channels, 2, `${asset.kind} ${asset.key}: channel count`); + if (asset.kind === 'music') { + assertEqual(metadata.bitrateKbps, 192, `music ${asset.key}: bitrate`); + assert(size >= 256 * 1024 && size <= 2 * 1024 * 1024, `music ${asset.key}: size must be 256 KiB to 2 MiB`); + assert(metadata.durationSeconds >= 45 && metadata.durationSeconds <= 70, `music ${asset.key}: duration must be 45 to 70 seconds`); + } else { + assertEqual(metadata.bitrateKbps, 128, `ambience ${asset.key}: bitrate`); + assert(size >= 64 * 1024 && size <= 1024 * 1024, `ambience ${asset.key}: size must be 64 KiB to 1 MiB`); + assert(metadata.durationSeconds >= 20 && metadata.durationSeconds <= 45, `ambience ${asset.key}: duration must be 20 to 45 seconds`); + } + }); + + assert(totalBytes <= 8 * 1024 * 1024, `camp audio assets must stay at or below 8 MiB; received ${totalBytes} bytes`); +} + +function assetUrlToPath(url) { + const marker = 'src/assets/audio/'; + const index = typeof url === 'string' ? url.indexOf(marker) : -1; + return index >= 0 ? url.slice(index).replaceAll('/', sep) : undefined; +} + +function readMp3Metadata(bytes) { + let searchOffset = 0; + if (bytes.subarray(0, 3).toString('ascii') === 'ID3' && bytes.length >= 10) { + const tagSize = ((bytes[6] & 0x7f) << 21) | ((bytes[7] & 0x7f) << 14) | ((bytes[8] & 0x7f) << 7) | (bytes[9] & 0x7f); + searchOffset = 10 + tagSize; + } + + const bitrateTable = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0]; + const sampleRateTable = [44100, 48000, 32000, 0]; + const searchLimit = Math.min(bytes.length - 4, searchOffset + 65536); + for (let offset = searchOffset; offset <= searchLimit; offset += 1) { + if (bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) { + continue; + } + const versionBits = (bytes[offset + 1] >> 3) & 0x03; + const layerBits = (bytes[offset + 1] >> 1) & 0x03; + if (versionBits !== 0x03 || layerBits !== 0x01) { + continue; + } + const bitrateKbps = bitrateTable[(bytes[offset + 2] >> 4) & 0x0f]; + const sampleRate = sampleRateTable[(bytes[offset + 2] >> 2) & 0x03]; + if (!bitrateKbps || !sampleRate) { + continue; + } + const channelMode = (bytes[offset + 3] >> 6) & 0x03; + return { + bitrateKbps, + sampleRate, + channels: channelMode === 0x03 ? 1 : 2, + durationSeconds: ((bytes.length - searchOffset) * 8) / (bitrateKbps * 1000) + }; + } + return undefined; +} + +function assertSelectionSoundscape(selection, context, getCampSoundscape) { + const skinId = selection.skin?.id; + assert(Boolean(skinId), `${context}: selection has no skin`); + if (!skinId || !expectedMapping[skinId]) { + return; + } + + const expected = expectedMapping[skinId]; + const soundscape = getCampSoundscape(skinId); + assertEqual(soundscape.music.id, expected.musicGroupId, `${context}: music group`); + assertEqual(soundscape.ambience.id, expected.ambienceId, `${context}: ambience`); +} + +function expectedSkinForOrdinal(battleOrdinal) { + return expectedBattleArcs.find( + ({ firstBattleOrdinal, lastBattleOrdinal }) => + battleOrdinal >= firstBattleOrdinal && battleOrdinal <= lastBattleOrdinal + )?.skinId; +} + +function assertNonEmptyString(value, context) { + assert(typeof value === 'string' && value.trim().length > 0, `${context} must be a non-empty string`); +} + +function assertEqual(actual, expected, context) { + assert(Object.is(actual, expected), `${context}: expected ${format(expected)}, received ${format(actual)}`); +} + +function assert(condition, message) { + if (!condition) { + errors.push(message); + } +} + +function format(value) { + return value === undefined ? 'undefined' : JSON.stringify(value); +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 52d1d3d..5494b3e 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2020,6 +2020,7 @@ try { await clickLegacyUi(page, 738, 642); await waitForCampAfterBattleResult(page); await waitForCampSkinTransition(page, 'yellow-turban'); + await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience'); await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp'); @@ -2037,6 +2038,12 @@ try { }); assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`); assertCampSkinState(firstCampProbe.state, 'yellow-turban', 'first camp'); + assertCampSoundscapeState(firstCampProbe.state, { + musicKey: 'camp-rally', + ambienceKey: 'campfire-ambience', + musicVolume: 0.22, + ambienceVolume: 0.12 + }, 'first camp'); assert( firstCampProbe.state?.campRoster?.pageCount === 1 && firstCampProbe.state.campRoster.totalCount === 3 && @@ -2075,7 +2082,10 @@ try { firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`), `Expected first camp deployment preview to avoid duplicate tiles: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}` ); - assert(firstCampProbe.summary?.includes('보유 군자금'), `Expected camp summary to show held gold: ${JSON.stringify(firstCampProbe)}`); + assert( + firstCampProbe.summary?.includes('군자금') && !firstCampProbe.summary.includes('\n'), + `Expected the compact camp summary to show held gold on one line: ${JSON.stringify(firstCampProbe)}` + ); assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`); assert( firstCampProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비')), @@ -2565,6 +2575,16 @@ try { undefined, { timeout: 30000 } ); + const firstCampTabAudioState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.audio); + assert( + firstCampTabAudioState?.music?.currentKey === firstCampProbe.state?.audio?.music?.currentKey && + firstCampTabAudioState?.ambience?.currentKey === firstCampProbe.state?.audio?.ambience?.currentKey && + firstCampTabAudioState?.music?.elementRevision === firstCampProbe.state?.audio?.music?.elementRevision && + firstCampTabAudioState?.ambience?.elementRevision === firstCampProbe.state?.audio?.ambience?.elementRevision && + firstCampTabAudioState?.music?.transition?.revision === firstCampProbe.state?.audio?.music?.transition?.revision && + firstCampTabAudioState?.ambience?.transition?.revision === firstCampProbe.state?.audio?.ambience?.transition?.revision, + `Expected camp tab changes to keep the existing soundscape elements: ${JSON.stringify({ before: firstCampProbe.state?.audio, after: firstCampTabAudioState })}` + ); const firstCampUseSupplyPoint = await readCampSupplyUseControlPoint(page, '콩'); await page.mouse.click(firstCampUseSupplyPoint.x, firstCampUseSupplyPoint.y); await page.waitForTimeout(120); @@ -5212,9 +5232,16 @@ try { await clickLegacyUi(page, 962, 310); await waitForCamp(page); await waitForCampSkinTransition(page, 'northern'); + await waitForCampSoundscapeTransition(page, 'camp-frontier', 'mountain-wind-ambience'); const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`); assertCampSkinState(finalCampState, 'northern', 'final camp'); + assertCampSoundscapeState(finalCampState, { + musicKey: 'camp-frontier', + ambienceKey: 'mountain-wind-ambience', + musicVolume: 0.2, + ambienceVolume: 0.09 + }, 'final camp'); assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`); assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`); assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`); @@ -7080,6 +7107,26 @@ async function waitForCampSkinTransition(page, expectedSkinId) { })); } +async function waitForCampSoundscapeTransition(page, expectedMusicKey, expectedAmbienceKey) { + await page.waitForFunction( + ({ musicKey, ambienceKey }) => { + const audio = window.__HEROS_DEBUG__?.camp?.()?.audio; + const channels = [audio?.music, audio?.ambience]; + return ( + audio?.music?.currentKey === musicKey && + audio?.ambience?.currentKey === ambienceKey && + channels.every((channel) => + channel?.transition?.active === false && + channel.transition.revision > 0 && + channel.transition.completedRevision === channel.transition.revision + ) + ); + }, + { musicKey: expectedMusicKey, ambienceKey: expectedAmbienceKey }, + { timeout: 30000 } + ); +} + async function waitForCampVisualFrame(page, expectedPage) { await page.waitForFunction( (pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber, @@ -8491,6 +8538,42 @@ function assertCampSkinState(state, expectedSkinId, label) { ); } +function assertCampSoundscapeState(state, expected, label) { + const soundscape = state?.campSoundscape; + const audio = state?.audio; + + assert( + soundscape?.skinId === state?.campSkin?.id && + soundscape.musicKey === expected.musicKey && + soundscape.ambienceKey === expected.ambienceKey && + Math.abs(soundscape.musicVolume - expected.musicVolume) <= 0.001 && + Math.abs(soundscape.ambienceVolume - expected.ambienceVolume) <= 0.001, + `Expected ${label} to select its era soundscape profile: ${JSON.stringify(soundscape)}` + ); + + for (const [channelName, expectedKey, expectedVolume, minFadeMs, maxFadeMs] of [ + ['music', expected.musicKey, expected.musicVolume, 700, 1100], + ['ambience', expected.ambienceKey, expected.ambienceVolume, 1000, 1400] + ]) { + const channel = audio?.[channelName]; + assert( + channel?.currentKey === expectedKey && + channel.pendingKey === null && + channel.hasPendingRequest === false && + channel.activeElementCount === 1 && + channel.elementRevision > 0 && + Math.abs(channel.targetVolume - expectedVolume) <= 0.001 && + channel.transition?.active === false && + channel.transition.revision > 0 && + channel.transition.completedRevision === channel.transition.revision && + channel.transition.last?.toKey === expectedKey && + channel.transition.last.durationMs >= minFadeMs && + channel.transition.last.durationMs <= maxFadeMs, + `Expected ${label} ${channelName} channel to finish one clean crossfade: ${JSON.stringify(channel)}` + ); + } +} + function isVisibleCampSkinView(view) { return Boolean( view?.visible === true && diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs index 033bac5..bea7013 100644 --- a/scripts/verify-sound-director.mjs +++ b/scripts/verify-sound-director.mjs @@ -140,6 +140,18 @@ try { }); const elementsDuringCrossfade = audioHarness.activeInstances(); assert.equal(elementsDuringCrossfade.length, 4, 'two channels should each retain one outgoing element during a crossfade'); + assertClose( + audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, + 0.18, + 'outgoing music should keep its settled volume at crossfade start' + ); + assertClose( + audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, + 0.05, + 'outgoing ambience should keep its settled volume at crossfade start' + ); + assertClose(audioHarness.byOriginalSrc('/audio/music-b.ogg').volume, 0, 'incoming music should start silent'); + assertClose(audioHarness.byOriginalSrc('/audio/ambience-b.ogg').volume, 0, 'incoming ambience should start silent'); director.setMuted(true); assert(elementsDuringCrossfade.every((element) => element.muted), 'mute should cover current and outgoing elements'); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index b06c1fd..9df121a 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -8,6 +8,7 @@ const checks = [ 'scripts/verify-battle-scenario-data.mjs', 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-camp-skin-data.mjs', + 'scripts/verify-camp-soundscape-data.mjs', 'scripts/verify-campaign-recruit-data.mjs', 'scripts/verify-equipment-catalog-data.mjs', 'scripts/verify-inventory-reward-data.mjs', @@ -16,7 +17,8 @@ const checks = [ 'scripts/verify-visual-asset-data.mjs', 'scripts/verify-unit-sprite-asset-data.mjs', 'scripts/verify-battle-map-asset-data.mjs', - 'scripts/verify-audio-asset-data.mjs' + 'scripts/verify-audio-asset-data.mjs', + 'scripts/verify-sound-director.mjs' ]; for (const scriptPath of checks) { diff --git a/src/assets/audio/ambience/campfire-ambience.mp3 b/src/assets/audio/ambience/campfire-ambience.mp3 new file mode 100644 index 0000000..f68b82f Binary files /dev/null and b/src/assets/audio/ambience/campfire-ambience.mp3 differ diff --git a/src/assets/audio/ambience/mountain-wind-ambience.mp3 b/src/assets/audio/ambience/mountain-wind-ambience.mp3 new file mode 100644 index 0000000..3014718 Binary files /dev/null and b/src/assets/audio/ambience/mountain-wind-ambience.mp3 differ diff --git a/src/assets/audio/ambience/nanzhong-night-ambience.mp3 b/src/assets/audio/ambience/nanzhong-night-ambience.mp3 new file mode 100644 index 0000000..403233f Binary files /dev/null and b/src/assets/audio/ambience/nanzhong-night-ambience.mp3 differ diff --git a/src/assets/audio/ambience/river-night-ambience.mp3 b/src/assets/audio/ambience/river-night-ambience.mp3 new file mode 100644 index 0000000..827a63b Binary files /dev/null and b/src/assets/audio/ambience/river-night-ambience.mp3 differ diff --git a/src/assets/audio/bgm/camp-frontier.mp3 b/src/assets/audio/bgm/camp-frontier.mp3 new file mode 100644 index 0000000..dd633bd Binary files /dev/null and b/src/assets/audio/bgm/camp-frontier.mp3 differ diff --git a/src/assets/audio/bgm/camp-grand-strategy.mp3 b/src/assets/audio/bgm/camp-grand-strategy.mp3 new file mode 100644 index 0000000..905c627 Binary files /dev/null and b/src/assets/audio/bgm/camp-grand-strategy.mp3 differ diff --git a/src/assets/audio/bgm/camp-rally.mp3 b/src/assets/audio/bgm/camp-rally.mp3 new file mode 100644 index 0000000..859e362 Binary files /dev/null and b/src/assets/audio/bgm/camp-rally.mp3 differ diff --git a/src/assets/audio/bgm/camp-wayfarer.mp3 b/src/assets/audio/bgm/camp-wayfarer.mp3 new file mode 100644 index 0000000..eb36ff8 Binary files /dev/null and b/src/assets/audio/bgm/camp-wayfarer.mp3 differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index d666415..d11b21d 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -380,9 +380,6 @@ export class SoundDirector { private setLoopTargetVolume(channel: LoopChannel, volume: number) { channel.targetVolume = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : channel.defaultVolume; - if (channel.current && !channel.transition.active) { - channel.current.volume = channel.targetVolume; - } } private requestLoopChannel(channel: LoopChannel, key?: string) { diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index 7d188c3..6382d72 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -1,4 +1,8 @@ import battlePrepUrl from '../../assets/audio/bgm/battle-prep.mp3'; +import campFrontierUrl from '../../assets/audio/bgm/camp-frontier.mp3'; +import campGrandStrategyUrl from '../../assets/audio/bgm/camp-grand-strategy.mp3'; +import campRallyUrl from '../../assets/audio/bgm/camp-rally.mp3'; +import campWayfarerUrl from '../../assets/audio/bgm/camp-wayfarer.mp3'; import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.mp3'; import oathThemeUrl from '../../assets/audio/bgm/oath-theme.mp3'; import storyDarkUrl from '../../assets/audio/bgm/story-dark.mp3'; @@ -15,13 +19,28 @@ import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3'; import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3'; import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3'; import victoryFanfareUrl from '../../assets/audio/sfx/victory-fanfare.wav'; +import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3'; +import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3'; +import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3'; +import riverNightAmbienceUrl from '../../assets/audio/ambience/river-night-ambience.mp3'; export const musicTracks = { 'title-theme': titleThemeUrl, 'story-dark': storyDarkUrl, 'oath-theme': oathThemeUrl, 'militia-theme': militiaThemeUrl, - 'battle-prep': battlePrepUrl + 'battle-prep': battlePrepUrl, + 'camp-rally': campRallyUrl, + 'camp-wayfarer': campWayfarerUrl, + 'camp-grand-strategy': campGrandStrategyUrl, + 'camp-frontier': campFrontierUrl +} as const; + +export const ambienceTracks = { + 'campfire-ambience': campfireAmbienceUrl, + 'river-night-ambience': riverNightAmbienceUrl, + 'mountain-wind-ambience': mountainWindAmbienceUrl, + 'nanzhong-night-ambience': nanzhongNightAmbienceUrl } as const; export const effectTracks = { @@ -40,4 +59,5 @@ export const effectTracks = { } as const; export type MusicTrackKey = keyof typeof musicTracks; +export type AmbienceTrackKey = keyof typeof ambienceTracks; export type EffectTrackKey = keyof typeof effectTracks; diff --git a/src/game/data/campSoundscapes.ts b/src/game/data/campSoundscapes.ts new file mode 100644 index 0000000..9b1f1b3 --- /dev/null +++ b/src/game/data/campSoundscapes.ts @@ -0,0 +1,119 @@ +import type { AmbienceTrackKey, MusicTrackKey } from '../audio/audioAssets'; +import type { CampSkinId } from './campSkins'; + +export type CampMusicGroupId = 'rally' | 'wayfarer' | 'grand-strategy' | 'frontier'; + +export type CampAmbienceId = 'campfire' | 'river-night' | 'mountain-wind' | 'nanzhong-night'; + +export type CampMusicTrackKey = Extract< + MusicTrackKey, + 'camp-rally' | 'camp-wayfarer' | 'camp-grand-strategy' | 'camp-frontier' +>; + +export type CampAmbienceTrackKey = Extract< + AmbienceTrackKey, + 'campfire-ambience' | 'river-night-ambience' | 'mountain-wind-ambience' | 'nanzhong-night-ambience' +>; + +export type CampMusicGroupDefinition = { + id: CampMusicGroupId; + trackKey: CampMusicTrackKey; + label: string; + volume: number; +}; + +export type CampAmbienceDefinition = { + id: CampAmbienceId; + trackKey: CampAmbienceTrackKey; + label: string; + volume: number; +}; + +export type CampSoundscapeMapping = { + musicGroupId: CampMusicGroupId; + ambienceId: CampAmbienceId; +}; + +export type CampSoundscape = { + skinId: CampSkinId; + music: CampMusicGroupDefinition; + ambience: CampAmbienceDefinition; +}; + +export const campMusicGroups: Record = { + rally: { + id: 'rally', + trackKey: 'camp-rally', + label: '결의와 출정', + volume: 0.22 + }, + wayfarer: { + id: 'wayfarer', + trackKey: 'camp-wayfarer', + label: '유랑의 밤', + volume: 0.18 + }, + 'grand-strategy': { + id: 'grand-strategy', + trackKey: 'camp-grand-strategy', + label: '대업의 계책', + volume: 0.2 + }, + frontier: { + id: 'frontier', + trackKey: 'camp-frontier', + label: '변경과 북벌', + volume: 0.2 + } +}; + +export const campAmbienceDefinitions: Record = { + campfire: { + id: 'campfire', + trackKey: 'campfire-ambience', + label: '장작불', + volume: 0.12 + }, + 'river-night': { + id: 'river-night', + trackKey: 'river-night-ambience', + label: '밤의 강물', + volume: 0.1 + }, + 'mountain-wind': { + id: 'mountain-wind', + trackKey: 'mountain-wind-ambience', + label: '산바람', + volume: 0.09 + }, + 'nanzhong-night': { + id: 'nanzhong-night', + trackKey: 'nanzhong-night-ambience', + label: '남중의 밤', + volume: 0.11 + } +}; + +export const campSoundscapeMapping: Record = { + 'yellow-turban': { musicGroupId: 'rally', ambienceId: 'campfire' }, + 'anti-dong': { musicGroupId: 'rally', ambienceId: 'campfire' }, + xuzhou: { musicGroupId: 'wayfarer', ambienceId: 'campfire' }, + wandering: { musicGroupId: 'wayfarer', ambienceId: 'campfire' }, + wolong: { musicGroupId: 'wayfarer', ambienceId: 'mountain-wind' }, + 'red-cliffs': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' }, + 'jing-yi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' }, + 'hanzhong-shuhan': { musicGroupId: 'grand-strategy', ambienceId: 'mountain-wind' }, + 'jingzhou-crisis': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' }, + 'yiling-baidi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' }, + nanzhong: { musicGroupId: 'frontier', ambienceId: 'nanzhong-night' }, + northern: { musicGroupId: 'frontier', ambienceId: 'mountain-wind' } +}; + +export function getCampSoundscape(skinId: CampSkinId): CampSoundscape { + const mapping = campSoundscapeMapping[skinId]; + return { + skinId, + music: campMusicGroups[mapping.musicGroupId], + ambience: campAmbienceDefinitions[mapping.ambienceId] + }; +} diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 632ff4d..d150e9e 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -16,6 +16,7 @@ import { compareUnitStrategyCoverage, getUnitStrategyCoverage, getUnitStrategyNa import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles'; import { getSortieFlow } from '../data/campaignFlow'; +import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes'; import { campaignPortraitKeysByUnitId, portraitAssetEntriesForKey, @@ -11070,6 +11071,7 @@ export class CampScene extends Phaser.Scene { private campSkinSeal?: Phaser.GameObjects.Arc; private campSkinBadge?: Phaser.GameObjects.Rectangle; private campSkinSummary?: Phaser.GameObjects.Text; + private campSoundscape?: CampSoundscape; private campSkinRenderedTextureKey?: string; private campSkinUsedFallback = false; private campSkinTransitionRevision = 0; @@ -11206,6 +11208,7 @@ export class CampScene extends Phaser.Scene { nextBattleId: sortieFlow.nextBattleId, currentBattleId: this.currentCampBattleId() }); + this.campSoundscape = getCampSoundscape(this.campSkinSelection.skin.id); this.campSkinBackdrop = undefined; this.campSkinWash = undefined; this.campSkinVignette = undefined; @@ -11223,7 +11226,12 @@ export class CampScene extends Phaser.Scene { this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments); this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id; this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id; - soundDirector.playMusic('militia-theme'); + soundDirector.playSoundscape({ + musicKey: this.campSoundscape.music.trackKey, + ambienceKey: this.campSoundscape.ambience.trackKey, + musicVolume: this.campSoundscape.music.volume, + ambienceVolume: this.campSoundscape.ambience.volume + }); this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey()); this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event)); this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event)); @@ -21673,6 +21681,20 @@ export class CampScene extends Phaser.Scene { campaignProgress: this.campaignTimelineProgress(), campBattleId: this.currentCampBattleId(), campTitle: this.currentCampTitle(), + campSoundscape: this.campSoundscape + ? { + skinId: this.campSoundscape.skinId, + musicGroupId: this.campSoundscape.music.id, + musicKey: this.campSoundscape.music.trackKey, + musicLabel: this.campSoundscape.music.label, + musicVolume: this.campSoundscape.music.volume, + ambienceId: this.campSoundscape.ambience.id, + ambienceKey: this.campSoundscape.ambience.trackKey, + ambienceLabel: this.campSoundscape.ambience.label, + ambienceVolume: this.campSoundscape.ambience.volume + } + : null, + audio: soundDirector.getDebugState(), campSkin: this.campSkinSelection ? { id: this.campSkinSelection.skin.id, diff --git a/src/main.ts b/src/main.ts index 94c4cf2..c939a21 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import Phaser from 'phaser'; -import { effectTracks, musicTracks } from './game/audio/audioAssets'; +import { ambienceTracks, effectTracks, musicTracks } from './game/audio/audioAssets'; import { soundDirector } from './game/audio/SoundDirector'; import { BootScene } from './game/scenes/BootScene'; import { startLazySceneFromGame } from './game/scenes/lazyScenes'; @@ -26,6 +26,7 @@ type DebugCampScene = Phaser.Scene & { type HerosDebugApi = { game: Phaser.Game; activeScenes: () => string[]; + audio: () => ReturnType; battle: () => unknown; camp: () => unknown; goToBattle: (battleId?: string) => Promise; @@ -57,6 +58,7 @@ const config: Phaser.Types.Core.GameConfig = { }; soundDirector.registerMusicTracks(musicTracks); +soundDirector.registerAmbienceTracks(ambienceTracks); soundDirector.registerEffectTracks(effectTracks); const game = new Phaser.Game(config); @@ -76,6 +78,7 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi { return { game, activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key), + audio: () => soundDirector.getDebugState(), battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' }, camp: () => campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene is not active yet.' }, goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined),