add zustand game state

This commit is contained in:
Tom Boullay
2026-04-30 14:29:29 +02:00
parent cf20aa8ea4
commit 0f845f28c5
11 changed files with 325 additions and 12 deletions
+133
View File
@@ -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 <p>Current state: {mainState}</p>;
}
```
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 <IntroContent />;
case "bike":
return <BikeContent />;
case "pylone":
return <PyloneContent />;
case "ferme":
return <FarmContent />;
case "outro":
return <OutroContent />;
}
```
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`.
+13
View File
@@ -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 (
<>
<GameStateHUD />
<Crosshair />
<InteractPrompt />
</>
);
}
+9 -3
View File
@@ -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",
},
],
},
+135
View File
@@ -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 <p>State courant : {mainState}</p>;
}
\`\`\`
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 <IntroContent />;
case "bike":
return <BikeContent />;
case "pylone":
return <PyloneContent />;
case "ferme":
return <FarmContent />;
case "outro":
return <OutroContent />;
}
\`\`\`
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.
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
<DocsDocument
content={animation}
frContent={animation}
meta="07"
meta="08"
title="Animation & 3D Model System"
/>
);
+1 -1
View File
@@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element {
<DocsDocument
content={editor}
frContent={editorFr}
meta="06"
meta="07"
title="Editor User Guide"
/>
);
+1 -1
View File
@@ -7,7 +7,7 @@ export function DocsFeaturesPage(): React.JSX.Element {
<DocsDocument
content={features}
frContent={featuresFr}
meta="05"
meta="06"
title="Features"
/>
);
+14
View File
@@ -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 (
<DocsDocument
content={zustand}
frContent={zustandFr}
meta="05"
title="Zustand Game State"
/>
);
}
+2 -6
View File
@@ -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 {
<DebugPerf />
</Suspense>
</Canvas>
<GameStateHUD />
<Crosshair />
<InteractPrompt />
<GameUI />
</>
);
}
+2
View File
@@ -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 },
+14
View File
@@ -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 (
<Suspense fallback={null}>
<LazyDocsZustandPage />
</Suspense>
);
}
export function DocsFeaturesRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>