From 41f7b2ad19de0d73db3dcb8489a5342aff8b9bf7 Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Mon, 11 May 2026 10:28:39 +0200 Subject: [PATCH 1/9] update step --- src/components/zone/ZoneDetection.tsx | 151 ++++++++++++++++++++++++++ src/data/zones.ts | 12 ++ src/hooks/useGameStep.ts | 12 ++ src/stateManager/GameStepManager.ts | 47 ++++++++ src/types/game.ts | 20 ++++ src/world/World.tsx | 6 + 6 files changed, 248 insertions(+) create mode 100644 src/components/zone/ZoneDetection.tsx create mode 100644 src/data/zones.ts create mode 100644 src/hooks/useGameStep.ts create mode 100644 src/stateManager/GameStepManager.ts create mode 100644 src/types/game.ts diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx new file mode 100644 index 0000000..9a53d29 --- /dev/null +++ b/src/components/zone/ZoneDetection.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import * as THREE from "three"; +import { ZONES } from "@/data/zones"; +import { GameStepManager } from "@/stateManager/GameStepManager"; +import { Debug } from "@/utils/debug/Debug"; + +const _playerPos = new THREE.Vector3(); +const _zonePos = new THREE.Vector3(); + +export function ZoneDetection(): null { + const camera = useThree((state) => state.camera); + const manager = GameStepManager.getInstance(); + const triggeredZones = useRef>(new Set()); + const debug = Debug.getInstance(); + + useEffect(() => { + if (!debug.active) return; + + const folder = debug.createFolder("Game"); + if (!folder) return; + + const gameState = { step: manager.getStep() }; + const playerPos = { x: 0, y: 0, z: 0 }; + + folder + .add(gameState, "step", ["intro", "outOfFabrik"]) + .name("Game Step") + .disable(); + + folder.add(playerPos, "x").name("Player X").listen().disable(); + folder.add(playerPos, "y").name("Player Y").listen().disable(); + folder.add(playerPos, "z").name("Player Z").listen().disable(); + + const unsubManager = manager.subscribe(() => { + gameState.step = manager.getStep(); + folder.controllersRecursive().forEach((c) => c.updateDisplay()); + }); + + let frameId: number; + const updatePlayerPos = (): void => { + camera.getWorldPosition(_playerPos); + playerPos.x = Math.round(_playerPos.x * 100) / 100; + playerPos.y = Math.round(_playerPos.y * 100) / 100; + playerPos.z = Math.round(_playerPos.z * 100) / 100; + folder.controllersRecursive().forEach((c) => c.updateDisplay()); + frameId = requestAnimationFrame(updatePlayerPos); + }; + updatePlayerPos(); + + return () => { + cancelAnimationFrame(frameId); + debug.destroyFolder("Game"); + unsubManager(); + }; + }, [debug, manager, camera]); + + useFrame(() => { + camera.getWorldPosition(_playerPos); + + for (const zone of ZONES) { + if (triggeredZones.current.has(zone.id)) continue; + + _zonePos.set(...zone.position); + + const distanceSq = _playerPos.distanceToSquared(_zonePos); + + if (distanceSq <= zone.radius * zone.radius) { + manager.transitionTo(zone.targetStep); + triggeredZones.current.add(zone.id); + break; + } + } + }); + + return null; +} + +interface ZoneVisualProps { + position: [number, number, number]; + radius: number; + height: number; + triggered: boolean; +} + +function ZoneVisual({ + position, + radius, + height, + triggered, +}: ZoneVisualProps): React.JSX.Element { + const color = triggered ? "#00ff00" : "#ff0000"; + + return ( + + + + + + + + + + + ); +} + +export function ZoneDebugVisuals(): React.JSX.Element | null { + const debug = Debug.getInstance(); + const camera = useThree((state) => state.camera); + const [triggeredZones, setTriggeredZones] = useState>(new Set()); + + useFrame(() => { + camera.getWorldPosition(_playerPos); + + for (const zone of ZONES) { + if (triggeredZones.has(zone.id)) continue; + + _zonePos.set(...zone.position); + + const distanceSq = _playerPos.distanceToSquared(_zonePos); + + if (distanceSq <= zone.radius * zone.radius) { + setTriggeredZones((prev) => new Set(prev).add(zone.id)); + break; + } + } + }); + + if (!debug.active) return null; + + return ( + <> + {ZONES.map((zone) => ( + + ))} + + ); +} diff --git a/src/data/zones.ts b/src/data/zones.ts new file mode 100644 index 0000000..06b254a --- /dev/null +++ b/src/data/zones.ts @@ -0,0 +1,12 @@ +import type { Zone } from "@/types/game"; +import type { Vector3Tuple } from "@/types/3d"; + +export const ZONES: Zone[] = [ + { + id: "fabrikExit", + position: [-5, 25, -15] as Vector3Tuple, + radius: 10, + height: 20, + targetStep: "outOfFabrik", + }, +]; diff --git a/src/hooks/useGameStep.ts b/src/hooks/useGameStep.ts new file mode 100644 index 0000000..03d07ef --- /dev/null +++ b/src/hooks/useGameStep.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from "react"; +import { GameStepManager } from "@/stateManager/GameStepManager"; +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), + })); +} diff --git a/src/stateManager/GameStepManager.ts b/src/stateManager/GameStepManager.ts new file mode 100644 index 0000000..aae5527 --- /dev/null +++ b/src/stateManager/GameStepManager.ts @@ -0,0 +1,47 @@ +import type { GameStep } from "@/types/game"; + +export class GameStepManager { + private static _instance: GameStepManager | null = null; + + private _currentStep: GameStep = "intro"; + private readonly _listeners = new Set<() => void>(); + + static getInstance(): GameStepManager { + if (!GameStepManager._instance) { + GameStepManager._instance = new GameStepManager(); + } + + return GameStepManager._instance; + } + + private constructor() {} + + getStep(): GameStep { + return this._currentStep; + } + + transitionTo(step: GameStep): void { + if (this._currentStep === step) return; + + this._currentStep = step; + this._emit(); + } + + subscribe(listener: () => void): () => void { + this._listeners.add(listener); + + return () => { + this._listeners.delete(listener); + }; + } + + destroy(): void { + this._currentStep = "intro"; + this._listeners.clear(); + GameStepManager._instance = null; + } + + private _emit(): void { + this._listeners.forEach((cb) => cb()); + } +} diff --git a/src/types/game.ts b/src/types/game.ts new file mode 100644 index 0000000..5f79fe5 --- /dev/null +++ b/src/types/game.ts @@ -0,0 +1,20 @@ +import type { Vector3Tuple } from "@/types/3d"; + +export type GameStep = "intro" | "outOfFabrik"; + +export interface Zone { + id: string; + position: Vector3Tuple; + radius: number; + height: number; + targetStep: GameStep; +} + +export interface GameState { + step: GameStep; +} + +export interface GameStepSnapshot { + step: GameStep; + transitionTo: (step: GameStep) => void; +} diff --git a/src/world/World.tsx b/src/world/World.tsx index af7c39f..ab24ce6 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -6,6 +6,10 @@ import { } from "@/data/playerConfig"; import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode"; +import { + ZoneDebugVisuals, + ZoneDetection, +} from "@/components/zone/ZoneDetection"; import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls"; import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers"; import { Environment } from "@/world/Environment"; @@ -28,6 +32,8 @@ export function World(): React.JSX.Element { + + {cameraMode === "debug" ? : null} {sceneMode === "game" ? ( -- 2.52.0 From 1b7813a5bbb57008943bfa2974c678e938eb5cb0 Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Mon, 11 May 2026 11:03:01 +0200 Subject: [PATCH 2/9] update flow --- GAME_FLOW.md | 114 +++++++++++++++++++++++ src/App.tsx | 3 + src/components/game/GameFlow.tsx | 57 ++++++++++++ src/components/ui/IntroUI.tsx | 129 ++++++++++++++++++++++++++ src/data/audioConfig.ts | 4 + src/hooks/useGameStep.ts | 7 +- src/stateManager/AudioManager.ts | 40 ++++++++ src/stateManager/GameStepManager.ts | 46 ++++++++- src/types/game.ts | 11 ++- src/world/World.tsx | 2 + src/world/player/PlayerController.tsx | 8 ++ 11 files changed, 415 insertions(+), 6 deletions(-) create mode 100644 GAME_FLOW.md create mode 100644 src/components/game/GameFlow.tsx create mode 100644 src/components/ui/IntroUI.tsx create mode 100644 src/data/audioConfig.ts 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); -- 2.52.0 From 32d644b09d5b4c9076c2bea71b994cdc1c79486e Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Mon, 11 May 2026 11:13:36 +0200 Subject: [PATCH 3/9] feat-intro --- GAME_FLOW.md | 6 +++--- src/components/zone/ZoneDetection.tsx | 2 +- src/data/zones.ts | 2 +- src/types/game.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/GAME_FLOW.md b/GAME_FLOW.md index 3b6cc0b..c3ed79c 100644 --- a/GAME_FLOW.md +++ b/GAME_FLOW.md @@ -3,7 +3,7 @@ ## Étapes du jeu ``` -intro → start-intro → naming → bienvenue → star-move → outOfFabrik +intro → start-intro → naming → bienvenue → star-move → bike ``` --- @@ -45,7 +45,7 @@ intro → start-intro → naming → bienvenue → star-move → outOfFabrik - **État** : Le joueur peut maintenant se déplacer librement - **Zone** : La détection de zone devient active (ZoneDetection) -### 6. `outOfFabrik` +### 6. `bike` - **Déclenchement** : Quand le joueur entre dans la zone de sortie - **Action** : Transition vers l'étape finale @@ -89,7 +89,7 @@ export const ZONES: Zone[] = [ 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 + targetStep: "bike", // Étape cible quand on entre dans la zone }, ]; ``` diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx index 9a53d29..843bbdb 100644 --- a/src/components/zone/ZoneDetection.tsx +++ b/src/components/zone/ZoneDetection.tsx @@ -24,7 +24,7 @@ export function ZoneDetection(): null { const playerPos = { x: 0, y: 0, z: 0 }; folder - .add(gameState, "step", ["intro", "outOfFabrik"]) + .add(gameState, "step", ["intro", "bike"]) .name("Game Step") .disable(); diff --git a/src/data/zones.ts b/src/data/zones.ts index 06b254a..974b67f 100644 --- a/src/data/zones.ts +++ b/src/data/zones.ts @@ -7,6 +7,6 @@ export const ZONES: Zone[] = [ position: [-5, 25, -15] as Vector3Tuple, radius: 10, height: 20, - targetStep: "outOfFabrik", + targetStep: "bike", }, ]; diff --git a/src/types/game.ts b/src/types/game.ts index 5f5e397..543e7df 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -6,7 +6,7 @@ export type GameStep = | "naming" | "bienvenue" | "star-move" - | "outOfFabrik"; + | "bike"; export interface Zone { id: string; -- 2.52.0 From f7b968abe7efa1d2b628a577d9e6530ad88f096d Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Mon, 11 May 2026 16:46:22 +0200 Subject: [PATCH 4/9] wip mission 2 --- GAME_FLOW.md | 119 +++++++++++++++---- package-lock.json | 39 +----- package.json | 3 +- src/App.tsx | 23 +++- src/components/3d/CentralObject.tsx | 60 ++++++++++ src/components/3d/VillageoisHelperObject.tsx | 53 +++++++++ src/components/game/GameFlow.tsx | 57 ++++++--- src/components/ui/DialogMessage.tsx | 54 +++++++++ src/components/ui/IntroUI.tsx | 11 +- src/components/zone/ZoneDetection.tsx | 30 +++-- src/data/audioConfig.ts | 3 + src/data/zones.ts | 9 +- src/hooks/useActivityCity.ts | 5 + src/hooks/useDialog.ts | 27 +++++ src/stateManager/GameStepManager.ts | 4 + src/stores/gameStore.ts | 30 +++++ src/types/game.ts | 6 +- src/world/World.tsx | 4 + src/world/player/PlayerController.tsx | 6 +- 19 files changed, 449 insertions(+), 94 deletions(-) create mode 100644 src/components/3d/CentralObject.tsx create mode 100644 src/components/3d/VillageoisHelperObject.tsx create mode 100644 src/components/ui/DialogMessage.tsx create mode 100644 src/hooks/useActivityCity.ts create mode 100644 src/hooks/useDialog.ts create mode 100644 src/stores/gameStore.ts diff --git a/GAME_FLOW.md b/GAME_FLOW.md index c3ed79c..ed68f79 100644 --- a/GAME_FLOW.md +++ b/GAME_FLOW.md @@ -3,7 +3,7 @@ ## Étapes du jeu ``` -intro → start-intro → naming → bienvenue → star-move → bike +intro → start-intro → naming → bienvenue → star-move → mission2 → searching_problem → preparation → outOfFabrik ``` --- @@ -14,11 +14,12 @@ intro → start-intro → naming → bienvenue → star-move → bike - État initial au chargement du jeu - Aucune action, juste une étape de départ +- Transition automatique vers `start-intro` ### 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()` +- **Action** : Joue l'audio d'intro (`intro`) - **Attente** : Attend que l'audio se termine - **Transition** : Vers `naming` quand l'audio se termine @@ -45,25 +46,50 @@ intro → start-intro → naming → bienvenue → star-move → bike - **État** : Le joueur peut maintenant se déplacer librement - **Zone** : La détection de zone devient active (ZoneDetection) -### 6. `bike` +### 6. `mission2` -- **Déclenchement** : Quand le joueur entre dans la zone de sortie +- **Déclenchement** : Quand le joueur entre dans la zone `fabrikExit` (position: `[-5, 25, -15]`) +- **Actions** : + - Stocke `activityCity: false` dans le store Zustand + - Joue l'audio `alertCentral` +- **État** : Les objets avec hook `useActivityCity()` détectent le changement et jouent leurs animations +- **Attente** : Le joueur atteint la zone de trigger pour `searching_problem` + +### 7. `searching_problem` + +- **Déclenchement** : Quand le joueur entre dans la zone `searchingProblemZone` (position: `[-5, 25, -30]`) +- **Actions** : + - Joue l'audio `searchingProblem` + - Affiche l'objet "central" (position: `[1, 15, -45]`) +- **Attente** : Le joueur interagit avec l'objet "central" + +### 8. `preparation` + +- **Déclenchement** : Quand le joueur interagit avec l'objet "central" +- **Actions** : + - Bloque le mouvement (`setCanMove(false)`) + - Cache l'objet "central" + +### 9. `outOfFabrik` + +- **Déclenchement** : (non implémenté pour le moment) - **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 | +| Fichier | Rôle | +| --------------------------------------- | --------------------------------------------------------- | +| `src/stores/gameStore.ts` | Store Zustand pour l'état global du jeu | +| `src/stateManager/GameStepManager.ts` | Synchronise avec le store Zustand | +| `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 et le message de bienvenue | +| `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone | +| `src/components/3d/CentralObject.tsx` | Objet interactif "central" pour la mission 2 | +| `src/data/audioConfig.ts` | Chemins des fichiers audio | +| `src/data/zones.ts` | Configuration des zones de transition | +| `src/hooks/useActivityCity.ts` | Hook pour détecter le changement d'activité de la ville | --- @@ -72,8 +98,10 @@ intro → start-intro → naming → bienvenue → star-move → bike ```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 + intro: "/sounds/fa.mp3", + bienvenue: "/sounds/fa.mp3", + alertCentral: "/sounds/fa.mp3", + searchingProblem: "/sounds/fa.mp3", }; ``` @@ -86,29 +114,74 @@ export const AUDIO_PATHS = { 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: "bike", // Étape cible quand on entre dans la zone + position: [-5, 25, -15], + radius: 10, + height: 20, + targetStep: "mission2", + }, + { + id: "searchingProblemZone", + position: [-5, 25, -30], + radius: 10, + height: 20, + targetStep: "searching_problem", }, ]; ``` --- +## Store Zustand + +```typescript +// src/stores/gameStore.ts +interface GameState { + step: GameStep; + activityCity: boolean; + playerName: string; + canMove: boolean; + setStep: (step: GameStep) => void; + setActivityCity: (value: boolean) => void; + setPlayerName: (name: string) => void; + setCanMove: (canMove: boolean) => void; +} +``` + +--- + +## Hooks personnalisés + +### useActivityCity + +Permet aux objets 3D de réagir au changement d'activité de la ville : + +```typescript +import { useActivityCity } from "@/hooks/useActivityCity"; + +function MyAnimatedObject() { + const activityCity = useActivityCity(); // true par défaut, false en mission2 + + // L'animation se déclenche quand activityCity change à false + // Utiliser useEffect pour réagir au changement +} +``` + +--- + ## 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 +- **Zone Visualization** : Anneaux visuels au sol pour les zones + cylindres transparents --- ## 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 +- Le store Zustand (`useGameStore`) est la source principale de vérité +- `GameStepManager` synchronise automatiquement avec le store Zustand lors des transitions +- Les transitions via les zones utilisent `GameStepManager.transitionTo()` qui met à jour le store - L'audio utilise un callback `onEnded` pour déclencher les transitions automatiques diff --git a/package-lock.json b/package-lock.json index 4fbc348..fa74fed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "r3f-perf": "^7.2.3", "react": "^19.2.4", "react-dom": "^19.2.4", - "three": "^0.183.2" + "three": "^0.183.2", + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -902,9 +903,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -922,9 +920,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -942,9 +937,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -962,9 +954,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -982,9 +971,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1002,9 +988,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2751,9 +2734,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2775,9 +2755,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2799,9 +2776,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2823,9 +2797,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4109,9 +4080,9 @@ } }, "node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/package.json b/package.json index 3a737f0..5ae9638 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "r3f-perf": "^7.2.3", "react": "^19.2.4", "react-dom": "^19.2.4", - "three": "^0.183.2" + "three": "^0.183.2", + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/src/App.tsx b/src/App.tsx index 0066013..56524af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,26 @@ -import { Suspense } from "react"; +import { Suspense, useEffect } 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 { DialogMessage } from "@/components/ui/DialogMessage"; +import { useGameStore } from "@/stores/gameStore"; import { DebugPerf } from "@/utils/debug/DebugPerf"; import { World } from "@/world/World"; function App(): React.JSX.Element { + const dialogMessage = useGameStore((state) => state.dialogMessage); + const hideDialog = useGameStore((state) => state.hideDialog); + + useEffect(() => { + if (dialogMessage) { + const timer = setTimeout(() => { + hideDialog(); + }, 3000); + return () => clearTimeout(timer); + } + }, [dialogMessage, hideDialog]); + return ( <> @@ -19,6 +33,13 @@ function App(): React.JSX.Element { + {dialogMessage && ( + + )} ); } diff --git a/src/components/3d/CentralObject.tsx b/src/components/3d/CentralObject.tsx new file mode 100644 index 0000000..485784e --- /dev/null +++ b/src/components/3d/CentralObject.tsx @@ -0,0 +1,60 @@ +import { InteractableObject } from "@/components/3d/InteractableObject"; +import { useGameStore } from "@/stores/gameStore"; +import { Debug } from "@/utils/debug/Debug"; +import type { Vector3Tuple } from "@/types/3d"; + +interface CentralObjectProps { + position: Vector3Tuple; +} + +export function CentralObject({ + position, +}: CentralObjectProps): React.JSX.Element { + const step = useGameStore((state) => state.step); + const setStep = useGameStore((state) => state.setStep); + const setCanMove = useGameStore((state) => state.setCanMove); + const showDialog = useGameStore((state) => state.showDialog); + const debug = Debug.getInstance(); + + const handlePress = (): void => { + console.log("[CentralObject] handlePress called, current step:", step); + + if (step === "helped") { + console.log("[CentralObject] Transitioning to manipulation"); + setCanMove(false); + setStep("manipulation"); + } else if (step === "searching") { + console.log("[CentralObject] Showing help message"); + showDialog( + "Cet objet est trop lourd pour le porter tout seul, trouve de l'aide", + ); + } else { + console.log("[CentralObject] Step is not helped or searching, skipping"); + } + }; + + const shouldShow = + step === "helped" || step === "manipulation" || debug.active; + + if (!shouldShow) { + return <>; + } + + console.log("[CentralObject] Rendering, step:", step, "position:", position); + + return ( + + + + + + + + + ); +} diff --git a/src/components/3d/VillageoisHelperObject.tsx b/src/components/3d/VillageoisHelperObject.tsx new file mode 100644 index 0000000..72c27a0 --- /dev/null +++ b/src/components/3d/VillageoisHelperObject.tsx @@ -0,0 +1,53 @@ +import { InteractableObject } from "@/components/3d/InteractableObject"; +import { useGameStore } from "@/stores/gameStore"; +import { Debug } from "@/utils/debug/Debug"; +import type { Vector3Tuple } from "@/types/3d"; + +interface VillageoisHelperObjectProps { + position: Vector3Tuple; +} + +export function VillageoisHelperObject({ + position, +}: VillageoisHelperObjectProps): React.JSX.Element { + const step = useGameStore((state) => state.step); + const setStep = useGameStore((state) => state.setStep); + const debug = Debug.getInstance(); + + const handlePress = (): void => { + console.log("[VillageoisHelper] handlePress called, current step:", step); + if (step === "searching") { + console.log("[VillageoisHelper] Transitioning to helped"); + setStep("helped"); + } + }; + + const shouldShow = step === "searching" || debug.active; + + if (!shouldShow) { + return <>; + } + + console.log( + "[VillageoisHelper] Rendering, step:", + step, + "position:", + position, + ); + + return ( + + + + + + + + + ); +} diff --git a/src/components/game/GameFlow.tsx b/src/components/game/GameFlow.tsx index 597382e..c1a03df 100644 --- a/src/components/game/GameFlow.tsx +++ b/src/components/game/GameFlow.tsx @@ -1,28 +1,23 @@ -import { useEffect, useRef, useState } from "react"; -import { GameStepManager } from "@/stateManager/GameStepManager"; +import { useEffect, useRef } from "react"; +import { useGameStore } from "@/stores/gameStore"; import { AudioManager } from "@/stateManager/AudioManager"; import { AUDIO_PATHS } from "@/data/audioConfig"; export function GameFlow(): null { - const manager = GameStepManager.getInstance(); + const step = useGameStore((state) => state.step); + const setStep = useGameStore((state) => state.setStep); + const setActivityCity = useGameStore((state) => state.setActivityCity); + const setCanMove = useGameStore((state) => state.setCanMove); 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"); + setStep("start-intro"); } - }, [step, manager]); + }, [step, setStep]); useEffect(() => { console.log("[GameFlow] useEffect triggered, step:", step); @@ -32,7 +27,7 @@ export function GameFlow(): null { const audio = AudioManager.getInstance(); audio.playSoundWithCallback(AUDIO_PATHS.intro, 0.5, () => { console.log("[GameFlow] Intro audio ended, transition to naming"); - manager.transitionTo("naming"); + setStep("naming"); }); return () => {}; @@ -43,15 +38,43 @@ export function GameFlow(): null { 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"); + setCanMove(true); + setStep("star-move"); }); return () => {}; } + if (step === "mission2") { + console.log("[GameFlow] mission2 - setting activityCity to false"); + setActivityCity(false); + const audio = AudioManager.getInstance(); + audio.playSound(AUDIO_PATHS.alertCentral, 0.5); + } + + if (step === "searching") { + console.log("[GameFlow] Playing searching audio"); + const audio = AudioManager.getInstance(); + audio.playSoundWithCallback(AUDIO_PATHS.searching, 0.5, () => { + console.log("[GameFlow] searching audio ended"); + }); + + return () => {}; + } + + if (step === "helped") { + console.log("[GameFlow] Playing helped audio"); + const audio = AudioManager.getInstance(); + audio.playSound(AUDIO_PATHS.helped, 0.5); + } + + if (step === "manipulation") { + console.log("[GameFlow] manipulation - blocking movement"); + setCanMove(false); + } + return undefined; - }, [step, manager]); + }, [step, setStep, setActivityCity, setCanMove]); return null; } diff --git a/src/components/ui/DialogMessage.tsx b/src/components/ui/DialogMessage.tsx new file mode 100644 index 0000000..f729c4d --- /dev/null +++ b/src/components/ui/DialogMessage.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; + +interface DialogMessageProps { + message: string; + duration?: number; + onClose?: () => void; +} + +export function DialogMessage({ + message, + duration = 3000, + onClose, +}: DialogMessageProps): React.JSX.Element | null { + const [visible, setVisible] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setVisible(false); + onClose?.(); + }, duration); + + return () => clearTimeout(timer); + }, [duration, onClose]); + + if (!visible) return null; + + return ( +
+

+ {message} +

+
+ ); +} diff --git a/src/components/ui/IntroUI.tsx b/src/components/ui/IntroUI.tsx index 10ffaad..cb20c48 100644 --- a/src/components/ui/IntroUI.tsx +++ b/src/components/ui/IntroUI.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; -import { useGameStep } from "@/hooks/useGameStep"; +import { useGameStore } from "@/stores/gameStore"; export function IntroUI(): React.JSX.Element | null { - const { step, setPlayerName, transitionTo } = useGameStep(); + const step = useGameStore((state) => state.step); + const setPlayerName = useGameStore((state) => state.setPlayerName); + const setStep = useGameStore((state) => state.setStep); const [inputValue, setInputValue] = useState(""); if (step !== "naming") return null; @@ -13,7 +15,7 @@ export function IntroUI(): React.JSX.Element | null { console.log("[IntroUI] Submitting, name:", inputValue.trim()); setPlayerName(inputValue.trim()); console.log("[IntroUI] Calling transitionTo('bienvenue')"); - transitionTo("bienvenue"); + setStep("bienvenue"); console.log("[IntroUI] After transitionTo, step should be:", step); }; @@ -98,7 +100,8 @@ export function IntroUI(): React.JSX.Element | null { } export function BienvenueDisplay(): React.JSX.Element | null { - const { step, playerName } = useGameStep(); + const step = useGameStore((state) => state.step); + const playerName = useGameStore((state) => state.playerName); if (step !== "bienvenue") return null; diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx index 843bbdb..4662482 100644 --- a/src/components/zone/ZoneDetection.tsx +++ b/src/components/zone/ZoneDetection.tsx @@ -3,16 +3,31 @@ import { useFrame, useThree } from "@react-three/fiber"; import * as THREE from "three"; import { ZONES } from "@/data/zones"; import { GameStepManager } from "@/stateManager/GameStepManager"; +import { useGameStore } from "@/stores/gameStore"; import { Debug } from "@/utils/debug/Debug"; +import type { GameStep } from "@/types/game"; const _playerPos = new THREE.Vector3(); const _zonePos = new THREE.Vector3(); +const GAME_STEPS: GameStep[] = [ + "intro", + "start-intro", + "naming", + "bienvenue", + "star-move", + "mission2", + "searching_problem", + "preparation", + "outOfFabrik", +]; + export function ZoneDetection(): null { const camera = useThree((state) => state.camera); const manager = GameStepManager.getInstance(); const triggeredZones = useRef>(new Set()); const debug = Debug.getInstance(); + const step = useGameStore((state) => state.step); useEffect(() => { if (!debug.active) return; @@ -20,20 +35,17 @@ export function ZoneDetection(): null { const folder = debug.createFolder("Game"); if (!folder) return; - const gameState = { step: manager.getStep() }; + const gameState = { step: step }; const playerPos = { x: 0, y: 0, z: 0 }; - folder - .add(gameState, "step", ["intro", "bike"]) - .name("Game Step") - .disable(); + folder.add(gameState, "step", GAME_STEPS).name("Game Step").disable(); folder.add(playerPos, "x").name("Player X").listen().disable(); folder.add(playerPos, "y").name("Player Y").listen().disable(); folder.add(playerPos, "z").name("Player Z").listen().disable(); - const unsubManager = manager.subscribe(() => { - gameState.step = manager.getStep(); + const unsubStore = useGameStore.subscribe((state) => { + gameState.step = state.step; folder.controllersRecursive().forEach((c) => c.updateDisplay()); }); @@ -51,9 +63,9 @@ export function ZoneDetection(): null { return () => { cancelAnimationFrame(frameId); debug.destroyFolder("Game"); - unsubManager(); + unsubStore(); }; - }, [debug, manager, camera]); + }, [debug, camera, step]); useFrame(() => { camera.getWorldPosition(_playerPos); diff --git a/src/data/audioConfig.ts b/src/data/audioConfig.ts index 6a6edd1..bc86196 100644 --- a/src/data/audioConfig.ts +++ b/src/data/audioConfig.ts @@ -1,4 +1,7 @@ export const AUDIO_PATHS = { intro: "/sounds/fa.mp3", bienvenue: "/sounds/fa.mp3", + alertCentral: "/sounds/fa.mp3", + searching: "/sounds/fa.mp3", + helped: "/sounds/fa.mp3", } as const; diff --git a/src/data/zones.ts b/src/data/zones.ts index 974b67f..5ed57e9 100644 --- a/src/data/zones.ts +++ b/src/data/zones.ts @@ -7,6 +7,13 @@ export const ZONES: Zone[] = [ position: [-5, 25, -15] as Vector3Tuple, radius: 10, height: 20, - targetStep: "bike", + targetStep: "mission2", + }, + { + id: "searchingZone", + position: [-5, 25, -30] as Vector3Tuple, + radius: 10, + height: 20, + targetStep: "searching", }, ]; diff --git a/src/hooks/useActivityCity.ts b/src/hooks/useActivityCity.ts new file mode 100644 index 0000000..8df853a --- /dev/null +++ b/src/hooks/useActivityCity.ts @@ -0,0 +1,5 @@ +import { useGameStore } from "@/stores/gameStore"; + +export function useActivityCity(): boolean { + return useGameStore((state) => state.activityCity); +} diff --git a/src/hooks/useDialog.ts b/src/hooks/useDialog.ts new file mode 100644 index 0000000..40cedbb --- /dev/null +++ b/src/hooks/useDialog.ts @@ -0,0 +1,27 @@ +import { useState } from "react"; + +interface DialogState { + message: string; + visible: boolean; +} + +export function useDialog(): { + dialog: DialogState; + showDialog: (message: string) => void; + hideDialog: () => void; +} { + const [dialog, setDialog] = useState({ + message: "", + visible: false, + }); + + const showDialog = (message: string): void => { + setDialog({ message, visible: true }); + }; + + const hideDialog = (): void => { + setDialog((prev) => ({ ...prev, visible: false })); + }; + + return { dialog, showDialog, hideDialog }; +} diff --git a/src/stateManager/GameStepManager.ts b/src/stateManager/GameStepManager.ts index 8d56988..1141dc2 100644 --- a/src/stateManager/GameStepManager.ts +++ b/src/stateManager/GameStepManager.ts @@ -1,4 +1,5 @@ import type { GameStep, GameStepSnapshot } from "@/types/game"; +import { useGameStore } from "@/stores/gameStore"; export class GameStepManager { private static _instance: GameStepManager | null = null; @@ -49,6 +50,7 @@ export class GameStepManager { this._currentStep = step; this._cachedSnapshot = null; + useGameStore.getState().setStep(step); this._emit(); } @@ -57,6 +59,7 @@ export class GameStepManager { this._playerName = name; this._cachedSnapshot = null; + useGameStore.getState().setPlayerName(name); this._emit(); } @@ -65,6 +68,7 @@ export class GameStepManager { this._canMove = canMove; this._cachedSnapshot = null; + useGameStore.getState().setCanMove(canMove); this._emit(); } diff --git a/src/stores/gameStore.ts b/src/stores/gameStore.ts new file mode 100644 index 0000000..4afc69e --- /dev/null +++ b/src/stores/gameStore.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; +import type { GameStep } from "@/types/game"; + +interface GameState { + step: GameStep; + activityCity: boolean; + playerName: string; + canMove: boolean; + dialogMessage: string | null; + setStep: (step: GameStep) => void; + setActivityCity: (value: boolean) => void; + setPlayerName: (name: string) => void; + setCanMove: (canMove: boolean) => void; + showDialog: (message: string) => void; + hideDialog: () => void; +} + +export const useGameStore = create((set) => ({ + step: "intro", + activityCity: true, + playerName: "", + canMove: false, + dialogMessage: null, + setStep: (step) => set({ step }), + setActivityCity: (value) => set({ activityCity: value }), + setPlayerName: (name) => set({ playerName: name }), + setCanMove: (canMove) => set({ canMove }), + showDialog: (message) => set({ dialogMessage: message }), + hideDialog: () => set({ dialogMessage: null }), +})); diff --git a/src/types/game.ts b/src/types/game.ts index 543e7df..f6b5ffe 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -6,7 +6,11 @@ export type GameStep = | "naming" | "bienvenue" | "star-move" - | "bike"; + | "mission2" + | "searching" + | "helped" + | "manipulation" + | "outOfFabrik"; export interface Zone { id: string; diff --git a/src/world/World.tsx b/src/world/World.tsx index 798af29..895abf7 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -11,6 +11,8 @@ import { ZoneDetection, } from "@/components/zone/ZoneDetection"; import { GameFlow } from "@/components/game/GameFlow"; +import { CentralObject } from "@/components/3d/CentralObject"; +import { VillageoisHelperObject } from "@/components/3d/VillageoisHelperObject"; import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls"; import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers"; import { Environment } from "@/world/Environment"; @@ -36,6 +38,8 @@ 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 50ff41d..500a9e4 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -24,7 +24,7 @@ import { PLAYER_XZ_DAMPING_FACTOR, } from "@/data/playerConfig"; import { InteractionManager } from "@/stateManager/InteractionManager"; -import { GameStepManager } from "@/stateManager/GameStepManager"; +import { useGameStore } from "@/stores/gameStore"; import type { Vector3Tuple } from "@/types/3d"; type Keys = { @@ -64,7 +64,7 @@ export function PlayerController({ const velocity = useRef(new THREE.Vector3()); const onFloor = useRef(false); const wantsJump = useRef(false); - const gameStepManager = GameStepManager.getInstance(); + const canMove = useGameStore((state) => state.canMove); const capsule = useRef( new Capsule( @@ -167,7 +167,7 @@ export function PlayerController({ }, []); useFrame((_, delta) => { - if (!gameStepManager.canMove()) { + if (!canMove) { velocity.current.set(0, 0, 0); camera.position.copy(capsule.current.end); return; -- 2.52.0 From 2c3f0db65bbc34d0e6924078028f04653bf1db1e Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 11 May 2026 18:02:00 +0200 Subject: [PATCH 5/9] refactor: move mission flow state into game store --- docs/technical/game-flow.md | 187 ++++++++++++++++++ docs/technical/mission-flow.md | 79 ++++++++ docs/technical/zustand.md | 8 +- src/components/game/GameFlow.tsx | 10 +- .../three/interaction/CentralObject.tsx | 10 +- .../interaction/VillageoisHelperObject.tsx | 6 +- src/components/ui/IntroUI.tsx | 12 +- src/components/zone/ZoneDetection.tsx | 13 +- src/data/docs/docsSections.ts | 14 +- src/data/docs/docsTranslations.ts | 80 +++++++- src/hooks/useActivityCity.ts | 4 +- src/hooks/useGameStep.ts | 11 -- src/managers/GameStepManager.ts | 95 --------- src/managers/stores/useGameStore.ts | 45 +++++ src/managers/stores/useMissionFlowStore.ts | 35 ---- src/pages/docs/mission-flow/page.tsx | 14 ++ src/pages/page.tsx | 8 +- src/router.tsx | 2 + src/routes/DocsRoute.tsx | 5 + src/types/game.ts | 8 - src/world/player/PlayerController.tsx | 3 +- 21 files changed, 461 insertions(+), 188 deletions(-) create mode 100644 docs/technical/game-flow.md create mode 100644 docs/technical/mission-flow.md delete mode 100644 src/hooks/useGameStep.ts delete mode 100644 src/managers/GameStepManager.ts delete mode 100644 src/managers/stores/useMissionFlowStore.ts create mode 100644 src/pages/docs/mission-flow/page.tsx diff --git a/docs/technical/game-flow.md b/docs/technical/game-flow.md new file mode 100644 index 0000000..ed68f79 --- /dev/null +++ b/docs/technical/game-flow.md @@ -0,0 +1,187 @@ +# Game Flow - La Fabrik + +## Étapes du jeu + +``` +intro → start-intro → naming → bienvenue → star-move → mission2 → searching_problem → preparation → outOfFabrik +``` + +--- + +## Détail des étapes + +### 1. `intro` (initial) + +- État initial au chargement du jeu +- Aucune action, juste une étape de départ +- Transition automatique vers `start-intro` + +### 2. `start-intro` + +- **Déclenchement** : Auto-transition depuis `intro` quand la scène est chargée +- **Action** : Joue l'audio d'intro (`intro`) +- **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. `mission2` + +- **Déclenchement** : Quand le joueur entre dans la zone `fabrikExit` (position: `[-5, 25, -15]`) +- **Actions** : + - Stocke `activityCity: false` dans le store Zustand + - Joue l'audio `alertCentral` +- **État** : Les objets avec hook `useActivityCity()` détectent le changement et jouent leurs animations +- **Attente** : Le joueur atteint la zone de trigger pour `searching_problem` + +### 7. `searching_problem` + +- **Déclenchement** : Quand le joueur entre dans la zone `searchingProblemZone` (position: `[-5, 25, -30]`) +- **Actions** : + - Joue l'audio `searchingProblem` + - Affiche l'objet "central" (position: `[1, 15, -45]`) +- **Attente** : Le joueur interagit avec l'objet "central" + +### 8. `preparation` + +- **Déclenchement** : Quand le joueur interagit avec l'objet "central" +- **Actions** : + - Bloque le mouvement (`setCanMove(false)`) + - Cache l'objet "central" + +### 9. `outOfFabrik` + +- **Déclenchement** : (non implémenté pour le moment) +- **Action** : Transition vers l'étape finale + +--- + +## Fichiers clés + +| Fichier | Rôle | +| --------------------------------------- | --------------------------------------------------------- | +| `src/stores/gameStore.ts` | Store Zustand pour l'état global du jeu | +| `src/stateManager/GameStepManager.ts` | Synchronise avec le store Zustand | +| `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 et le message de bienvenue | +| `src/components/zone/ZoneDetection.tsx` | Détecte quand le joueur entre dans une zone | +| `src/components/3d/CentralObject.tsx` | Objet interactif "central" pour la mission 2 | +| `src/data/audioConfig.ts` | Chemins des fichiers audio | +| `src/data/zones.ts` | Configuration des zones de transition | +| `src/hooks/useActivityCity.ts` | Hook pour détecter le changement d'activité de la ville | + +--- + +## Configuration audio + +```typescript +// src/data/audioConfig.ts +export const AUDIO_PATHS = { + intro: "/sounds/fa.mp3", + bienvenue: "/sounds/fa.mp3", + alertCentral: "/sounds/fa.mp3", + searchingProblem: "/sounds/fa.mp3", +}; +``` + +--- + +## Configuration des zones + +```typescript +// src/data/zones.ts +export const ZONES: Zone[] = [ + { + id: "fabrikExit", + position: [-5, 25, -15], + radius: 10, + height: 20, + targetStep: "mission2", + }, + { + id: "searchingProblemZone", + position: [-5, 25, -30], + radius: 10, + height: 20, + targetStep: "searching_problem", + }, +]; +``` + +--- + +## Store Zustand + +```typescript +// src/stores/gameStore.ts +interface GameState { + step: GameStep; + activityCity: boolean; + playerName: string; + canMove: boolean; + setStep: (step: GameStep) => void; + setActivityCity: (value: boolean) => void; + setPlayerName: (name: string) => void; + setCanMove: (canMove: boolean) => void; +} +``` + +--- + +## Hooks personnalisés + +### useActivityCity + +Permet aux objets 3D de réagir au changement d'activité de la ville : + +```typescript +import { useActivityCity } from "@/hooks/useActivityCity"; + +function MyAnimatedObject() { + const activityCity = useActivityCity(); // true par défaut, false en mission2 + + // L'animation se déclenche quand activityCity change à false + // Utiliser useEffect pour réagir au changement +} +``` + +--- + +## 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 + cylindres transparents + +--- + +## Notes techniques + +- Le mouvement du joueur est bloqué tant que `canMove` est `false` +- Le store Zustand (`useGameStore`) est la source principale de vérité +- `GameStepManager` synchronise automatiquement avec le store Zustand lors des transitions +- Les transitions via les zones utilisent `GameStepManager.transitionTo()` qui met à jour le store +- L'audio utilise un callback `onEnded` pour déclencher les transitions automatiques diff --git a/docs/technical/mission-flow.md b/docs/technical/mission-flow.md new file mode 100644 index 0000000..4c2f766 --- /dev/null +++ b/docs/technical/mission-flow.md @@ -0,0 +1,79 @@ +# Mission Flow + +This document describes the mission intro and mission 2 prototype flow after it was merged into the current architecture. + +## Source Of Truth + +Mission flow state lives in the global game store: + +```txt +src/managers/stores/useGameStore.ts +``` + +The store owns the `missionFlow` slice: + +```ts +missionFlow: { + step: GameStep; + activityCity: boolean; + playerName: string; + canMove: boolean; + dialogMessage: string | null; +} +``` + +This keeps global gameplay state in Zustand instead of splitting it across a separate mission store or a gameplay manager. + +## Managers Boundary + +Managers stay responsible for local runtime services: + +- `AudioManager` owns audio elements, audio pools, music playback, category volume, and stereo pan. +- `InteractionManager` owns transient focused/nearby/held interaction handles. + +Mission progression is not owned by a manager. Components update the store through explicit actions such as `setFlowStep`, `setCanMove`, `showDialog`, and `hideDialog`. + +## Runtime Components + +- `src/components/game/GameFlow.tsx` reacts to `missionFlow.step` and triggers one-off side effects such as intro audio and movement unlocks. +- `src/components/zone/ZoneDetection.tsx` reads the camera position and moves the flow to a target step when the player enters a configured zone. +- `src/components/three/interaction/CentralObject.tsx` and `VillageoisHelperObject.tsx` expose temporary interactive mission objects. +- `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `BienvenueDisplay`, and `DialogMessage`. +- `src/world/player/PlayerController.tsx` reads `missionFlow.canMove` as an additional movement lock. + +## Step Sequence + +The prototype currently uses these steps: + +```ts +"intro" | + "start-intro" | + "naming" | + "bienvenue" | + "star-move" | + "mission2" | + "searching" | + "helped" | + "manipulation" | + "outOfFabrik"; +``` + +These steps are mission-flow prototype states. They do not replace `mainState` or the repair mission step machine used by `RepairGame`. + +## Zone Configuration + +Zone triggers live in: + +```txt +src/data/zones.ts +``` + +Each zone has an id, position, radius, height, and `targetStep`. `ZoneDetection` marks a zone as triggered after the first activation so the same zone does not replay its transition every frame. + +## Rules + +- Keep mission flow state in `useGameStore.missionFlow`. +- Do not reintroduce `GameStepManager` for global state transitions. +- Do not create a second Zustand store for mission flow unless the state becomes independent from game progression. +- Keep side effects such as audio playback in components or service managers, but keep the state transition itself in the store. +- Keep per-frame values such as camera position and zone distance checks out of Zustand. diff --git a/docs/technical/zustand.md b/docs/technical/zustand.md index 3a20fb9..44b7285 100644 --- a/docs/technical/zustand.md +++ b/docs/technical/zustand.md @@ -59,6 +59,7 @@ Rule of thumb: The store exposes: - `mainState`: the active game phase +- `missionFlow`: intro and mission 2 prototype state - `intro`: intro-specific state - `bike`: e-bike mission state - `pylone`: power grid mission state @@ -66,6 +67,8 @@ The store exposes: - `outro`: ending state - actions for direct updates and progression updates +The `missionFlow` slice contains the prototype step, player name, movement lock, city activity flag, and temporary dialog message. It is in the main game store because it is global gameplay state used by UI, world components, and the player controller. + The mission steps currently use this sequence: ```ts @@ -141,6 +144,8 @@ For repair missions, it mounts the reusable `RepairGame` component with a missio Mission-specific behavior stays in `src/data/gameplay/repairMissions.ts`: each mission can define its broken nodes, placeholder targets, scan duration, and reassembly duration without adding mission branches to `RepairGame`. +The intro and mission 2 prototype flow is documented separately in `docs/technical/mission-flow.md`. It intentionally uses the same `useGameStore` source of truth instead of a dedicated `GameStepManager` or a second Zustand store. + That means the scene can progressively move toward this pattern: ```tsx @@ -171,8 +176,9 @@ Current overlays: - `Crosshair`: player aiming helper - `InteractPrompt`: interaction prompt - `RepairMovementLockIndicator`: player-facing indicator shown while repair steps temporarily disable movement +- Mission flow overlays such as `IntroUI`, `BienvenueDisplay`, and `DialogMessage` are mounted by `src/pages/page.tsx` because they are route-level HTML overlays rather than persistent game HUD elements. -`src/pages/page.tsx` should stay thin and mount only the canvas and `GameUI`. +`src/pages/page.tsx` should stay thin and mount the canvas, persistent `GameUI`, and route-level overlays. ## Regression Rules diff --git a/src/components/game/GameFlow.tsx b/src/components/game/GameFlow.tsx index e97722c..c2c88f0 100644 --- a/src/components/game/GameFlow.tsx +++ b/src/components/game/GameFlow.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef } from "react"; import { AudioManager } from "@/managers/AudioManager"; -import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; +import { useGameStore } from "@/managers/stores/useGameStore"; import { AUDIO_PATHS } from "@/data/audioConfig"; export function GameFlow(): null { - const step = useMissionFlowStore((state) => state.step); - const setStep = useMissionFlowStore((state) => state.setStep); - const setActivityCity = useMissionFlowStore((state) => state.setActivityCity); - const setCanMove = useMissionFlowStore((state) => state.setCanMove); + const step = useGameStore((state) => state.missionFlow.step); + const setStep = useGameStore((state) => state.setFlowStep); + const setActivityCity = useGameStore((state) => state.setActivityCity); + const setCanMove = useGameStore((state) => state.setCanMove); const hasInitialized = useRef(false); useEffect(() => { diff --git a/src/components/three/interaction/CentralObject.tsx b/src/components/three/interaction/CentralObject.tsx index 33a907e..64628cd 100644 --- a/src/components/three/interaction/CentralObject.tsx +++ b/src/components/three/interaction/CentralObject.tsx @@ -1,5 +1,5 @@ import { InteractableObject } from "@/components/three/interaction/InteractableObject"; -import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; +import { useGameStore } from "@/managers/stores/useGameStore"; import { Debug } from "@/utils/debug/Debug"; import type { Vector3Tuple } from "@/types/three/three"; @@ -10,10 +10,10 @@ interface CentralObjectProps { export function CentralObject({ position, }: CentralObjectProps): React.JSX.Element { - const step = useMissionFlowStore((state) => state.step); - const setStep = useMissionFlowStore((state) => state.setStep); - const setCanMove = useMissionFlowStore((state) => state.setCanMove); - const showDialog = useMissionFlowStore((state) => state.showDialog); + const step = useGameStore((state) => state.missionFlow.step); + const setStep = useGameStore((state) => state.setFlowStep); + const setCanMove = useGameStore((state) => state.setCanMove); + const showDialog = useGameStore((state) => state.showDialog); const debug = Debug.getInstance(); const handlePress = (): void => { diff --git a/src/components/three/interaction/VillageoisHelperObject.tsx b/src/components/three/interaction/VillageoisHelperObject.tsx index 38e1b14..e10f156 100644 --- a/src/components/three/interaction/VillageoisHelperObject.tsx +++ b/src/components/three/interaction/VillageoisHelperObject.tsx @@ -1,5 +1,5 @@ import { InteractableObject } from "@/components/three/interaction/InteractableObject"; -import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; +import { useGameStore } from "@/managers/stores/useGameStore"; import { Debug } from "@/utils/debug/Debug"; import type { Vector3Tuple } from "@/types/three/three"; @@ -10,8 +10,8 @@ interface VillageoisHelperObjectProps { export function VillageoisHelperObject({ position, }: VillageoisHelperObjectProps): React.JSX.Element { - const step = useMissionFlowStore((state) => state.step); - const setStep = useMissionFlowStore((state) => state.setStep); + const step = useGameStore((state) => state.missionFlow.step); + const setStep = useGameStore((state) => state.setFlowStep); const debug = Debug.getInstance(); const handlePress = (): void => { diff --git a/src/components/ui/IntroUI.tsx b/src/components/ui/IntroUI.tsx index 6f8ac62..4d092b3 100644 --- a/src/components/ui/IntroUI.tsx +++ b/src/components/ui/IntroUI.tsx @@ -1,10 +1,10 @@ import { useState } from "react"; -import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; +import { useGameStore } from "@/managers/stores/useGameStore"; export function IntroUI(): React.JSX.Element | null { - const step = useMissionFlowStore((state) => state.step); - const setPlayerName = useMissionFlowStore((state) => state.setPlayerName); - const setStep = useMissionFlowStore((state) => state.setStep); + const step = useGameStore((state) => state.missionFlow.step); + const setPlayerName = useGameStore((state) => state.setPlayerName); + const setStep = useGameStore((state) => state.setFlowStep); const [inputValue, setInputValue] = useState(""); if (step !== "naming") return null; @@ -100,8 +100,8 @@ export function IntroUI(): React.JSX.Element | null { } export function BienvenueDisplay(): React.JSX.Element | null { - const step = useMissionFlowStore((state) => state.step); - const playerName = useMissionFlowStore((state) => state.playerName); + const step = useGameStore((state) => state.missionFlow.step); + const playerName = useGameStore((state) => state.missionFlow.playerName); if (step !== "bienvenue") return null; diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx index 9315b40..fd7049b 100644 --- a/src/components/zone/ZoneDetection.tsx +++ b/src/components/zone/ZoneDetection.tsx @@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useFrame, useThree } from "@react-three/fiber"; import * as THREE from "three"; import { ZONES } from "@/data/zones"; -import { GameStepManager } from "@/managers/GameStepManager"; -import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; +import { useGameStore } from "@/managers/stores/useGameStore"; import { Debug } from "@/utils/debug/Debug"; import type { GameStep } from "@/types/game"; @@ -25,10 +24,10 @@ const GAME_STEPS: GameStep[] = [ export function ZoneDetection(): null { const camera = useThree((state) => state.camera); - const manager = GameStepManager.getInstance(); const triggeredZones = useRef>(new Set()); const debug = Debug.getInstance(); - const step = useMissionFlowStore((state) => state.step); + const step = useGameStore((state) => state.missionFlow.step); + const setStep = useGameStore((state) => state.setFlowStep); useEffect(() => { if (!debug.active) return; @@ -45,8 +44,8 @@ export function ZoneDetection(): null { folder.add(playerPos, "y").name("Player Y").listen().disable(); folder.add(playerPos, "z").name("Player Z").listen().disable(); - const unsubStore = useMissionFlowStore.subscribe((state) => { - gameState.step = state.step; + const unsubStore = useGameStore.subscribe((state) => { + gameState.step = state.missionFlow.step; folder.controllersRecursive().forEach((c) => c.updateDisplay()); }); @@ -79,7 +78,7 @@ export function ZoneDetection(): null { const distanceSq = _playerPos.distanceToSquared(_zonePos); if (distanceSq <= zone.radius * zone.radius) { - manager.transitionTo(zone.targetStep); + setStep(zone.targetStep); triggeredZones.current.add(zone.id); break; } diff --git a/src/data/docs/docsSections.ts b/src/data/docs/docsSections.ts index 86bc869..a8fcfdc 100644 --- a/src/data/docs/docsSections.ts +++ b/src/data/docs/docsSections.ts @@ -50,6 +50,12 @@ export const docGroups: DocGroup[] = [ subtitle: "Progression store", meta: "06", }, + { + path: "/docs/mission-flow", + title: "Mission Flow", + subtitle: "Intro and mission 2 prototype", + meta: "07", + }, ], }, { @@ -59,25 +65,25 @@ export const docGroups: DocGroup[] = [ path: "/docs/features", title: "Features", subtitle: "Implemented scope", - meta: "07", + meta: "08", }, { path: "/docs/main-feature", title: "Main Feature", subtitle: "Repair-game prototype", - meta: "08", + meta: "09", }, { path: "/docs/editor", title: "Editor User Guide", subtitle: "Editing workflow", - meta: "09", + meta: "10", }, { path: "/docs/animation", title: "Animation & 3D Model System", subtitle: "Components and usage", - meta: "010", + meta: "11", }, ], }, diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts index edd79bb..ce624c9 100644 --- a/src/data/docs/docsTranslations.ts +++ b/src/data/docs/docsTranslations.ts @@ -328,6 +328,7 @@ Règle simple : Le store expose : - \`mainState\` : phase active du jeu +- \`missionFlow\` : état prototype de l'intro et de la mission 2 - \`intro\` : état spécifique à l'intro - \`bike\` : état de la mission vélo - \`pylone\` : état de la mission réseau électrique @@ -335,6 +336,8 @@ Le store expose : - \`outro\` : état de fin - des actions de mise à jour directe et des actions de progression +Le slice \`missionFlow\` contient l'étape prototype, le prénom joueur, le lock de déplacement, le flag d'activité de la ville et le message de dialogue temporaire. Il vit dans le store principal parce qu'il s'agit d'un état gameplay global utilisé par l'UI, le world et le controller joueur. + Les étapes de mission utilisent actuellement cette séquence : \`\`\`ts @@ -401,6 +404,8 @@ Pour les missions de réparation, il monte le composant réutilisable \`RepairGa \`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\` et \`completeMission\`. Les ids de mission, étapes de mission et guards partagés vivent dans \`src/types/gameplay/repairMission.ts\`, ce qui évite à la configuration statique des missions de dépendre du store Zustand. Le flow de réparation de production supporte actuellement les transitions \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`. +Le flow prototype intro et mission 2 est documenté séparément dans \`docs/technical/mission-flow.md\`. Il utilise volontairement la même source de vérité \`useGameStore\`, sans \`GameStepManager\` dédié ni second store Zustand. + La scène peut donc évoluer progressivement vers ce pattern : \`\`\`tsx @@ -431,8 +436,9 @@ Overlays actuels : - \`Crosshair\` : aide de visée joueur - \`InteractPrompt\` : prompt d'interaction - \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement +- Les overlays du flow mission comme \`IntroUI\`, \`BienvenueDisplay\` et \`DialogMessage\` sont montés par \`src/pages/page.tsx\`, car ce sont des overlays HTML de route plutôt qu'un HUD de jeu persistant. -\`src/pages/page.tsx\` doit rester fin et monter seulement le canvas et \`GameUI\`. +\`src/pages/page.tsx\` doit rester fin et monter le canvas, le \`GameUI\` persistant et les overlays de route. ## Règles anti-régression @@ -448,6 +454,78 @@ Overlays actuels : Déplacer la validation de réparation dans les données de mission lorsque chaque mission aura ses propres nodes de modules cassés, assets de remplacement et événements de complétion. `; +export const missionFlowFr = `# Flow de mission + +Ce document décrit le flow prototype d'intro et de mission 2 après son intégration dans l'architecture actuelle. + +## Source de vérité + +L'état du flow de mission vit dans le store global du jeu : + +\`\`\`txt +src/managers/stores/useGameStore.ts +\`\`\` + +Le store possède le slice \`missionFlow\` : + +\`\`\`ts +missionFlow: { + step: GameStep; + activityCity: boolean; + playerName: string; + canMove: boolean; + dialogMessage: string | null; +} +\`\`\` + +Cela garde l'état gameplay global dans Zustand, au lieu de le répartir entre un store mission séparé ou un manager gameplay. + +## Frontière des managers + +Les managers restent responsables de services runtime locaux : + +- \`AudioManager\` possède les éléments audio, les pools audio, la musique, le volume par catégorie et le pan stéréo. +- \`InteractionManager\` possède les handles d'interaction transitoires, focus, nearby et held. + +La progression de mission n'est pas possédée par un manager. Les composants mettent à jour le store via des actions explicites comme \`setFlowStep\`, \`setCanMove\`, \`showDialog\` et \`hideDialog\`. + +## Composants runtime + +- \`src/components/game/GameFlow.tsx\` réagit à \`missionFlow.step\` et déclenche les effets ponctuels comme l'audio d'intro et le déblocage du mouvement. +- \`src/components/zone/ZoneDetection.tsx\` lit la position caméra et fait passer le flow à une étape cible quand le joueur entre dans une zone configurée. +- \`src/components/three/interaction/CentralObject.tsx\` et \`VillageoisHelperObject.tsx\` exposent les objets interactifs temporaires de mission. +- \`src/pages/page.tsx\` monte les overlays HTML de mission : \`IntroUI\`, \`BienvenueDisplay\` et \`DialogMessage\`. +- \`src/world/player/PlayerController.tsx\` lit \`missionFlow.canMove\` comme lock de déplacement supplémentaire. + +## Séquence d'étapes + +Le prototype utilise actuellement ces étapes : + +\`\`\`ts +"intro" | "start-intro" | "naming" | "bienvenue" | "star-move" | "mission2" | "searching" | "helped" | "manipulation" | "outOfFabrik" +\`\`\` + +Ces étapes sont propres au prototype de flow mission. Elles ne remplacent pas \`mainState\` ni la machine d'étapes repair utilisée par \`RepairGame\`. + +## Configuration des zones + +Les triggers de zones vivent dans : + +\`\`\`txt +src/data/zones.ts +\`\`\` + +Chaque zone possède un id, une position, un rayon, une hauteur et un \`targetStep\`. \`ZoneDetection\` marque une zone comme déclenchée après sa première activation pour éviter de rejouer la même transition à chaque frame. + +## Règles + +- Garder l'état du flow mission dans \`useGameStore.missionFlow\`. +- Ne pas réintroduire de \`GameStepManager\` pour les transitions globales. +- Ne pas créer un second store Zustand pour le flow mission sauf si cet état devient réellement indépendant de la progression du jeu. +- Garder les effets de bord comme l'audio dans les composants ou les managers de service, mais garder la transition d'état dans le store. +- Ne pas mettre les valeurs par-frame comme la position caméra ou les distances de zones dans Zustand. +`; + export const featuresFr = `# Fonctionnalités implémentées Ce document liste les fonctionnalités présentes dans le code actuel. diff --git a/src/hooks/useActivityCity.ts b/src/hooks/useActivityCity.ts index d11c604..d1477ae 100644 --- a/src/hooks/useActivityCity.ts +++ b/src/hooks/useActivityCity.ts @@ -1,5 +1,5 @@ -import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; +import { useGameStore } from "@/managers/stores/useGameStore"; export function useActivityCity(): boolean { - return useMissionFlowStore((state) => state.activityCity); + return useGameStore((state) => state.missionFlow.activityCity); } diff --git a/src/hooks/useGameStep.ts b/src/hooks/useGameStep.ts deleted file mode 100644 index ceec100..0000000 --- a/src/hooks/useGameStep.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useSyncExternalStore } from "react"; -import { GameStepManager } from "@/managers/GameStepManager"; -import type { GameStepSnapshot } from "@/types/game"; - -const manager = GameStepManager.getInstance(); - -export function useGameStep(): GameStepSnapshot { - return useSyncExternalStore(manager.subscribe.bind(manager), () => - manager.getSnapshot(), - ); -} diff --git a/src/managers/GameStepManager.ts b/src/managers/GameStepManager.ts deleted file mode 100644 index 864779e..0000000 --- a/src/managers/GameStepManager.ts +++ /dev/null @@ -1,95 +0,0 @@ -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()); - } -} diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 36672c8..113835a 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { GameStep } from "@/types/game"; import { isRepairMissionId, type MissionStep, @@ -19,9 +20,18 @@ interface MissionState { dialogueAudio: string | null; } +interface MissionFlowState { + activityCity: boolean; + canMove: boolean; + dialogMessage: string | null; + playerName: string; + step: GameStep; +} + interface GameState { mainState: MainGameState; isCinematicPlaying: boolean; + missionFlow: MissionFlowState; intro: IntroState; bike: MissionState & { isRepaired: boolean; @@ -41,7 +51,12 @@ interface GameState { interface GameActions { setMainState: (mainState: MainGameState) => void; setCinematicPlaying: (isCinematicPlaying: boolean) => void; + hideDialog: () => void; + setActivityCity: (activityCity: boolean) => void; + setCanMove: (canMove: boolean) => void; + setFlowStep: (step: GameStep) => void; setIntroState: (intro: Partial) => void; + setPlayerName: (playerName: string) => void; setBikeState: (bike: Partial) => void; setPyloneState: (pylone: Partial) => void; setFermeState: (ferme: Partial) => void; @@ -56,6 +71,7 @@ interface GameActions { advanceGameState: () => void; rewindGameState: () => void; resetGame: () => void; + showDialog: (dialogMessage: string) => void; } type GameStore = GameState & GameActions; @@ -225,6 +241,13 @@ function createInitialGameState(): GameState { return { mainState: "intro", isCinematicPlaying: false, + missionFlow: { + activityCity: true, + canMove: false, + dialogMessage: null, + playerName: "", + step: "intro", + }, intro: { dialogueAudio: null, hasCompleted: false, @@ -256,8 +279,26 @@ export const useGameStore = create()((set) => ({ ...createInitialGameState(), setMainState: (mainState) => set({ mainState }), setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }), + hideDialog: () => + set((state) => ({ + missionFlow: { ...state.missionFlow, dialogMessage: null }, + })), + setActivityCity: (activityCity) => + set((state) => ({ + missionFlow: { ...state.missionFlow, activityCity }, + })), + setCanMove: (canMove) => + set((state) => ({ + missionFlow: { ...state.missionFlow, canMove }, + })), + setFlowStep: (step) => + set((state) => ({ missionFlow: { ...state.missionFlow, step } })), setIntroState: (intro) => set((state) => ({ intro: { ...state.intro, ...intro } })), + setPlayerName: (playerName) => + set((state) => ({ + missionFlow: { ...state.missionFlow, playerName }, + })), setBikeState: (bike) => set((state) => ({ bike: { ...state.bike, ...bike } })), setPyloneState: (pylone) => @@ -300,4 +341,8 @@ export const useGameStore = create()((set) => ({ return { outro: { ...state.outro, hasStarted: false } }; }), resetGame: () => set(createInitialGameState()), + showDialog: (dialogMessage) => + set((state) => ({ + missionFlow: { ...state.missionFlow, dialogMessage }, + })), })); diff --git a/src/managers/stores/useMissionFlowStore.ts b/src/managers/stores/useMissionFlowStore.ts deleted file mode 100644 index bf8c284..0000000 --- a/src/managers/stores/useMissionFlowStore.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 }), -})); diff --git a/src/pages/docs/mission-flow/page.tsx b/src/pages/docs/mission-flow/page.tsx new file mode 100644 index 0000000..bce4e20 --- /dev/null +++ b/src/pages/docs/mission-flow/page.tsx @@ -0,0 +1,14 @@ +import missionFlow from "../../../../docs/technical/mission-flow.md?raw"; +import { DocsDocument } from "@/components/docs/DocsDocument"; +import { missionFlowFr } from "@/data/docs/docsTranslations"; + +export function DocsMissionFlowPage(): React.JSX.Element { + return ( + + ); +} diff --git a/src/pages/page.tsx b/src/pages/page.tsx index 4a32105..181cba9 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -6,7 +6,7 @@ import { DialogMessage } from "@/components/ui/DialogMessage"; import { GameUI } from "@/components/ui/GameUI"; import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; -import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; +import { useGameStore } from "@/managers/stores/useGameStore"; import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider"; import { INITIAL_SCENE_LOADING_STATE, @@ -15,8 +15,10 @@ import { import { World } from "@/world/World"; export function HomePage(): React.JSX.Element { - const dialogMessage = useMissionFlowStore((state) => state.dialogMessage); - const hideDialog = useMissionFlowStore((state) => state.hideDialog); + const dialogMessage = useGameStore( + (state) => state.missionFlow.dialogMessage, + ); + const hideDialog = useGameStore((state) => state.hideDialog); const [sceneLoadingState, setSceneLoadingState] = useState( INITIAL_SCENE_LOADING_STATE, ); diff --git a/src/router.tsx b/src/router.tsx index 7be6634..11e09ac 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -14,6 +14,7 @@ import { DocsHandTrackingRoute, DocsLayoutRoute, DocsMainFeatureRoute, + DocsMissionFlowRoute, DocsReadmeRoute, DocsTargetArchitectureRoute, DocsTechnicalEditorRoute, @@ -49,6 +50,7 @@ const docsChildRoutes = [ { path: "technical-editor", component: DocsTechnicalEditorRoute }, { path: "hand-tracking", component: DocsHandTrackingRoute }, { path: "zustand", component: DocsZustandRoute }, + { path: "mission-flow", component: DocsMissionFlowRoute }, { path: "features", component: DocsFeaturesRoute }, { path: "main-feature", component: DocsMainFeatureRoute }, { path: "editor", component: DocsEditorRoute }, diff --git a/src/routes/DocsRoute.tsx b/src/routes/DocsRoute.tsx index a9e42a7..bd517f5 100644 --- a/src/routes/DocsRoute.tsx +++ b/src/routes/DocsRoute.tsx @@ -55,6 +55,10 @@ const LazyDocsZustandPage = lazyNamed( () => import("@/pages/docs/zustand/page"), "DocsZustandPage", ); +const LazyDocsMissionFlowPage = lazyNamed( + () => import("@/pages/docs/mission-flow/page"), + "DocsMissionFlowPage", +); const LazyDocsFeaturesPage = lazyNamed( () => import("@/pages/docs/features/page"), "DocsFeaturesPage", @@ -83,6 +87,7 @@ export const DocsTechnicalEditorRoute = createDocsRoute( ); export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage); export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage); +export const DocsMissionFlowRoute = createDocsRoute(LazyDocsMissionFlowPage); export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage); export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage); export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage); diff --git a/src/types/game.ts b/src/types/game.ts index 4165c5c..292f30a 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -23,11 +23,3 @@ export interface Zone { export interface GameState { step: GameStep; } - -export interface GameStepSnapshot { - step: GameStep; - playerName: string; - canMove: boolean; - transitionTo: (step: GameStep) => void; - setPlayerName: (name: string) => void; -} diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index f69faeb..aee4fe0 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -26,7 +26,6 @@ import { import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked"; import { InteractionManager } from "@/managers/InteractionManager"; import { useGameStore } from "@/managers/stores/useGameStore"; -import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import type { Vector3Tuple } from "@/types/three/three"; @@ -95,7 +94,7 @@ export function PlayerController({ const velocity = useRef(new THREE.Vector3()); const onFloor = useRef(false); const wantsJump = useRef(false); - const canMove = useMissionFlowStore((state) => state.canMove); + const canMove = useGameStore((state) => state.missionFlow.canMove); const capsule = useRef( new Capsule( -- 2.52.0 From eab552a09b5632fac79870de42b5f5409437a529 Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Tue, 12 May 2026 13:42:23 +0200 Subject: [PATCH 6/9] PR: refactor name --- .../{VillageoisHelperObject.tsx => NPCHelper.tsx} | 15 ++------------- .../{CentralObject.tsx => PyloneDestroyed.tsx} | 14 +++----------- src/world/World.tsx | 8 ++++---- 3 files changed, 9 insertions(+), 28 deletions(-) rename src/components/three/interaction/{VillageoisHelperObject.tsx => NPCHelper.tsx} (71%) rename src/components/three/interaction/{CentralObject.tsx => PyloneDestroyed.tsx} (73%) diff --git a/src/components/three/interaction/VillageoisHelperObject.tsx b/src/components/three/interaction/NPCHelper.tsx similarity index 71% rename from src/components/three/interaction/VillageoisHelperObject.tsx rename to src/components/three/interaction/NPCHelper.tsx index e10f156..1bda30e 100644 --- a/src/components/three/interaction/VillageoisHelperObject.tsx +++ b/src/components/three/interaction/NPCHelper.tsx @@ -3,21 +3,17 @@ import { useGameStore } from "@/managers/stores/useGameStore"; import { Debug } from "@/utils/debug/Debug"; import type { Vector3Tuple } from "@/types/three/three"; -interface VillageoisHelperObjectProps { +interface NPCHelperProps { position: Vector3Tuple; } -export function VillageoisHelperObject({ - position, -}: VillageoisHelperObjectProps): React.JSX.Element { +export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element { const step = useGameStore((state) => state.missionFlow.step); const setStep = useGameStore((state) => state.setFlowStep); const debug = Debug.getInstance(); const handlePress = (): void => { - console.log("[VillageoisHelper] handlePress called, current step:", step); if (step === "searching") { - console.log("[VillageoisHelper] Transitioning to helped"); setStep("helped"); } }; @@ -28,13 +24,6 @@ export function VillageoisHelperObject({ return <>; } - console.log( - "[VillageoisHelper] Rendering, step:", - step, - "position:", - position, - ); - return ( state.missionFlow.step); const setStep = useGameStore((state) => state.setFlowStep); const setCanMove = useGameStore((state) => state.setCanMove); @@ -17,19 +17,13 @@ export function CentralObject({ const debug = Debug.getInstance(); const handlePress = (): void => { - console.log("[CentralObject] handlePress called, current step:", step); - if (step === "helped") { - console.log("[CentralObject] Transitioning to manipulation"); setCanMove(false); setStep("manipulation"); } else if (step === "searching") { - console.log("[CentralObject] Showing help message"); showDialog( "Cet objet est trop lourd pour le porter tout seul, trouve de l'aide", ); - } else { - console.log("[CentralObject] Step is not helped or searching, skipping"); } }; @@ -40,8 +34,6 @@ export function CentralObject({ return <>; } - console.log("[CentralObject] Rendering, step:", step, "position:", position); - return ( - - + + {noMusic ? null : } {noCinematics ? null : } {noDialogues ? null : } -- 2.52.0 From 8d197ba26b024b5d40b9803de18c938734b8ad9f Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Tue, 12 May 2026 13:47:05 +0200 Subject: [PATCH 7/9] PR: refactor state game --- src/components/game/GameFlow.tsx | 4 ++-- src/components/three/interaction/NPCHelper.tsx | 4 ++-- src/components/three/interaction/PyloneDestroyed.tsx | 4 ++-- src/components/ui/IntroUI.tsx | 6 +++--- src/components/zone/ZoneDetection.tsx | 6 +++--- src/managers/stores/useGameStore.ts | 10 +++++----- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/game/GameFlow.tsx b/src/components/game/GameFlow.tsx index c2c88f0..c6eb975 100644 --- a/src/components/game/GameFlow.tsx +++ b/src/components/game/GameFlow.tsx @@ -4,8 +4,8 @@ import { useGameStore } from "@/managers/stores/useGameStore"; import { AUDIO_PATHS } from "@/data/audioConfig"; export function GameFlow(): null { - const step = useGameStore((state) => state.missionFlow.step); - const setStep = useGameStore((state) => state.setFlowStep); + const step = useGameStore((state) => state.intro.currentStep); + const setStep = useGameStore((state) => state.setIntroStep); const setActivityCity = useGameStore((state) => state.setActivityCity); const setCanMove = useGameStore((state) => state.setCanMove); const hasInitialized = useRef(false); diff --git a/src/components/three/interaction/NPCHelper.tsx b/src/components/three/interaction/NPCHelper.tsx index 1bda30e..e89e981 100644 --- a/src/components/three/interaction/NPCHelper.tsx +++ b/src/components/three/interaction/NPCHelper.tsx @@ -8,8 +8,8 @@ interface NPCHelperProps { } export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element { - const step = useGameStore((state) => state.missionFlow.step); - const setStep = useGameStore((state) => state.setFlowStep); + const step = useGameStore((state) => state.intro.currentStep); + const setStep = useGameStore((state) => state.setIntroStep); const debug = Debug.getInstance(); const handlePress = (): void => { diff --git a/src/components/three/interaction/PyloneDestroyed.tsx b/src/components/three/interaction/PyloneDestroyed.tsx index 8066314..f7c567c 100644 --- a/src/components/three/interaction/PyloneDestroyed.tsx +++ b/src/components/three/interaction/PyloneDestroyed.tsx @@ -10,8 +10,8 @@ interface PyloneDestroyedProps { export function PyloneDestroyed({ position, }: PyloneDestroyedProps): React.JSX.Element { - const step = useGameStore((state) => state.missionFlow.step); - const setStep = useGameStore((state) => state.setFlowStep); + const step = useGameStore((state) => state.intro.currentStep); + const setStep = useGameStore((state) => state.setIntroStep); const setCanMove = useGameStore((state) => state.setCanMove); const showDialog = useGameStore((state) => state.showDialog); const debug = Debug.getInstance(); diff --git a/src/components/ui/IntroUI.tsx b/src/components/ui/IntroUI.tsx index 4d092b3..751175c 100644 --- a/src/components/ui/IntroUI.tsx +++ b/src/components/ui/IntroUI.tsx @@ -2,9 +2,9 @@ import { useState } from "react"; import { useGameStore } from "@/managers/stores/useGameStore"; export function IntroUI(): React.JSX.Element | null { - const step = useGameStore((state) => state.missionFlow.step); + const step = useGameStore((state) => state.intro.currentStep); const setPlayerName = useGameStore((state) => state.setPlayerName); - const setStep = useGameStore((state) => state.setFlowStep); + const setStep = useGameStore((state) => state.setIntroStep); const [inputValue, setInputValue] = useState(""); if (step !== "naming") return null; @@ -100,7 +100,7 @@ export function IntroUI(): React.JSX.Element | null { } export function BienvenueDisplay(): React.JSX.Element | null { - const step = useGameStore((state) => state.missionFlow.step); + const step = useGameStore((state) => state.intro.currentStep); const playerName = useGameStore((state) => state.missionFlow.playerName); if (step !== "bienvenue") return null; diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx index fd7049b..3ce4f56 100644 --- a/src/components/zone/ZoneDetection.tsx +++ b/src/components/zone/ZoneDetection.tsx @@ -26,8 +26,8 @@ export function ZoneDetection(): null { const camera = useThree((state) => state.camera); const triggeredZones = useRef>(new Set()); const debug = Debug.getInstance(); - const step = useGameStore((state) => state.missionFlow.step); - const setStep = useGameStore((state) => state.setFlowStep); + const step = useGameStore((state) => state.intro.currentStep); + const setStep = useGameStore((state) => state.setIntroStep); useEffect(() => { if (!debug.active) return; @@ -45,7 +45,7 @@ export function ZoneDetection(): null { folder.add(playerPos, "z").name("Player Z").listen().disable(); const unsubStore = useGameStore.subscribe((state) => { - gameState.step = state.missionFlow.step; + gameState.step = state.intro.currentStep; folder.controllersRecursive().forEach((c) => c.updateDisplay()); }); diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 113835a..810855f 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -10,6 +10,7 @@ export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; export type { MissionStep, RepairMissionId }; interface IntroState { + currentStep: GameStep; dialogueAudio: string | null; hasCompleted: boolean; isBikeUnlocked: boolean; @@ -25,7 +26,6 @@ interface MissionFlowState { canMove: boolean; dialogMessage: string | null; playerName: string; - step: GameStep; } interface GameState { @@ -54,7 +54,7 @@ interface GameActions { hideDialog: () => void; setActivityCity: (activityCity: boolean) => void; setCanMove: (canMove: boolean) => void; - setFlowStep: (step: GameStep) => void; + setIntroStep: (step: GameStep) => void; setIntroState: (intro: Partial) => void; setPlayerName: (playerName: string) => void; setBikeState: (bike: Partial) => void; @@ -246,9 +246,9 @@ function createInitialGameState(): GameState { canMove: false, dialogMessage: null, playerName: "", - step: "intro", }, intro: { + currentStep: "intro", dialogueAudio: null, hasCompleted: false, isBikeUnlocked: false, @@ -291,8 +291,8 @@ export const useGameStore = create()((set) => ({ set((state) => ({ missionFlow: { ...state.missionFlow, canMove }, })), - setFlowStep: (step) => - set((state) => ({ missionFlow: { ...state.missionFlow, step } })), + setIntroStep: (step: GameStep) => + set((state) => ({ intro: { ...state.intro, currentStep: step } })), setIntroState: (intro) => set((state) => ({ intro: { ...state.intro, ...intro } })), setPlayerName: (playerName) => -- 2.52.0 From 700c088c4886632527df20b175feb3cb5ed2124f Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Tue, 12 May 2026 14:29:38 +0200 Subject: [PATCH 8/9] fix : audio and data issues --- .../ui/debug/GameStateDebugPanel.tsx | 22 +++++++++++++++---- src/data/audioConfig.ts | 10 ++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/components/ui/debug/GameStateDebugPanel.tsx b/src/components/ui/debug/GameStateDebugPanel.tsx index 664153d..92a1ffc 100644 --- a/src/components/ui/debug/GameStateDebugPanel.tsx +++ b/src/components/ui/debug/GameStateDebugPanel.tsx @@ -4,6 +4,20 @@ import { useGameStore, } from "@/managers/stores/useGameStore"; import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission"; +import { type GameStep } from "@/types/game"; + +const GAME_STEPS: GameStep[] = [ + "intro", + "start-intro", + "naming", + "bienvenue", + "star-move", + "mission2", + "searching", + "helped", + "manipulation", + "outOfFabrik", +]; const MAIN_STATES: MainGameState[] = [ "intro", @@ -29,7 +43,7 @@ export function GameStateDebugPanel(): React.JSX.Element { const detail = useGameStore((state) => { switch (state.mainState) { case "intro": - return state.intro.hasCompleted ? "completed" : "waiting"; + return state.intro.currentStep; case "bike": return state.bike.currentStep; case "pylone": @@ -41,7 +55,7 @@ export function GameStateDebugPanel(): React.JSX.Element { } }); const setMainState = useGameStore((state) => state.setMainState); - const setIntroState = useGameStore((state) => state.setIntroState); + const setIntroStep = useGameStore((state) => state.setIntroStep); const setBikeState = useGameStore((state) => state.setBikeState); const setPyloneState = useGameStore((state) => state.setPyloneState); const setFermeState = useGameStore((state) => state.setFermeState); @@ -52,14 +66,14 @@ export function GameStateDebugPanel(): React.JSX.Element { const subStateOptions = mainState === "intro" - ? ["waiting", "completed"] + ? GAME_STEPS : mainState === "outro" ? ["waiting", "started"] : MISSION_STEPS; function setSubState(nextSubState: string): void { if (mainState === "intro") { - setIntroState({ hasCompleted: nextSubState === "completed" }); + setIntroStep(nextSubState as GameStep); return; } diff --git a/src/data/audioConfig.ts b/src/data/audioConfig.ts index bc86196..6a7c6bc 100644 --- a/src/data/audioConfig.ts +++ b/src/data/audioConfig.ts @@ -1,7 +1,7 @@ export const AUDIO_PATHS = { - intro: "/sounds/fa.mp3", - bienvenue: "/sounds/fa.mp3", - alertCentral: "/sounds/fa.mp3", - searching: "/sounds/fa.mp3", - helped: "/sounds/fa.mp3", + intro: "/sounds/effect/fa.mp3", + bienvenue: "/sounds/effect/fa.mp3", + alertCentral: "/sounds/effect/fa.mp3", + searching: "/sounds/effect/fa.mp3", + helped: "/sounds/effect/fa.mp3", } as const; -- 2.52.0 From ceffedf684d648ddefe174550f45db723720ac8a Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Tue, 12 May 2026 16:51:35 +0200 Subject: [PATCH 9/9] fix : lint --- src/components/game/GameFlow.tsx | 18 +--- src/components/ui/IntroUI.tsx | 7 +- .../ui/debug/GameStateDebugPanel.tsx | 15 +--- src/components/zone/ZoneDetection.tsx | 83 ++++++++----------- src/hooks/useDialog.ts | 27 ------ src/managers/stores/useGameStore.ts | 42 +--------- src/types/game.ts | 17 +++- src/types/gameplay/repairMission.ts | 40 +++++++++ 8 files changed, 93 insertions(+), 156 deletions(-) delete mode 100644 src/hooks/useDialog.ts diff --git a/src/components/game/GameFlow.tsx b/src/components/game/GameFlow.tsx index c6eb975..1434ac7 100644 --- a/src/components/game/GameFlow.tsx +++ b/src/components/game/GameFlow.tsx @@ -11,22 +11,16 @@ export function GameFlow(): null { const hasInitialized = useRef(false); useEffect(() => { - console.log("[GameFlow] Current step:", step); if (!hasInitialized.current && step === "intro") { hasInitialized.current = true; - console.log("[GameFlow] Transition to start-intro"); setStep("start-intro"); } }, [step, setStep]); 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"); setStep("naming"); }); @@ -34,10 +28,8 @@ export function GameFlow(): null { } 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"); setCanMove(true); setStep("star-move"); }); @@ -46,30 +38,22 @@ export function GameFlow(): null { } if (step === "mission2") { - console.log("[GameFlow] mission2 - setting activityCity to false"); setActivityCity(false); const audio = AudioManager.getInstance(); audio.playSound(AUDIO_PATHS.alertCentral, 0.5); } if (step === "searching") { - console.log("[GameFlow] Playing searching audio"); const audio = AudioManager.getInstance(); - audio.playSoundWithCallback(AUDIO_PATHS.searching, 0.5, () => { - console.log("[GameFlow] searching audio ended"); - }); - - return () => {}; + audio.playSound(AUDIO_PATHS.searching, 0.5); } if (step === "helped") { - console.log("[GameFlow] Playing helped audio"); const audio = AudioManager.getInstance(); audio.playSound(AUDIO_PATHS.helped, 0.5); } if (step === "manipulation") { - console.log("[GameFlow] manipulation - blocking movement"); setCanMove(false); } diff --git a/src/components/ui/IntroUI.tsx b/src/components/ui/IntroUI.tsx index 751175c..45ace4e 100644 --- a/src/components/ui/IntroUI.tsx +++ b/src/components/ui/IntroUI.tsx @@ -12,11 +12,8 @@ export function IntroUI(): React.JSX.Element | null { const handleSubmit = (): void => { if (inputValue.trim() === "") return; - console.log("[IntroUI] Submitting, name:", inputValue.trim()); setPlayerName(inputValue.trim()); - console.log("[IntroUI] Calling transitionTo('bienvenue')"); setStep("bienvenue"); - console.log("[IntroUI] After transitionTo, step should be:", step); }; const handleKeyDown = (e: React.KeyboardEvent): void => { @@ -59,14 +56,14 @@ export function IntroUI(): React.JSX.Element | null { textAlign: "center", }} > - Quel est votre prénom ? + Quel est votre prenom ? setInputValue(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Votre prénom" + placeholder="Votre prenom" autoFocus style={{ padding: "0.75rem", diff --git a/src/components/ui/debug/GameStateDebugPanel.tsx b/src/components/ui/debug/GameStateDebugPanel.tsx index 92a1ffc..7632d6f 100644 --- a/src/components/ui/debug/GameStateDebugPanel.tsx +++ b/src/components/ui/debug/GameStateDebugPanel.tsx @@ -4,20 +4,7 @@ import { useGameStore, } from "@/managers/stores/useGameStore"; import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission"; -import { type GameStep } from "@/types/game"; - -const GAME_STEPS: GameStep[] = [ - "intro", - "start-intro", - "naming", - "bienvenue", - "star-move", - "mission2", - "searching", - "helped", - "manipulation", - "outOfFabrik", -]; +import { GAME_STEPS, type GameStep } from "@/types/game"; const MAIN_STATES: MainGameState[] = [ "intro", diff --git a/src/components/zone/ZoneDetection.tsx b/src/components/zone/ZoneDetection.tsx index 3ce4f56..16070f6 100644 --- a/src/components/zone/ZoneDetection.tsx +++ b/src/components/zone/ZoneDetection.tsx @@ -4,24 +4,11 @@ import * as THREE from "three"; import { ZONES } from "@/data/zones"; import { useGameStore } from "@/managers/stores/useGameStore"; import { Debug } from "@/utils/debug/Debug"; -import type { GameStep } from "@/types/game"; +import { GAME_STEPS } from "@/types/game"; const _playerPos = new THREE.Vector3(); const _zonePos = new THREE.Vector3(); -const GAME_STEPS: GameStep[] = [ - "intro", - "start-intro", - "naming", - "bienvenue", - "star-move", - "mission2", - "searching", - "helped", - "manipulation", - "outOfFabrik", -]; - export function ZoneDetection(): null { const camera = useThree((state) => state.camera); const triggeredZones = useRef>(new Set()); @@ -88,41 +75,6 @@ export function ZoneDetection(): null { return null; } -interface ZoneVisualProps { - position: [number, number, number]; - radius: number; - height: number; - triggered: boolean; -} - -function ZoneVisual({ - position, - radius, - height, - triggered, -}: ZoneVisualProps): React.JSX.Element { - const color = triggered ? "#00ff00" : "#ff0000"; - - return ( - - - - - - - - - - - ); -} - export function ZoneDebugVisuals(): React.JSX.Element | null { const debug = Debug.getInstance(); const camera = useThree((state) => state.camera); @@ -161,3 +113,36 @@ export function ZoneDebugVisuals(): React.JSX.Element | null { ); } + +function ZoneVisual({ + position, + radius, + height, + triggered, +}: { + position: [number, number, number]; + radius: number; + height: number; + triggered: boolean; +}): React.JSX.Element { + const color = triggered ? "#00ff00" : "#ff0000"; + + return ( + + + + + + + + + + + ); +} diff --git a/src/hooks/useDialog.ts b/src/hooks/useDialog.ts deleted file mode 100644 index 40cedbb..0000000 --- a/src/hooks/useDialog.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useState } from "react"; - -interface DialogState { - message: string; - visible: boolean; -} - -export function useDialog(): { - dialog: DialogState; - showDialog: (message: string) => void; - hideDialog: () => void; -} { - const [dialog, setDialog] = useState({ - message: "", - visible: false, - }); - - const showDialog = (message: string): void => { - setDialog({ message, visible: true }); - }; - - const hideDialog = (): void => { - setDialog((prev) => ({ ...prev, visible: false })); - }; - - return { dialog, showDialog, hideDialog }; -} diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 810855f..15489dd 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import type { GameStep } from "@/types/game"; import { isRepairMissionId, + getNextMissionStep, + getPreviousMissionStep, type MissionStep, type RepairMissionId, } from "@/types/gameplay/repairMission"; @@ -77,46 +79,6 @@ interface GameActions { type GameStore = GameState & GameActions; type GameStateUpdate = Partial; -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", diff --git a/src/types/game.ts b/src/types/game.ts index 292f30a..44bd7a5 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -12,6 +12,19 @@ export type GameStep = | "manipulation" | "outOfFabrik"; +export const GAME_STEPS: readonly GameStep[] = [ + "intro", + "start-intro", + "naming", + "bienvenue", + "star-move", + "mission2", + "searching", + "helped", + "manipulation", + "outOfFabrik", +] as const; + export interface Zone { id: string; position: Vector3Tuple; @@ -19,7 +32,3 @@ export interface Zone { height: number; targetStep: GameStep; } - -export interface GameState { - step: GameStep; -} diff --git a/src/types/gameplay/repairMission.ts b/src/types/gameplay/repairMission.ts index f8836b0..dc78e93 100644 --- a/src/types/gameplay/repairMission.ts +++ b/src/types/gameplay/repairMission.ts @@ -30,3 +30,43 @@ export function isRepairMissionId(value: string): value is RepairMissionId { export function isMissionStep(value: string): value is MissionStep { return (MISSION_STEPS as readonly string[]).includes(value); } + +export 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"; + } +} + +export 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"; + } +} -- 2.52.0