14 Commits

Author SHA1 Message Date
math-pixel 988c8305bc update remove naming step 2026-05-14 11:58:43 +02:00
math-pixel 5ee52ec752 feat: video intro 2026-05-14 11:50:40 +02:00
math-pixel 3ece1d76de feat: video player 2026-05-14 11:35:03 +02:00
math-pixel 3222d2ed3d update: position intro 2026-05-14 11:16:58 +02:00
math-pixel 41c38a35b2 feat/net shader 2026-05-14 10:34:36 +02:00
math-pixel 6399a2f89f feat: add net shader 2026-05-13 17:07:12 +02:00
math-pixel f5d3d080c8 update bike 2026-05-13 14:05:25 +02:00
math-pixel cac66ebaee Merge branch 'develop' into feat/intro 2026-05-13 11:14:25 +02:00
math-pixel c155d847e9 Merge branch 'develop' into feat/intro 2026-05-13 11:11:28 +02:00
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
34 changed files with 605 additions and 290 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,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -42
View File
@@ -1,64 +1,36 @@
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 playVideo = useGameStore((state) => state.playVideo);
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");
playVideo("/videos/intro.webm");
} }
}, [step, setStep]); }, [step, setStep, sceneReady, playVideo]);
useEffect(() => { useEffect(() => {
if (step === "start-intro") { if (step === "sequence_video" && !isCinematicPlaying) {
const audio = AudioManager.getInstance(); setStep("start-move");
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;
} }
+25
View File
@@ -0,0 +1,25 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { createNetShader } from "@/shaders/NetShader";
export function NetTest(): React.JSX.Element {
const materialRef = useRef<THREE.ShaderMaterial>(null);
useFrame((_, delta) => {
if (materialRef.current) {
materialRef.current.uniforms.uTime.value += delta;
}
});
return (
<mesh position={[0, 2, -5]}>
<planeGeometry args={[2, 2, 1, 1]} />
<primitive
object={createNetShader()}
ref={materialRef}
attach="material"
/>
</mesh>
);
}
@@ -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",
+1 -97
View File
@@ -1,106 +1,10 @@
import { useState } from "react";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
export function IntroUI(): React.JSX.Element | null {
const step = useGameStore((state) => state.intro.currentStep);
const setPlayerName = useGameStore((state) => state.setPlayerName);
const setStep = useGameStore((state) => state.setIntroStep);
const [inputValue, setInputValue] = useState("");
if (step !== "naming") return null;
const handleSubmit = (): void => {
if (inputValue.trim() === "") return;
setPlayerName(inputValue.trim());
setStep("bienvenue");
};
const handleKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === "Enter") {
handleSubmit();
}
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
zIndex: 1000,
}}
>
<div
style={{
backgroundColor: "#1a1a1a",
padding: "2rem",
borderRadius: "12px",
display: "flex",
flexDirection: "column",
gap: "1.5rem",
minWidth: "300px",
}}
>
<h2
style={{
color: "#fff",
margin: 0,
fontSize: "1.5rem",
textAlign: "center",
}}
>
Quel est votre prenom ?
</h2>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Votre prenom"
autoFocus
style={{
padding: "0.75rem",
fontSize: "1rem",
borderRadius: "6px",
border: "1px solid #444",
backgroundColor: "#2a2a2a",
color: "#fff",
outline: "none",
}}
/>
<button
onClick={handleSubmit}
disabled={inputValue.trim() === ""}
style={{
padding: "0.75rem",
fontSize: "1rem",
borderRadius: "6px",
border: "none",
backgroundColor: inputValue.trim() ? "#4a9" : "#444",
color: "#fff",
cursor: inputValue.trim() ? "pointer" : "not-allowed",
transition: "background-color 0.2s",
}}
>
Valider
</button>
</div>
</div>
);
}
export function BienvenueDisplay(): React.JSX.Element | null { 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
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef } from "react";
import { useGameStore } from "@/managers/stores/useGameStore";
export function VideoPlayer(): null {
const currentVideo = useGameStore((state) => state.missionFlow.currentVideo);
const clearVideo = useGameStore((state) => state.clearVideo);
const setCinematicPlaying = useGameStore(
(state) => state.setCinematicPlaying,
);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (currentVideo) {
setCinematicPlaying(true);
}
}, [currentVideo, setCinematicPlaying]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && currentVideo) {
closeVideo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [currentVideo]);
const closeVideo = () => {
if (videoRef.current) {
videoRef.current.pause();
}
clearVideo();
setCinematicPlaying(false);
};
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleEnded = () => {
closeVideo();
};
video.addEventListener("ended", handleEnded);
return () => video.removeEventListener("ended", handleEnded);
}, []);
if (!currentVideo) return null;
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
backgroundColor: "rgba(0, 0, 0, 0.95)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 9999,
}}
>
<video
ref={videoRef}
src={currentVideo}
autoPlay
playsInline
style={{
maxWidth: "100%",
maxHeight: "100%",
cursor: "pointer",
}}
onClick={closeVideo}
/>
</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;
} }
-45
View File
@@ -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",
+1 -1
View File
@@ -11,5 +11,5 @@ export const PLAYER_MAX_DELTA = 0.05;
export const PLAYER_ACCELERATION_MULTIPLIER = 9; export const PLAYER_ACCELERATION_MULTIPLIER = 9;
export const PLAYER_XZ_DAMPING_FACTOR = 8; export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0]; export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [6.56,5,71.55];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0]; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
+5 -12
View File
@@ -1,19 +1,12 @@
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[] = [
{ {
id: "fabrikExit", id: "fabrikExit",
position: [-5, 25, -15] as Vector3Tuple, position: [18.43,0,75.3] as Vector3Tuple,
radius: 10, radius: 4,
height: 20, height: 10,
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":
+69 -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;
@@ -28,17 +29,21 @@ interface MissionFlowState {
canMove: boolean; canMove: boolean;
dialogMessage: string | null; dialogMessage: string | null;
playerName: string; playerName: string;
currentVideo: string | null;
} }
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 +58,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 +72,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;
@@ -74,6 +79,8 @@ interface GameActions {
rewindGameState: () => void; rewindGameState: () => void;
resetGame: () => void; resetGame: () => void;
showDialog: (dialogMessage: string) => void; showDialog: (dialogMessage: string) => void;
playVideo: (videoSrc: string) => void;
clearVideo: () => void;
} }
type GameStore = GameState & GameActions; type GameStore = GameState & GameActions;
@@ -104,22 +111,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 +151,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,11 +230,13 @@ 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,
dialogMessage: null, dialogMessage: null,
playerName: "", playerName: "",
currentVideo: null,
}, },
intro: { intro: {
currentStep: "intro", currentStep: "intro",
@@ -241,6 +270,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 +303,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 +310,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);
} }
@@ -307,4 +346,12 @@ export const useGameStore = create<GameStore>()((set) => ({
set((state) => ({ set((state) => ({
missionFlow: { ...state.missionFlow, dialogMessage }, missionFlow: { ...state.missionFlow, dialogMessage },
})), })),
playVideo: (videoSrc) =>
set((state) => ({
missionFlow: { ...state.missionFlow, currentVideo: videoSrc },
})),
clearVideo: () =>
set((state) => ({
missionFlow: { ...state.missionFlow, currentVideo: null },
})),
})); }));
+7 -3
View File
@@ -4,7 +4,7 @@ import * as THREE from "three";
import { DebugPerf } from "@/components/debug/DebugPerf"; import { DebugPerf } from "@/components/debug/DebugPerf";
import { DialogMessage } from "@/components/ui/DialogMessage"; import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI"; import { GameUI } from "@/components/ui/GameUI";
import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI"; import { BienvenueDisplay } from "@/components/ui/IntroUI";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider"; import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
@@ -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 (
@@ -63,7 +68,6 @@ export function HomePage(): React.JSX.Element {
</Suspense> </Suspense>
</Canvas> </Canvas>
<GameUI /> <GameUI />
<IntroUI />
<BienvenueDisplay /> <BienvenueDisplay />
{dialogMessage ? ( {dialogMessage ? (
<DialogMessage <DialogMessage
+62
View File
@@ -0,0 +1,62 @@
import { ShaderMaterial } from "three";
export const createNetShader = (): ShaderMaterial => {
return new ShaderMaterial({
transparent: true,
depthWrite: false,
uniforms: {
uTime: { value: 0 },
uGridScale: { value: 11.3 },
uPincushionStrength: { value: 0.4 },
uBloomIntensity: { value: 0.8 },
uGridThickness: { value: 0.05 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform float uGridScale;
uniform float uPincushionStrength;
uniform float uBloomIntensity;
uniform float uGridThickness;
varying vec2 vUv;
vec2 applyPincushion(vec2 uv, float strength) {
vec2 center = uv - 0.5;
float dist = length(center);
float distortion = 1.0 + dist * dist * strength;
return center * distortion + 0.5;
}
float grid(vec2 uv, float scale, float thickness) {
vec2 gridUV = fract(uv * scale);
float edgeX = step(gridUV.x, thickness) + step(1.0 - thickness, gridUV.x);
float edgeY = step(gridUV.y, thickness) + step(1.0 - thickness, gridUV.y);
float line = min(edgeX + edgeY, 1.0);
return line;
}
void main() {
vec2 uv = applyPincushion(vUv, uPincushionStrength);
float gridPattern = grid(uv, uGridScale, uGridThickness);
vec3 gridColor = vec3(1.0, 0.8, 0.2);
float bloom = gridPattern * uBloomIntensity;
vec3 col = gridColor * (0.3 + bloom);
gl_FragColor = vec4(col, gridPattern * 0.95);
}
`,
});
};
+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[];
} }
+4 -20
View File
@@ -1,28 +1,12 @@
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
export type GameStep = export type GameStep = "intro" | "sequence_video" | "start-move" | "bike";
| "intro"
| "start-intro"
| "naming"
| "bienvenue"
| "star-move"
| "mission2"
| "searching"
| "helped"
| "manipulation"
| "outOfFabrik";
export const GAME_STEPS: readonly GameStep[] = [ export const GAME_STEPS: readonly GameStep[] = [
"intro", "intro",
"start-intro", "sequence_video",
"naming", "start-move",
"bienvenue", "bike",
"star-move",
"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],
+7 -1
View File
@@ -28,6 +28,8 @@ import { GameMap } from "@/world/GameMap";
import { GameStageContent } from "@/world/GameStageContent"; 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";
import { NetTest } from "@/components/three/NetTest";
import { VideoPlayer } from "@/components/ui/VideoPlayer";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
interface WorldProps { interface WorldProps {
@@ -61,6 +63,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
return ( return (
<> <>
<Environment /> <Environment />
<VideoPlayer />
<Lighting /> <Lighting />
<DebugHelpers /> <DebugHelpers />
{showHandTrackingGloves ? ( {showHandTrackingGloves ? (
@@ -98,7 +101,10 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
) : null} ) : null}
</> </>
) : ( ) : (
<TestMap onOctreeReady={handleOctreeReady} /> <>
<TestMap onOctreeReady={handleOctreeReady} />
<NetTest />
</>
)} )}
{sceneMode !== "game" && spawnPlayer ? ( {sceneMode !== "game" && spawnPlayer ? (