-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(map): play zone music when available
- Loading branch information
Showing
8 changed files
with
276 additions
and
7 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
import * as THREE from 'three'; | ||
import { ClientDb, SoundEntriesRecord, ZoneMusicRecord } from '@wowserhq/format'; | ||
import DbManager from '../db/DbManager.js'; | ||
import { AssetHost, loadAsset, normalizePath } from '../asset.js'; | ||
import ZoneMusic from './ZoneMusic.js'; | ||
import zoneMusic from './ZoneMusic.js'; | ||
|
||
type SoundManagerOptions = { | ||
host: AssetHost; | ||
dbManager?: DbManager; | ||
}; | ||
|
||
class SoundManager { | ||
#host: AssetHost; | ||
|
||
#loadedSounds = new Map<string, AudioBuffer>(); | ||
#loadingSounds = new Map<string, Promise<AudioBuffer>>(); | ||
|
||
#context: AudioContext; | ||
#listener: THREE.AudioListener; | ||
#camera: THREE.Camera; | ||
|
||
#dbManager: DbManager; | ||
#zoneMusicDb: ClientDb<ZoneMusicRecord>; | ||
#soundEntriesDb: ClientDb<SoundEntriesRecord>; | ||
|
||
#desiredZoneMusicId: number; | ||
#currentZoneMusicId: number; | ||
#zoneMusic: ZoneMusic; | ||
|
||
#musicSource: THREE.Audio; | ||
|
||
constructor(options: SoundManagerOptions) { | ||
this.#host = options.host; | ||
this.#dbManager = options.dbManager ?? new DbManager({ host: options.host }); | ||
|
||
this.#context = THREE.AudioContext.getContext(); | ||
this.#listener = new THREE.AudioListener(); | ||
this.#musicSource = new THREE.Audio(this.#listener); | ||
this.#zoneMusic = new ZoneMusic(this.#musicSource); | ||
|
||
this.#loadDbs().catch((error) => console.error(error)); | ||
this.#syncZoneMusic().catch((error) => console.error(error)); | ||
} | ||
|
||
get camera() { | ||
return this.#camera; | ||
} | ||
|
||
set camera(camera: THREE.Camera) { | ||
this.#camera = camera; | ||
this.#camera.add(this.#listener); | ||
} | ||
|
||
setZoneMusic(zoneMusicId: number) { | ||
this.#desiredZoneMusicId = zoneMusicId; | ||
} | ||
|
||
async #loadDbs() { | ||
this.#zoneMusicDb = await this.#dbManager.get('ZoneMusic.dbc', ZoneMusicRecord); | ||
this.#soundEntriesDb = await this.#dbManager.get('SoundEntries.dbc', SoundEntriesRecord); | ||
} | ||
|
||
async #syncZoneMusic() { | ||
if (!this.#zoneMusicDb || !this.#soundEntriesDb) { | ||
requestAnimationFrame(() => this.#syncZoneMusic().catch((error) => console.error(error))); | ||
return; | ||
} | ||
|
||
if (this.#desiredZoneMusicId !== this.#currentZoneMusicId) { | ||
await this.#updateZoneMusic(this.#desiredZoneMusicId); | ||
} | ||
|
||
requestAnimationFrame(() => this.#syncZoneMusic().catch((error) => console.error(error))); | ||
} | ||
|
||
async #updateZoneMusic(zoneMusicId: number) { | ||
if (this.#currentZoneMusicId === zoneMusicId) { | ||
return; | ||
} | ||
|
||
// Let current zone music finish gracefully | ||
this.#zoneMusic.suspend(); | ||
|
||
const musicRecord = this.#zoneMusicDb.getRecord(zoneMusicId); | ||
if (!musicRecord) { | ||
this.#currentZoneMusicId = zoneMusicId; | ||
return; | ||
} | ||
|
||
const soundRecords = musicRecord.sounds.map((soundEntriesId) => | ||
this.#soundEntriesDb.getRecord(soundEntriesId), | ||
); | ||
|
||
const sounds = {}; | ||
for (const soundRecord of soundRecords) { | ||
for (const file of soundRecord.file) { | ||
if (file.trim().length === 0) { | ||
continue; | ||
} | ||
|
||
const path = `${soundRecord.directoryBase}\\${file.trim()}`; | ||
const buffer = await this.#getSound(path); | ||
sounds[file] = buffer; | ||
} | ||
} | ||
|
||
this.#zoneMusic.update(musicRecord, soundRecords, sounds); | ||
|
||
// Resume with new zone music | ||
this.#zoneMusic.resume(); | ||
|
||
this.#currentZoneMusicId = zoneMusicId; | ||
} | ||
|
||
#getSound(path: string): Promise<AudioBuffer> { | ||
const refId = normalizePath(path); | ||
|
||
const loaded = this.#loadedSounds.get(refId); | ||
if (loaded) { | ||
return Promise.resolve(loaded); | ||
} | ||
|
||
const alreadyLoading = this.#loadingSounds.get(refId); | ||
if (alreadyLoading) { | ||
return alreadyLoading; | ||
} | ||
|
||
const loading = this.#loadSound(refId, path); | ||
this.#loadingSounds.set(refId, loading); | ||
|
||
return loading; | ||
} | ||
|
||
async #loadSound(refId: string, path: string) { | ||
let buffer: AudioBuffer; | ||
try { | ||
const data = await loadAsset(this.#host, path); | ||
buffer = await this.#context.decodeAudioData(data); | ||
|
||
this.#loadedSounds.set(refId, buffer); | ||
} finally { | ||
this.#loadingSounds.delete(refId); | ||
} | ||
|
||
return buffer; | ||
} | ||
} | ||
|
||
export default SoundManager; | ||
export { SoundManager }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
import * as THREE from 'three'; | ||
import { SoundEntriesRecord, ZoneMusicRecord } from '@wowserhq/format'; | ||
import { SOUND_AMBIENCE } from './const.js'; | ||
import { getRandomInt } from './util.js'; | ||
|
||
class ZoneMusic { | ||
#musicRecord: ZoneMusicRecord; | ||
#soundRecords: SoundEntriesRecord[]; | ||
#sounds: Record<string, AudioBuffer>; | ||
#ambience: SOUND_AMBIENCE; | ||
#audioSource: THREE.Audio; | ||
#scheduleTimeout: ReturnType<typeof setTimeout>; | ||
#lastPlayed: number; | ||
|
||
constructor(audioSource: THREE.Audio) { | ||
this.#audioSource = audioSource; | ||
this.#ambience = SOUND_AMBIENCE.AMBIENCE_DAY; | ||
} | ||
|
||
get ambience() { | ||
return this.#ambience; | ||
} | ||
|
||
set ambience(ambience: SOUND_AMBIENCE) { | ||
this.#ambience = ambience; | ||
} | ||
|
||
resume() { | ||
this.#schedule(); | ||
} | ||
|
||
stop() { | ||
this.#audioSource.stop(); | ||
this.#audioSource.onEnded = null; | ||
|
||
this.suspend(); | ||
} | ||
|
||
suspend() { | ||
clearTimeout(this.#scheduleTimeout); | ||
this.#scheduleTimeout = null; | ||
} | ||
|
||
update( | ||
musicRecord: ZoneMusicRecord, | ||
soundRecords: SoundEntriesRecord[], | ||
sounds: Record<string, AudioBuffer>, | ||
) { | ||
this.#musicRecord = musicRecord; | ||
this.#soundRecords = soundRecords; | ||
this.#sounds = sounds; | ||
} | ||
|
||
#play() { | ||
const soundEntries = this.#soundRecords[this.#ambience]; | ||
const soundFiles = soundEntries.file.filter((file) => file.trim().length > 0); | ||
|
||
// TODO use freq as weight | ||
const soundFile = soundFiles[getRandomInt(0, soundFiles.length - 1)]; | ||
|
||
const soundBuffer = this.#sounds[soundFile]; | ||
this.#audioSource.setBuffer(soundBuffer); | ||
|
||
this.#audioSource.onEnded = () => { | ||
this.#audioSource.isPlaying = false; | ||
this.#lastPlayed = Date.now(); | ||
this.#scheduleTimeout = null; | ||
|
||
this.#schedule(); | ||
}; | ||
|
||
this.#audioSource.play(); | ||
} | ||
|
||
#schedule() { | ||
if (this.#audioSource.isPlaying || this.#scheduleTimeout) { | ||
return; | ||
} | ||
|
||
const silenceMin = this.#musicRecord.silenceIntervalMin[this.#ambience]; | ||
const silenceMax = this.#musicRecord.silenceIntervalMax[this.#ambience]; | ||
const silence = getRandomInt(silenceMin, silenceMax); | ||
|
||
// Truncate silence by last played | ||
const elapsed = this.#lastPlayed ? Date.now() - this.#lastPlayed : 0; | ||
const delay = Math.max(silence - elapsed, 0); | ||
|
||
this.#scheduleTimeout = setTimeout(() => this.#play(), delay); | ||
} | ||
} | ||
|
||
export default ZoneMusic; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
enum SOUND_AMBIENCE { | ||
AMBIENCE_DAY = 0, | ||
AMBIENCE_NIGHT, | ||
AMBIENCE_COUNT, | ||
} | ||
|
||
export { SOUND_AMBIENCE }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
const getRandomInt = (min: number, max: number) => { | ||
min = Math.ceil(min); | ||
max = Math.floor(max); | ||
return Math.floor(Math.random() * (max - min + 1)) + min; | ||
}; | ||
|
||
export { getRandomInt }; |