diff --git a/GAME_FLOW.md b/GAME_FLOW.md new file mode 100644 index 0000000..3b6cc0b --- /dev/null +++ b/GAME_FLOW.md @@ -0,0 +1,114 @@ +# Game Flow - La Fabrik + +## Étapes du jeu + +``` +intro → start-intro → naming → bienvenue → star-move → outOfFabrik +``` + +--- + +## Détail des étapes + +### 1. `intro` (initial) + +- État initial au chargement du jeu +- Aucune action, juste une étape de départ + +### 2. `start-intro` + +- **Déclenchement** : Auto-transition depuis `intro` quand la scène est chargée +- **Action** : Joue l'audio d'intro via `AudioManager.playSoundWithCallback()` +- **Attente** : Attend que l'audio se termine +- **Transition** : Vers `naming` quand l'audio se termine + +### 3. `naming` + +- **Déclenchement** : Quand l'audio d'intro se termine +- **Action** : Affiche un input pour demander le prénom du joueur +- **Attente** : L'utilisateur entre son prénom et valide +- **Transition** : Vers `bienvenue` quand l'utilisateur valide + +### 4. `bienvenue` + +- **Déclenchement** : Quand l'utilisateur valide son prénom +- **Actions** : + - Affiche "Bienvenue {prénom} !" à l'écran + - Joue l'audio de bienvenue +- **Attente** : Attend que l'audio se termine +- **Transition** : Vers `star-move` quand l'audio se termine + +### 5. `star-move` + +- **Déclenchement** : Quand l'audio de bienvenue se termine +- **Action** : Active le mouvement du joueur (`setCanMove(true)`) +- **État** : Le joueur peut maintenant se déplacer librement +- **Zone** : La détection de zone devient active (ZoneDetection) + +### 6. `outOfFabrik` + +- **Déclenchement** : Quand le joueur entre dans la zone de sortie +- **Action** : Transition vers l'étape finale +- **Zone** : Détectée par `ZoneDetection` quand le joueur approche de la position configurée + +--- + +## Fichiers clés + +| Fichier | Rôle | +| ---------------------------------------- | ------------------------------------------------------------- | +| `src/stateManager/GameStepManager.ts` | Gère l'état global du jeu (étape actuelle, prénom, mouvement) | +| `src/components/game/GameFlow.tsx` | Gère les transitions automatiques et la lecture audio | +| `src/components/ui/IntroUI.tsx` | Affiche l'input pour le prénom | +| `src/components/ui/BienvenueDisplay.tsx` | Affiche le message de bienvenue | +| `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone | +| `src/data/audioConfig.ts` | Chemins des fichiers audio | +| `src/data/zones.ts` | Configuration des zones de transition | + +--- + +## Configuration audio + +```typescript +// src/data/audioConfig.ts +export const AUDIO_PATHS = { + intro: "/sounds/fa.mp3", // Audio joué pendant start-intro + bienvenue: "/sounds/fa.mp3", // Audio joué pendant bienvenue +}; +``` + +--- + +## Configuration des zones + +```typescript +// src/data/zones.ts +export const ZONES: Zone[] = [ + { + id: "fabrikExit", + position: [50, 0, 50], // Position de la zone de sortie + radius: 10, // Rayon de détection + height: 20, // Hauteur de la zone (pour la visualisation) + targetStep: "outOfFabrik", // Étape cible quand on entre dans la zone + }, +]; +``` + +--- + +## Debug + +En mode debug (`?debug` dans l'URL), on peut voir : + +- **Game Step** : L'étape actuelle dans le panneau lil-gui +- **Player Position** : Position X, Y, Z du joueur en temps réel +- **Zone Visualization** : Anneaux visuels au sol pour les zones + +--- + +## Notes techniques + +- Le mouvement du joueur est bloqué tant que `canMove` est `false` +- `useSyncExternalStore` est utilisé pour synchroniser l'état du jeu avec React +- Les transitions sont gérées par le `GameStepManager` via le pattern singleton +- L'audio utilise un callback `onEnded` pour déclencher les transitions automatiques diff --git a/src/App.tsx b/src/App.tsx index 5879bcd..0066013 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { Suspense } from "react"; import { Canvas } from "@react-three/fiber"; import { Crosshair } from "@/components/ui/Crosshair"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; +import { IntroUI, BienvenueDisplay } from "@/components/ui/IntroUI"; import { DebugPerf } from "@/utils/debug/DebugPerf"; import { World } from "@/world/World"; @@ -16,6 +17,8 @@ function App(): React.JSX.Element { + + ); } diff --git a/src/components/game/GameFlow.tsx b/src/components/game/GameFlow.tsx new file mode 100644 index 0000000..597382e --- /dev/null +++ b/src/components/game/GameFlow.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from "react"; +import { GameStepManager } from "@/stateManager/GameStepManager"; +import { AudioManager } from "@/stateManager/AudioManager"; +import { AUDIO_PATHS } from "@/data/audioConfig"; + +export function GameFlow(): null { + const manager = GameStepManager.getInstance(); + const hasInitialized = useRef(false); + const [step, setStep] = useState(manager.getStep()); + + useEffect(() => { + const unsubscribe = manager.subscribe(() => { + setStep(manager.getStep()); + }); + return unsubscribe; + }, [manager]); + + useEffect(() => { + console.log("[GameFlow] Current step:", step); + if (!hasInitialized.current && step === "intro") { + hasInitialized.current = true; + console.log("[GameFlow] Transition to start-intro"); + manager.transitionTo("start-intro"); + } + }, [step, manager]); + + useEffect(() => { + console.log("[GameFlow] useEffect triggered, step:", step); + + if (step === "start-intro") { + console.log("[GameFlow] Playing intro audio"); + const audio = AudioManager.getInstance(); + audio.playSoundWithCallback(AUDIO_PATHS.intro, 0.5, () => { + console.log("[GameFlow] Intro audio ended, transition to naming"); + manager.transitionTo("naming"); + }); + + return () => {}; + } + + if (step === "bienvenue") { + console.log("[GameFlow] Playing bienvenue audio"); + const audio = AudioManager.getInstance(); + audio.playSoundWithCallback(AUDIO_PATHS.bienvenue, 0.5, () => { + console.log("[GameFlow] Bienvenue audio ended, enable movement"); + manager.setCanMove(true); + manager.transitionTo("star-move"); + }); + + return () => {}; + } + + return undefined; + }, [step, manager]); + + return null; +} diff --git a/src/components/ui/IntroUI.tsx b/src/components/ui/IntroUI.tsx new file mode 100644 index 0000000..10ffaad --- /dev/null +++ b/src/components/ui/IntroUI.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { useGameStep } from "@/hooks/useGameStep"; + +export function IntroUI(): React.JSX.Element | null { + const { step, setPlayerName, transitionTo } = useGameStep(); + const [inputValue, setInputValue] = useState(""); + + if (step !== "naming") return null; + + const handleSubmit = (): void => { + if (inputValue.trim() === "") return; + + console.log("[IntroUI] Submitting, name:", inputValue.trim()); + setPlayerName(inputValue.trim()); + console.log("[IntroUI] Calling transitionTo('bienvenue')"); + transitionTo("bienvenue"); + console.log("[IntroUI] After transitionTo, step should be:", step); + }; + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Enter") { + handleSubmit(); + } + }; + + return ( +
+
+

