5 Commits

Author SHA1 Message Date
math-pixel f567540f22 fix: step issue 2026-05-13 10:00:19 +02:00
math-pixel 688302d985 wip 2026-05-13 09:05:45 +02:00
math-pixel f9d7c3f00e update: loading waiting 2026-05-12 21:47:54 +02:00
math-pixel 28c6ef199f feat: sequencing 2026-05-12 21:44:43 +02:00
math-pixel ff79448ce8 update : cinematic trigger 2026-05-12 17:07:53 +02:00
19 changed files with 396 additions and 168 deletions
+226
View File
@@ -0,0 +1,226 @@
# Game States & Substates
Documentation technique pour le testing et debugging du flow de jeu.
## Vue d'ensemble
```
intro ──► bike ──► pylone ──► ferme ──► outro
```
---
## Main States
| State | Description |
| -------- | ------------------------------------------------------------------ |
| `intro` | Séquence d'introduction (cinématique, naming, premier déplacement) |
| `bike` | Mission de réparation du vélo |
| `pylone` | Quête du pylone (alert → searching → helped → manipulation) |
| `ferme` | Mission de réparation de la ferme |
| `outro` | Fin du jeu |
---
## Intro (GameStep)
```
┌─────────────────────────────────────────────────────────────┐
│ intro.currentStep │
└─────────────────────────────────────────────────────────────┘
intro ──► sequence_video ──► naming ──► start-move ──► bike
```
### Étapes
| Step | Trigger | Action | Passage vers |
| ---------------- | -------- | ---------------------------------------------- | ------------------------------------------------ |
| `intro` | Initial | État initial | Auto → `sequence_video` quand `sceneReady: true` |
| `sequence_video` | GameFlow | Déclenche la cinématique dans `GameCinematics` | Fin cinématique → `naming` |
| `naming` | IntroUI | Affiche l'input pour le prénom | Submit → `start-move` |
| `start-move` | GameFlow | Active le mouvement (`canMove: true`) | Via zone "fabrikExit" → `bike` |
### Transition vers bike
- **Trigger**: Zone `fabrikExit` dans `zones.ts`
- **Action**: `advanceGameState()` dans `ZoneDetection.tsx`
- **Résultat**: `mainState: "bike"`, `intro.hasCompleted: true`
---
## Bike (MissionStep)
```
┌─────────────────────────────────────────────────────────────┐
│ bike.currentStep (MissionStep) │
└─────────────────────────────────────────────────────────────┘
locked ──► waiting ──► inspected ──► fragmented ──► scanning ──► repairing ──► reassembling ──► done
```
### Transition vers pylone
- **Trigger**: `bike.currentStep: "done"`
- **Action**: `completeBikeState()` dans useGameStore
- **Résultat**: `mainState: "pylone"`, `pylone.currentStep: "locked"`
---
## Pylone (PyloneStep)
```
┌─────────────────────────────────────────────────────────────┐
│ pylone.currentStep (PyloneStep) │
└─────────────────────────────────────────────────────────────┘
locked (bypass) ──► alert ──► searching ──► helped ──► manipulation ──► outro
```
### Étapes
| Step | Trigger | Action | Passage vers |
| -------------- | ---------------------------------- | ---------------------------------- | --------------------------------------------------------- |
| `locked` | Initial (après bike) | État initial | **Bypass automatique**`alert` via `advanceGameState()` |
| `alert` | `advanceGameState()` | Affiche l'alerte (à implémenter) | Via `advanceGameState()` |
| `searching` | `advanceGameState()` | Déclenché par zone "searchingZone" | Via `advanceGameState()` |
| `helped` | Interaction avec `NPCHelper` | Dialogue avec le villageois | Via interaction 3D |
| `manipulation` | Interaction avec `PyloneDestroyed` | Interaction avec l'objet central | Via `advanceGameState()``outro` |
### Bypass automatique
```typescript
// useGameStore.ts - advancePyloneStep()
if (state.pylone.currentStep === "locked") {
return { pylone: { ...state.pylone, currentStep: "alert" } };
}
```
### Transition vers outro
- **Trigger**: `pylone.currentStep: "manipulation"` + `advanceGameState()`
- **Action**: `advancePyloneStep()` détecte fin de la séquence
- **Résultat**: `mainState: "outro"`
---
## Ferme (MissionStep)
```
┌─────────────────────────────────────────────────────────────┐
│ ferme.currentStep (MissionStep) │
└─────────────────────────────────────────────────────────────┘
locked ──► waiting ──► inspected ──► fragmented ──► scanning ──► repairing ──► reassembling ──► done
```
### Transition vers outro
- **Trigger**: `ferme.currentStep: "done"`
- **Action**: `completeFermeState()` dans useGameStore
- **Résultat**: `mainState: "outro"`, `ferme.irrigationFixed: true`
---
## Outro
```
┌─────────────────────────────────────────────────────────────┐
│ outro.hasStarted │
└─────────────────────────────────────────────────────────────┘
waiting ──► started
```
---
## Debug Panel
Le debug panel permet de tester toutes les transitions :
### Utilisation
1. Ouvrir le jeu en mode debug (`Debug: true` dans `Debug.ts`)
2. Le panneau "Game State" apparaît en bas à gauche
3. **Main state**: Sélectionner le state principal
4. **Sub state**: Sélectionner le sub-state
5. **Previous/Next step**: Avancer ou reculer d'un step
6. **Reset**: Remettre à l'état initial
### Raccourcis clavier
| Action | Clavier |
| ------- | ------------------ |
| Avancer | Debug panel button |
| Reculer | Debug panel button |
| Reset | Debug panel button |
---
## Comment tester chaque section
### Tester l'intro
1. Vérifier que `sceneReady: false` au démarrage
2. Attendre que le loader termine (`sceneReady: true`)
3. Vérifier `intro.currentStep: "intro"` → auto vers `sequence_video`
4. Si cinématique fonctionne : `sequence_video``naming`
5. Entrer un prénom : `naming``start-move`
6. Vérifier `canMove: true` après `start-move`
7. Entrer dans la zone `fabrikExit``mainState: "bike"`
### Tester bike
1. Via debug panel, avancer jusqu'à `done`
2. Vérifier `mainState: "pylone"`
### Tester pylone
1. Via debug panel, avancer (bypass `locked``alert`)
2. Vérifier `pylone.currentStep: "alert"`
3. Avancer : `alert``searching``helped``manipulation`
4. Après `manipulation`, vérifier `mainState: "outro"`
### Tester ferme
1. Via debug panel, avancer dans bike jusqu'à `done`
2. Vérifier `mainState: "pylone"``ferme` (après pylone)
3. Avancer jusqu'à `done`
4. Vérifier `mainState: "outro"`
---
## Fichiers clés
| Fichier | Rôle |
| ------------------------------------------------- | ------------------------------------- |
| `src/managers/stores/useGameStore.ts` | Store Zustand avec toutes les actions |
| `src/types/game.ts` | Définition de `GameStep` |
| `src/types/gameplay/pylone.ts` | Définition de `PyloneStep` |
| `src/types/gameplay/repairMission.ts` | Définition de `MissionStep` |
| `src/components/game/GameFlow.tsx` | Logique de transition de l'intro |
| `src/components/zone/ZoneDetection.tsx` | Déclenchement des zones |
| `src/components/ui/debug/GameStateDebugPanel.tsx` | Outil de debug |
---
## État initial
```typescript
{
mainState: "intro",
isCinematicPlaying: false,
sceneReady: false,
missionFlow: {
activityCity: true,
canMove: false,
dialogMessage: null,
playerName: "",
},
intro: { currentStep: "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 },
}
```
+15
View File
@@ -1,6 +1,21 @@
{ {
"version": 1, "version": 1,
"cinematics": [ "cinematics": [
{
"id": "intro_sequence",
"trigger": "intro_sequence",
"cameraKeyframes": [
{ "time": 0, "position": [8, 5, 12], "target": [0, 2, 0] },
{ "time": 8, "position": [12, 4, -6], "target": [10, 1.4, -8] },
{ "time": 16, "position": [5, 6, -15], "target": [0, 3, -20] },
{ "time": 24, "position": [0, 8, -30], "target": [0, 0, -40] }
],
"dialogueCues": [
{ "time": 0, "dialogueId": "intro_welcome" },
{ "time": 8, "dialogueId": "intro_explanation" },
{ "time": 16, "dialogueId": "intro_mission" }
]
},
{ {
"id": "intro_overview", "id": "intro_overview",
"timecode": 0, "timecode": 0,
+12 -42
View File
@@ -1,64 +1,34 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { AUDIO_PATHS } from "@/data/audioConfig";
export function GameFlow(): null { export function GameFlow(): null {
const step = useGameStore((state) => state.intro.currentStep); const step = useGameStore((state) => state.intro.currentStep);
const setStep = useGameStore((state) => state.setIntroStep); const setStep = useGameStore((state) => state.setIntroStep);
const setActivityCity = useGameStore((state) => state.setActivityCity); const isCinematicPlaying = useGameStore((state) => state.isCinematicPlaying);
const sceneReady = useGameStore((state) => state.sceneReady);
const setCanMove = useGameStore((state) => state.setCanMove); const setCanMove = useGameStore((state) => state.setCanMove);
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
useEffect(() => { useEffect(() => {
if (!hasInitialized.current && step === "intro") { if (!hasInitialized.current && step === "intro" && sceneReady) {
hasInitialized.current = true; hasInitialized.current = true;
setStep("start-intro"); setStep("sequence_video");
} }
}, [step, setStep]); }, [step, setStep, sceneReady]);
useEffect(() => { useEffect(() => {
if (step === "start-intro") { if (step === "sequence_video" && !isCinematicPlaying) {
const audio = AudioManager.getInstance(); setStep("naming");
audio.playSoundWithCallback(AUDIO_PATHS.intro, 0.5, () => {
setStep("naming");
});
return () => {};
} }
}, [step, isCinematicPlaying, setStep]);
if (step === "bienvenue") { useEffect(() => {
const audio = AudioManager.getInstance(); if (step === "start-move") {
audio.playSoundWithCallback(AUDIO_PATHS.bienvenue, 0.5, () => { setCanMove(true);
setCanMove(true);
setStep("star-move");
});
return () => {};
}
if (step === "mission2") {
setActivityCity(false);
const audio = AudioManager.getInstance();
audio.playSound(AUDIO_PATHS.alertCentral, 0.5);
}
if (step === "searching") {
const audio = AudioManager.getInstance();
audio.playSound(AUDIO_PATHS.searching, 0.5);
}
if (step === "helped") {
const audio = AudioManager.getInstance();
audio.playSound(AUDIO_PATHS.helped, 0.5);
}
if (step === "manipulation") {
setCanMove(false);
} }
return undefined; return undefined;
}, [step, setStep, setActivityCity, setCanMove]); }, [step, setCanMove]);
return null; return null;
} }
@@ -8,17 +8,17 @@ interface NPCHelperProps {
} }
export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element { export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element {
const step = useGameStore((state) => state.intro.currentStep); const step = useGameStore((state) => state.pylone.currentStep);
const setStep = useGameStore((state) => state.setIntroStep); const setPyloneStep = useGameStore((state) => state.setPyloneState);
const debug = Debug.getInstance(); const debug = Debug.getInstance();
const handlePress = (): void => { const handlePress = (): void => {
if (step === "searching") { if (step === "searching") {
setStep("helped"); setPyloneStep({ currentStep: "helped" });
} }
}; };
const shouldShow = step === "searching" || debug.active; const shouldShow = step === "searching" || step === "helped" || debug.active;
if (!shouldShow) { if (!shouldShow) {
return <></>; return <></>;
@@ -10,8 +10,8 @@ interface PyloneDestroyedProps {
export function PyloneDestroyed({ export function PyloneDestroyed({
position, position,
}: PyloneDestroyedProps): React.JSX.Element { }: PyloneDestroyedProps): React.JSX.Element {
const step = useGameStore((state) => state.intro.currentStep); const step = useGameStore((state) => state.pylone.currentStep);
const setStep = useGameStore((state) => state.setIntroStep); const setPyloneStep = useGameStore((state) => state.setPyloneState);
const setCanMove = useGameStore((state) => state.setCanMove); const setCanMove = useGameStore((state) => state.setCanMove);
const showDialog = useGameStore((state) => state.showDialog); const showDialog = useGameStore((state) => state.showDialog);
const debug = Debug.getInstance(); const debug = Debug.getInstance();
@@ -19,7 +19,7 @@ export function PyloneDestroyed({
const handlePress = (): void => { const handlePress = (): void => {
if (step === "helped") { if (step === "helped") {
setCanMove(false); setCanMove(false);
setStep("manipulation"); setPyloneStep({ currentStep: "manipulation" });
} else if (step === "searching") { } else if (step === "searching") {
showDialog( showDialog(
"Cet objet est trop lourd pour le porter tout seul, trouve de l'aide", "Cet objet est trop lourd pour le porter tout seul, trouve de l'aide",
+2 -2
View File
@@ -13,7 +13,7 @@ export function IntroUI(): React.JSX.Element | null {
if (inputValue.trim() === "") return; if (inputValue.trim() === "") return;
setPlayerName(inputValue.trim()); setPlayerName(inputValue.trim());
setStep("bienvenue"); setStep("start-move");
}; };
const handleKeyDown = (e: React.KeyboardEvent): void => { const handleKeyDown = (e: React.KeyboardEvent): void => {
@@ -100,7 +100,7 @@ export function BienvenueDisplay(): React.JSX.Element | null {
const step = useGameStore((state) => state.intro.currentStep); const step = useGameStore((state) => state.intro.currentStep);
const playerName = useGameStore((state) => state.missionFlow.playerName); const playerName = useGameStore((state) => state.missionFlow.playerName);
if (step !== "bienvenue") return null; if (step !== "start-move") return null;
return ( return (
<div <div
+11 -13
View File
@@ -5,6 +5,7 @@ import {
} from "@/managers/stores/useGameStore"; } from "@/managers/stores/useGameStore";
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission"; import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
import { GAME_STEPS, type GameStep } from "@/types/game"; import { GAME_STEPS, type GameStep } from "@/types/game";
import { PYLONE_STEPS, type PyloneStep } from "@/types/gameplay/pylone";
const MAIN_STATES: MainGameState[] = [ const MAIN_STATES: MainGameState[] = [
"intro", "intro",
@@ -54,9 +55,11 @@ export function GameStateDebugPanel(): React.JSX.Element {
const subStateOptions = const subStateOptions =
mainState === "intro" mainState === "intro"
? GAME_STEPS ? GAME_STEPS
: mainState === "outro" : mainState === "pylone"
? ["waiting", "started"] ? PYLONE_STEPS
: MISSION_STEPS; : mainState === "outro"
? ["waiting", "started"]
: MISSION_STEPS;
function setSubState(nextSubState: string): void { function setSubState(nextSubState: string): void {
if (mainState === "intro") { if (mainState === "intro") {
@@ -64,6 +67,11 @@ export function GameStateDebugPanel(): React.JSX.Element {
return; return;
} }
if (mainState === "pylone") {
setPyloneState({ currentStep: nextSubState as PyloneStep });
return;
}
if (mainState === "outro") { if (mainState === "outro") {
setOutroState({ hasStarted: nextSubState === "started" }); setOutroState({ hasStarted: nextSubState === "started" });
return; return;
@@ -76,11 +84,6 @@ export function GameStateDebugPanel(): React.JSX.Element {
return; return;
} }
if (mainState === "pylone") {
setPyloneState({ currentStep: nextSubState });
return;
}
if (mainState === "ferme") { if (mainState === "ferme") {
setFermeState({ currentStep: nextSubState }); setFermeState({ currentStep: nextSubState });
return; return;
@@ -95,11 +98,6 @@ export function GameStateDebugPanel(): React.JSX.Element {
return; return;
} }
if (nextMainState === "pylone" && pyloneStep === "locked") {
setPyloneState({ currentStep: "waiting" });
return;
}
if (nextMainState === "ferme" && fermeStep === "locked") { if (nextMainState === "ferme" && fermeStep === "locked") {
setFermeState({ currentStep: "waiting" }); setFermeState({ currentStep: "waiting" });
} }
+8 -1
View File
@@ -14,7 +14,10 @@ export function ZoneDetection(): null {
const triggeredZones = useRef<Set<string>>(new Set()); const triggeredZones = useRef<Set<string>>(new Set());
const debug = Debug.getInstance(); const debug = Debug.getInstance();
const step = useGameStore((state) => state.intro.currentStep); const step = useGameStore((state) => state.intro.currentStep);
const mainState = useGameStore((state) => state.mainState);
const setStep = useGameStore((state) => state.setIntroStep); const setStep = useGameStore((state) => state.setIntroStep);
const setPyloneStep = useGameStore((state) => state.setPyloneState);
const advanceGameState = useGameStore((state) => state.advanceGameState);
useEffect(() => { useEffect(() => {
if (!debug.active) return; if (!debug.active) return;
@@ -65,7 +68,11 @@ export function ZoneDetection(): null {
const distanceSq = _playerPos.distanceToSquared(_zonePos); const distanceSq = _playerPos.distanceToSquared(_zonePos);
if (distanceSq <= zone.radius * zone.radius) { if (distanceSq <= zone.radius * zone.radius) {
setStep(zone.targetStep); if (zone.targetStep === "bike" && mainState === "intro") {
advanceGameState();
} else {
setStep(zone.targetStep);
}
triggeredZones.current.add(zone.id); triggeredZones.current.add(zone.id);
break; break;
} }
+1 -46
View File
@@ -52,7 +52,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description: description:
"Repair the damaged cooling module before relaunching the bike", "Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf", modelPath: "/models/ebike/model.gltf",
modelScale: 0.50, modelScale: 0.5,
stageUiPath: "/assets/UI/ebike.webm", stageUiPath: "/assets/UI/ebike.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH, interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH,
@@ -85,51 +85,6 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
}, },
], ],
}, },
pylone: {
id: "pylone",
label: "Power pylon",
description:
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
modelPath: "/models/pylone/model.gltf",
stageUiPath: "/assets/UI/centrale.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.8,
requiredReplacementPartId: "pylone-grid-relay-replacement",
scanPartSeconds: 1.4,
brokenParts: [
{
id: "pylone-grid-relay",
label: "Grid relay",
nodeName: "lampe",
placeholderName: "placeholder_1",
},
{
id: "pylone-damaged-panel",
label: "Damaged solar panel",
nodeName: "panneau2",
placeholderName: "placeholder_2",
},
],
replacementParts: [
{
id: "pylone-grid-relay-replacement",
label: "Replacement grid relay",
modelPath: "/models/pylone/model.gltf",
},
{
id: "pylone-stone-decoy",
label: "Stone counterweight",
modelPath: "/models/galet/model.gltf",
},
{
id: "pylone-cooling-decoy",
label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf",
},
],
},
ferme: { ferme: {
id: "ferme", id: "ferme",
label: "Vertical farm", label: "Vertical farm",
+2 -9
View File
@@ -1,4 +1,4 @@
import type { Zone } from "@/types/game"; import type { Zone, GameStep } from "@/types/game";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
export const ZONES: Zone[] = [ export const ZONES: Zone[] = [
@@ -7,13 +7,6 @@ export const ZONES: Zone[] = [
position: [-5, 25, -15] as Vector3Tuple, position: [-5, 25, -15] as Vector3Tuple,
radius: 10, radius: 10,
height: 20, height: 20,
targetStep: "mission2", targetStep: "bike" as GameStep,
},
{
id: "searchingZone",
position: [-5, 25, -30] as Vector3Tuple,
radius: 10,
height: 20,
targetStep: "searching",
}, },
]; ];
@@ -2,14 +2,12 @@ import { useGameStore } from "@/managers/stores/useGameStore";
import type { MissionStep } from "@/types/gameplay/repairMission"; import type { MissionStep } from "@/types/gameplay/repairMission";
export function useRepairMovementLocked(): boolean { export function useRepairMovementLocked(): boolean {
return false;
return useGameStore((state) => { return useGameStore((state) => {
switch (state.mainState) { switch (state.mainState) {
case "bike": case "bike":
return isRepairMovementLocked(state.bike.currentStep); return isRepairMovementLocked(state.bike.currentStep);
case "pylone": case "pylone":
return isRepairMovementLocked(state.pylone.currentStep); return state.pylone.currentStep === "manipulation";
case "ferme": case "ferme":
return isRepairMovementLocked(state.ferme.currentStep); return isRepairMovementLocked(state.ferme.currentStep);
case "intro": case "intro":
+57 -22
View File
@@ -7,9 +7,10 @@ import {
type MissionStep, type MissionStep,
type RepairMissionId, type RepairMissionId,
} from "@/types/gameplay/repairMission"; } from "@/types/gameplay/repairMission";
import { type PyloneStep } from "@/types/gameplay/pylone";
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
export type { MissionStep, RepairMissionId }; export type { MissionStep, RepairMissionId, PyloneStep };
interface IntroState { interface IntroState {
currentStep: GameStep; currentStep: GameStep;
@@ -33,12 +34,15 @@ interface MissionFlowState {
interface GameState { interface GameState {
mainState: MainGameState; mainState: MainGameState;
isCinematicPlaying: boolean; isCinematicPlaying: boolean;
sceneReady: boolean;
missionFlow: MissionFlowState; missionFlow: MissionFlowState;
intro: IntroState; intro: IntroState;
bike: MissionState & { bike: MissionState & {
isRepaired: boolean; isRepaired: boolean;
}; };
pylone: MissionState & { pylone: {
currentStep: PyloneStep;
dialogueAudio: string | null;
isPowered: boolean; isPowered: boolean;
}; };
ferme: MissionState & { ferme: MissionState & {
@@ -53,6 +57,7 @@ interface GameState {
interface GameActions { interface GameActions {
setMainState: (mainState: MainGameState) => void; setMainState: (mainState: MainGameState) => void;
setCinematicPlaying: (isCinematicPlaying: boolean) => void; setCinematicPlaying: (isCinematicPlaying: boolean) => void;
setSceneReady: (sceneReady: boolean) => void;
hideDialog: () => void; hideDialog: () => void;
setActivityCity: (activityCity: boolean) => void; setActivityCity: (activityCity: boolean) => void;
setCanMove: (canMove: boolean) => void; setCanMove: (canMove: boolean) => void;
@@ -66,7 +71,6 @@ interface GameActions {
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void; setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
completeIntro: () => void; completeIntro: () => void;
completeBike: () => void; completeBike: () => void;
completePylone: () => void;
completeFerme: () => void; completeFerme: () => void;
completeMission: (mission: RepairMissionId) => void; completeMission: (mission: RepairMissionId) => void;
startOutro: () => void; startOutro: () => void;
@@ -104,22 +108,7 @@ function completeBikeState(state: GameState): GameStateUpdate {
}, },
pylone: { pylone: {
...state.pylone, ...state.pylone,
currentStep: "waiting", currentStep: "locked",
},
};
}
function completePyloneState(state: GameState): GameStateUpdate {
return {
mainState: "ferme",
pylone: {
...state.pylone,
currentStep: "done",
isPowered: true,
},
ferme: {
...state.ferme,
currentStep: "waiting",
}, },
}; };
} }
@@ -159,13 +148,48 @@ function completeMissionState(
switch (mission) { switch (mission) {
case "bike": case "bike":
return completeBikeState(state); return completeBikeState(state);
case "pylone":
return completePyloneState(state);
case "ferme": case "ferme":
return completeFermeState(state); return completeFermeState(state);
} }
} }
function getNextPyloneStep(step: PyloneStep): PyloneStep {
switch (step) {
case "locked":
return "alert";
case "alert":
return "searching";
case "searching":
return "helped";
case "helped":
return "manipulation";
case "manipulation":
return "manipulation";
}
}
function advancePyloneStep(state: GameState): GameStateUpdate {
if (state.pylone.currentStep === "locked") {
return {
pylone: { ...state.pylone, currentStep: "alert" },
};
}
const nextStep = getNextPyloneStep(state.pylone.currentStep);
if (
nextStep === "manipulation" &&
state.pylone.currentStep === "manipulation"
) {
return {
mainState: "outro",
pylone: { ...state.pylone, currentStep: "manipulation" },
};
}
return {
pylone: { ...state.pylone, currentStep: nextStep },
};
}
function advanceRepairMissionState( function advanceRepairMissionState(
state: GameState, state: GameState,
mission: RepairMissionId, mission: RepairMissionId,
@@ -203,6 +227,7 @@ function createInitialGameState(): GameState {
return { return {
mainState: "intro", mainState: "intro",
isCinematicPlaying: false, isCinematicPlaying: false,
sceneReady: false,
missionFlow: { missionFlow: {
activityCity: true, activityCity: true,
canMove: false, canMove: false,
@@ -241,6 +266,7 @@ export const useGameStore = create<GameStore>()((set) => ({
...createInitialGameState(), ...createInitialGameState(),
setMainState: (mainState) => set({ mainState }), setMainState: (mainState) => set({ mainState }),
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }), setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
setSceneReady: (sceneReady) => set({ sceneReady }),
hideDialog: () => hideDialog: () =>
set((state) => ({ set((state) => ({
missionFlow: { ...state.missionFlow, dialogMessage: null }, missionFlow: { ...state.missionFlow, dialogMessage: null },
@@ -273,7 +299,6 @@ export const useGameStore = create<GameStore>()((set) => ({
set((state) => setMissionStepState(state, mission, step)), set((state) => setMissionStepState(state, mission, step)),
completeIntro: () => set(completeIntroState), completeIntro: () => set(completeIntroState),
completeBike: () => set((state) => completeMissionState(state, "bike")), completeBike: () => set((state) => completeMissionState(state, "bike")),
completePylone: () => set((state) => completeMissionState(state, "pylone")),
completeFerme: () => set((state) => completeMissionState(state, "ferme")), completeFerme: () => set((state) => completeMissionState(state, "ferme")),
completeMission: (mission) => completeMission: (mission) =>
set((state) => completeMissionState(state, mission)), set((state) => completeMissionState(state, mission)),
@@ -281,9 +306,19 @@ export const useGameStore = create<GameStore>()((set) => ({
advanceGameState: () => advanceGameState: () =>
set((state) => { set((state) => {
if (state.mainState === "intro") { if (state.mainState === "intro") {
if (state.intro.currentStep === "bike") {
return {
mainState: "bike",
intro: { ...state.intro, hasCompleted: true },
};
}
return completeIntroState(state); return completeIntroState(state);
} }
if (state.mainState === "pylone") {
return advancePyloneStep(state);
}
if (isRepairMissionId(state.mainState)) { if (isRepairMissionId(state.mainState)) {
return advanceRepairMissionState(state, state.mainState); return advanceRepairMissionState(state, state.mainState);
} }
+6 -1
View File
@@ -19,6 +19,7 @@ export function HomePage(): React.JSX.Element {
(state) => state.missionFlow.dialogMessage, (state) => state.missionFlow.dialogMessage,
); );
const hideDialog = useGameStore((state) => state.hideDialog); const hideDialog = useGameStore((state) => state.hideDialog);
const setSceneReady = useGameStore((state) => state.setSceneReady);
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>( const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
INITIAL_SCENE_LOADING_STATE, INITIAL_SCENE_LOADING_STATE,
); );
@@ -42,13 +43,17 @@ export function HomePage(): React.JSX.Element {
return currentState; return currentState;
} }
if (nextState.status === "ready" && currentState.status !== "ready") {
setSceneReady(true);
}
return { return {
...nextState, ...nextState,
progress: Math.max(currentState.progress, nextState.progress), progress: Math.max(currentState.progress, nextState.progress),
}; };
}); });
}, },
[], [setSceneReady],
); );
return ( return (
+1
View File
@@ -14,6 +14,7 @@ export interface CinematicDialogueCue {
export interface CinematicDefinition { export interface CinematicDefinition {
id: string; id: string;
timecode?: number; timecode?: number;
trigger?: string;
cameraKeyframes: CinematicCameraKeyframe[]; cameraKeyframes: CinematicCameraKeyframe[];
dialogueCues?: CinematicDialogueCue[]; dialogueCues?: CinematicDialogueCue[];
} }
+6 -16
View File
@@ -2,27 +2,17 @@ import type { Vector3Tuple } from "@/types/three/three";
export type GameStep = export type GameStep =
| "intro" | "intro"
| "start-intro" | "sequence_video"
| "naming" | "naming"
| "bienvenue" | "start-move"
| "star-move" | "bike";
| "mission2"
| "searching"
| "helped"
| "manipulation"
| "outOfFabrik";
export const GAME_STEPS: readonly GameStep[] = [ export const GAME_STEPS: readonly GameStep[] = [
"intro", "intro",
"start-intro", "sequence_video",
"naming", "naming",
"bienvenue", "start-move",
"star-move", "bike",
"mission2",
"searching",
"helped",
"manipulation",
"outOfFabrik",
] as const; ] as const;
export interface Zone { export interface Zone {
+18
View File
@@ -0,0 +1,18 @@
export type PyloneStep =
| "locked"
| "alert"
| "searching"
| "helped"
| "manipulation";
export const PYLONE_STEPS: readonly PyloneStep[] = [
"locked",
"alert",
"searching",
"helped",
"manipulation",
] as const;
export function isPyloneStep(value: string): value is PyloneStep {
return PYLONE_STEPS.includes(value as PyloneStep);
}
+2 -2
View File
@@ -1,4 +1,4 @@
export type RepairMissionId = "bike" | "pylone" | "ferme"; export type RepairMissionId = "bike" | "ferme";
export type MissionStep = export type MissionStep =
| "locked" | "locked"
@@ -10,7 +10,7 @@ export type MissionStep =
| "reassembling" | "reassembling"
| "done"; | "done";
export const REPAIR_MISSION_IDS = ["bike", "pylone", "ferme"] as const; export const REPAIR_MISSION_IDS = ["bike", "ferme"] as const;
export const MISSION_STEPS = [ export const MISSION_STEPS = [
"locked", "locked",
+21
View File
@@ -20,6 +20,7 @@ export function GameCinematics(): null {
const [dialogueManifest, setDialogueManifest] = const [dialogueManifest, setDialogueManifest] =
useState<DialogueManifest | null>(null); useState<DialogueManifest | null>(null);
const playedCinematicsRef = useRef(new Set<string>()); const playedCinematicsRef = useRef(new Set<string>());
const triggeredCinematicsRef = useRef(new Set<string>());
const timelineRef = useRef<gsap.core.Timeline | null>(null); const timelineRef = useRef<gsap.core.Timeline | null>(null);
const activeAudiosRef = useRef(new Set<HTMLAudioElement>()); const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
const startedAtRef = useRef<number | null>(null); const startedAtRef = useRef<number | null>(null);
@@ -64,7 +65,25 @@ export function GameCinematics(): null {
const elapsedTime = clock.getElapsedTime() - startedAtRef.current; const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
const currentStep = useGameStore.getState().intro.currentStep;
manifest.cinematics.forEach((cinematic) => { manifest.cinematics.forEach((cinematic) => {
if (cinematic.trigger) {
if (triggeredCinematicsRef.current.has(cinematic.id)) return;
if (currentStep !== cinematic.trigger) return;
if (cinematic.dialogueCues && !dialogueManifest) return;
triggeredCinematicsRef.current.add(cinematic.id);
playCinematic(camera, cinematic, timelineRef, {
dialogueManifest,
activeAudiosRef,
onComplete: () => {
triggeredCinematicsRef.current.delete(cinematic.id);
},
});
return;
}
if (cinematic.timecode === undefined) return; if (cinematic.timecode === undefined) return;
if (cinematic.timecode > elapsedTime) return; if (cinematic.timecode > elapsedTime) return;
if (cinematic.dialogueCues && !dialogueManifest) return; if (cinematic.dialogueCues && !dialogueManifest) return;
@@ -95,6 +114,7 @@ function playCinematic(
dialogueOptions: { dialogueOptions: {
dialogueManifest: DialogueManifest | null; dialogueManifest: DialogueManifest | null;
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>; activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
onComplete?: () => void;
}, },
): void { ): void {
const firstKeyframe = cinematic.cameraKeyframes[0]; const firstKeyframe = cinematic.cameraKeyframes[0];
@@ -113,6 +133,7 @@ function playCinematic(
onComplete: () => { onComplete: () => {
timelineRef.current = null; timelineRef.current = null;
useGameStore.getState().setCinematicPlaying(false); useGameStore.getState().setCinematicPlaying(false);
dialogueOptions.onComplete?.();
}, },
}); });
-4
View File
@@ -19,10 +19,6 @@ const GAME_REPAIR_ZONES = [
mission: "bike", mission: "bike",
position: [8, 0, -6], position: [8, 0, -6],
}, },
{
mission: "pylone",
position: [64, 0, -66],
},
{ {
mission: "ferme", mission: "ferme",
position: [-24, 0, 42], position: [-24, 0, 42],