From b9970c4e0358e0397446f1ae2bb3e5c3d99a3495 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 30 Apr 2026 14:29:29 +0200 Subject: [PATCH] add zustand game state --- docs/technical/zustand.md | 133 +++++++++++++++++++++++ src/components/ui/GameUI.tsx | 13 +++ src/data/docs/docsSections.ts | 12 ++- src/data/docs/docsTranslations.ts | 135 ++++++++++++++++++++++++ src/pages/docs/animation/page.tsx | 2 +- src/pages/docs/editor/page.tsx | 2 +- src/pages/docs/features/page.tsx | 2 +- src/pages/docs/zustand/page.tsx | 14 +++ src/pages/page.tsx | 8 +- src/router.tsx | 2 + src/routes/docs/DocsRouteComponents.tsx | 14 +++ 11 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 docs/technical/zustand.md create mode 100644 src/components/ui/GameUI.tsx create mode 100644 src/pages/docs/zustand/page.tsx diff --git a/docs/technical/zustand.md b/docs/technical/zustand.md new file mode 100644 index 0000000..0913a70 --- /dev/null +++ b/docs/technical/zustand.md @@ -0,0 +1,133 @@ +# Zustand Game State + +This document explains how Zustand is used in the current project. + +## Why Zustand Exists Here + +The project needs one shared source of truth for the player's progression through the experience. + +The current progression is split into main states: + +| Main state | Role | +| ---------- | ------------------------------- | +| `intro` | Onboarding and opening sequence | +| `bike` | E-bike repair sequence | +| `pylone` | Power grid sequence | +| `ferme` | Vertical farm sequence | +| `outro` | Ending sequence | + +Each main state can also own smaller sub state, such as the current mission step, dialogue audio, or completion flags. + +Zustand is useful because React and React Three Fiber components can subscribe only to the state slice they need. When that slice changes, only the subscribed components re-render. + +## Store Location + +The game progression store lives here: + +```txt +src/managers/stores/useGameStore.ts +``` + +The store is placed under `src/managers/stores/` because it belongs to the gameplay orchestration layer, not to a specific visual component. + +## Current Shape + +The store exposes: + +- `mainState`: the active game phase +- `intro`: intro-specific state +- `bike`: e-bike mission state +- `pylone`: power grid mission state +- `ferme`: farm mission state +- `outro`: ending state +- actions for direct updates and progression updates + +The mission steps currently use this sequence: + +```ts +"locked" | "waiting" | "inspect" | "scanning" | "repairing" | "done"; +``` + +## Reading State In Components + +Use selectors to read only what the component needs. + +```tsx +import { useGameStore } from "@/managers/stores/useGameStore"; + +export function Example(): React.JSX.Element { + const mainState = useGameStore((state) => state.mainState); + + return

Current state: {mainState}