+ Quel est votre prénom ? +

+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Votre prénom" + autoFocus + style={{ + padding: "0.75rem", + fontSize: "1rem", + borderRadius: "6px", + border: "1px solid #444", + backgroundColor: "#2a2a2a", + color: "#fff", + outline: "none", + }} + /> + +
+
+ ); +} + +export function BienvenueDisplay(): React.JSX.Element | null { + const { step, playerName } = useGameStep(); + + if (step !== "bienvenue") return null; + + return ( +
+

+ Bienvenue {playerName} ! +

+
+ ); +} diff --git a/src/data/audioConfig.ts b/src/data/audioConfig.ts new file mode 100644 index 0000000..6a6edd1 --- /dev/null +++ b/src/data/audioConfig.ts @@ -0,0 +1,4 @@ +export const AUDIO_PATHS = { + intro: "/sounds/fa.mp3", + bienvenue: "/sounds/fa.mp3", +} as const; diff --git a/src/hooks/useGameStep.ts b/src/hooks/useGameStep.ts index 03d07ef..a956f1e 100644 --- a/src/hooks/useGameStep.ts +++ b/src/hooks/useGameStep.ts @@ -5,8 +5,7 @@ import type { GameStepSnapshot } from "@/types/game"; const manager = GameStepManager.getInstance(); export function useGameStep(): GameStepSnapshot { - return useSyncExternalStore(manager.subscribe.bind(manager), () => ({ - step: manager.getStep(), - transitionTo: manager.transitionTo.bind(manager), - })); + return useSyncExternalStore(manager.subscribe.bind(manager), () => + manager.getSnapshot(), + ); } diff --git a/src/stateManager/AudioManager.ts b/src/stateManager/AudioManager.ts index 1fcc256..4e59ca5 100644 --- a/src/stateManager/AudioManager.ts +++ b/src/stateManager/AudioManager.ts @@ -40,6 +40,46 @@ export class AudioManager { }); } + playSoundWithCallback( + path: string, + volume: number, + onEnded: () => void, + ): void { + console.log("[AudioManager] playSoundWithCallback:", path); + const audio = new Audio(path); + audio.volume = Math.max(0, Math.min(1, volume)); + audio.currentTime = 0; + + audio.addEventListener("ended", () => { + console.log("[AudioManager] Audio ended:", path); + onEnded(); + }); + + audio.addEventListener("error", (e) => { + console.error("[AudioManager] Audio error:", path, e); + }); + + audio + .play() + .then(() => { + console.log("[AudioManager] Audio playing:", path); + }) + .catch((error: unknown) => { + console.error("[AudioManager] Play failed:", path, error); + if ( + error instanceof DOMException && + AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name) + ) { + return; + } + + logger.error("AudioManager", "Failed to play sound", { + path, + error: AudioManager._toLogValue(error), + }); + }); + } + destroy(): void { this._audioPools.forEach((pool) => { pool.forEach((audio) => { diff --git a/src/stateManager/GameStepManager.ts b/src/stateManager/GameStepManager.ts index aae5527..8d56988 100644 --- a/src/stateManager/GameStepManager.ts +++ b/src/stateManager/GameStepManager.ts @@ -1,10 +1,13 @@ -import type { GameStep } from "@/types/game"; +import type { GameStep, GameStepSnapshot } from "@/types/game"; 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) { @@ -20,10 +23,48 @@ export class GameStepManager { 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; + this._emit(); + } + + setPlayerName(name: string): void { + if (this._playerName === name) return; + + this._playerName = name; + this._cachedSnapshot = null; + this._emit(); + } + + setCanMove(canMove: boolean): void { + if (this._canMove === canMove) return; + + this._canMove = canMove; + this._cachedSnapshot = null; this._emit(); } @@ -37,7 +78,10 @@ export class GameStepManager { destroy(): void { this._currentStep = "intro"; + this._playerName = ""; + this._canMove = false; this._listeners.clear(); + this._cachedSnapshot = null; GameStepManager._instance = null; } diff --git a/src/types/game.ts b/src/types/game.ts index 5f79fe5..5f5e397 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -1,6 +1,12 @@ import type { Vector3Tuple } from "@/types/3d"; -export type GameStep = "intro" | "outOfFabrik"; +export type GameStep = + | "intro" + | "start-intro" + | "naming" + | "bienvenue" + | "star-move" + | "outOfFabrik"; export interface Zone { id: string; @@ -16,5 +22,8 @@ export interface GameState { export interface GameStepSnapshot { step: GameStep; + playerName: string; + canMove: boolean; transitionTo: (step: GameStep) => void; + setPlayerName: (name: string) => void; } diff --git a/src/world/World.tsx b/src/world/World.tsx index ab24ce6..798af29 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -10,6 +10,7 @@ import { ZoneDebugVisuals, ZoneDetection, } from "@/components/zone/ZoneDetection"; +import { GameFlow } from "@/components/game/GameFlow"; import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls"; import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers"; import { Environment } from "@/world/Environment"; @@ -34,6 +35,7 @@ export function World(): React.JSX.Element { + {cameraMode === "debug" ? : null} {sceneMode === "game" ? ( diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index 341c6d9..50ff41d 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -24,6 +24,7 @@ import { PLAYER_XZ_DAMPING_FACTOR, } from "@/data/playerConfig"; import { InteractionManager } from "@/stateManager/InteractionManager"; +import { GameStepManager } from "@/stateManager/GameStepManager"; import type { Vector3Tuple } from "@/types/3d"; type Keys = { @@ -63,6 +64,7 @@ export function PlayerController({ const velocity = useRef(new THREE.Vector3()); const onFloor = useRef(false); const wantsJump = useRef(false); + const gameStepManager = GameStepManager.getInstance(); const capsule = useRef( new Capsule( @@ -165,6 +167,12 @@ export function PlayerController({ }, []); useFrame((_, delta) => { + if (!gameStepManager.canMove()) { + velocity.current.set(0, 0, 0); + camera.position.copy(capsule.current.end); + return; + } + const dt = Math.min(delta, PLAYER_MAX_DELTA); camera.getWorldDirection(_forward);