Merge branch 'develop' into feat/main-feature
This commit is contained in:
@@ -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`.
|
||||||
Generated
+2
-1
@@ -20,7 +20,8 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"three": "^0.183.2"
|
"three": "^0.183.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
+2
-1
@@ -29,7 +29,8 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"three": "^0.183.2"
|
"three": "^0.183.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Crosshair } from "@/components/ui/Crosshair";
|
||||||
|
import { GameStateHUD } from "@/components/ui/GameStateHUD";
|
||||||
|
import { HandTrackingOverlay } from "@/components/ui/HandTrackingOverlay";
|
||||||
|
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||||
|
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||||
|
|
||||||
|
export function GameUI(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GameStateHUD />
|
||||||
|
<Crosshair />
|
||||||
|
<InteractPrompt />
|
||||||
|
<HandTrackingVisualizer />
|
||||||
|
<HandTrackingOverlay />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,12 @@ export const docGroups: DocGroup[] = [
|
|||||||
subtitle: "Webcam interaction pipeline",
|
subtitle: "Webcam interaction pipeline",
|
||||||
meta: "05",
|
meta: "05",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/docs/zustand",
|
||||||
|
title: "Zustand Game State",
|
||||||
|
subtitle: "Progression store",
|
||||||
|
meta: "06",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,25 +59,25 @@ export const docGroups: DocGroup[] = [
|
|||||||
path: "/docs/features",
|
path: "/docs/features",
|
||||||
title: "Features",
|
title: "Features",
|
||||||
subtitle: "Implemented scope",
|
subtitle: "Implemented scope",
|
||||||
meta: "06",
|
meta: "07",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/main-feature",
|
path: "/docs/main-feature",
|
||||||
title: "Main Feature",
|
title: "Main Feature",
|
||||||
subtitle: "Hand grab prototype",
|
subtitle: "Repair-game prototype",
|
||||||
meta: "07",
|
meta: "08",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/editor",
|
path: "/docs/editor",
|
||||||
title: "Editor User Guide",
|
title: "Editor User Guide",
|
||||||
subtitle: "Editing workflow",
|
subtitle: "Editing workflow",
|
||||||
meta: "08",
|
meta: "09",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/docs/animation",
|
path: "/docs/animation",
|
||||||
title: "Animation & 3D Model System",
|
title: "Animation & 3D Model System",
|
||||||
subtitle: "Components and usage",
|
subtitle: "Components and usage",
|
||||||
meta: "07",
|
meta: "010",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -206,6 +206,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.
|
- 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
|
export const featuresFr = `# Fonctionnalités implémentées
|
||||||
|
|
||||||
Ce document liste les fonctionnalités présentes dans le code actuel.
|
Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||||
|
|||||||
@@ -467,6 +467,77 @@ canvas {
|
|||||||
background: #38bdf8;
|
background: #38bdf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 page */
|
||||||
.editor-container {
|
.editor-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
||||||
|
type MissionStep =
|
||||||
|
| "locked"
|
||||||
|
| "waiting"
|
||||||
|
| "inspected"
|
||||||
|
| "fragmented"
|
||||||
|
| "scanning"
|
||||||
|
| "repairing"
|
||||||
|
| "done";
|
||||||
|
|
||||||
|
interface IntroState {
|
||||||
|
dialogueAudio: string | null;
|
||||||
|
hasCompleted: boolean;
|
||||||
|
isBikeUnlocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissionState {
|
||||||
|
currentStep: MissionStep;
|
||||||
|
dialogueAudio: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
|
}));
|
||||||
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
|
|||||||
<DocsDocument
|
<DocsDocument
|
||||||
content={animation}
|
content={animation}
|
||||||
frContent={animation}
|
frContent={animation}
|
||||||
meta="07"
|
meta="08"
|
||||||
title="Animation & 3D Model System"
|
title="Animation & 3D Model System"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element {
|
|||||||
<DocsDocument
|
<DocsDocument
|
||||||
content={editor}
|
content={editor}
|
||||||
frContent={editorFr}
|
frContent={editorFr}
|
||||||
meta="08"
|
meta="09"
|
||||||
title="Editor User Guide"
|
title="Editor User Guide"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-9
@@ -1,11 +1,8 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import { Crosshair } from "@/components/ui/Crosshair";
|
|
||||||
import { HandTrackingOverlay } from "@/components/ui/HandTrackingOverlay";
|
|
||||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
|
||||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
|
||||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
|
||||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||||
|
import { GameUI } from "@/components/ui/GameUI";
|
||||||
|
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||||
import { World } from "@/world/World";
|
import { World } from "@/world/World";
|
||||||
|
|
||||||
export function HomePage(): React.JSX.Element {
|
export function HomePage(): React.JSX.Element {
|
||||||
@@ -17,10 +14,7 @@ export function HomePage(): React.JSX.Element {
|
|||||||
<DebugPerf />
|
<DebugPerf />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
<Crosshair />
|
<GameUI />
|
||||||
<InteractPrompt />
|
|
||||||
<HandTrackingVisualizer />
|
|
||||||
<HandTrackingOverlay />
|
|
||||||
</HandTrackingProvider>
|
</HandTrackingProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
DocsReadmeRoute,
|
DocsReadmeRoute,
|
||||||
DocsTargetArchitectureRoute,
|
DocsTargetArchitectureRoute,
|
||||||
DocsTechnicalEditorRoute,
|
DocsTechnicalEditorRoute,
|
||||||
|
DocsZustandRoute,
|
||||||
} from "@/routes/DocsRoute";
|
} from "@/routes/DocsRoute";
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
@@ -47,6 +48,7 @@ const docsChildRoutes = [
|
|||||||
{ path: "target-architecture", component: DocsTargetArchitectureRoute },
|
{ path: "target-architecture", component: DocsTargetArchitectureRoute },
|
||||||
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
|
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
|
||||||
{ path: "hand-tracking", component: DocsHandTrackingRoute },
|
{ path: "hand-tracking", component: DocsHandTrackingRoute },
|
||||||
|
{ path: "zustand", component: DocsZustandRoute },
|
||||||
{ path: "features", component: DocsFeaturesRoute },
|
{ path: "features", component: DocsFeaturesRoute },
|
||||||
{ path: "main-feature", component: DocsMainFeatureRoute },
|
{ path: "main-feature", component: DocsMainFeatureRoute },
|
||||||
{ path: "editor", component: DocsEditorRoute },
|
{ path: "editor", component: DocsEditorRoute },
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ const LazyDocsHandTrackingPage = lazyNamed(
|
|||||||
() => import("@/pages/docs/hand-tracking/page"),
|
() => import("@/pages/docs/hand-tracking/page"),
|
||||||
"DocsHandTrackingPage",
|
"DocsHandTrackingPage",
|
||||||
);
|
);
|
||||||
|
const LazyDocsZustandPage = lazyNamed(
|
||||||
|
() => import("@/pages/docs/zustand/page"),
|
||||||
|
"DocsZustandPage",
|
||||||
|
);
|
||||||
const LazyDocsFeaturesPage = lazyNamed(
|
const LazyDocsFeaturesPage = lazyNamed(
|
||||||
() => import("@/pages/docs/features/page"),
|
() => import("@/pages/docs/features/page"),
|
||||||
"DocsFeaturesPage",
|
"DocsFeaturesPage",
|
||||||
@@ -84,6 +88,10 @@ export function DocsHandTrackingRoute(): React.JSX.Element {
|
|||||||
return withDocsSuspense(LazyDocsHandTrackingPage);
|
return withDocsSuspense(LazyDocsHandTrackingPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DocsZustandRoute(): React.JSX.Element {
|
||||||
|
return withDocsSuspense(LazyDocsZustandPage);
|
||||||
|
}
|
||||||
|
|
||||||
export function DocsFeaturesRoute(): React.JSX.Element {
|
export function DocsFeaturesRoute(): React.JSX.Element {
|
||||||
return withDocsSuspense(LazyDocsFeaturesPage);
|
return withDocsSuspense(LazyDocsFeaturesPage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/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} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Environment } from "@/world/Environment";
|
|||||||
import { GameMusic } from "@/world/GameMusic";
|
import { GameMusic } from "@/world/GameMusic";
|
||||||
import { Lighting } from "@/world/Lighting";
|
import { Lighting } from "@/world/Lighting";
|
||||||
import { GameMap } from "@/world/GameMap";
|
import { GameMap } from "@/world/GameMap";
|
||||||
|
import { GameStageContent } from "@/world/GameStageContent";
|
||||||
import { Player } from "@/world/player/Player";
|
import { Player } from "@/world/player/Player";
|
||||||
import { TestMap } from "@/world/debug/TestMap";
|
import { TestMap } from "@/world/debug/TestMap";
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export function World(): React.JSX.Element {
|
|||||||
<>
|
<>
|
||||||
<GameMusic />
|
<GameMusic />
|
||||||
<GameMap onOctreeReady={setOctree} />
|
<GameMap onOctreeReady={setOctree} />
|
||||||
|
<GameStageContent />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TestMap onOctreeReady={setOctree} />
|
<TestMap onOctreeReady={setOctree} />
|
||||||
|
|||||||
Reference in New Issue
Block a user