; +} +``` + +This is better than reading the whole store, because the component re-renders only when `mainState` changes. + +## Updating State + +Prefer explicit actions from the store. + +```ts +const advanceGameState = useGameStore((state) => state.advanceGameState); + +advanceGameState(); +``` + +For development and debug tooling, direct setters also exist: + +```ts +const setMainState = useGameStore((state) => state.setMainState); + +setMainState("bike"); +``` + +Direct setters are useful for debug panels, but production gameplay should prefer business actions such as `advanceGameState`, `completeBike`, or `completePylone`. + +## World Integration + +`src/world/GameStageContent.tsx` subscribes to `mainState` and mounts stage-specific content. + +That means the scene can progressively move toward this pattern: + +```tsx +switch (mainState) { + case "intro": + return ; + case "bike": + return ; + case "pylone": + return ; + case "ferme": + return ; + case "outro": + return ; +} +``` + +In React Three Fiber, mounting and unmounting JSX controls what appears in the Three.js scene. When a state-specific component disappears from JSX, React removes it from the scene. + +## UI Integration + +`src/components/ui/GameUI.tsx` groups the HTML overlays used by the playable route. + +Current overlays: + +- `GameStateHUD`: debug-only progression panel shown with `?debug` +- `Crosshair`: player aiming helper +- `InteractPrompt`: interaction prompt + +`src/pages/page.tsx` should stay thin and mount only the canvas and `GameUI`. + +## Regression Rules + +- Do not store per-frame values in Zustand. +- Use `useRef` for high-frequency mutable values such as player velocity, temporary vectors, or animation-loop data. +- Use selectors instead of reading the whole store in components. +- Keep gameplay transitions inside store actions when possible. +- Keep debug-only controls behind `?debug`. +- Add new state only when a real runtime feature needs it. + +## Next Steps + +The next natural step is to replace the temporary stage anchors in `GameStageContent` with real stage components, for example `IntroContent`, `BikeContent`, `PyloneContent`, `FermeContent`, and `OutroContent`. diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx new file mode 100644 index 0000000..2581d02 --- /dev/null +++ b/src/components/ui/GameUI.tsx @@ -0,0 +1,13 @@ +import { Crosshair } from "@/components/ui/Crosshair"; +import { GameStateHUD } from "@/components/ui/GameStateHUD"; +import { InteractPrompt } from "@/components/ui/InteractPrompt"; + +export function GameUI(): React.JSX.Element { + return ( + <> + + + + + ); +} diff --git a/src/data/docs/docsSections.ts b/src/data/docs/docsSections.ts index 8c4aad6..07343c6 100644 --- a/src/data/docs/docsSections.ts +++ b/src/data/docs/docsSections.ts @@ -38,6 +38,12 @@ export const docGroups: DocGroup[] = [ subtitle: "Implementation details", meta: "04", }, + { + path: "/docs/zustand", + title: "Zustand Game State", + subtitle: "Progression store", + meta: "05", + }, ], }, { @@ -47,19 +53,19 @@ export const docGroups: DocGroup[] = [ path: "/docs/features", title: "Features", subtitle: "Implemented scope", - meta: "05", + meta: "06", }, { path: "/docs/editor", title: "Editor User Guide", subtitle: "Editing workflow", - meta: "06", + meta: "07", }, { path: "/docs/animation", title: "Animation & 3D Model System", subtitle: "Components and usage", - meta: "07", + meta: "08", }, ], }, diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts index 82d45ec..6c1ad7d 100644 --- a/src/data/docs/docsTranslations.ts +++ b/src/data/docs/docsTranslations.ts @@ -207,6 +207,141 @@ Ce document décrit l'architecture visée à moyen terme pour le projet. - Les chemins runtime uniquement debug doivent être clairement marqués et faciles à retirer plus tard. `; +export const zustandFr = `# État de jeu Zustand + +Ce document explique comment Zustand est utilisé dans le projet actuel. + +## Pourquoi Zustand existe ici + +Le projet a besoin d'une source de vérité partagée pour suivre la progression du joueur dans l'expérience. + +La progression actuelle est découpée en main states : + +| Main state | Rôle | +| --- | --- | +| \`intro\` | Onboarding et séquence d'ouverture | +| \`bike\` | Séquence de réparation du vélo électrique | +| \`pylone\` | Séquence du réseau électrique | +| \`ferme\` | Séquence de la ferme verticale | +| \`outro\` | Séquence de fin | + +Chaque main state peut aussi posséder un sous-état plus fin, comme l'étape de mission courante, l'audio de dialogue ou des flags de complétion. + +Zustand est utile parce que les composants React et React Three Fiber peuvent s'abonner uniquement à la partie de state dont ils ont besoin. Quand cette partie change, seuls les composants abonnés se mettent à jour. + +## Emplacement du store + +Le store de progression du jeu vit ici : + +\`\`\`txt +src/managers/stores/useGameStore.ts +\`\`\` + +Le store est placé dans \`src/managers/stores/\` parce qu'il appartient à la couche d'orchestration gameplay, pas à un composant visuel précis. + +## Forme actuelle + +Le store expose : + +- \`mainState\` : phase active du jeu +- \`intro\` : état spécifique à l'intro +- \`bike\` : état de la mission vélo +- \`pylone\` : état de la mission réseau électrique +- \`ferme\` : état de la mission ferme +- \`outro\` : état de fin +- des actions de mise à jour directe et des actions de progression + +Les étapes de mission utilisent actuellement cette séquence : + +\`\`\`ts +"locked" | "waiting" | "inspect" | "scanning" | "repairing" | "done" +\`\`\` + +## Lire le state dans un composant + +Utilise des selectors pour lire uniquement ce dont le composant a besoin. + +\`\`\`tsx +import { useGameStore } from "@/managers/stores/useGameStore"; + +export function Example(): React.JSX.Element { + const mainState = useGameStore((state) => state.mainState); + + return

State courant : {mainState}

; +} +\`\`\` + +C'est mieux que de lire tout le store, car le composant se re-render uniquement quand \`mainState\` change. + +## Mettre à jour le state + +Préfère les actions explicites du store. + +\`\`\`ts +const advanceGameState = useGameStore((state) => state.advanceGameState); + +advanceGameState(); +\`\`\` + +Pour le développement et le debug, des setters directs existent aussi : + +\`\`\`ts +const setMainState = useGameStore((state) => state.setMainState); + +setMainState("bike"); +\`\`\` + +Les setters directs sont pratiques pour les panneaux debug, mais le gameplay de production devrait préférer les actions métier comme \`advanceGameState\`, \`completeBike\` ou \`completePylone\`. + +## Intégration avec le World + +\`src/world/GameStageContent.tsx\` s'abonne à \`mainState\` et monte le contenu spécifique au state courant. + +La scène peut donc évoluer progressivement vers ce pattern : + +\`\`\`tsx +switch (mainState) { + case "intro": + return ; + case "bike": + return ; + case "pylone": + return ; + case "ferme": + return ; + case "outro": + return ; +} +\`\`\` + +Dans React Three Fiber, monter ou démonter du JSX contrôle ce qui apparaît dans la scène Three.js. Quand un composant lié à un state disparaît du JSX, React le retire de la scène. + +## Intégration UI + +\`src/components/ui/GameUI.tsx\` regroupe les overlays HTML utilisés par la route jouable. + +Overlays actuels : + +- \`GameStateHUD\` : panneau de progression debug visible avec \`?debug\` +- \`Crosshair\` : aide de visée joueur +- \`InteractPrompt\` : prompt d'interaction + +\`src/pages/page.tsx\` doit rester fin et monter seulement le canvas et \`GameUI\`. + +## Règles anti-régression + +- Ne pas stocker les valeurs mises à jour à chaque frame dans Zustand. +- Utiliser \`useRef\` pour les valeurs mutables fréquentes comme la vélocité joueur, les vecteurs temporaires ou les données de boucle d'animation. +- Utiliser des selectors au lieu de lire tout le store dans les composants. +- Garder les transitions gameplay dans les actions du store quand possible. +- Garder les contrôles debug derrière \`?debug\`. +- Ajouter du state uniquement quand une vraie fonctionnalité runtime en a besoin. + +## Prochaines étapes + +La prochaine étape naturelle est de remplacer les ancres temporaires de \`GameStageContent\` par de vrais composants de phase, par exemple \`IntroContent\`, \`BikeContent\`, \`PyloneContent\`, \`FermeContent\` et \`OutroContent\`. +`; + export const featuresFr = `# Fonctionnalités implémentées Ce document liste les fonctionnalités présentes dans le code actuel. diff --git a/src/pages/docs/animation/page.tsx b/src/pages/docs/animation/page.tsx index 93975ce..fb5404b 100644 --- a/src/pages/docs/animation/page.tsx +++ b/src/pages/docs/animation/page.tsx @@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element { ); diff --git a/src/pages/docs/editor/page.tsx b/src/pages/docs/editor/page.tsx index 53c7236..deb3960 100644 --- a/src/pages/docs/editor/page.tsx +++ b/src/pages/docs/editor/page.tsx @@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element { ); diff --git a/src/pages/docs/features/page.tsx b/src/pages/docs/features/page.tsx index 4b41580..8010b5f 100644 --- a/src/pages/docs/features/page.tsx +++ b/src/pages/docs/features/page.tsx @@ -7,7 +7,7 @@ export function DocsFeaturesPage(): React.JSX.Element { ); diff --git a/src/pages/docs/zustand/page.tsx b/src/pages/docs/zustand/page.tsx new file mode 100644 index 0000000..529be3a --- /dev/null +++ b/src/pages/docs/zustand/page.tsx @@ -0,0 +1,14 @@ +import zustand from "../../../../docs/technical/zustand.md?raw"; +import { DocsDocument } from "@/components/docs/DocsDocument"; +import { zustandFr } from "@/data/docs/docsTranslations"; + +export function DocsZustandPage(): React.JSX.Element { + return ( + + ); +} diff --git a/src/pages/page.tsx b/src/pages/page.tsx index a601019..dad9a41 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -1,9 +1,7 @@ import { Suspense } from "react"; import { Canvas } from "@react-three/fiber"; -import { Crosshair } from "@/components/ui/Crosshair"; -import { GameStateHUD } from "@/components/ui/GameStateHUD"; -import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { DebugPerf } from "@/components/debug/DebugPerf"; +import { GameUI } from "@/components/ui/GameUI"; import { World } from "@/world/World"; export function HomePage(): React.JSX.Element { @@ -15,9 +13,7 @@ export function HomePage(): React.JSX.Element { - - - + ); } diff --git a/src/router.tsx b/src/router.tsx index d17b5c1..6e53dcd 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -15,6 +15,7 @@ import { DocsReadmeRoute, DocsTargetArchitectureRoute, DocsTechnicalEditorRoute, + DocsZustandRoute, } from "@/routes/docs/DocsRouteComponents"; const rootRoute = createRootRoute({ @@ -44,6 +45,7 @@ const docsChildRoutes = [ { path: "architecture", component: DocsArchitectureRoute }, { path: "target-architecture", component: DocsTargetArchitectureRoute }, { path: "technical-editor", component: DocsTechnicalEditorRoute }, + { path: "zustand", component: DocsZustandRoute }, { path: "features", component: DocsFeaturesRoute }, { path: "editor", component: DocsEditorRoute }, { path: "animation", component: DocsAnimationRoute }, diff --git a/src/routes/docs/DocsRouteComponents.tsx b/src/routes/docs/DocsRouteComponents.tsx index 51f96bd..3e591ea 100644 --- a/src/routes/docs/DocsRouteComponents.tsx +++ b/src/routes/docs/DocsRouteComponents.tsx @@ -30,6 +30,12 @@ const LazyDocsTechnicalEditorPage = lazy(() => })), ); +const LazyDocsZustandPage = lazy(() => + import("@/pages/docs/zustand/page").then((module) => ({ + default: module.DocsZustandPage, + })), +); + const LazyDocsFeaturesPage = lazy(() => import("@/pages/docs/features/page").then((module) => ({ default: module.DocsFeaturesPage, @@ -88,6 +94,14 @@ export function DocsTechnicalEditorRoute(): React.JSX.Element { ); } +export function DocsZustandRoute(): React.JSX.Element { + return ( + + + + ); +} + export function DocsFeaturesRoute(): React.JSX.Element { return (