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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { GameStep, GameStepSnapshot } from "@/types/game";
|
||||
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
|
||||
|
||||
export class GameStepManager {
|
||||
private static _instance: GameStepManager | null = null;
|
||||
|
||||
private _currentStep: GameStep = "intro";
|
||||
private _playerName = "";
|
||||
private _canMove = false;
|
||||
private readonly _listeners = new Set<() => void>();
|
||||
private _cachedSnapshot: GameStepSnapshot | null = null;
|
||||
|
||||
static getInstance(): GameStepManager {
|
||||
if (!GameStepManager._instance) {
|
||||
GameStepManager._instance = new GameStepManager();
|
||||
}
|
||||
|
||||
return GameStepManager._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
getStep(): GameStep {
|
||||
return this._currentStep;
|
||||
}
|
||||
|
||||
getPlayerName(): string {
|
||||
return this._playerName;
|
||||
}
|
||||
|
||||
canMove(): boolean {
|
||||
return this._canMove;
|
||||
}
|
||||
|
||||
getSnapshot(): GameStepSnapshot {
|
||||
if (!this._cachedSnapshot) {
|
||||
this._cachedSnapshot = {
|
||||
step: this._currentStep,
|
||||
playerName: this._playerName,
|
||||
canMove: this._canMove,
|
||||
transitionTo: this.transitionTo.bind(this),
|
||||
setPlayerName: this.setPlayerName.bind(this),
|
||||
};
|
||||
}
|
||||
return this._cachedSnapshot;
|
||||
}
|
||||
|
||||
transitionTo(step: GameStep): void {
|
||||
if (this._currentStep === step) return;
|
||||
|
||||
this._currentStep = step;
|
||||
this._cachedSnapshot = null;
|
||||
useMissionFlowStore.getState().setStep(step);
|
||||
this._emit();
|
||||
}
|
||||
|
||||
setPlayerName(name: string): void {
|
||||
if (this._playerName === name) return;
|
||||
|
||||
this._playerName = name;
|
||||
this._cachedSnapshot = null;
|
||||
useMissionFlowStore.getState().setPlayerName(name);
|
||||
this._emit();
|
||||
}
|
||||
|
||||
setCanMove(canMove: boolean): void {
|
||||
if (this._canMove === canMove) return;
|
||||
|
||||
this._canMove = canMove;
|
||||
this._cachedSnapshot = null;
|
||||
useMissionFlowStore.getState().setCanMove(canMove);
|
||||
this._emit();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this._listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this._listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this._currentStep = "intro";
|
||||
this._playerName = "";
|
||||
this._canMove = false;
|
||||
this._listeners.clear();
|
||||
this._cachedSnapshot = null;
|
||||
GameStepManager._instance = null;
|
||||
}
|
||||
|
||||
private _emit(): void {
|
||||
this._listeners.forEach((cb) => cb());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
GrabInteractableHandle,
|
||||
InteractableHandle,
|
||||
InteractionSnapshot,
|
||||
} from "@/types/interaction/interaction";
|
||||
import { EventEmitter } from "@/utils/core/EventEmitter";
|
||||
|
||||
interface InteractionManagerEvents {
|
||||
change: void;
|
||||
}
|
||||
|
||||
export class InteractionManager {
|
||||
private static _instance: InteractionManager | null = null;
|
||||
|
||||
private _focused: InteractableHandle | null = null;
|
||||
private readonly _nearbyHandles = new Set<InteractableHandle>();
|
||||
private _holding = false;
|
||||
private _handHolding = false;
|
||||
private _holdingHandle: GrabInteractableHandle | null = null;
|
||||
private _snapshot: InteractionSnapshot = {
|
||||
focused: null,
|
||||
nearby: false,
|
||||
holding: false,
|
||||
handHolding: false,
|
||||
};
|
||||
private readonly _events = new EventEmitter<InteractionManagerEvents>();
|
||||
|
||||
static getInstance(): InteractionManager {
|
||||
if (!InteractionManager._instance) {
|
||||
InteractionManager._instance = new InteractionManager();
|
||||
}
|
||||
|
||||
return InteractionManager._instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
getState(): InteractionSnapshot {
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
setFocused(handle: InteractableHandle | null): void {
|
||||
if (this._focused === handle) return;
|
||||
if (this._holding) return;
|
||||
|
||||
this._focused = handle;
|
||||
this._emit();
|
||||
}
|
||||
|
||||
setNearby(handle: InteractableHandle, nearby: boolean): void {
|
||||
const hadHandle = this._nearbyHandles.has(handle);
|
||||
if (nearby === hadHandle) return;
|
||||
|
||||
if (nearby) {
|
||||
this._nearbyHandles.add(handle);
|
||||
} else {
|
||||
this._nearbyHandles.delete(handle);
|
||||
}
|
||||
|
||||
this._emit();
|
||||
}
|
||||
|
||||
setHandHolding(holding: boolean): void {
|
||||
if (this._handHolding === holding) return;
|
||||
|
||||
this._handHolding = holding;
|
||||
this._emit();
|
||||
}
|
||||
|
||||
pressInteract(): void {
|
||||
if (!this._focused) return;
|
||||
|
||||
if (this._focused.kind === "grab") {
|
||||
this._holding = true;
|
||||
this._holdingHandle = this._focused;
|
||||
} else {
|
||||
this._holding = false;
|
||||
this._holdingHandle = null;
|
||||
}
|
||||
|
||||
this._focused.onPress();
|
||||
this._emit();
|
||||
}
|
||||
|
||||
releaseInteract(): void {
|
||||
const handle = this._holding ? this._holdingHandle : null;
|
||||
if (!handle) return;
|
||||
|
||||
handle.onRelease();
|
||||
this._holding = false;
|
||||
this._holdingHandle = null;
|
||||
this._emit();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this._events.on("change", listener);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this._focused = null;
|
||||
this._nearbyHandles.clear();
|
||||
this._holding = false;
|
||||
this._handHolding = false;
|
||||
this._holdingHandle = null;
|
||||
this._snapshot = {
|
||||
focused: null,
|
||||
nearby: false,
|
||||
holding: false,
|
||||
handHolding: false,
|
||||
};
|
||||
this._events.clear();
|
||||
InteractionManager._instance = null;
|
||||
}
|
||||
|
||||
private _emit(): void {
|
||||
this._snapshot = {
|
||||
focused: this._focused,
|
||||
nearby: this._nearbyHandles.size > 0,
|
||||
holding: this._holding,
|
||||
handHolding: this._handHolding,
|
||||
};
|
||||
this._events.emit("change", undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
isRepairMissionId,
|
||||
type MissionStep,
|
||||
type RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
|
||||
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
||||
export type { MissionStep, RepairMissionId };
|
||||
|
||||
interface IntroState {
|
||||
dialogueAudio: string | null;
|
||||
hasCompleted: boolean;
|
||||
isBikeUnlocked: boolean;
|
||||
}
|
||||
|
||||
interface MissionState {
|
||||
currentStep: MissionStep;
|
||||
dialogueAudio: string | null;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
mainState: MainGameState;
|
||||
isCinematicPlaying: boolean;
|
||||
intro: IntroState;
|
||||
bike: MissionState & {
|
||||
isRepaired: boolean;
|
||||
};
|
||||
pylone: MissionState & {
|
||||
isPowered: boolean;
|
||||
};
|
||||
ferme: MissionState & {
|
||||
irrigationFixed: boolean;
|
||||
};
|
||||
outro: {
|
||||
dialogueAudio: string | null;
|
||||
hasStarted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
|
||||
setOutroState: (outro: Partial<GameState["outro"]>) => void;
|
||||
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
|
||||
completeIntro: () => void;
|
||||
completeBike: () => void;
|
||||
completePylone: () => void;
|
||||
completeFerme: () => void;
|
||||
completeMission: (mission: RepairMissionId) => void;
|
||||
startOutro: () => void;
|
||||
advanceGameState: () => void;
|
||||
rewindGameState: () => void;
|
||||
resetGame: () => void;
|
||||
}
|
||||
|
||||
type GameStore = GameState & GameActions;
|
||||
type GameStateUpdate = Partial<GameState>;
|
||||
|
||||
function getNextMissionStep(step: MissionStep): MissionStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
return "waiting";
|
||||
case "waiting":
|
||||
return "inspected";
|
||||
case "inspected":
|
||||
return "fragmented";
|
||||
case "fragmented":
|
||||
return "scanning";
|
||||
case "scanning":
|
||||
return "repairing";
|
||||
case "repairing":
|
||||
return "reassembling";
|
||||
case "reassembling":
|
||||
case "done":
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
case "waiting":
|
||||
return "locked";
|
||||
case "inspected":
|
||||
return "waiting";
|
||||
case "fragmented":
|
||||
return "inspected";
|
||||
case "scanning":
|
||||
return "fragmented";
|
||||
case "repairing":
|
||||
return "scanning";
|
||||
case "reassembling":
|
||||
return "repairing";
|
||||
case "done":
|
||||
return "reassembling";
|
||||
}
|
||||
}
|
||||
|
||||
function completeIntroState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "bike",
|
||||
intro: {
|
||||
...state.intro,
|
||||
hasCompleted: true,
|
||||
isBikeUnlocked: true,
|
||||
},
|
||||
bike: {
|
||||
...state.bike,
|
||||
currentStep: "waiting",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeBikeState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "pylone",
|
||||
bike: {
|
||||
...state.bike,
|
||||
currentStep: "done",
|
||||
isRepaired: true,
|
||||
},
|
||||
pylone: {
|
||||
...state.pylone,
|
||||
currentStep: "waiting",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completePyloneState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "ferme",
|
||||
pylone: {
|
||||
...state.pylone,
|
||||
currentStep: "done",
|
||||
isPowered: true,
|
||||
},
|
||||
ferme: {
|
||||
...state.ferme,
|
||||
currentStep: "waiting",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeFermeState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "outro",
|
||||
ferme: {
|
||||
...state.ferme,
|
||||
currentStep: "done",
|
||||
irrigationFixed: true,
|
||||
},
|
||||
outro: {
|
||||
...state.outro,
|
||||
hasStarted: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setMissionStepState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
step: MissionStep,
|
||||
): GameStateUpdate {
|
||||
return {
|
||||
[mission]: {
|
||||
...state[mission],
|
||||
currentStep: step,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
switch (mission) {
|
||||
case "bike":
|
||||
return completeBikeState(state);
|
||||
case "pylone":
|
||||
return completePyloneState(state);
|
||||
case "ferme":
|
||||
return completeFermeState(state);
|
||||
}
|
||||
}
|
||||
|
||||
function advanceRepairMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
const nextStep = getNextMissionStep(state[mission].currentStep);
|
||||
if (nextStep === "done") {
|
||||
return completeMissionState(state, mission);
|
||||
}
|
||||
|
||||
return setMissionStepState(state, mission, nextStep);
|
||||
}
|
||||
|
||||
function rewindRepairMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
return setMissionStepState(
|
||||
state,
|
||||
mission,
|
||||
getPreviousMissionStep(state[mission].currentStep),
|
||||
);
|
||||
}
|
||||
|
||||
function startOutroState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "outro",
|
||||
outro: {
|
||||
...state.outro,
|
||||
hasStarted: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialGameState(): GameState {
|
||||
return {
|
||||
mainState: "intro",
|
||||
isCinematicPlaying: false,
|
||||
intro: {
|
||||
dialogueAudio: null,
|
||||
hasCompleted: false,
|
||||
isBikeUnlocked: false,
|
||||
},
|
||||
bike: {
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
isRepaired: false,
|
||||
},
|
||||
pylone: {
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
isPowered: false,
|
||||
},
|
||||
ferme: {
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
irrigationFixed: false,
|
||||
},
|
||||
outro: {
|
||||
dialogueAudio: null,
|
||||
hasStarted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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) =>
|
||||
set((state) => ({ bike: { ...state.bike, ...bike } })),
|
||||
setPyloneState: (pylone) =>
|
||||
set((state) => ({ pylone: { ...state.pylone, ...pylone } })),
|
||||
setFermeState: (ferme) =>
|
||||
set((state) => ({ ferme: { ...state.ferme, ...ferme } })),
|
||||
setOutroState: (outro) =>
|
||||
set((state) => ({ outro: { ...state.outro, ...outro } })),
|
||||
setMissionStep: (mission, step) =>
|
||||
set((state) => setMissionStepState(state, mission, step)),
|
||||
completeIntro: () => set(completeIntroState),
|
||||
completeBike: () => set((state) => completeMissionState(state, "bike")),
|
||||
completePylone: () => set((state) => completeMissionState(state, "pylone")),
|
||||
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
|
||||
completeMission: (mission) =>
|
||||
set((state) => completeMissionState(state, mission)),
|
||||
startOutro: () => set(startOutroState),
|
||||
advanceGameState: () =>
|
||||
set((state) => {
|
||||
if (state.mainState === "intro") {
|
||||
return completeIntroState(state);
|
||||
}
|
||||
|
||||
if (isRepairMissionId(state.mainState)) {
|
||||
return advanceRepairMissionState(state, state.mainState);
|
||||
}
|
||||
|
||||
return startOutroState(state);
|
||||
}),
|
||||
rewindGameState: () =>
|
||||
set((state) => {
|
||||
if (state.mainState === "intro") {
|
||||
return { intro: { ...state.intro, hasCompleted: false } };
|
||||
}
|
||||
|
||||
if (isRepairMissionId(state.mainState)) {
|
||||
return rewindRepairMissionState(state, state.mainState);
|
||||
}
|
||||
|
||||
return { outro: { ...state.outro, hasStarted: false } };
|
||||
}),
|
||||
resetGame: () => set(createInitialGameState()),
|
||||
}));
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from "zustand";
|
||||
import type { GameStep } from "@/types/game";
|
||||
|
||||
interface MissionFlowState {
|
||||
activityCity: boolean;
|
||||
canMove: boolean;
|
||||
dialogMessage: string | null;
|
||||
playerName: string;
|
||||
step: GameStep;
|
||||
}
|
||||
|
||||
interface MissionFlowActions {
|
||||
hideDialog: () => void;
|
||||
setActivityCity: (value: boolean) => void;
|
||||
setCanMove: (canMove: boolean) => void;
|
||||
setPlayerName: (name: string) => void;
|
||||
setStep: (step: GameStep) => void;
|
||||
showDialog: (message: string) => void;
|
||||
}
|
||||
|
||||
export const useMissionFlowStore = create<
|
||||
MissionFlowState & MissionFlowActions
|
||||
>((set) => ({
|
||||
activityCity: true,
|
||||
canMove: false,
|
||||
dialogMessage: null,
|
||||
playerName: "",
|
||||
step: "intro",
|
||||
hideDialog: () => set({ dialogMessage: null }),
|
||||
setActivityCity: (activityCity) => set({ activityCity }),
|
||||
setCanMove: (canMove) => set({ canMove }),
|
||||
setPlayerName: (playerName) => set({ playerName }),
|
||||
setStep: (step) => set({ step }),
|
||||
showDialog: (dialogMessage) => set({ dialogMessage }),
|
||||
}));
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user