refactor: move mission flow state into game store
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-05-11 18:02:00 +02:00
parent 67b35eb8d7
commit 836d591617
21 changed files with 461 additions and 188 deletions
+187
View File
@@ -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
+79
View File
@@ -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.
+7 -1
View File
@@ -59,6 +59,7 @@ Rule of thumb:
The store exposes: The store exposes:
- `mainState`: the active game phase - `mainState`: the active game phase
- `missionFlow`: intro and mission 2 prototype state
- `intro`: intro-specific state - `intro`: intro-specific state
- `bike`: e-bike mission state - `bike`: e-bike mission state
- `pylone`: power grid mission state - `pylone`: power grid mission state
@@ -66,6 +67,8 @@ The store exposes:
- `outro`: ending state - `outro`: ending state
- actions for direct updates and progression updates - 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: The mission steps currently use this sequence:
```ts ```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`. 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: That means the scene can progressively move toward this pattern:
```tsx ```tsx
@@ -171,8 +176,9 @@ Current overlays:
- `Crosshair`: player aiming helper - `Crosshair`: player aiming helper
- `InteractPrompt`: interaction prompt - `InteractPrompt`: interaction prompt
- `RepairMovementLockIndicator`: player-facing indicator shown while repair steps temporarily disable movement - `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 ## Regression Rules
+5 -5
View File
@@ -1,13 +1,13 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { AUDIO_PATHS } from "@/data/audioConfig"; import { AUDIO_PATHS } from "@/data/audioConfig";
export function GameFlow(): null { export function GameFlow(): null {
const step = useMissionFlowStore((state) => state.step); const step = useGameStore((state) => state.missionFlow.step);
const setStep = useMissionFlowStore((state) => state.setStep); const setStep = useGameStore((state) => state.setFlowStep);
const setActivityCity = useMissionFlowStore((state) => state.setActivityCity); const setActivityCity = useGameStore((state) => state.setActivityCity);
const setCanMove = useMissionFlowStore((state) => state.setCanMove); const setCanMove = useGameStore((state) => state.setCanMove);
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
useEffect(() => { useEffect(() => {
@@ -1,5 +1,5 @@
import { InteractableObject } from "@/components/three/interaction/InteractableObject"; 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 { Debug } from "@/utils/debug/Debug";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
@@ -10,10 +10,10 @@ interface CentralObjectProps {
export function CentralObject({ export function CentralObject({
position, position,
}: CentralObjectProps): React.JSX.Element { }: CentralObjectProps): React.JSX.Element {
const step = useMissionFlowStore((state) => state.step); const step = useGameStore((state) => state.missionFlow.step);
const setStep = useMissionFlowStore((state) => state.setStep); const setStep = useGameStore((state) => state.setFlowStep);
const setCanMove = useMissionFlowStore((state) => state.setCanMove); const setCanMove = useGameStore((state) => state.setCanMove);
const showDialog = useMissionFlowStore((state) => state.showDialog); const showDialog = useGameStore((state) => state.showDialog);
const debug = Debug.getInstance(); const debug = Debug.getInstance();
const handlePress = (): void => { const handlePress = (): void => {
@@ -1,5 +1,5 @@
import { InteractableObject } from "@/components/three/interaction/InteractableObject"; 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 { Debug } from "@/utils/debug/Debug";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
@@ -10,8 +10,8 @@ interface VillageoisHelperObjectProps {
export function VillageoisHelperObject({ export function VillageoisHelperObject({
position, position,
}: VillageoisHelperObjectProps): React.JSX.Element { }: VillageoisHelperObjectProps): React.JSX.Element {
const step = useMissionFlowStore((state) => state.step); const step = useGameStore((state) => state.missionFlow.step);
const setStep = useMissionFlowStore((state) => state.setStep); const setStep = useGameStore((state) => state.setFlowStep);
const debug = Debug.getInstance(); const debug = Debug.getInstance();
const handlePress = (): void => { const handlePress = (): void => {
+6 -6
View File
@@ -1,10 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; import { useGameStore } from "@/managers/stores/useGameStore";
export function IntroUI(): React.JSX.Element | null { export function IntroUI(): React.JSX.Element | null {
const step = useMissionFlowStore((state) => state.step); const step = useGameStore((state) => state.missionFlow.step);
const setPlayerName = useMissionFlowStore((state) => state.setPlayerName); const setPlayerName = useGameStore((state) => state.setPlayerName);
const setStep = useMissionFlowStore((state) => state.setStep); const setStep = useGameStore((state) => state.setFlowStep);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
if (step !== "naming") return null; if (step !== "naming") return null;
@@ -100,8 +100,8 @@ export function IntroUI(): React.JSX.Element | null {
} }
export function BienvenueDisplay(): React.JSX.Element | null { export function BienvenueDisplay(): React.JSX.Element | null {
const step = useMissionFlowStore((state) => state.step); const step = useGameStore((state) => state.missionFlow.step);
const playerName = useMissionFlowStore((state) => state.playerName); const playerName = useGameStore((state) => state.missionFlow.playerName);
if (step !== "bienvenue") return null; if (step !== "bienvenue") return null;
+6 -7
View File
@@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
import { ZONES } from "@/data/zones"; import { ZONES } from "@/data/zones";
import { GameStepManager } from "@/managers/GameStepManager"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
import { Debug } from "@/utils/debug/Debug"; import { Debug } from "@/utils/debug/Debug";
import type { GameStep } from "@/types/game"; import type { GameStep } from "@/types/game";
@@ -25,10 +24,10 @@ const GAME_STEPS: GameStep[] = [
export function ZoneDetection(): null { export function ZoneDetection(): null {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const manager = GameStepManager.getInstance();
const triggeredZones = useRef<Set<string>>(new Set()); const triggeredZones = useRef<Set<string>>(new Set());
const debug = Debug.getInstance(); const debug = Debug.getInstance();
const step = useMissionFlowStore((state) => state.step); const step = useGameStore((state) => state.missionFlow.step);
const setStep = useGameStore((state) => state.setFlowStep);
useEffect(() => { useEffect(() => {
if (!debug.active) return; if (!debug.active) return;
@@ -45,8 +44,8 @@ export function ZoneDetection(): null {
folder.add(playerPos, "y").name("Player Y").listen().disable(); folder.add(playerPos, "y").name("Player Y").listen().disable();
folder.add(playerPos, "z").name("Player Z").listen().disable(); folder.add(playerPos, "z").name("Player Z").listen().disable();
const unsubStore = useMissionFlowStore.subscribe((state) => { const unsubStore = useGameStore.subscribe((state) => {
gameState.step = state.step; gameState.step = state.missionFlow.step;
folder.controllersRecursive().forEach((c) => c.updateDisplay()); folder.controllersRecursive().forEach((c) => c.updateDisplay());
}); });
@@ -79,7 +78,7 @@ export function ZoneDetection(): null {
const distanceSq = _playerPos.distanceToSquared(_zonePos); const distanceSq = _playerPos.distanceToSquared(_zonePos);
if (distanceSq <= zone.radius * zone.radius) { if (distanceSq <= zone.radius * zone.radius) {
manager.transitionTo(zone.targetStep); setStep(zone.targetStep);
triggeredZones.current.add(zone.id); triggeredZones.current.add(zone.id);
break; break;
} }
+10 -4
View File
@@ -50,6 +50,12 @@ export const docGroups: DocGroup[] = [
subtitle: "Progression store", subtitle: "Progression store",
meta: "06", 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", path: "/docs/features",
title: "Features", title: "Features",
subtitle: "Implemented scope", subtitle: "Implemented scope",
meta: "07", meta: "08",
}, },
{ {
path: "/docs/main-feature", path: "/docs/main-feature",
title: "Main Feature", title: "Main Feature",
subtitle: "Repair-game prototype", subtitle: "Repair-game prototype",
meta: "08", meta: "09",
}, },
{ {
path: "/docs/editor", path: "/docs/editor",
title: "Editor User Guide", title: "Editor User Guide",
subtitle: "Editing workflow", subtitle: "Editing workflow",
meta: "09", meta: "10",
}, },
{ {
path: "/docs/animation", path: "/docs/animation",
title: "Animation & 3D Model System", title: "Animation & 3D Model System",
subtitle: "Components and usage", subtitle: "Components and usage",
meta: "010", meta: "11",
}, },
], ],
}, },
+79 -1
View File
@@ -328,6 +328,7 @@ Règle simple :
Le store expose : Le store expose :
- \`mainState\` : phase active du jeu - \`mainState\` : phase active du jeu
- \`missionFlow\` : état prototype de l'intro et de la mission 2
- \`intro\` : état spécifique à l'intro - \`intro\` : état spécifique à l'intro
- \`bike\` : état de la mission vélo - \`bike\` : état de la mission vélo
- \`pylone\` : état de la mission réseau électrique - \`pylone\` : état de la mission réseau électrique
@@ -335,6 +336,8 @@ Le store expose :
- \`outro\` : état de fin - \`outro\` : état de fin
- des actions de mise à jour directe et des actions de progression - 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 : Les étapes de mission utilisent actuellement cette séquence :
\`\`\`ts \`\`\`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\`. \`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 : La scène peut donc évoluer progressivement vers ce pattern :
\`\`\`tsx \`\`\`tsx
@@ -431,8 +436,9 @@ Overlays actuels :
- \`Crosshair\` : aide de visée joueur - \`Crosshair\` : aide de visée joueur
- \`InteractPrompt\` : prompt d'interaction - \`InteractPrompt\` : prompt d'interaction
- \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement - \`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 ## 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. 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 export const featuresFr = `# Fonctionnalités implémentées
Ce document liste les fonctionnalités présentes dans le code actuel. Ce document liste les fonctionnalités présentes dans le code actuel.
+2 -2
View File
@@ -1,5 +1,5 @@
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore"; import { useGameStore } from "@/managers/stores/useGameStore";
export function useActivityCity(): boolean { export function useActivityCity(): boolean {
return useMissionFlowStore((state) => state.activityCity); return useGameStore((state) => state.missionFlow.activityCity);
} }
-11
View File
@@ -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(),
);
}
-95
View File
@@ -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());
}
}
+45
View File
@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { GameStep } from "@/types/game";
import { import {
isRepairMissionId, isRepairMissionId,
type MissionStep, type MissionStep,
@@ -19,9 +20,18 @@ interface MissionState {
dialogueAudio: string | null; dialogueAudio: string | null;
} }
interface MissionFlowState {
activityCity: boolean;
canMove: boolean;
dialogMessage: string | null;
playerName: string;
step: GameStep;
}
interface GameState { interface GameState {
mainState: MainGameState; mainState: MainGameState;
isCinematicPlaying: boolean; isCinematicPlaying: boolean;
missionFlow: MissionFlowState;
intro: IntroState; intro: IntroState;
bike: MissionState & { bike: MissionState & {
isRepaired: boolean; isRepaired: boolean;
@@ -41,7 +51,12 @@ interface GameState {
interface GameActions { interface GameActions {
setMainState: (mainState: MainGameState) => void; setMainState: (mainState: MainGameState) => void;
setCinematicPlaying: (isCinematicPlaying: boolean) => void; setCinematicPlaying: (isCinematicPlaying: boolean) => void;
hideDialog: () => void;
setActivityCity: (activityCity: boolean) => void;
setCanMove: (canMove: boolean) => void;
setFlowStep: (step: GameStep) => void;
setIntroState: (intro: Partial<IntroState>) => void; setIntroState: (intro: Partial<IntroState>) => void;
setPlayerName: (playerName: string) => void;
setBikeState: (bike: Partial<GameState["bike"]>) => void; setBikeState: (bike: Partial<GameState["bike"]>) => void;
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void; setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
setFermeState: (ferme: Partial<GameState["ferme"]>) => void; setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
@@ -56,6 +71,7 @@ interface GameActions {
advanceGameState: () => void; advanceGameState: () => void;
rewindGameState: () => void; rewindGameState: () => void;
resetGame: () => void; resetGame: () => void;
showDialog: (dialogMessage: string) => void;
} }
type GameStore = GameState & GameActions; type GameStore = GameState & GameActions;
@@ -225,6 +241,13 @@ function createInitialGameState(): GameState {
return { return {
mainState: "intro", mainState: "intro",
isCinematicPlaying: false, isCinematicPlaying: false,
missionFlow: {
activityCity: true,
canMove: false,
dialogMessage: null,
playerName: "",
step: "intro",
},
intro: { intro: {
dialogueAudio: null, dialogueAudio: null,
hasCompleted: false, hasCompleted: false,
@@ -256,8 +279,26 @@ export const useGameStore = create<GameStore>()((set) => ({
...createInitialGameState(), ...createInitialGameState(),
setMainState: (mainState) => set({ mainState }), setMainState: (mainState) => set({ mainState }),
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }), 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) => setIntroState: (intro) =>
set((state) => ({ intro: { ...state.intro, ...intro } })), set((state) => ({ intro: { ...state.intro, ...intro } })),
setPlayerName: (playerName) =>
set((state) => ({
missionFlow: { ...state.missionFlow, playerName },
})),
setBikeState: (bike) => setBikeState: (bike) =>
set((state) => ({ bike: { ...state.bike, ...bike } })), set((state) => ({ bike: { ...state.bike, ...bike } })),
setPyloneState: (pylone) => setPyloneState: (pylone) =>
@@ -300,4 +341,8 @@ export const useGameStore = create<GameStore>()((set) => ({
return { outro: { ...state.outro, hasStarted: false } }; return { outro: { ...state.outro, hasStarted: false } };
}), }),
resetGame: () => set(createInitialGameState()), resetGame: () => set(createInitialGameState()),
showDialog: (dialogMessage) =>
set((state) => ({
missionFlow: { ...state.missionFlow, dialogMessage },
})),
})); }));
@@ -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 }),
}));
+14
View File
@@ -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 (
<DocsDocument
content={missionFlow}
frContent={missionFlowFr}
meta="07"
title="Mission Flow"
/>
);
}
+5 -3
View File
@@ -6,7 +6,7 @@ import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI"; import { GameUI } from "@/components/ui/GameUI";
import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI"; import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; 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 { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import { import {
INITIAL_SCENE_LOADING_STATE, INITIAL_SCENE_LOADING_STATE,
@@ -15,8 +15,10 @@ import {
import { World } from "@/world/World"; import { World } from "@/world/World";
export function HomePage(): React.JSX.Element { export function HomePage(): React.JSX.Element {
const dialogMessage = useMissionFlowStore((state) => state.dialogMessage); const dialogMessage = useGameStore(
const hideDialog = useMissionFlowStore((state) => state.hideDialog); (state) => state.missionFlow.dialogMessage,
);
const hideDialog = useGameStore((state) => state.hideDialog);
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>( const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
INITIAL_SCENE_LOADING_STATE, INITIAL_SCENE_LOADING_STATE,
); );
+2
View File
@@ -14,6 +14,7 @@ import {
DocsHandTrackingRoute, DocsHandTrackingRoute,
DocsLayoutRoute, DocsLayoutRoute,
DocsMainFeatureRoute, DocsMainFeatureRoute,
DocsMissionFlowRoute,
DocsReadmeRoute, DocsReadmeRoute,
DocsTargetArchitectureRoute, DocsTargetArchitectureRoute,
DocsTechnicalEditorRoute, DocsTechnicalEditorRoute,
@@ -49,6 +50,7 @@ const docsChildRoutes = [
{ path: "technical-editor", component: DocsTechnicalEditorRoute }, { path: "technical-editor", component: DocsTechnicalEditorRoute },
{ path: "hand-tracking", component: DocsHandTrackingRoute }, { path: "hand-tracking", component: DocsHandTrackingRoute },
{ path: "zustand", component: DocsZustandRoute }, { path: "zustand", component: DocsZustandRoute },
{ path: "mission-flow", component: DocsMissionFlowRoute },
{ path: "features", component: DocsFeaturesRoute }, { path: "features", component: DocsFeaturesRoute },
{ path: "main-feature", component: DocsMainFeatureRoute }, { path: "main-feature", component: DocsMainFeatureRoute },
{ path: "editor", component: DocsEditorRoute }, { path: "editor", component: DocsEditorRoute },
+5
View File
@@ -55,6 +55,10 @@ const LazyDocsZustandPage = lazyNamed(
() => import("@/pages/docs/zustand/page"), () => import("@/pages/docs/zustand/page"),
"DocsZustandPage", "DocsZustandPage",
); );
const LazyDocsMissionFlowPage = lazyNamed(
() => import("@/pages/docs/mission-flow/page"),
"DocsMissionFlowPage",
);
const LazyDocsFeaturesPage = lazyNamed( const LazyDocsFeaturesPage = lazyNamed(
() => import("@/pages/docs/features/page"), () => import("@/pages/docs/features/page"),
"DocsFeaturesPage", "DocsFeaturesPage",
@@ -83,6 +87,7 @@ export const DocsTechnicalEditorRoute = createDocsRoute(
); );
export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage); export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage);
export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage); export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage);
export const DocsMissionFlowRoute = createDocsRoute(LazyDocsMissionFlowPage);
export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage); export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);
export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage); export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage);
export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage); export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage);
-8
View File
@@ -23,11 +23,3 @@ export interface Zone {
export interface GameState { export interface GameState {
step: GameStep; step: GameStep;
} }
export interface GameStepSnapshot {
step: GameStep;
playerName: string;
canMove: boolean;
transitionTo: (step: GameStep) => void;
setPlayerName: (name: string) => void;
}
+1 -2
View File
@@ -26,7 +26,6 @@ import {
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked"; import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
import { InteractionManager } from "@/managers/InteractionManager"; import { InteractionManager } from "@/managers/InteractionManager";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
@@ -95,7 +94,7 @@ export function PlayerController({
const velocity = useRef(new THREE.Vector3()); const velocity = useRef(new THREE.Vector3());
const onFloor = useRef(false); const onFloor = useRef(false);
const wantsJump = useRef(false); const wantsJump = useRef(false);
const canMove = useMissionFlowStore((state) => state.canMove); const canMove = useGameStore((state) => state.missionFlow.canMove);
const capsule = useRef( const capsule = useRef(
new Capsule( new Capsule(