add zustand game state
This commit is contained in:
@@ -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`.
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={animation}
|
||||
frContent={animation}
|
||||
meta="07"
|
||||
meta="08"
|
||||
title="Animation & 3D Model System"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={editor}
|
||||
frContent={editorFr}
|
||||
meta="06"
|
||||
meta="07"
|
||||
title="Editor User Guide"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export function DocsFeaturesPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={features}
|
||||
frContent={featuresFr}
|
||||
meta="05"
|
||||
meta="06"
|
||||
title="Features"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user