From 269628948330a6e9af98a930ab62f9a7932a1293 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 30 Apr 2026 14:04:01 +0200 Subject: [PATCH 1/6] add zustand game progression store --- package-lock.json | 3 +- package.json | 3 +- src/managers/stores/useGameStore.ts | 91 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/managers/stores/useGameStore.ts diff --git a/package-lock.json b/package-lock.json index aa6d43a..a417dbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "react-dom": "^19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "three": "^0.183.2" + "three": "^0.183.2", + "zustand": "^5.0.12" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/package.json b/package.json index e9c0ac5..00d7630 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "react-dom": "^19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "three": "^0.183.2" + "three": "^0.183.2", + "zustand": "^5.0.12" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts new file mode 100644 index 0000000..6d0e8fd --- /dev/null +++ b/src/managers/stores/useGameStore.ts @@ -0,0 +1,91 @@ +import { create } from "zustand"; + +export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; +export type MissionStep = "locked" | "inspect" | "repair" | "done"; + +export interface IntroState { + dialogueAudio: string | null; + hasCompleted: boolean; + isBikeUnlocked: boolean; +} + +export interface MissionState { + currentStep: MissionStep; + dialogueAudio: string | null; +} + +export interface GameState { + mainState: MainGameState; + intro: IntroState; + bike: MissionState & { + isRepaired: boolean; + }; + pylone: MissionState & { + isPowered: boolean; + }; + ferme: MissionState & { + irrigationFixed: boolean; + }; + outro: { + dialogueAudio: string | null; + hasStarted: boolean; + }; +} + +interface GameActions { + setMainState: (mainState: MainGameState) => void; + setIntroState: (intro: Partial) => void; + setBikeState: (bike: Partial) => void; + setPyloneState: (pylone: Partial) => void; + setFermeState: (ferme: Partial) => void; + setOutroState: (outro: Partial) => void; + resetGame: () => void; +} + +export type GameStore = GameState & GameActions; + +function createInitialGameState(): GameState { + return { + mainState: "intro", + intro: { + dialogueAudio: ""null, + hasCompleted: false, + isBikeUnlocked: false, + }, + bike: { + currentStep: "locked", + dialogueAudio: null, + isRepaired: false, + }, + pylone: { + currentStep: "locked", + dialogueAudio: null, + isPowered: false, + }, + ferme: { + currentStep: "locked", + dialogueAudio: null, + irrigationFixed: false, + }, + outro: { + dialogueAudio: null, + hasStarted: false, + }, + }; +} + +export const useGameStore = create()((set) => ({ + ...createInitialGameState(), + setMainState: (mainState) => set({ mainState }), + setIntroState: (intro) => + set((state) => ({ intro: { ...state.intro, ...intro } })), + setBikeState: (bike) => + set((state) => ({ bike: { ...state.bike, ...bike } })), + setPyloneState: (pylone) => + set((state) => ({ pylone: { ...state.pylone, ...pylone } })), + setFermeState: (ferme) => + set((state) => ({ ferme: { ...state.ferme, ...ferme } })), + setOutroState: (outro) => + set((state) => ({ outro: { ...state.outro, ...outro } })), + resetGame: () => set(createInitialGameState()), +})); From 7c7dbdb588e19760aedd905759bdd8eea85cb4d9 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 30 Apr 2026 14:24:59 +0200 Subject: [PATCH 2/6] connect game progression state to world --- src/components/three/AnimatedModel.tsx | 2 +- src/components/ui/GameStateHUD.tsx | 70 ++++++++++ src/index.css | 71 ++++++++++ src/managers/stores/useGameStore.ts | 184 ++++++++++++++++++++++++- src/pages/page.tsx | 2 + src/world/GameStageContent.tsx | 44 ++++++ src/world/World.tsx | 6 +- 7 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/GameStateHUD.tsx create mode 100644 src/world/GameStageContent.tsx diff --git a/src/components/three/AnimatedModel.tsx b/src/components/three/AnimatedModel.tsx index f9c5991..099f14c 100644 --- a/src/components/three/AnimatedModel.tsx +++ b/src/components/three/AnimatedModel.tsx @@ -151,7 +151,7 @@ export function AnimatedModel({ defaultAction.play(); // eslint-disable-next-line react-hooks/set-state-in-effect setIsReady(true); - // eslint-disable-next-line react-hooks/set-state-in-effect + setCurrentAnim(defaultAction.getClip().name); onLoaded?.(); } else { diff --git a/src/components/ui/GameStateHUD.tsx b/src/components/ui/GameStateHUD.tsx new file mode 100644 index 0000000..eec8a3d --- /dev/null +++ b/src/components/ui/GameStateHUD.tsx @@ -0,0 +1,70 @@ +import { Debug } from "@/utils/debug/Debug"; +import { + type MainGameState, + useGameStore, +} from "@/managers/stores/useGameStore"; + +const MAIN_STATES: MainGameState[] = [ + "intro", + "bike", + "pylone", + "ferme", + "outro", +]; + +export function GameStateHUD(): React.JSX.Element | null { + const debug = Debug.getInstance(); + const mainState = useGameStore((state) => state.mainState); + const detail = useGameStore((state) => { + switch (state.mainState) { + case "intro": + return state.intro.hasCompleted ? "completed" : "waiting"; + case "bike": + return state.bike.currentStep; + case "pylone": + return state.pylone.currentStep; + case "ferme": + return state.ferme.currentStep; + case "outro": + return state.outro.hasStarted ? "started" : "waiting"; + } + }); + const setMainState = useGameStore((state) => state.setMainState); + const advanceGameState = useGameStore((state) => state.advanceGameState); + const resetGame = useGameStore((state) => state.resetGame); + + if (!debug.active) return null; + + return ( + + ); +} diff --git a/src/index.css b/src/index.css index a32c152..763ef06 100644 --- a/src/index.css +++ b/src/index.css @@ -391,6 +391,77 @@ canvas { letter-spacing: 0.03em; } +.game-state-hud { + position: fixed; + top: 18px; + right: 18px; + z-index: 20; + display: grid; + gap: 12px; + width: min(320px, calc(100vw - 36px)); + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 18px; + background: rgba(4, 7, 13, 0.78); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35); + color: #f8fafc; + backdrop-filter: blur(16px); +} + +.game-state-hud__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.game-state-hud__header span, +.game-state-hud__detail { + color: rgba(248, 250, 252, 0.68); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.game-state-hud__header strong { + font-size: 16px; + letter-spacing: -0.03em; + text-transform: uppercase; +} + +.game-state-hud__detail { + margin: 0; + text-transform: none; +} + +.game-state-hud__states, +.game-state-hud__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.game-state-hud button { + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: #f8fafc; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.game-state-hud button:hover, +.game-state-hud button:focus-visible, +.game-state-hud button.is-active { + border-color: rgba(125, 211, 252, 0.75); + background: rgba(125, 211, 252, 0.18); + outline: none; +} + /* Editor page */ .editor-container { position: fixed; diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 6d0e8fd..78e683e 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -1,7 +1,14 @@ import { create } from "zustand"; export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; -export type MissionStep = "locked" | "inspect" | "repair" | "done"; +export type MissionStep = + | "locked" + | "waiting" + | "inspected" + | "fragmented" + | "scanning" + | "repairing" + | "done"; export interface IntroState { dialogueAudio: string | null; @@ -39,31 +46,54 @@ interface GameActions { setPyloneState: (pylone: Partial) => void; setFermeState: (ferme: Partial) => void; setOutroState: (outro: Partial) => void; + completeIntro: () => void; + completeBike: () => void; + completePylone: () => void; + completeFerme: () => void; + startOutro: () => void; + advanceGameState: () => void; resetGame: () => void; } export type GameStore = GameState & GameActions; +function getNextMissionStep(step: MissionStep): MissionStep { + switch (step) { + case "locked": + case "waiting": + return "inspected"; + case "inspected": + return "fragmented"; + case "fragmented": + return "scanning"; + case "scanning": + return "repairing"; + case "repairing": + case "done": + return "done"; + } +} + function createInitialGameState(): GameState { return { mainState: "intro", intro: { - dialogueAudio: ""null, + dialogueAudio: null, hasCompleted: false, isBikeUnlocked: false, }, bike: { - currentStep: "locked", + currentStep: "waiting", dialogueAudio: null, isRepaired: false, }, pylone: { - currentStep: "locked", + currentStep: "waiting", dialogueAudio: null, isPowered: false, }, ferme: { - currentStep: "locked", + currentStep: "waiting", dialogueAudio: null, irrigationFixed: false, }, @@ -87,5 +117,149 @@ export const useGameStore = create()((set) => ({ set((state) => ({ ferme: { ...state.ferme, ...ferme } })), setOutroState: (outro) => set((state) => ({ outro: { ...state.outro, ...outro } })), + completeIntro: () => + set((state) => ({ + mainState: "bike", + intro: { + ...state.intro, + hasCompleted: true, + isBikeUnlocked: true, + }, + bike: { + ...state.bike, + currentStep: "inspected", + }, + })), + completeBike: () => + set((state) => ({ + mainState: "pylone", + bike: { + ...state.bike, + currentStep: "done", + isRepaired: true, + }, + pylone: { + ...state.pylone, + currentStep: "inspected", + }, + })), + completePylone: () => + set((state) => ({ + mainState: "ferme", + pylone: { + ...state.pylone, + currentStep: "done", + isPowered: true, + }, + ferme: { + ...state.ferme, + currentStep: "inspected", + }, + })), + completeFerme: () => + set((state) => ({ + mainState: "outro", + ferme: { + ...state.ferme, + currentStep: "done", + irrigationFixed: true, + }, + outro: { + ...state.outro, + hasStarted: true, + }, + })), + startOutro: () => + set((state) => ({ + mainState: "outro", + outro: { + ...state.outro, + hasStarted: true, + }, + })), + advanceGameState: () => + set((state) => { + if (state.mainState === "intro") { + return { + mainState: "bike", + intro: { + ...state.intro, + hasCompleted: true, + isBikeUnlocked: true, + }, + bike: { + ...state.bike, + currentStep: "inspected", + }, + }; + } + + if (state.mainState === "bike") { + const nextStep = getNextMissionStep(state.bike.currentStep); + if (nextStep === "done") { + return { + mainState: "pylone", + bike: { + ...state.bike, + currentStep: "done", + isRepaired: true, + }, + pylone: { + ...state.pylone, + currentStep: "inspected", + }, + }; + } + + return { bike: { ...state.bike, currentStep: nextStep } }; + } + + if (state.mainState === "pylone") { + const nextStep = getNextMissionStep(state.pylone.currentStep); + if (nextStep === "done") { + return { + mainState: "ferme", + pylone: { + ...state.pylone, + currentStep: "done", + isPowered: true, + }, + ferme: { + ...state.ferme, + currentStep: "inspected", + }, + }; + } + + return { pylone: { ...state.pylone, currentStep: nextStep } }; + } + + if (state.mainState === "ferme") { + const nextStep = getNextMissionStep(state.ferme.currentStep); + if (nextStep === "done") { + return { + mainState: "outro", + ferme: { + ...state.ferme, + currentStep: "done", + irrigationFixed: true, + }, + outro: { + ...state.outro, + hasStarted: true, + }, + }; + } + + return { ferme: { ...state.ferme, currentStep: nextStep } }; + } + + return { + outro: { + ...state.outro, + hasStarted: true, + }, + }; + }), resetGame: () => set(createInitialGameState()), })); diff --git a/src/pages/page.tsx b/src/pages/page.tsx index 2635e63..a601019 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -1,6 +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 { World } from "@/world/World"; @@ -14,6 +15,7 @@ export function HomePage(): React.JSX.Element { + diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx new file mode 100644 index 0000000..ca16da8 --- /dev/null +++ b/src/world/GameStageContent.tsx @@ -0,0 +1,44 @@ +import { useGameStore } from "@/managers/stores/useGameStore"; +import type { Vector3Tuple } from "@/types/three"; + +interface StageAnchorProps { + color: string; + position: Vector3Tuple; + scale?: number; +} + +function StageAnchor({ + color, + position, + scale = 1, +}: StageAnchorProps): React.JSX.Element { + return ( + + + + + + + ); +} + +export function GameStageContent(): React.JSX.Element { + const mainState = useGameStore((state) => state.mainState); + + switch (mainState) { + case "intro": + return ; + case "bike": + return ; + case "pylone": + return ; + case "ferme": + return ; + case "outro": + return ; + } +} diff --git a/src/world/World.tsx b/src/world/World.tsx index b945c38..fe0344b 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -11,6 +11,7 @@ import { DebugHelpers } from "@/components/debug/scene/DebugHelpers"; import { Environment } from "@/world/Environment"; import { Lighting } from "@/world/Lighting"; import { GameMap } from "@/world/GameMap"; +import { GameStageContent } from "@/world/GameStageContent"; import { Player } from "@/world/player/Player"; import { TestScene } from "@/world/debug/TestScene"; @@ -31,7 +32,10 @@ export function World(): React.JSX.Element { {cameraMode === "debug" ? : null} {sceneMode === "game" ? ( - + <> + + + ) : ( )} From b9970c4e0358e0397446f1ae2bb3e5c3d99a3495 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 30 Apr 2026 14:29:29 +0200 Subject: [PATCH 3/6] 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 ( From 8884edb28119352ea0505875aa9df5d091666c8f Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 30 Apr 2026 14:38:07 +0200 Subject: [PATCH 4/6] clarify managers and zustand store responsibilities --- docs/technical/zustand.md | 32 ++++++++++++++++++++++++++++++- src/data/docs/docsTranslations.ts | 26 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/technical/zustand.md b/docs/technical/zustand.md index 0913a70..8e05717 100644 --- a/docs/technical/zustand.md +++ b/docs/technical/zustand.md @@ -30,6 +30,30 @@ 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. +## Managers vs Store + +Managers are responsible for local runtime objects and imperative behavior. + +Examples: + +- `AudioManager` owns audio elements and sound pools. +- `InteractionManager` owns transient interaction handles and input-oriented behavior. + +Managers can read from or write to the Zustand store when their local behavior needs to affect global gameplay progression. + +The Zustand store is responsible for durable global state: + +- current main state +- mission sub state +- progression flags +- dialogue/audio references +- state transitions + +Rule of thumb: + +- manager = runtime objects, side effects, and local imperative logic +- store = global gameplay state that UI or world components can subscribe to + ## Current Shape The store exposes: @@ -45,7 +69,13 @@ The store exposes: The mission steps currently use this sequence: ```ts -"locked" | "waiting" | "inspect" | "scanning" | "repairing" | "done"; +"locked" | + "waiting" | + "inspected" | + "fragmented" | + "scanning" | + "repairing" | + "done"; ``` ## Reading State In Components diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts index 6c1ad7d..d7a95b3 100644 --- a/src/data/docs/docsTranslations.ts +++ b/src/data/docs/docsTranslations.ts @@ -239,6 +239,30 @@ 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. +## Managers vs Store + +Les managers sont responsables des objets runtime locaux et des comportements impératifs. + +Exemples : + +- \`AudioManager\` possède les éléments audio et les pools de sons. +- \`InteractionManager\` possède les handles d'interaction transitoires et la logique orientée input. + +Un manager peut lire ou mettre à jour le store Zustand quand son comportement local doit impacter la progression globale du jeu. + +Le store Zustand est responsable de l'état global durable : + +- main state courant +- sous-état de mission +- flags de progression +- références de dialogue/audio +- transitions de state + +Règle simple : + +- manager = objets runtime, effets de bord et logique impérative locale +- store = état gameplay global auquel l'UI ou le world peuvent s'abonner + ## Forme actuelle Le store expose : @@ -254,7 +278,7 @@ Le store expose : Les étapes de mission utilisent actuellement cette séquence : \`\`\`ts -"locked" | "waiting" | "inspect" | "scanning" | "repairing" | "done" +"locked" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "done" \`\`\` ## Lire le state dans un composant From a14f776e5d5deac336ab9b06d0b2711ca5a7724a Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 30 Apr 2026 14:59:41 +0200 Subject: [PATCH 5/6] address zustand progression review feedback --- src/components/ui/GameStateHUD.tsx | 7 +- src/managers/stores/useGameStore.ts | 196 ++++++++++++---------------- 2 files changed, 88 insertions(+), 115 deletions(-) diff --git a/src/components/ui/GameStateHUD.tsx b/src/components/ui/GameStateHUD.tsx index eec8a3d..f38bafd 100644 --- a/src/components/ui/GameStateHUD.tsx +++ b/src/components/ui/GameStateHUD.tsx @@ -44,10 +44,15 @@ export function GameStateHUD(): React.JSX.Element | null {

Sub state: {detail}

-
+
{MAIN_STATES.map((state) => (