Merge remote-tracking branch 'origin/develop' into feat/mission-2
# Conflicts: # package-lock.json # package.json # src/App.tsx # src/components/three/interaction/CentralObject.tsx # src/components/three/interaction/VillageoisHelperObject.tsx # src/managers/GameStepManager.ts # src/stateManager/AudioManager.ts # src/world/World.tsx # src/world/player/PlayerController.tsx
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
|
||||
|
||||
interface AudioContextWindow extends Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||
music: 1,
|
||||
sfx: 1,
|
||||
dialogue: 1,
|
||||
};
|
||||
|
||||
interface PlaySoundOptions {
|
||||
category?: OneShotAudioCategory;
|
||||
pan?: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
interface StereoNodes {
|
||||
source: MediaElementAudioSourceNode;
|
||||
panner: StereoPannerNode;
|
||||
}
|
||||
|
||||
interface OneShotAudioState {
|
||||
category: OneShotAudioCategory;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export class AudioManager {
|
||||
private static _instance: AudioManager | null = null;
|
||||
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
|
||||
private readonly _stereoNodes = new WeakMap<HTMLAudioElement, StereoNodes>();
|
||||
private readonly _oneShotStates = new WeakMap<
|
||||
HTMLAudioElement,
|
||||
OneShotAudioState
|
||||
>();
|
||||
private readonly _categoryVolumes: Record<AudioCategory, number> = {
|
||||
...DEFAULT_CATEGORY_VOLUMES,
|
||||
};
|
||||
private _audioContext: AudioContext | null = null;
|
||||
private _music: HTMLAudioElement | null = null;
|
||||
private _musicPath: string | null = null;
|
||||
private _musicVolume = 1;
|
||||
private _musicUnlockHandler: (() => void) | null = null;
|
||||
|
||||
private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
|
||||
private static readonly DEFAULT_SOUND_CATEGORY: OneShotAudioCategory = "sfx";
|
||||
private static readonly IGNORED_PLAYBACK_ERRORS = new Set([
|
||||
"AbortError",
|
||||
"NotAllowedError",
|
||||
]);
|
||||
|
||||
static getInstance(): AudioManager {
|
||||
if (!AudioManager._instance) {
|
||||
AudioManager._instance = new AudioManager();
|
||||
}
|
||||
|
||||
return AudioManager._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
setCategoryVolume(category: AudioCategory, volume: number): void {
|
||||
this._categoryVolumes[category] = AudioManager._clampVolume(volume);
|
||||
|
||||
if (category === "music" && this._music) {
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateOneShotVolumes(category);
|
||||
}
|
||||
|
||||
getCategoryVolume(category: AudioCategory): number {
|
||||
return this._categoryVolumes[category];
|
||||
}
|
||||
|
||||
playSound(
|
||||
path: string,
|
||||
volume = 1,
|
||||
options: PlaySoundOptions = {},
|
||||
): HTMLAudioElement {
|
||||
const audio = this._acquireAudio(path);
|
||||
const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY;
|
||||
const baseVolume = AudioManager._clampVolume(volume);
|
||||
this._oneShotStates.set(audio, { category, volume: baseVolume });
|
||||
audio.volume = this._getEffectiveVolume(category, baseVolume);
|
||||
audio.playbackRate = options.playbackRate ?? 1;
|
||||
audio.currentTime = 0;
|
||||
this._setStereoPan(audio, options.pan ?? 0);
|
||||
|
||||
if (this._audioContext?.state === "suspended") {
|
||||
void this._audioContext.resume();
|
||||
}
|
||||
|
||||
void audio.play().catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("AudioManager", "Failed to play sound", {
|
||||
path,
|
||||
category,
|
||||
error: AudioManager._toLogValue(error),
|
||||
});
|
||||
});
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
playSoundWithCallback(
|
||||
path: string,
|
||||
volume: number,
|
||||
onEnded: () => void,
|
||||
options: PlaySoundOptions = {},
|
||||
): HTMLAudioElement {
|
||||
const audio = this.playSound(path, volume, options);
|
||||
audio.addEventListener("ended", onEnded, { once: true });
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
playMusic(path: string, volume = 1): void {
|
||||
this._musicVolume = AudioManager._clampVolume(volume);
|
||||
|
||||
if (this._musicPath === path && this._music) {
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
if (!this._music.paused) return;
|
||||
} else {
|
||||
this.stopMusic();
|
||||
this._music = new Audio(path);
|
||||
this._music.loop = true;
|
||||
this._musicPath = path;
|
||||
}
|
||||
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
|
||||
void this._music.play().catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
||||
) {
|
||||
this._waitForUserGestureToPlayMusic();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("AudioManager", "Failed to play music", {
|
||||
path,
|
||||
error: AudioManager._toLogValue(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stopMusic(): void {
|
||||
this._removeMusicUnlockHandler();
|
||||
this._music?.pause();
|
||||
this._music = null;
|
||||
this._musicPath = null;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopMusic();
|
||||
this._audioPools.forEach((pool) => {
|
||||
pool.forEach((audio) => {
|
||||
audio.pause();
|
||||
audio.src = "";
|
||||
});
|
||||
});
|
||||
this._audioPools.clear();
|
||||
void this._audioContext?.close();
|
||||
this._audioContext = null;
|
||||
AudioManager._instance = null;
|
||||
}
|
||||
|
||||
private _acquireAudio(path: string): HTMLAudioElement {
|
||||
const existingPool = this._audioPools.get(path);
|
||||
|
||||
if (existingPool) {
|
||||
const availableAudio = existingPool.find(
|
||||
(audio) => audio.paused || audio.ended,
|
||||
);
|
||||
if (availableAudio) return availableAudio;
|
||||
|
||||
if (existingPool.length < AudioManager.MAX_POOL_SIZE_PER_SOUND) {
|
||||
const pooledAudio = new Audio(path);
|
||||
existingPool.push(pooledAudio);
|
||||
return pooledAudio;
|
||||
}
|
||||
|
||||
const recycledAudio = existingPool[0];
|
||||
if (recycledAudio) return recycledAudio;
|
||||
}
|
||||
|
||||
const initialAudio = new Audio(path);
|
||||
this._audioPools.set(path, [initialAudio]);
|
||||
return initialAudio;
|
||||
}
|
||||
|
||||
private _waitForUserGestureToPlayMusic(): void {
|
||||
if (this._musicUnlockHandler) return;
|
||||
|
||||
this._musicUnlockHandler = () => {
|
||||
this._removeMusicUnlockHandler();
|
||||
const music = this._music;
|
||||
if (!music) return;
|
||||
|
||||
void music.play().catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("AudioManager", "Failed to unlock music playback", {
|
||||
path: this._musicPath,
|
||||
error: AudioManager._toLogValue(error),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", this._musicUnlockHandler, {
|
||||
once: true,
|
||||
});
|
||||
window.addEventListener("keydown", this._musicUnlockHandler, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
private _removeMusicUnlockHandler(): void {
|
||||
if (!this._musicUnlockHandler) return;
|
||||
|
||||
window.removeEventListener("pointerdown", this._musicUnlockHandler);
|
||||
window.removeEventListener("keydown", this._musicUnlockHandler);
|
||||
this._musicUnlockHandler = null;
|
||||
}
|
||||
|
||||
private _setStereoPan(audio: HTMLAudioElement, pan: number): void {
|
||||
const audioContext = this._getAudioContext();
|
||||
if (!audioContext || !("createStereoPanner" in audioContext)) return;
|
||||
|
||||
let nodes = this._stereoNodes.get(audio);
|
||||
if (!nodes) {
|
||||
nodes = {
|
||||
source: audioContext.createMediaElementSource(audio),
|
||||
panner: audioContext.createStereoPanner(),
|
||||
};
|
||||
nodes.source.connect(nodes.panner).connect(audioContext.destination);
|
||||
this._stereoNodes.set(audio, nodes);
|
||||
}
|
||||
|
||||
nodes.panner.pan.value = AudioManager._clampPan(pan);
|
||||
}
|
||||
|
||||
private _getAudioContext(): AudioContext | null {
|
||||
if (this._audioContext) return this._audioContext;
|
||||
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext ??
|
||||
(window as AudioContextWindow).webkitAudioContext ??
|
||||
null;
|
||||
if (!AudioContextConstructor) return null;
|
||||
|
||||
this._audioContext = new AudioContextConstructor();
|
||||
return this._audioContext;
|
||||
}
|
||||
|
||||
private _getEffectiveVolume(category: AudioCategory, volume: number): number {
|
||||
return AudioManager._clampVolume(volume) * this._categoryVolumes[category];
|
||||
}
|
||||
|
||||
private _updateOneShotVolumes(category: AudioCategory): void {
|
||||
if (category === "music") return;
|
||||
|
||||
this._audioPools.forEach((pool) => {
|
||||
pool.forEach((audio) => {
|
||||
const state = this._oneShotStates.get(audio);
|
||||
if (!state || state.category !== category) return;
|
||||
|
||||
audio.volume = this._getEffectiveVolume(category, state.volume);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static _clampPan(pan: number): number {
|
||||
return Math.max(-1, Math.min(1, pan));
|
||||
}
|
||||
|
||||
private static _clampVolume(volume: number): number {
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
private static _toLogValue(error: unknown): Error | DOMException | string {
|
||||
if (error instanceof Error || error instanceof DOMException) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user