From 836d591617b1ec78dc069448ae85ade0890975e6 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 11 May 2026 18:02:00 +0200 Subject: [PATCH] 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(