Merge pull request #11 from La-Fabrik-Durable/feat/zustand

Feat/zustand
This commit is contained in:
Tom Boullay
2026-04-30 15:07:44 +02:00
committed by GitHub
21 changed files with 814 additions and 16 deletions
+163
View File
@@ -0,0 +1,163 @@
# 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.
## 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:
- `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" |
"inspected" |
"fragmented" |
"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`.
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -1,5 +1,5 @@
import { useGLTF } from "@react-three/drei";
import type { Vector3Tuple } from "@/types/3d";
import type { Vector3Tuple } from "@/types/three";
export interface SimpleModelConfig {
modelPath: string;
+1 -1
View File
@@ -1,4 +1,4 @@
export { AnimatedModel, useAnimatedModel } from "./AnimatedModel";
export { AnimatedModel } from "./AnimatedModel";
export type { AnimatedModelConfig } from "./AnimatedModel";
export { SimpleModel } from "./SimpleModel";
+75
View File
@@ -0,0 +1,75 @@
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 (
<aside className="game-state-hud" aria-label="Game state debug panel">
<div className="game-state-hud__header">
<span>Game State</span>
<strong>{mainState}</strong>
</div>
<p className="game-state-hud__detail">Sub state: {detail}</p>
<div
className="game-state-hud__states"
aria-label="Main states"
role="group"
>
{MAIN_STATES.map((state) => (
<button
key={state}
aria-pressed={state === mainState}
className={state === mainState ? "is-active" : undefined}
type="button"
onClick={() => setMainState(state)}
>
{state}
</button>
))}
</div>
<div className="game-state-hud__actions">
<button type="button" onClick={advanceGameState}>
Next step
</button>
<button type="button" onClick={resetGame}>
Reset
</button>
</div>
</aside>
);
}
+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",
},
],
},
+159
View File
@@ -207,6 +207,165 @@ 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.
## 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 :
- \`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" | "inspected" | "fragmented" | "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.
+71
View File
@@ -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;
+233
View File
@@ -0,0 +1,233 @@
import { create } from "zustand";
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
export type MissionStep =
| "locked"
| "waiting"
| "inspected"
| "fragmented"
| "scanning"
| "repairing"
| "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<IntroState>) => void;
setBikeState: (bike: Partial<GameState["bike"]>) => void;
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
setOutroState: (outro: Partial<GameState["outro"]>) => void;
completeIntro: () => void;
completeBike: () => void;
completePylone: () => void;
completeFerme: () => void;
startOutro: () => void;
advanceGameState: () => void;
resetGame: () => void;
}
export type GameStore = GameState & GameActions;
type GameStateUpdate = Partial<GameState>;
function getNextMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
return "waiting";
case "waiting":
return "inspected";
case "inspected":
return "fragmented";
case "fragmented":
return "scanning";
case "scanning":
return "repairing";
case "repairing":
case "done":
return "done";
}
}
function completeIntroState(state: GameState): GameStateUpdate {
return {
mainState: "bike",
intro: {
...state.intro,
hasCompleted: true,
isBikeUnlocked: true,
},
bike: {
...state.bike,
currentStep: "waiting",
},
};
}
function completeBikeState(state: GameState): GameStateUpdate {
return {
mainState: "pylone",
bike: {
...state.bike,
currentStep: "done",
isRepaired: true,
},
pylone: {
...state.pylone,
currentStep: "waiting",
},
};
}
function completePyloneState(state: GameState): GameStateUpdate {
return {
mainState: "ferme",
pylone: {
...state.pylone,
currentStep: "done",
isPowered: true,
},
ferme: {
...state.ferme,
currentStep: "waiting",
},
};
}
function completeFermeState(state: GameState): GameStateUpdate {
return {
mainState: "outro",
ferme: {
...state.ferme,
currentStep: "done",
irrigationFixed: true,
},
outro: {
...state.outro,
hasStarted: true,
},
};
}
function startOutroState(state: GameState): GameStateUpdate {
return {
mainState: "outro",
outro: {
...state.outro,
hasStarted: true,
},
};
}
function createInitialGameState(): GameState {
return {
mainState: "intro",
intro: {
dialogueAudio: null,
hasCompleted: false,
isBikeUnlocked: false,
},
bike: {
currentStep: "waiting",
dialogueAudio: null,
isRepaired: false,
},
pylone: {
currentStep: "waiting",
dialogueAudio: null,
isPowered: false,
},
ferme: {
currentStep: "waiting",
dialogueAudio: null,
irrigationFixed: false,
},
outro: {
dialogueAudio: null,
hasStarted: false,
},
};
}
export const useGameStore = create<GameStore>()((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 } })),
completeIntro: () => set(completeIntroState),
completeBike: () => set(completeBikeState),
completePylone: () => set(completePyloneState),
completeFerme: () => set(completeFermeState),
startOutro: () => set(startOutroState),
advanceGameState: () =>
set((state) => {
if (state.mainState === "intro") {
return completeIntroState(state);
}
if (state.mainState === "bike") {
const nextStep = getNextMissionStep(state.bike.currentStep);
if (nextStep === "done") {
return completeBikeState(state);
}
return { bike: { ...state.bike, currentStep: nextStep } };
}
if (state.mainState === "pylone") {
const nextStep = getNextMissionStep(state.pylone.currentStep);
if (nextStep === "done") {
return completePyloneState(state);
}
return { pylone: { ...state.pylone, currentStep: nextStep } };
}
if (state.mainState === "ferme") {
const nextStep = getNextMissionStep(state.ferme.currentStep);
if (nextStep === "done") {
return completeFermeState(state);
}
return { ferme: { ...state.ferme, currentStep: nextStep } };
}
return startOutroState(state);
}),
resetGame: () => set(createInitialGameState()),
}));
+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 -4
View File
@@ -1,8 +1,7 @@
import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import { Crosshair } from "@/components/ui/Crosshair";
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 {
@@ -14,8 +13,7 @@ export function HomePage(): React.JSX.Element {
<DebugPerf />
</Suspense>
</Canvas>
<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}>
+44
View File
@@ -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 (
<group position={position} scale={scale}>
<mesh>
<octahedronGeometry args={[1.2, 0]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.25}
/>
</mesh>
</group>
);
}
export function GameStageContent(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
switch (mainState) {
case "intro":
return <StageAnchor color="#7dd3fc" position={[0, 4, 0]} />;
case "bike":
return <StageAnchor color="#facc15" position={[8, 3, -6]} />;
case "pylone":
return <StageAnchor color="#a78bfa" position={[64, 6, -66]} />;
case "ferme":
return <StageAnchor color="#86efac" position={[-24, 5, 42]} />;
case "outro":
return <StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />;
}
}
+5 -1
View File
@@ -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" ? <DebugCameraControls /> : null}
{sceneMode === "game" ? (
<GameMap onOctreeReady={setOctree} />
<>
<GameMap onOctreeReady={setOctree} />
<GameStageContent />
</>
) : (
<TestScene onOctreeReady={setOctree} />
)}