Merge branch 'develop' into feat/repair-game

This commit is contained in:
Tom Boullay
2026-05-11 17:31:14 +02:00
48 changed files with 5816 additions and 35 deletions
+129 -4
View File
@@ -1,17 +1,53 @@
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",
@@ -27,11 +63,38 @@ export class AudioManager {
private constructor() {}
playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void {
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);
audio.volume = Math.max(0, Math.min(1, volume));
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 (
@@ -43,14 +106,19 @@ export class AudioManager {
logger.error("AudioManager", "Failed to play sound", {
path,
category,
error: AudioManager._toLogValue(error),
});
});
return audio;
}
playMusic(path: string, volume = 1): void {
this._musicVolume = AudioManager._clampVolume(volume);
if (this._musicPath === path && this._music) {
this._music.volume = Math.max(0, Math.min(1, volume));
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
if (!this._music.paused) return;
} else {
this.stopMusic();
@@ -59,7 +127,7 @@ export class AudioManager {
this._musicPath = path;
}
this._music.volume = Math.max(0, Math.min(1, volume));
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
void this._music.play().catch((error: unknown) => {
if (
@@ -93,6 +161,8 @@ export class AudioManager {
});
});
this._audioPools.clear();
void this._audioContext?.close();
this._audioContext = null;
AudioManager._instance = null;
}
@@ -159,6 +229,61 @@ export class AudioManager {
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;
+4
View File
@@ -21,6 +21,7 @@ interface MissionState {
interface GameState {
mainState: MainGameState;
isCinematicPlaying: boolean;
intro: IntroState;
bike: MissionState & {
isRepaired: boolean;
@@ -39,6 +40,7 @@ interface GameState {
interface GameActions {
setMainState: (mainState: MainGameState) => void;
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
setIntroState: (intro: Partial<IntroState>) => void;
setBikeState: (bike: Partial<GameState["bike"]>) => void;
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
@@ -222,6 +224,7 @@ function startOutroState(state: GameState): GameStateUpdate {
function createInitialGameState(): GameState {
return {
mainState: "intro",
isCinematicPlaying: false,
intro: {
dialogueAudio: null,
hasCompleted: false,
@@ -252,6 +255,7 @@ function createInitialGameState(): GameState {
export const useGameStore = create<GameStore>()((set) => ({
...createInitialGameState(),
setMainState: (mainState) => set({ mainState }),
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
setIntroState: (intro) =>
set((state) => ({ intro: { ...state.intro, ...intro } })),
setBikeState: (bike) =>
+87
View File
@@ -0,0 +1,87 @@
import { create } from "zustand";
import { AudioManager } from "@/managers/AudioManager";
import type { AudioCategory } from "@/managers/AudioManager";
export type SubtitleLanguage = "fr" | "en";
export type RepairRuntime = "js" | "python";
interface SettingsState {
isSettingsMenuOpen: boolean;
musicVolume: number;
sfxVolume: number;
dialogueVolume: number;
subtitlesEnabled: boolean;
subtitleLanguage: SubtitleLanguage;
repairRuntime: RepairRuntime;
}
interface SettingsActions {
setSettingsMenuOpen: (open: boolean) => void;
setMusicVolume: (volume: number) => void;
setSfxVolume: (volume: number) => void;
setDialogueVolume: (volume: number) => void;
setSubtitlesEnabled: (enabled: boolean) => void;
setSubtitleLanguage: (language: SubtitleLanguage) => void;
setRepairRuntime: (runtime: RepairRuntime) => void;
resetSettings: () => void;
}
type SettingsStore = SettingsState & SettingsActions;
const DEFAULT_SETTINGS: SettingsState = {
isSettingsMenuOpen: false,
musicVolume: 1,
sfxVolume: 1,
dialogueVolume: 1,
subtitlesEnabled: true,
subtitleLanguage: "fr",
repairRuntime: "js",
};
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, volume));
}
function setAudioCategoryVolume(
category: AudioCategory,
volume: number,
): number {
const nextVolume = clampVolume(volume);
AudioManager.getInstance().setCategoryVolume(category, nextVolume);
return nextVolume;
}
function applyDefaultAudioSettings(): void {
AudioManager.getInstance().setCategoryVolume(
"music",
DEFAULT_SETTINGS.musicVolume,
);
AudioManager.getInstance().setCategoryVolume(
"sfx",
DEFAULT_SETTINGS.sfxVolume,
);
AudioManager.getInstance().setCategoryVolume(
"dialogue",
DEFAULT_SETTINGS.dialogueVolume,
);
}
applyDefaultAudioSettings();
export const useSettingsStore = create<SettingsStore>()((set) => ({
...DEFAULT_SETTINGS,
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
setMusicVolume: (volume) =>
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
setSfxVolume: (volume) =>
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
setDialogueVolume: (volume) =>
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
setRepairRuntime: (repairRuntime) => set({ repairRuntime }),
resetSettings: () => {
applyDefaultAudioSettings();
set(DEFAULT_SETTINGS);
},
}));
+24
View File
@@ -0,0 +1,24 @@
import { create } from "zustand";
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
interface ActiveSubtitle {
speaker: DialogueSpeaker;
text: string;
}
interface SubtitleState {
activeSubtitle: ActiveSubtitle | null;
}
interface SubtitleActions {
setActiveSubtitle: (subtitle: ActiveSubtitle | null) => void;
clearActiveSubtitle: () => void;
}
type SubtitleStore = SubtitleState & SubtitleActions;
export const useSubtitleStore = create<SubtitleStore>()((set) => ({
activeSubtitle: null,
setActiveSubtitle: (activeSubtitle) => set({ activeSubtitle }),
clearActiveSubtitle: () => set({ activeSubtitle: null }),
}));