Merge branch 'develop' of https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
This commit is contained in:
@@ -72,14 +72,23 @@ It tracks:
|
|||||||
- `gameMapLoaded`: map data and visible map nodes settled
|
- `gameMapLoaded`: map data and visible map nodes settled
|
||||||
- `gameStageLoaded`: Rapier gameplay stage mounted
|
- `gameStageLoaded`: Rapier gameplay stage mounted
|
||||||
- `showGameStage`: true when the map is ready enough to mount gameplay content
|
- `showGameStage`: true when the map is ready enough to mount gameplay content
|
||||||
- `gameplayReady`: true when map, stage, and octree are all ready
|
- `shadowsReady`: renderer, shadow lights, and scene matrices have been forced once after the scene is mounted
|
||||||
|
- `gameplayReady`: true when map, stage, octree, and the shadow warmup are all ready
|
||||||
|
|
||||||
The final game-scene readiness condition is:
|
The base game-scene readiness condition before the shadow warmup is:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
showGameStage && gameStageLoaded && octree !== null;
|
showGameStage && gameStageLoaded && octree !== null;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After that condition is met, `SceneShadowWarmup` runs one final loading step:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Activation des ombres -> Ombres prêtes -> Gameplay prêt
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed.
|
||||||
|
|
||||||
The debug physics scene is ready when:
|
The debug physics scene is ready when:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
-227
@@ -584,22 +584,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "arbre",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [50.072, 2.2583, 78.7082],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "arbre",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [50.072, 2.2583, 78.7082],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "arbre",
|
"name": "arbre",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -888,22 +872,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "arbre",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [59.1794, 2.2557, 73.349],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "arbre",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [59.1794, 2.2557, 73.349],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "arbre",
|
"name": "arbre",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -1112,22 +1080,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "arbre",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [74.0452, 2.309, 59.2374],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "arbre",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [74.0452, 2.309, 59.2374],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "arbre",
|
"name": "arbre",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -2754,22 +2706,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [73.7334, 1.1132, 54.1382],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [73.7334, 1.1132, 54.1382],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -3330,22 +3266,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [67.9046, 0.5562, 74.8395],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [67.9046, 0.5562, 74.8395],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -3714,22 +3634,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [73.5205, 0.3748, 75.9136],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [73.5205, 0.3748, 75.9136],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -3858,22 +3762,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [66.999, 1.7223, 48.3983],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [66.999, 1.7223, 48.3983],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -4914,22 +4802,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [61.3924, 0.4621, 82.2195],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [61.3924, 0.4621, 82.2195],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -5122,22 +4994,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [61.1082, 0.6236, 77.7642],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [61.1082, 0.6236, 77.7642],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -5170,22 +5026,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [53.1033, 1.6054, 63.3842],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [53.1033, 1.6054, 63.3842],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -5266,22 +5106,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [59.647, 1.5484, 59.429],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [59.647, 1.5484, 59.429],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -5410,22 +5234,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [69.2496, 0.6286, 71.5478],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [69.2496, 0.6286, 71.5478],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -6226,22 +6034,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [58.3126, 0.686, 77.9828],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "buisson",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [58.3126, 0.686, 77.9828],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "buisson",
|
"name": "buisson",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -37602,23 +37394,6 @@
|
|||||||
"rotation": [0, 0, 0],
|
"rotation": [0, 0, 0],
|
||||||
"scale": [1, 1, 1],
|
"scale": [1, 1, 1],
|
||||||
"children": [
|
"children": [
|
||||||
{
|
|
||||||
"name": "ebike",
|
|
||||||
"type": "Object3D",
|
|
||||||
"role": "group",
|
|
||||||
"position": [0, 0, 0],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1],
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "ebike",
|
|
||||||
"type": "Object3D",
|
|
||||||
"position": [42.2399, 4.5484, 34.6468],
|
|
||||||
"rotation": [0, 0, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "zone1_residence",
|
"name": "zone1_residence",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
@@ -40477,14 +40252,14 @@
|
|||||||
"name": "lafabrik",
|
"name": "lafabrik",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
"position": [59.4973, 6.2746, 64.6354],
|
"position": [59.4973, 6.2746, 64.6354],
|
||||||
"rotation": [-3.1416, -0.7309, -3.1416],
|
"rotation": [-3.1416, 2.4107, -3.1416],
|
||||||
"scale": [1, 2, 1],
|
"scale": [1, 2, 1],
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"name": "lafabrik",
|
"name": "lafabrik",
|
||||||
"type": "Mesh",
|
"type": "Mesh",
|
||||||
"position": [59.4973, 6.2746, 64.6354],
|
"position": [59.4973, 6.2746, 64.6354],
|
||||||
"rotation": [-3.1416, -0.7309, -3.1416],
|
"rotation": [-3.1416, 2.4107, -3.1416],
|
||||||
"scale": [1, 2, 1]
|
"scale": [1, 2, 1]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -31,14 +31,13 @@
|
|||||||
"id": "narrateur_bienvenueaaltera",
|
"id": "narrateur_bienvenueaaltera",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3",
|
"audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3",
|
||||||
"subtitleCueIndex": 1,
|
"subtitleCueIndex": 1
|
||||||
"timecode": 0
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "narrateur_intro_prenom",
|
"id": "narrateur_intro_prenom",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_intro_prenom.mp3",
|
"audio": "/sounds/dialogue/narrateur_intro_prenom.mp3",
|
||||||
"subtitleCueIndex": 2
|
"subtitleCueIndices": [1, 2]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "narrateur_intro_apresprenom",
|
"id": "narrateur_intro_apresprenom",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
1
|
1
|
||||||
00:00:00,000 --> 00:00:02,760
|
00:00:00,000 --> 00:00:09,000
|
||||||
Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech.
|
Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech.
|
||||||
|
|
||||||
2
|
2
|
||||||
00:00:00,000 --> 00:00:11,592
|
00:00:09,000 --> 00:00:11,592
|
||||||
Avant de commencer, comment tu t'appelles ?
|
Avant de commencer, comment tu t'appelles ?
|
||||||
|
|
||||||
3
|
3
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
|
|||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
|
||||||
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||||
import {
|
import {
|
||||||
EBIKE_CAMERA_TRANSFORM,
|
EBIKE_CAMERA_TRANSFORM,
|
||||||
EBIKE_DROP_PLAYER_TRANSFORM,
|
EBIKE_DROP_PLAYER_TRANSFORM,
|
||||||
|
EBIKE_WORLD_ROTATION_Y,
|
||||||
} from "@/data/ebike/ebikeConfig";
|
} from "@/data/ebike/ebikeConfig";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import "@/types/ebike/ebikeWindow";
|
import "@/types/ebike/ebikeWindow";
|
||||||
@@ -31,7 +33,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
const model = useClonedObject(scene);
|
const model = useClonedObject(scene);
|
||||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||||
|
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
const updateEbikeSounds = useEbikeSounds();
|
||||||
|
|
||||||
// Map active mainState to target repair zone coordinate
|
// Map active mainState to target repair zone coordinate
|
||||||
const destPos = useMemo(() => {
|
const destPos = useMemo(() => {
|
||||||
@@ -67,7 +72,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
position[1] - PLAYER_EYE_HEIGHT,
|
position[1] - PLAYER_EYE_HEIGHT,
|
||||||
position[2],
|
position[2],
|
||||||
]);
|
]);
|
||||||
const restingRotationRef = useRef<number>(0);
|
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||||
|
|
||||||
// State for debug visualization (synced from refs during useFrame)
|
// State for debug visualization (synced from refs during useFrame)
|
||||||
@@ -102,6 +107,12 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (groupRef.current) {
|
if (groupRef.current) {
|
||||||
if (movementMode === "ebike") {
|
if (movementMode === "ebike") {
|
||||||
|
updateEbikeSounds({
|
||||||
|
mounted: true,
|
||||||
|
driving: window.ebikeDriveInputActive === true,
|
||||||
|
breakdown: window.ebikeBreakdownActive === true,
|
||||||
|
});
|
||||||
|
|
||||||
restingPositionRef.current = [
|
restingPositionRef.current = [
|
||||||
groupRef.current.position.x,
|
groupRef.current.position.x,
|
||||||
groupRef.current.position.y,
|
groupRef.current.position.y,
|
||||||
@@ -133,6 +144,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
setDebugRestingPosition([...restingPositionRef.current]);
|
setDebugRestingPosition([...restingPositionRef.current]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
updateEbikeSounds({ mounted: false, driving: false, breakdown: false });
|
||||||
groupRef.current.position.set(...restingPositionRef.current);
|
groupRef.current.position.set(...restingPositionRef.current);
|
||||||
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
|
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
|
||||||
|
|
||||||
@@ -159,7 +171,14 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleInteract = useCallback((): void => {
|
const handleInteract = useCallback((): void => {
|
||||||
|
if (window.ebikeBreakdownActive === true) return;
|
||||||
|
|
||||||
if (movementMode === "walk") {
|
if (movementMode === "walk") {
|
||||||
|
if (mainState === "ebike" && ebikeStep === "waiting") {
|
||||||
|
setMissionStep("ebike", "inspected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cameraOffset = new THREE.Vector3(
|
const cameraOffset = new THREE.Vector3(
|
||||||
...EBIKE_CAMERA_TRANSFORM.position,
|
...EBIKE_CAMERA_TRANSFORM.position,
|
||||||
);
|
);
|
||||||
@@ -213,7 +232,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
useGameStore.getState().setPlayerMovementMode("walk");
|
useGameStore.getState().setPlayerMovementMode("walk");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [movementMode, camera, position]);
|
}, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]);
|
||||||
|
|
||||||
// Store handleInteract in a ref for use in debug folder callback
|
// Store handleInteract in a ref for use in debug folder callback
|
||||||
const handleInteractRef = useRef(handleInteract);
|
const handleInteractRef = useRef(handleInteract);
|
||||||
@@ -239,12 +258,20 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<group ref={groupRef} position={position}>
|
<group
|
||||||
|
ref={groupRef}
|
||||||
|
position={position}
|
||||||
|
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||||
|
>
|
||||||
<primitive object={model} />
|
<primitive object={model} />
|
||||||
<InteractableObject
|
<InteractableObject
|
||||||
kind="trigger"
|
kind="trigger"
|
||||||
label={
|
label={
|
||||||
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
|
mainState === "ebike" && ebikeStep === "waiting"
|
||||||
|
? "Inspecter l'e-bike"
|
||||||
|
: movementMode === "walk"
|
||||||
|
? "Monter sur le bike"
|
||||||
|
: "Descendre du bike"
|
||||||
}
|
}
|
||||||
position={position}
|
position={position}
|
||||||
radius={15}
|
radius={15}
|
||||||
@@ -263,7 +290,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
height={0.8}
|
height={0.8}
|
||||||
startPos={gpsStartPos}
|
startPos={gpsStartPos}
|
||||||
destPos={destPos}
|
destPos={destPos}
|
||||||
mapImageUrl="/assets/gps/map_background.png"
|
mapImageUrl="/assets/world/gps/map_background.png"
|
||||||
worldBounds={{
|
worldBounds={{
|
||||||
minX: -166,
|
minX: -166,
|
||||||
maxX: 163,
|
maxX: 163,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type {
|
|||||||
DialogueSpeaker,
|
DialogueSpeaker,
|
||||||
DialogueVoiceId,
|
DialogueVoiceId,
|
||||||
} from "@/types/dialogues/dialogues";
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import {
|
||||||
|
getDialogueCueIndices,
|
||||||
|
getDialogueFirstCueIndex,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||||
@@ -34,7 +38,7 @@ function getNextCueIndex(
|
|||||||
): number {
|
): number {
|
||||||
const cueIndexes = manifest.dialogues
|
const cueIndexes = manifest.dialogues
|
||||||
.filter((dialogue) => dialogue.voice === voice)
|
.filter((dialogue) => dialogue.voice === voice)
|
||||||
.map((dialogue) => dialogue.subtitleCueIndex);
|
.flatMap((dialogue) => getDialogueCueIndices(dialogue));
|
||||||
|
|
||||||
return Math.max(0, ...cueIndexes) + 1;
|
return Math.max(0, ...cueIndexes) + 1;
|
||||||
}
|
}
|
||||||
@@ -93,12 +97,15 @@ async function createFrenchSrtCue(
|
|||||||
manifest: DialogueManifest,
|
manifest: DialogueManifest,
|
||||||
dialogue: DialogueDefinition,
|
dialogue: DialogueDefinition,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const firstCueIndex = getDialogueFirstCueIndex(dialogue);
|
||||||
|
if (firstCueIndex === undefined) return;
|
||||||
|
|
||||||
const srtPath = getFrenchSrtPath(dialogue.voice);
|
const srtPath = getFrenchSrtPath(dialogue.voice);
|
||||||
const response = await fetch(srtPath);
|
const response = await fetch(srtPath);
|
||||||
const content = response.ok ? await response.text() : "";
|
const content = response.ok ? await response.text() : "";
|
||||||
const nextContent = appendSrtCueIfMissing(
|
const nextContent = appendSrtCueIfMissing(
|
||||||
content,
|
content,
|
||||||
dialogue.subtitleCueIndex,
|
firstCueIndex,
|
||||||
getVoiceSpeaker(manifest, dialogue.voice),
|
getVoiceSpeaker(manifest, dialogue.voice),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,7 +129,8 @@ function getManifestErrors(manifest: DialogueManifest | null): string[] {
|
|||||||
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`);
|
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isInteger(dialogue.subtitleCueIndex)) {
|
const cueIndices = getDialogueCueIndices(dialogue);
|
||||||
|
if (cueIndices.length === 0) {
|
||||||
errors.push(`${label}: cue SRT invalide.`);
|
errors.push(`${label}: cue SRT invalide.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +168,18 @@ function getPatchedDialogue(
|
|||||||
id: patch.id ?? dialogue.id,
|
id: patch.id ?? dialogue.id,
|
||||||
voice: patch.voice ?? dialogue.voice,
|
voice: patch.voice ?? dialogue.voice,
|
||||||
audio: patch.audio ?? dialogue.audio,
|
audio: patch.audio ?? dialogue.audio,
|
||||||
subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (patch.subtitleCueIndex !== undefined) {
|
||||||
|
nextDialogue.subtitleCueIndex = patch.subtitleCueIndex;
|
||||||
|
} else if (dialogue.subtitleCueIndex !== undefined) {
|
||||||
|
nextDialogue.subtitleCueIndex = dialogue.subtitleCueIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogue.subtitleCueIndices !== undefined) {
|
||||||
|
nextDialogue.subtitleCueIndices = dialogue.subtitleCueIndices;
|
||||||
|
}
|
||||||
|
|
||||||
if ("timecode" in patch) {
|
if ("timecode" in patch) {
|
||||||
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
|
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
|
||||||
} else if (dialogue.timecode !== undefined) {
|
} else if (dialogue.timecode !== undefined) {
|
||||||
@@ -252,8 +269,9 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await createFrenchSrtCue(nextManifest, dialogue);
|
await createFrenchSrtCue(nextManifest, dialogue);
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(dialogue) ?? "?";
|
||||||
setStatus(
|
setStatus(
|
||||||
`Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`,
|
`Nouveau dialogue ajoute avec cue FR ${cueIndex}. Sauvegarde le manifeste pour le garder.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
@@ -333,12 +351,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
async function handleCreateFrenchSrtCue(): Promise<void> {
|
async function handleCreateFrenchSrtCue(): Promise<void> {
|
||||||
if (!manifest || !selectedDialogue) return;
|
if (!manifest || !selectedDialogue) return;
|
||||||
|
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue) ?? "?";
|
||||||
setIsCreatingSrtCue(true);
|
setIsCreatingSrtCue(true);
|
||||||
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`);
|
setStatus(`Creation de la cue FR ${cueIndex}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createFrenchSrtCue(manifest, selectedDialogue);
|
await createFrenchSrtCue(manifest, selectedDialogue);
|
||||||
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`);
|
setStatus(`Cue FR ${cueIndex} prete.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
setStatus(message);
|
setStatus(message);
|
||||||
@@ -478,7 +497,7 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
value={selectedDialogue.subtitleCueIndex}
|
value={getDialogueFirstCueIndex(selectedDialogue) ?? ""}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedDialogue({
|
updateSelectedDialogue({
|
||||||
subtitleCueIndex: Math.max(1, Number(event.target.value)),
|
subtitleCueIndex: Math.max(1, Number(event.target.value)),
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
DialogueSpeaker,
|
DialogueSpeaker,
|
||||||
DialogueVoiceId,
|
DialogueVoiceId,
|
||||||
} from "@/types/dialogues/dialogues";
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import {
|
||||||
|
getDialogueCueIndices,
|
||||||
|
getDialogueFirstCueIndex,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import {
|
import {
|
||||||
@@ -181,7 +185,7 @@ function getExpectedCueIndexes(
|
|||||||
voice: DialogueVoiceId,
|
voice: DialogueVoiceId,
|
||||||
): number[] {
|
): number[] {
|
||||||
return getExpectedDialogues(manifest, voice)
|
return getExpectedDialogues(manifest, voice)
|
||||||
.map((dialogue) => dialogue.subtitleCueIndex)
|
.flatMap((dialogue) => getDialogueCueIndices(dialogue))
|
||||||
.filter(
|
.filter(
|
||||||
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
|
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
|
||||||
)
|
)
|
||||||
@@ -196,7 +200,11 @@ function getExpectedDialogues(
|
|||||||
|
|
||||||
return [...manifest.dialogues]
|
return [...manifest.dialogues]
|
||||||
.filter((dialogue) => dialogue.voice === voice)
|
.filter((dialogue) => dialogue.voice === voice)
|
||||||
.sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex);
|
.sort((a, b) => {
|
||||||
|
const aIndex = getDialogueFirstCueIndex(a) ?? 0;
|
||||||
|
const bIndex = getDialogueFirstCueIndex(b) ?? 0;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCueBlockRange(
|
function findCueBlockRange(
|
||||||
@@ -577,7 +585,7 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
{expectedDialogues.map((dialogue) => (
|
{expectedDialogues.map((dialogue) => (
|
||||||
<option key={dialogue.id} value={dialogue.id}>
|
<option key={dialogue.id} value={dialogue.id}>
|
||||||
Cue {dialogue.subtitleCueIndex} - {dialogue.id}
|
Cue {getDialogueFirstCueIndex(dialogue) ?? "?"} - {dialogue.id}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -585,7 +593,7 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
|
|
||||||
{selectedDialogue && (
|
{selectedDialogue && (
|
||||||
<div className="editor-srt-audio-card">
|
<div className="editor-srt-audio-card">
|
||||||
<span>Cue {selectedDialogue.subtitleCueIndex}</span>
|
<span>Cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"}</span>
|
||||||
<strong>{selectedDialogue.id}</strong>
|
<strong>{selectedDialogue.id}</strong>
|
||||||
<audio
|
<audio
|
||||||
key={selectedDialogue.audio}
|
key={selectedDialogue.audio}
|
||||||
@@ -609,39 +617,52 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
<div className="editor-srt-time-actions">
|
<div className="editor-srt-time-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={
|
||||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start")
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
}
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined)
|
||||||
|
handleSetCueTime(cueIndex, "start");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Set start
|
Set start
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={
|
||||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end")
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
}
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined) handleSetCueTime(cueIndex, "end");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Set end
|
Set end
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={
|
||||||
handleNudgeCue(
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
selectedDialogue.subtitleCueIndex,
|
|
||||||
-CUE_NUDGE_SECONDS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined)
|
||||||
|
handleNudgeCue(cueIndex, -CUE_NUDGE_SECONDS);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
-100ms
|
-100ms
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={
|
||||||
handleNudgeCue(
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
selectedDialogue.subtitleCueIndex,
|
|
||||||
CUE_NUDGE_SECONDS,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined)
|
||||||
|
handleNudgeCue(cueIndex, CUE_NUDGE_SECONDS);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
+100ms
|
+100ms
|
||||||
</button>
|
</button>
|
||||||
@@ -649,9 +670,15 @@ export function EditorSrtPanel(): React.JSX.Element {
|
|||||||
<button
|
<button
|
||||||
className="editor-srt-jump-button"
|
className="editor-srt-jump-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleJumpToCue(selectedDialogue.subtitleCueIndex)}
|
disabled={
|
||||||
|
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||||
|
if (cueIndex !== undefined) handleJumpToCue(cueIndex);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Aller a la cue {selectedDialogue.subtitleCueIndex}
|
Aller a la cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { MissionNotification } from "@/components/ui/MissionNotification";
|
||||||
|
import {
|
||||||
|
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
|
||||||
|
EBIKE_BREAKDOWN_DIALOGUE_ID,
|
||||||
|
EBIKE_INTRO_RIDE_DURATION_MS,
|
||||||
|
EBIKE_SOUNDS,
|
||||||
|
} from "@/data/ebike/ebikeConfig";
|
||||||
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
|
export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||||
|
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||||
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||||
|
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
||||||
|
const hasStartedBreakdown = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
|
||||||
|
|
||||||
|
setIntroStep("ebike-intro-ride");
|
||||||
|
}, [introStep, movementMode, setIntroStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introStep !== "ebike-intro-ride") return undefined;
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setIntroStep("ebike-breakdown");
|
||||||
|
}, EBIKE_INTRO_RIDE_DURATION_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [introStep, setIntroStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasStartedBreakdown.current = true;
|
||||||
|
setBreakdownDialogueDone(false);
|
||||||
|
window.ebikeBreakdownActive = true;
|
||||||
|
AudioManager.getInstance().playSound(EBIKE_SOUNDS.panne, 0.95, {
|
||||||
|
category: "sfx",
|
||||||
|
});
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
const dialogueTimeoutId = window.setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
const manifest = await loadDialogueManifest();
|
||||||
|
if (isCancelled || !manifest) {
|
||||||
|
setBreakdownDialogueDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = await playDialogueById(
|
||||||
|
manifest,
|
||||||
|
EBIKE_BREAKDOWN_DIALOGUE_ID,
|
||||||
|
);
|
||||||
|
if (isCancelled || !audio) {
|
||||||
|
setBreakdownDialogueDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => {
|
||||||
|
setBreakdownDialogueDone(true);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
}, EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
window.clearTimeout(dialogueTimeoutId);
|
||||||
|
};
|
||||||
|
}, [introStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introStep !== "ebike-breakdown") return;
|
||||||
|
if (!breakdownDialogueDone || movementMode !== "walk") return;
|
||||||
|
|
||||||
|
window.ebikeBreakdownActive = false;
|
||||||
|
completeIntro();
|
||||||
|
}, [breakdownDialogueDone, completeIntro, introStep, movementMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introStep === "ebike-breakdown") return;
|
||||||
|
|
||||||
|
window.ebikeBreakdownActive = false;
|
||||||
|
if (introStep !== "completed") {
|
||||||
|
hasStartedBreakdown.current = false;
|
||||||
|
}
|
||||||
|
}, [introStep]);
|
||||||
|
|
||||||
|
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MissionNotification
|
||||||
|
mission="ebike"
|
||||||
|
visible={introStep === "await-ebike-mount"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
|
||||||
import { AUDIO_PATHS } from "@/data/audioConfig";
|
|
||||||
|
|
||||||
export function GameFlow(): null {
|
|
||||||
const step = useGameStore((state) => state.intro.currentStep);
|
|
||||||
const setStep = useGameStore((state) => state.setIntroStep);
|
|
||||||
const setActivityCity = useGameStore((state) => state.setActivityCity);
|
|
||||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
|
||||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
|
||||||
const hasInitialized = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasInitialized.current && step === "intro") {
|
|
||||||
hasInitialized.current = true;
|
|
||||||
setStep("start-intro");
|
|
||||||
}
|
|
||||||
}, [step, setStep]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (step === "start-intro") {
|
|
||||||
const audio = AudioManager.getInstance();
|
|
||||||
audio.playSoundWithCallback(AUDIO_PATHS.intro, 0.5, () => {
|
|
||||||
setStep("naming");
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "bienvenue") {
|
|
||||||
const audio = AudioManager.getInstance();
|
|
||||||
audio.playSoundWithCallback(AUDIO_PATHS.bienvenue, 0.5, () => {
|
|
||||||
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);
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
completeIntro();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}, [completeIntro, step, setStep, setActivityCity, setCanMove]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface SiteButtonProps {
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteButton({
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
onClick,
|
||||||
|
}: SiteButtonProps): React.JSX.Element {
|
||||||
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={() => setIsPressed(true)}
|
||||||
|
onMouseUp={() => setIsPressed(false)}
|
||||||
|
onMouseLeave={() => setIsPressed(false)}
|
||||||
|
className="site-button"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
padding: "12px 20px",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
background: disabled ? "#b0b0b0" : "#FFF",
|
||||||
|
boxShadow: disabled
|
||||||
|
? "none"
|
||||||
|
: isPressed
|
||||||
|
? "0 4px 10px 0 rgba(0, 0, 0, 0.35)"
|
||||||
|
: "0 7px 14.4px 0 rgba(0, 0, 0, 0.25)",
|
||||||
|
border: "none",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
color: disabled ? "#888888" : "#000",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(18px, 3vw, 26px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.3px",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
transition: "box-shadow 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { SiteCardConfig } from "@/data/site/siteConfig";
|
||||||
|
|
||||||
|
interface SiteCardProps {
|
||||||
|
config: SiteCardConfig;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
variant?: "default" | "situation";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteCard({
|
||||||
|
config,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
variant = "default",
|
||||||
|
}: SiteCardProps): React.JSX.Element {
|
||||||
|
const { label, imagePath, disabled } = config;
|
||||||
|
const isSituation = variant === "situation";
|
||||||
|
|
||||||
|
const getBackground = (): string => {
|
||||||
|
if (imagePath) return `url(${imagePath}) center/cover`;
|
||||||
|
if (disabled) return "rgba(255, 255, 255, 0.42)";
|
||||||
|
return "#b8b8b8";
|
||||||
|
};
|
||||||
|
|
||||||
|
const borderColor = selected ? "#a8d5a2" : "rgba(255, 255, 255, 0.55)";
|
||||||
|
|
||||||
|
const textColor = disabled ? "rgba(77, 77, 77, 0.72)" : "#4d4d4d";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={label}
|
||||||
|
className="site-card-button"
|
||||||
|
style={{
|
||||||
|
width: isSituation
|
||||||
|
? "clamp(220px, 24vw, 300px)"
|
||||||
|
: "clamp(120px, 15vw, 160px)",
|
||||||
|
height: isSituation
|
||||||
|
? "clamp(48px, 6vw, 60px)"
|
||||||
|
: "clamp(140px, 18vw, 180px)",
|
||||||
|
border: `3px solid ${borderColor}`,
|
||||||
|
background: getBackground(),
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!imagePath && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
fontSize: isSituation
|
||||||
|
? "clamp(14px, 1.8vw, 22px)"
|
||||||
|
: "clamp(10px, 1.5vw, 14px)",
|
||||||
|
fontWeight: isSituation ? 700 : 500,
|
||||||
|
textAlign: "center",
|
||||||
|
padding: 8,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
|
||||||
|
|
||||||
|
const DISCLAIMER_TEXT =
|
||||||
|
"Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
|
||||||
|
|
||||||
|
const TEXT_DISPLAY_DURATION = 5000;
|
||||||
|
const FADE_OUT_DURATION = 1000;
|
||||||
|
const TRANSITION_DELAY = 250;
|
||||||
|
const SKIP_KEYS = new Set(["Enter", " ", "Escape"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen 0: Disclaimer
|
||||||
|
*/
|
||||||
|
export function SiteDisclaimerScreen(): React.JSX.Element {
|
||||||
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
const [textOpacity, setTextOpacity] = useState(prefersReducedMotion ? 1 : 0);
|
||||||
|
const hasSkipped = useRef(false);
|
||||||
|
|
||||||
|
const handleSkip = useCallback(() => {
|
||||||
|
if (hasSkipped.current) return;
|
||||||
|
hasSkipped.current = true;
|
||||||
|
setStep("welcome");
|
||||||
|
}, [setStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fadeInTimeout = window.setTimeout(() => {
|
||||||
|
setTextOpacity(1);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const fadeOutTimeout = window.setTimeout(() => {
|
||||||
|
setTextOpacity(0);
|
||||||
|
}, TEXT_DISPLAY_DURATION);
|
||||||
|
|
||||||
|
const transitionTimeout = window.setTimeout(
|
||||||
|
handleSkip,
|
||||||
|
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (SKIP_KEYS.has(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSkip();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(fadeInTimeout);
|
||||||
|
window.clearTimeout(fadeOutTimeout);
|
||||||
|
window.clearTimeout(transitionTimeout);
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleSkip]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Avertissement"
|
||||||
|
onClick={handleSkip}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 48,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
aria-live="polite"
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxWidth: 800,
|
||||||
|
opacity: textOpacity,
|
||||||
|
transition: prefersReducedMotion
|
||||||
|
? "none"
|
||||||
|
: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DISCLAIMER_TEXT}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig";
|
||||||
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
|
||||||
|
interface SiteLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
...SITE_BACKGROUND_STYLE,
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
color: "#fff",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Subtitles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig";
|
||||||
|
|
||||||
|
const MOBILE_TEXT =
|
||||||
|
"Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale.";
|
||||||
|
|
||||||
|
export function SiteMobileBlocker(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 32,
|
||||||
|
gap: 48,
|
||||||
|
...SITE_BACKGROUND_STYLE,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/assets/logo/logo.jpg"
|
||||||
|
alt="Logo Altera"
|
||||||
|
style={{ width: 120, height: "auto" }}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 4px 10px rgba(0, 0, 0, 0.4)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxWidth: 320,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MOBILE_TEXT}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { SiteButton } from "@/components/site/SiteButton";
|
||||||
|
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
||||||
|
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import {
|
||||||
|
playDialogueById,
|
||||||
|
stopCurrentDialogue,
|
||||||
|
} from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen 3: Name input
|
||||||
|
* The displayed name is forced to SITE_CONFIG.presetPlayerName — the
|
||||||
|
* field reveals one letter per keystroke until the preset name is complete.
|
||||||
|
*/
|
||||||
|
export function SiteNamingScreen(): React.JSX.Element {
|
||||||
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
||||||
|
const [charIndex, setCharIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const presetPlayerName = SITE_CONFIG.presetPlayerName;
|
||||||
|
const displayValue = presetPlayerName.slice(0, charIndex);
|
||||||
|
const isComplete = charIndex >= presetPlayerName.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const manifest = await loadDialogueManifest();
|
||||||
|
if (cancelled || !manifest) return;
|
||||||
|
await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
stopCurrentDialogue();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNameChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const nextLength = Math.min(
|
||||||
|
event.target.value.length,
|
||||||
|
presetPlayerName.length,
|
||||||
|
);
|
||||||
|
setCharIndex(nextLength);
|
||||||
|
},
|
||||||
|
[presetPlayerName.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirm = (): void => {
|
||||||
|
if (isComplete) {
|
||||||
|
setPlayerName(presetPlayerName);
|
||||||
|
setStep("transition");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 80,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 950,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="player-name-label"
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(18px, 3vw, 26px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.3px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quel est votre prénom ?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
placeholder="Écrivez votre prénom ici"
|
||||||
|
aria-labelledby="player-name-label"
|
||||||
|
aria-describedby="player-name-hint"
|
||||||
|
autoComplete="off"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
padding: "clamp(8px, 1.5vw, 10px)",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
minWidth: 280,
|
||||||
|
gap: 10,
|
||||||
|
border: "4px solid #FFF",
|
||||||
|
background: "#D9D9D9",
|
||||||
|
outline: "none",
|
||||||
|
color: "#333",
|
||||||
|
caretColor: "#333",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(16px, 2.5vw, 20px)",
|
||||||
|
textAlign: "left",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
id="player-name-hint"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
padding: 0,
|
||||||
|
margin: -1,
|
||||||
|
overflow: "hidden",
|
||||||
|
clip: "rect(0, 0, 0, 0)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
border: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Votre personnage s'appelle {presetPlayerName}. Tapez{" "}
|
||||||
|
{presetPlayerName.length} caractères pour révéler son nom.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton
|
||||||
|
label="CONFIRMER"
|
||||||
|
disabled={!isComplete}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { SiteCard } from "@/components/site/SiteCard";
|
||||||
|
import { SiteButton } from "@/components/site/SiteButton";
|
||||||
|
import { SITUATION_CARDS } from "@/data/site/siteConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen 2: Situation selection
|
||||||
|
*/
|
||||||
|
export function SiteSituationScreen(): React.JSX.Element {
|
||||||
|
const selectedSituationIndex = useSiteStore(
|
||||||
|
(state) => state.selectedSituationIndex,
|
||||||
|
);
|
||||||
|
const setSelectedSituationIndex = useSiteStore(
|
||||||
|
(state) => state.setSelectedSituationIndex,
|
||||||
|
);
|
||||||
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
|
||||||
|
const canProceed = selectedSituationIndex !== null;
|
||||||
|
|
||||||
|
const handleConfirm = (): void => {
|
||||||
|
if (canProceed) {
|
||||||
|
setStep("naming");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 40,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 1208,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(20px, 4vw, 32px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.6px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quelle est votre situation ?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(2, minmax(220px, 300px))",
|
||||||
|
gap: "24px 28px",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SITUATION_CARDS.map((card, index) => (
|
||||||
|
<SiteCard
|
||||||
|
key={card.id}
|
||||||
|
config={card}
|
||||||
|
selected={selectedSituationIndex === index}
|
||||||
|
variant="situation"
|
||||||
|
onSelect={() => {
|
||||||
|
if (!card.disabled) {
|
||||||
|
setSelectedSituationIndex(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton
|
||||||
|
label="CONFIRMER"
|
||||||
|
disabled={!canProceed}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
import { setSiteVisited } from "@/utils/cookies/siteVisitCookie";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import {
|
||||||
|
playDialogueById,
|
||||||
|
stopCurrentDialogue,
|
||||||
|
} from "@/utils/dialogues/playDialogue";
|
||||||
|
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||||
|
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
|
||||||
|
|
||||||
|
const FADE_DURATION_MS = 1000;
|
||||||
|
const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
|
||||||
|
const NO_DIALOGUE_FALLBACK_MS = 3000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition overlay: black screen with transition dialogue and subtitles,
|
||||||
|
* then redirect to /. A safety timeout guarantees the redirect happens even if
|
||||||
|
* the dialogue audio fails to fire `ended`.
|
||||||
|
*/
|
||||||
|
export function SiteTransitionOverlay(): React.JSX.Element {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const reset = useSiteStore((state) => state.reset);
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
const [screenOpacity, setScreenOpacity] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSiteVisited();
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
const timeoutIds: number[] = [];
|
||||||
|
|
||||||
|
// Defer the opacity flip one tick so the CSS transition has an
|
||||||
|
// initial frame at opacity 0 before flipping to 1.
|
||||||
|
const fadeInId = window.setTimeout(() => {
|
||||||
|
setScreenOpacity(1);
|
||||||
|
}, 0);
|
||||||
|
timeoutIds.push(fadeInId);
|
||||||
|
|
||||||
|
const redirectToGame = (): void => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
reset();
|
||||||
|
navigate({ to: "/" });
|
||||||
|
}, FADE_DURATION_MS);
|
||||||
|
timeoutIds.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const manifest = await loadDialogueManifest();
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const dialogueAudio = manifest
|
||||||
|
? await playDialogueById(manifest, SITE_DIALOGUE_IDS.transition)
|
||||||
|
: null;
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
if (dialogueAudio) {
|
||||||
|
const safetyId = window.setTimeout(
|
||||||
|
redirectToGame,
|
||||||
|
DIALOGUE_FALLBACK_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
timeoutIds.push(safetyId);
|
||||||
|
|
||||||
|
dialogueAudio.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(safetyId);
|
||||||
|
redirectToGame();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackId = window.setTimeout(
|
||||||
|
redirectToGame,
|
||||||
|
NO_DIALOGUE_FALLBACK_MS,
|
||||||
|
);
|
||||||
|
timeoutIds.push(fallbackId);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
timeoutIds.forEach(window.clearTimeout);
|
||||||
|
stopCurrentDialogue();
|
||||||
|
};
|
||||||
|
}, [navigate, reset]);
|
||||||
|
|
||||||
|
const fadeTransition = prefersReducedMotion
|
||||||
|
? "none"
|
||||||
|
: `opacity ${FADE_DURATION_MS}ms ease-in-out`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
zIndex: 0,
|
||||||
|
opacity: screenOpacity,
|
||||||
|
transition: fadeTransition,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Subtitles must live inside this overlay's stacking context
|
||||||
|
(z-index 1000) so they render above the black screen. The
|
||||||
|
<Subtitles /> in SiteLayout sits behind this overlay. */}
|
||||||
|
<Subtitles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { SiteCard } from "@/components/site/SiteCard";
|
||||||
|
import { SiteButton } from "@/components/site/SiteButton";
|
||||||
|
import { EXPERIENCE_CARDS } from "@/data/site/siteConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen 1: Welcome
|
||||||
|
*/
|
||||||
|
export function SiteWelcomeScreen(): React.JSX.Element {
|
||||||
|
const selectedExperienceIndex = useSiteStore(
|
||||||
|
(state) => state.selectedExperienceIndex,
|
||||||
|
);
|
||||||
|
const setSelectedExperienceIndex = useSiteStore(
|
||||||
|
(state) => state.setSelectedExperienceIndex,
|
||||||
|
);
|
||||||
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
|
||||||
|
const canProceed = selectedExperienceIndex !== null;
|
||||||
|
|
||||||
|
const handleNext = (): void => {
|
||||||
|
if (canProceed) {
|
||||||
|
setStep("situation");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 40,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 1208,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: '"Nersans One", system-ui, sans-serif',
|
||||||
|
fontSize: "clamp(40px, 8vw, 64px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-3px",
|
||||||
|
margin: 0,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
BIENVENUE A ALTERA
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(18px, 3vw, 26px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.3px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Communauté convivialiste
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(20px, 4vw, 32px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.6px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choisissez une expérience :
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EXPERIENCE_CARDS.map((card, index) => (
|
||||||
|
<SiteCard
|
||||||
|
key={card.id}
|
||||||
|
config={card}
|
||||||
|
selected={selectedExperienceIndex === index}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!card.disabled) {
|
||||||
|
setSelectedExperienceIndex(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton label="SUIVANT" disabled={!canProceed} onClick={handleNext} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ export function RepairGame({
|
|||||||
<RepairMissionAssetPreloader config={config} />
|
<RepairMissionAssetPreloader config={config} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{step === "waiting" ? (
|
{step === "waiting" && mission !== "ebike" ? (
|
||||||
<RepairInspectionObject
|
<RepairInspectionObject
|
||||||
config={config}
|
config={config}
|
||||||
worldPosition={snappedPosition}
|
worldPosition={snappedPosition}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
|
||||||
|
|
||||||
interface NPCHelperProps {
|
|
||||||
position: Vector3Tuple;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element {
|
|
||||||
const step = useGameStore((state) => state.intro.currentStep);
|
|
||||||
const setStep = useGameStore((state) => state.setIntroStep);
|
|
||||||
const debug = Debug.getInstance();
|
|
||||||
|
|
||||||
const handlePress = (): void => {
|
|
||||||
if (step === "searching") {
|
|
||||||
setStep("helped");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldShow = step === "searching" || debug.active;
|
|
||||||
|
|
||||||
if (!shouldShow) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InteractableObject
|
|
||||||
kind="trigger"
|
|
||||||
label="villageois_helper"
|
|
||||||
position={position}
|
|
||||||
onPress={handlePress}
|
|
||||||
>
|
|
||||||
<group position={position}>
|
|
||||||
<mesh>
|
|
||||||
<sphereGeometry args={[0.5, 16, 16]} />
|
|
||||||
<meshStandardMaterial color="cyan" />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
</InteractableObject>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
|
||||||
|
|
||||||
interface PyloneDestroyedProps {
|
|
||||||
position: Vector3Tuple;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PyloneDestroyed({
|
|
||||||
position,
|
|
||||||
}: PyloneDestroyedProps): React.JSX.Element {
|
|
||||||
const step = useGameStore((state) => state.intro.currentStep);
|
|
||||||
const setStep = useGameStore((state) => state.setIntroStep);
|
|
||||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
|
||||||
const showDialog = useGameStore((state) => state.showDialog);
|
|
||||||
const debug = Debug.getInstance();
|
|
||||||
|
|
||||||
const handlePress = (): void => {
|
|
||||||
if (step === "helped") {
|
|
||||||
setCanMove(false);
|
|
||||||
setStep("manipulation");
|
|
||||||
} else if (step === "searching") {
|
|
||||||
showDialog(
|
|
||||||
"Cet objet est trop lourd pour le porter tout seul, trouve de l'aide",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldShow =
|
|
||||||
step === "helped" || step === "manipulation" || debug.active;
|
|
||||||
|
|
||||||
if (!shouldShow) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InteractableObject
|
|
||||||
kind="trigger"
|
|
||||||
label="central"
|
|
||||||
position={position}
|
|
||||||
onPress={handlePress}
|
|
||||||
>
|
|
||||||
<group position={position}>
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
|
||||||
<meshStandardMaterial color="orange" />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
</InteractableObject>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
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 {
|
|
||||||
const step = useGameStore((state) => state.intro.currentStep);
|
|
||||||
const playerName = useGameStore((state) => state.missionFlow.playerName);
|
|
||||||
|
|
||||||
if (step !== "bienvenue") return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: "20%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
|
||||||
padding: "1rem 2rem",
|
|
||||||
borderRadius: "8px",
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
color: "#fff",
|
|
||||||
margin: 0,
|
|
||||||
fontSize: "1.25rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Bienvenue {playerName} !
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotifications";
|
||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
|
interface MissionNotificationProps {
|
||||||
|
mission: RepairMissionId;
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissionNotification({
|
||||||
|
mission,
|
||||||
|
visible = true,
|
||||||
|
}: MissionNotificationProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="mission-notification__glow" />
|
||||||
|
<span className="mission-notification__image-wrap">
|
||||||
|
<img
|
||||||
|
className="mission-notification__image"
|
||||||
|
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
|
||||||
|
alt="Nouvel objectif de mission"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
|
|
||||||
|
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
|
||||||
|
const LOADING_LOGO_PATH = "/assets/logo/logo.jpg";
|
||||||
|
|
||||||
|
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = path;
|
||||||
|
}
|
||||||
|
|
||||||
interface SceneLoadingOverlayProps {
|
interface SceneLoadingOverlayProps {
|
||||||
state: SceneLoadingState;
|
state: SceneLoadingState;
|
||||||
}
|
}
|
||||||
@@ -15,11 +23,47 @@ export function SceneLoadingOverlay({
|
|||||||
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
|
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div className="scene-loading-overlay__content">
|
<img
|
||||||
<strong>{state.currentStep}</strong>
|
alt=""
|
||||||
|
className="scene-loading-overlay__background"
|
||||||
|
src={LOADING_BACKGROUND_PATH}
|
||||||
|
/>
|
||||||
|
<div className="scene-loading-overlay__shade" />
|
||||||
|
<img
|
||||||
|
alt="La Fabrik Durable"
|
||||||
|
className="scene-loading-overlay__logo"
|
||||||
|
src={LOADING_LOGO_PATH}
|
||||||
|
/>
|
||||||
|
<div className="scene-loading-overlay__footer">
|
||||||
|
<div className="scene-loading-overlay__meta">
|
||||||
|
<div className="scene-loading-overlay__label">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<svg
|
||||||
|
className="scene-loading-overlay__spinner"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16 3a13 13 0 1 1-9.2 3.8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="3.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6.8 6.8V2.8H2.8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="3.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<strong>{progress}%</strong>
|
||||||
|
</div>
|
||||||
<div className="scene-loading-overlay__track">
|
<div className="scene-loading-overlay__track">
|
||||||
<span style={{ width: `${progress}%` }} />
|
<span style={{ width: `${progress}%` }} />
|
||||||
<em>{progress}%</em>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function FadeToVideoOverlay(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 29,
|
||||||
|
background: "#000",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import {
|
||||||
|
playDialogueById,
|
||||||
|
stopCurrentDialogue,
|
||||||
|
} from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
|
const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Black screen overlay that plays the intro dialogue (with synced subtitles)
|
||||||
|
* via the dialogue manifest, then transitions to the reveal step.
|
||||||
|
*/
|
||||||
|
export function IntroDialogueOverlay(): React.JSX.Element {
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let safetyTimeoutId: number | null = null;
|
||||||
|
|
||||||
|
const advance = (): void => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId);
|
||||||
|
setIntroStep("reveal");
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const manifest = await loadDialogueManifest();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const audio = manifest
|
||||||
|
? await playDialogueById(manifest, SITE_DIALOGUE_IDS.introOrder)
|
||||||
|
: null;
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safetyTimeoutId = window.setTimeout(
|
||||||
|
advance,
|
||||||
|
DIALOGUE_FALLBACK_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
audio.addEventListener("ended", advance, { once: true });
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId);
|
||||||
|
stopCurrentDialogue();
|
||||||
|
};
|
||||||
|
}, [setIntroStep]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Dialogue d'introduction"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
zIndex: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Subtitles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
|
||||||
|
|
||||||
|
const REVEAL_DURATION_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fade-out overlay revealing the game world.
|
||||||
|
* Moves to the ebike onboarding step when the fade is done. The intro only
|
||||||
|
* completes after the player rides the ebike and triggers the breakdown.
|
||||||
|
*/
|
||||||
|
export function IntroRevealOverlay(): React.JSX.Element {
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
const [opacity, setOpacity] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fadeTimeout = window.setTimeout(() => {
|
||||||
|
setOpacity(0);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const completeTimeout = window.setTimeout(() => {
|
||||||
|
setCanMove(true);
|
||||||
|
setIntroStep("await-ebike-mount");
|
||||||
|
}, REVEAL_DURATION_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(fadeTimeout);
|
||||||
|
window.clearTimeout(completeTimeout);
|
||||||
|
};
|
||||||
|
}, [setCanMove, setIntroStep]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
opacity,
|
||||||
|
transition: prefersReducedMotion
|
||||||
|
? "none"
|
||||||
|
: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
|
||||||
|
zIndex: 998,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
|
||||||
|
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
|
||||||
|
const SKIP_KEYS = new Set(["Enter", " "]);
|
||||||
|
const SKIP_HINT_HIDE_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen video player for the intro cinematic.
|
||||||
|
* Advances to the dialogue-intro step when the video ends or the user skips.
|
||||||
|
*/
|
||||||
|
export function IntroVideoPlayer(): React.JSX.Element {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const hideHintTimeoutRef = useRef<number | null>(null);
|
||||||
|
const [showSkipHint, setShowSkipHint] = useState(false);
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
|
||||||
|
const handleVideoEnd = useCallback(() => {
|
||||||
|
setIntroStep("dialogue-intro");
|
||||||
|
}, [setIntroStep]);
|
||||||
|
|
||||||
|
const handleSkip = useCallback(() => {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
setIntroStep("dialogue-intro");
|
||||||
|
}, [setIntroStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (SKIP_KEYS.has(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSkip();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [handleSkip]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideHintTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(hideHintTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(() => {
|
||||||
|
setShowSkipHint(true);
|
||||||
|
|
||||||
|
if (hideHintTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(hideHintTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideHintTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setShowSkipHint(false);
|
||||||
|
hideHintTimeoutRef.current = null;
|
||||||
|
}, SKIP_HINT_HIDE_DELAY_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Vidéo d'introduction. Appuyez sur Entrée pour passer."
|
||||||
|
onClick={handleSkip}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
zIndex: 1000,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={INTRO_VIDEO_PATH}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
onEnded={handleVideoEnd}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 32,
|
||||||
|
right: 32,
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
opacity: showSkipHint ? 1 : 0,
|
||||||
|
transition: "opacity 240ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Appuyez pour passer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { FadeToVideoOverlay } from "./FadeToVideoOverlay";
|
||||||
|
export { IntroVideoPlayer } from "./IntroVideoPlayer";
|
||||||
|
export { IntroDialogueOverlay } from "./IntroDialogueOverlay";
|
||||||
|
export { IntroRevealOverlay } from "./IntroRevealOverlay";
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { ZONES } from "@/data/zones";
|
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
|
||||||
import { GAME_STEPS } from "@/data/game/gameStateConfig";
|
|
||||||
|
|
||||||
const _playerPos = new THREE.Vector3();
|
|
||||||
const _zonePos = new THREE.Vector3();
|
|
||||||
|
|
||||||
export function ZoneDetection(): null {
|
|
||||||
const camera = useThree((state) => state.camera);
|
|
||||||
const triggeredZones = useRef<Set<string>>(new Set());
|
|
||||||
const debug = Debug.getInstance();
|
|
||||||
const step = useGameStore((state) => state.intro.currentStep);
|
|
||||||
const setStep = useGameStore((state) => state.setIntroStep);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!debug.active) return;
|
|
||||||
|
|
||||||
const folder = debug.createFolder("Game");
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
const gameState = { step: step };
|
|
||||||
const playerPos = { x: 0, y: 0, z: 0 };
|
|
||||||
|
|
||||||
folder.add(gameState, "step", GAME_STEPS).name("Game Step").disable();
|
|
||||||
|
|
||||||
folder.add(playerPos, "x").name("Player X").listen().disable();
|
|
||||||
folder.add(playerPos, "y").name("Player Y").listen().disable();
|
|
||||||
folder.add(playerPos, "z").name("Player Z").listen().disable();
|
|
||||||
|
|
||||||
const unsubStore = useGameStore.subscribe((state) => {
|
|
||||||
gameState.step = state.intro.currentStep;
|
|
||||||
folder.controllersRecursive().forEach((c) => c.updateDisplay());
|
|
||||||
});
|
|
||||||
|
|
||||||
let frameId: number;
|
|
||||||
const updatePlayerPos = (): void => {
|
|
||||||
camera.getWorldPosition(_playerPos);
|
|
||||||
playerPos.x = Math.round(_playerPos.x * 100) / 100;
|
|
||||||
playerPos.y = Math.round(_playerPos.y * 100) / 100;
|
|
||||||
playerPos.z = Math.round(_playerPos.z * 100) / 100;
|
|
||||||
folder.controllersRecursive().forEach((c) => c.updateDisplay());
|
|
||||||
frameId = requestAnimationFrame(updatePlayerPos);
|
|
||||||
};
|
|
||||||
updatePlayerPos();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frameId);
|
|
||||||
debug.destroyFolder("Game");
|
|
||||||
unsubStore();
|
|
||||||
};
|
|
||||||
}, [debug, camera, step]);
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
camera.getWorldPosition(_playerPos);
|
|
||||||
|
|
||||||
for (const zone of ZONES) {
|
|
||||||
if (triggeredZones.current.has(zone.id)) continue;
|
|
||||||
|
|
||||||
_zonePos.set(...zone.position);
|
|
||||||
|
|
||||||
const distanceSq = _playerPos.distanceToSquared(_zonePos);
|
|
||||||
|
|
||||||
if (distanceSq <= zone.radius * zone.radius) {
|
|
||||||
setStep(zone.targetStep);
|
|
||||||
triggeredZones.current.add(zone.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ZoneDebugVisuals(): React.JSX.Element | null {
|
|
||||||
const debug = Debug.getInstance();
|
|
||||||
const camera = useThree((state) => state.camera);
|
|
||||||
const [triggeredZones, setTriggeredZones] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
camera.getWorldPosition(_playerPos);
|
|
||||||
|
|
||||||
for (const zone of ZONES) {
|
|
||||||
if (triggeredZones.has(zone.id)) continue;
|
|
||||||
|
|
||||||
_zonePos.set(...zone.position);
|
|
||||||
|
|
||||||
const distanceSq = _playerPos.distanceToSquared(_zonePos);
|
|
||||||
|
|
||||||
if (distanceSq <= zone.radius * zone.radius) {
|
|
||||||
setTriggeredZones((prev) => new Set(prev).add(zone.id));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!debug.active) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{ZONES.map((zone) => (
|
|
||||||
<ZoneVisual
|
|
||||||
key={zone.id}
|
|
||||||
position={[zone.position[0], 0.1, zone.position[2]]}
|
|
||||||
radius={zone.radius}
|
|
||||||
height={zone.height}
|
|
||||||
triggered={triggeredZones.has(zone.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ZoneVisual({
|
|
||||||
position,
|
|
||||||
radius,
|
|
||||||
height,
|
|
||||||
triggered,
|
|
||||||
}: {
|
|
||||||
position: [number, number, number];
|
|
||||||
radius: number;
|
|
||||||
height: number;
|
|
||||||
triggered: boolean;
|
|
||||||
}): React.JSX.Element {
|
|
||||||
const color = triggered ? "#00ff00" : "#ff0000";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group position={position}>
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
|
||||||
<ringGeometry args={[radius - 0.3, radius, 32]} />
|
|
||||||
<meshBasicMaterial color={color} side={THREE.DoubleSide} />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={[0, height / 2, 0]}>
|
|
||||||
<cylinderGeometry args={[radius, radius, height, 32, 1, true]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color={color}
|
|
||||||
transparent
|
|
||||||
opacity={0.15}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
export const AUDIO_PATHS = {
|
|
||||||
intro: "/sounds/effect/fa.mp3",
|
|
||||||
bienvenue: "/sounds/effect/fa.mp3",
|
|
||||||
alertCentral: "/sounds/effect/fa.mp3",
|
|
||||||
searching: "/sounds/effect/fa.mp3",
|
|
||||||
helped: "/sounds/effect/fa.mp3",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AudioCategory = "music" | "sfx" | "dialogue";
|
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||||
|
|
||||||
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||||
|
|||||||
@@ -14,3 +14,22 @@ export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
|||||||
position: [0, 1.5, -3],
|
position: [0, 1.5, -3],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4];
|
||||||
|
export const EBIKE_WORLD_ROTATION_Y = 2.4107;
|
||||||
|
|
||||||
|
export const EBIKE_INTRO_RIDE_DURATION_MS = 5000;
|
||||||
|
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
|
||||||
|
|
||||||
|
export const EBIKE_MAX_SPEED = 3;
|
||||||
|
export const EBIKE_ACCELERATION_DURATION_MS = 2000;
|
||||||
|
export const EBIKE_DECELERATION_DURATION_MS = 2000;
|
||||||
|
|
||||||
|
export const EBIKE_SOUNDS = {
|
||||||
|
depart: "/sounds/effect/ebike-depart.mp3",
|
||||||
|
roule: "/sounds/effect/ebike-roule.mp3",
|
||||||
|
ralenti: "/sounds/effect/ebike-ralenti.mp3",
|
||||||
|
panne: "/sounds/effect/ebike-panne.mp3",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EBIKE_BREAKDOWN_DIALOGUE_ID = "narrateur_ebikecasse";
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import type { GameStep, MainGameState } from "@/types/game";
|
import type { GameStep, MainGameState, SiteStep } from "@/types/game";
|
||||||
|
|
||||||
export const GAME_STEPS: readonly GameStep[] = [
|
/**
|
||||||
"intro",
|
* Steps for the /site onboarding page
|
||||||
"start-intro",
|
*/
|
||||||
|
export const SITE_STEPS: readonly SiteStep[] = [
|
||||||
|
"welcome",
|
||||||
|
"situation",
|
||||||
"naming",
|
"naming",
|
||||||
"bienvenue",
|
"transition",
|
||||||
"star-move",
|
];
|
||||||
"mission2",
|
|
||||||
"searching",
|
/**
|
||||||
"helped",
|
* Steps for the intro sequence (after /site, on / route)
|
||||||
"manipulation",
|
*/
|
||||||
"outOfFabrik",
|
export const GAME_STEPS: readonly GameStep[] = [
|
||||||
|
"loading-map",
|
||||||
|
"fade-to-video",
|
||||||
|
"video",
|
||||||
|
"dialogue-intro",
|
||||||
|
"reveal",
|
||||||
|
"await-ebike-mount",
|
||||||
|
"ebike-intro-ride",
|
||||||
|
"ebike-breakdown",
|
||||||
|
"completed",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MAIN_GAME_STATES: readonly MainGameState[] = [
|
export const MAIN_GAME_STATES: readonly MainGameState[] = [
|
||||||
@@ -21,9 +33,14 @@ export const MAIN_GAME_STATES: readonly MainGameState[] = [
|
|||||||
"outro",
|
"outro",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const SITE_STEP_VALUES: ReadonlySet<string> = new Set(SITE_STEPS);
|
||||||
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
|
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
|
||||||
const MAIN_GAME_STATE_VALUES: ReadonlySet<string> = new Set(MAIN_GAME_STATES);
|
const MAIN_GAME_STATE_VALUES: ReadonlySet<string> = new Set(MAIN_GAME_STATES);
|
||||||
|
|
||||||
|
export function isSiteStep(value: unknown): value is SiteStep {
|
||||||
|
return typeof value === "string" && SITE_STEP_VALUES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function isGameStep(value: unknown): value is GameStep {
|
export function isGameStep(value: unknown): value is GameStep {
|
||||||
return typeof value === "string" && GAME_STEP_VALUES.has(value);
|
return typeof value === "string" && GAME_STEP_VALUES.has(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
|
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
||||||
|
{
|
||||||
|
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
||||||
|
pylon: "/assets/world/UI/pylon-mission-notification.png",
|
||||||
|
farm: "/assets/world/UI/farm-mission-notification.png",
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
RepairMissionId,
|
RepairMissionId,
|
||||||
RepairMissionTriggerConfig,
|
RepairMissionTriggerConfig,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
|
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||||
|
|
||||||
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||||
Record<RepairMissionId, string>
|
Record<RepairMissionId, string>
|
||||||
@@ -10,9 +11,7 @@ export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
|||||||
pylon: "repair:pylon",
|
pylon: "repair:pylon",
|
||||||
};
|
};
|
||||||
|
|
||||||
const EBIKE_REPAIR_POSITION = [
|
const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
|
||||||
42.2399, 4.5484, 34.6468,
|
|
||||||
] as const satisfies Vector3Tuple;
|
|
||||||
|
|
||||||
const REPAIR_MISSION_POSITIONS = {
|
const REPAIR_MISSION_POSITIONS = {
|
||||||
ebike: EBIKE_REPAIR_POSITION,
|
ebike: EBIKE_REPAIR_POSITION,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type {
|
|||||||
RepairMissionId,
|
RepairMissionId,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
|
const REPAIR_INTERACT_UI_PATH = "/assets/world/UI/interagir.webm";
|
||||||
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
|
const REPAIR_BROKEN_UI_PATH = "/assets/world/UI/cassé.webm";
|
||||||
|
|
||||||
const DEFAULT_REPAIR_CASE = {
|
const DEFAULT_REPAIR_CASE = {
|
||||||
position: [0, 0.4, 1.8],
|
position: [0, 0.4, 1.8],
|
||||||
@@ -21,7 +21,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
"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.3,
|
modelScale: 0.3,
|
||||||
stageUiPath: "/assets/UI/ebike.webm",
|
stageUiPath: "/assets/world/UI/ebike.webm",
|
||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
description:
|
description:
|
||||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||||
modelPath: "/models/pylone/model.gltf",
|
modelPath: "/models/pylone/model.gltf",
|
||||||
stageUiPath: "/assets/UI/centrale.webm",
|
stageUiPath: "/assets/world/UI/centrale.webm",
|
||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
description:
|
description:
|
||||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||||
modelPath: "/models/fermeverticale/model.gltf",
|
modelPath: "/models/fermeverticale/model.gltf",
|
||||||
stageUiPath: "/assets/UI/laferme.webm",
|
stageUiPath: "/assets/world/UI/laferme.webm",
|
||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
case: DEFAULT_REPAIR_CASE,
|
case: DEFAULT_REPAIR_CASE,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { Vector3Tuple } from "@/types/three/three";
|
|||||||
export const PLAYER_EYE_HEIGHT = 1.75;
|
export const PLAYER_EYE_HEIGHT = 1.75;
|
||||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||||
|
|
||||||
export const PLAYER_WALK_SPEED = 11;
|
export const PLAYER_WALK_SPEED = 5;
|
||||||
export const PLAYER_EBIKE_SPEED = 25;
|
export const PLAYER_EBIKE_SPEED = 20;
|
||||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||||
export const PLAYER_JUMP_SPEED = 9;
|
export const PLAYER_JUMP_SPEED = 9;
|
||||||
export const PLAYER_GRAVITY = 30;
|
export const PLAYER_GRAVITY = 30;
|
||||||
@@ -14,5 +14,5 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
|||||||
export const PLAYER_FALL_RESPAWN_Y = -20;
|
export const PLAYER_FALL_RESPAWN_Y = -20;
|
||||||
export const PLAYER_FALL_RESPAWN_DELAY = 3;
|
export const PLAYER_FALL_RESPAWN_DELAY = 3;
|
||||||
|
|
||||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
|
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64];
|
||||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Dialogue manifest IDs used by the /site flow and the intro sequence.
|
||||||
|
* Defined once here so components don't hold magic strings.
|
||||||
|
*/
|
||||||
|
export const SITE_DIALOGUE_IDS = {
|
||||||
|
naming: "narrateur_intro_prenom",
|
||||||
|
transition: "narrateur_intro_apresprenom",
|
||||||
|
introOrder: "narrateur_ordreebike",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
const BACKGROUND_IMAGE = "/assets/bg-site.png";
|
||||||
|
|
||||||
|
export const SITE_CONFIG = {
|
||||||
|
backgroundImage: BACKGROUND_IMAGE,
|
||||||
|
presetPlayerName: "Danyl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared background style used by SiteLayout and SiteMobileBlocker.
|
||||||
|
*/
|
||||||
|
export const SITE_BACKGROUND_STYLE: CSSProperties = {
|
||||||
|
backgroundColor: "#87CEEB",
|
||||||
|
backgroundImage: `url(${BACKGROUND_IMAGE})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SiteCardConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
imagePath?: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for screen 1: "Choisissez une expérience"
|
||||||
|
*/
|
||||||
|
export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [
|
||||||
|
{ id: "exp-fabrik", label: "La Fabrik", disabled: false },
|
||||||
|
{ id: "exp-ferme", label: "La Ferme verticale", disabled: true },
|
||||||
|
{ id: "exp-energie", label: "La Zone d'énergie", disabled: true },
|
||||||
|
{ id: "exp-ecole", label: "L'École", disabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for screen 2: "Quelle est votre situation ?"
|
||||||
|
*/
|
||||||
|
export const SITUATION_CARDS: readonly SiteCardConfig[] = [
|
||||||
|
{ id: "sit-sans-domicile", label: "Sans domicile fixe", disabled: true },
|
||||||
|
{ id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true },
|
||||||
|
{
|
||||||
|
id: "sit-refugie-climat",
|
||||||
|
label: "Réfugié.e climatique",
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{ id: "sit-autre", label: "Autre", disabled: true },
|
||||||
|
];
|
||||||
@@ -28,8 +28,8 @@ export const CHARACTER_CONFIGS = {
|
|||||||
id: "gerant",
|
id: "gerant",
|
||||||
label: "Gerant",
|
label: "Gerant",
|
||||||
modelPath: "/models/gerant-animated/model.gltf",
|
modelPath: "/models/gerant-animated/model.gltf",
|
||||||
position: [45.2, 0, 45.5],
|
position: [59.5, 0, 64.64],
|
||||||
rotation: [0, -1.55, 0],
|
rotation: [0, 2.41, 0],
|
||||||
scale: [1, 1, 1],
|
scale: [1, 1, 1],
|
||||||
animations: ["idle", "walk"],
|
animations: ["idle", "walk"],
|
||||||
defaultAnimation: "idle",
|
defaultAnimation: "idle",
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Zone } from "@/types/game";
|
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
|
||||||
|
|
||||||
export const ZONES: Zone[] = [
|
|
||||||
{
|
|
||||||
id: "fabrikExit",
|
|
||||||
position: [-5, 25, -15] as Vector3Tuple,
|
|
||||||
radius: 10,
|
|
||||||
height: 20,
|
|
||||||
targetStep: "mission2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "searchingZone",
|
|
||||||
position: [-5, 25, -30] as Vector3Tuple,
|
|
||||||
radius: 10,
|
|
||||||
height: 20,
|
|
||||||
targetStep: "searching",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { EBIKE_SOUNDS } from "@/data/ebike/ebikeConfig";
|
||||||
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
|
|
||||||
|
type EbikeSoundState = "idle" | "depart" | "roule" | "ralenti";
|
||||||
|
|
||||||
|
interface UpdateEbikeSoundsOptions {
|
||||||
|
mounted: boolean;
|
||||||
|
driving: boolean;
|
||||||
|
breakdown: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudio(audio: HTMLAudioElement | null): void {
|
||||||
|
if (!audio) return;
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio.loop = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEbikeSounds(): (options: UpdateEbikeSoundsOptions) => void {
|
||||||
|
const stateRef = useRef<EbikeSoundState>("idle");
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const stopCurrent = useCallback(() => {
|
||||||
|
stopAudio(audioRef.current);
|
||||||
|
audioRef.current = null;
|
||||||
|
stateRef.current = "idle";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playDepart = useCallback(() => {
|
||||||
|
stopCurrent();
|
||||||
|
const audio = AudioManager.getInstance().playSound(
|
||||||
|
EBIKE_SOUNDS.depart,
|
||||||
|
0.8,
|
||||||
|
{
|
||||||
|
category: "sfx",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
audioRef.current = audio;
|
||||||
|
stateRef.current = "depart";
|
||||||
|
audio.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => {
|
||||||
|
if (stateRef.current !== "depart") return;
|
||||||
|
if (window.ebikeDriveInputActive !== true) {
|
||||||
|
stateRef.current = "idle";
|
||||||
|
audioRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollingAudio = AudioManager.getInstance().playSound(
|
||||||
|
EBIKE_SOUNDS.roule,
|
||||||
|
0.72,
|
||||||
|
{ category: "sfx" },
|
||||||
|
);
|
||||||
|
rollingAudio.loop = true;
|
||||||
|
audioRef.current = rollingAudio;
|
||||||
|
stateRef.current = "roule";
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}, [stopCurrent]);
|
||||||
|
|
||||||
|
const playRalenti = useCallback(() => {
|
||||||
|
stopCurrent();
|
||||||
|
const audio = AudioManager.getInstance().playSound(
|
||||||
|
EBIKE_SOUNDS.ralenti,
|
||||||
|
0.72,
|
||||||
|
{
|
||||||
|
category: "sfx",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
audioRef.current = audio;
|
||||||
|
stateRef.current = "ralenti";
|
||||||
|
audio.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => {
|
||||||
|
if (stateRef.current !== "ralenti") return;
|
||||||
|
audioRef.current = null;
|
||||||
|
stateRef.current = "idle";
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}, [stopCurrent]);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
({ mounted, driving, breakdown }: UpdateEbikeSoundsOptions) => {
|
||||||
|
if (!mounted || breakdown) {
|
||||||
|
stopCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (driving) {
|
||||||
|
if (stateRef.current === "idle" || stateRef.current === "ralenti") {
|
||||||
|
playDepart();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateRef.current === "depart" || stateRef.current === "roule") {
|
||||||
|
playRalenti();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[playDepart, playRalenti, stopCurrent],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => stopCurrent, [stopCurrent]);
|
||||||
|
|
||||||
|
return update;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const MOBILE_MEDIA_QUERY =
|
||||||
|
"(max-width: 767px), (pointer: coarse) and (hover: none)";
|
||||||
|
|
||||||
|
function subscribeToMobileQuery(callback: () => void): () => void {
|
||||||
|
const query = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||||
|
query.addEventListener("change", callback);
|
||||||
|
return () => query.removeEventListener("change", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMobileSnapshot(): boolean {
|
||||||
|
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerMobileSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the device is a phone or a touch-only tablet.
|
||||||
|
* Uses matchMedia so layout decisions follow CSS conventions
|
||||||
|
* and avoid resize-handler churn.
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeToMobileQuery,
|
||||||
|
getMobileSnapshot,
|
||||||
|
getServerMobileSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||||
|
|
||||||
|
function subscribeToReducedMotion(callback: () => void): () => void {
|
||||||
|
const query = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
|
query.addEventListener("change", callback);
|
||||||
|
return () => query.removeEventListener("change", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReducedMotionSnapshot(): boolean {
|
||||||
|
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerReducedMotionSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the user has requested reduced motion at the OS level.
|
||||||
|
* UI fades and transitions should collapse to 0ms when this is true.
|
||||||
|
*/
|
||||||
|
export function usePrefersReducedMotion(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeToReducedMotion,
|
||||||
|
getReducedMotionSnapshot,
|
||||||
|
getServerReducedMotionSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,13 @@ interface UseWorldSceneLoadingOptions {
|
|||||||
interface UseWorldSceneLoadingResult {
|
interface UseWorldSceneLoadingResult {
|
||||||
octree: Octree | null;
|
octree: Octree | null;
|
||||||
gameplayReady: boolean;
|
gameplayReady: boolean;
|
||||||
|
shouldWarmUpShadows: boolean;
|
||||||
showGameStage: boolean;
|
showGameStage: boolean;
|
||||||
handleGameStageLoaded: () => void;
|
handleGameStageLoaded: () => void;
|
||||||
handleGameMapLoaded: () => void;
|
handleGameMapLoaded: () => void;
|
||||||
handleOctreeReady: (octree: Octree) => void;
|
handleOctreeReady: (octree: Octree) => void;
|
||||||
|
handleShadowWarmupReady: () => void;
|
||||||
|
handleShadowWarmupStarted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorldSceneLoading({
|
export function useWorldSceneLoading({
|
||||||
@@ -24,13 +27,19 @@ export function useWorldSceneLoading({
|
|||||||
const [octree, setOctree] = useState<Octree | null>(null);
|
const [octree, setOctree] = useState<Octree | null>(null);
|
||||||
const [gameMapLoaded, setGameMapLoaded] = useState(false);
|
const [gameMapLoaded, setGameMapLoaded] = useState(false);
|
||||||
const [gameStageLoaded, setGameStageLoaded] = useState(false);
|
const [gameStageLoaded, setGameStageLoaded] = useState(false);
|
||||||
|
const [shadowsReady, setShadowsReady] = useState(false);
|
||||||
const showGameStage = sceneMode === "game" && gameMapLoaded;
|
const showGameStage = sceneMode === "game" && gameMapLoaded;
|
||||||
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
|
const gameSceneReadyForShadows =
|
||||||
|
showGameStage && gameStageLoaded && octree !== null;
|
||||||
|
const shadowWarmupReady = sceneMode === "game" && gameSceneReadyForShadows;
|
||||||
|
const shouldWarmUpShadows = shadowWarmupReady && !shadowsReady;
|
||||||
|
const gameplayReady = gameSceneReadyForShadows && shadowsReady;
|
||||||
const sceneReady =
|
const sceneReady =
|
||||||
(sceneMode === "game" && gameplayReady) ||
|
(sceneMode === "game" && gameplayReady) ||
|
||||||
(sceneMode === "physics" && octree !== null);
|
(sceneMode === "physics" && octree !== null);
|
||||||
|
|
||||||
const handleGameMapLoaded = useCallback(() => {
|
const handleGameMapLoaded = useCallback(() => {
|
||||||
|
setShadowsReady(false);
|
||||||
setGameMapLoaded(true);
|
setGameMapLoaded(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -45,6 +54,7 @@ export function useWorldSceneLoading({
|
|||||||
|
|
||||||
const handleOctreeReady = useCallback(
|
const handleOctreeReady = useCallback(
|
||||||
(nextOctree: Octree) => {
|
(nextOctree: Octree) => {
|
||||||
|
setShadowsReady(false);
|
||||||
setOctree(nextOctree);
|
setOctree(nextOctree);
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Collision prête",
|
currentStep: "Collision prête",
|
||||||
@@ -55,6 +65,23 @@ export function useWorldSceneLoading({
|
|||||||
[onLoadingStateChange],
|
[onLoadingStateChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleShadowWarmupStarted = useCallback(() => {
|
||||||
|
onLoadingStateChange?.({
|
||||||
|
currentStep: "Activation des ombres",
|
||||||
|
progress: 0.97,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
}, [onLoadingStateChange]);
|
||||||
|
|
||||||
|
const handleShadowWarmupReady = useCallback(() => {
|
||||||
|
setShadowsReady(true);
|
||||||
|
onLoadingStateChange?.({
|
||||||
|
currentStep: "Ombres prêtes",
|
||||||
|
progress: 0.99,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
}, [onLoadingStateChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Initialisation du jeu",
|
currentStep: "Initialisation du jeu",
|
||||||
@@ -88,9 +115,12 @@ export function useWorldSceneLoading({
|
|||||||
return {
|
return {
|
||||||
octree,
|
octree,
|
||||||
gameplayReady,
|
gameplayReady,
|
||||||
|
shouldWarmUpShadows,
|
||||||
showGameStage,
|
showGameStage,
|
||||||
handleGameStageLoaded,
|
handleGameStageLoaded,
|
||||||
handleGameMapLoaded,
|
handleGameMapLoaded,
|
||||||
handleOctreeReady,
|
handleOctreeReady,
|
||||||
|
handleShadowWarmupReady,
|
||||||
|
handleShadowWarmupStarted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+288
-37
@@ -1,5 +1,15 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Nersans One";
|
||||||
|
src:
|
||||||
|
url("/fonts/NersansOne.woff2") format("woff2"),
|
||||||
|
url("/fonts/NersansOne.woff") format("woff");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Base document reset */
|
/* Base document reset */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -34,6 +44,18 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Site onboarding — accessible focus rings (WCAG 2.4.7) */
|
||||||
|
.site-card-button:focus-visible,
|
||||||
|
.site-button:focus-visible {
|
||||||
|
outline: 3px solid #ffffff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card-button[aria-pressed="true"]:focus-visible {
|
||||||
|
outline-color: #a8d5a2;
|
||||||
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -851,72 +873,301 @@ canvas {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #ffffff;
|
overflow: hidden;
|
||||||
|
background: #04070d;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: opacity 640ms ease;
|
transition: opacity 500ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-loading-overlay--ready {
|
.scene-loading-overlay--ready {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-loading-overlay__content {
|
.scene-loading-overlay__background,
|
||||||
display: grid;
|
.scene-loading-overlay__shade {
|
||||||
justify-items: center;
|
position: absolute;
|
||||||
gap: 18px;
|
inset: 0;
|
||||||
width: min(360px, calc(100vw - 48px));
|
|
||||||
padding: 28px;
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
|
||||||
border-radius: 28px;
|
|
||||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-loading-overlay strong {
|
.scene-loading-overlay__background {
|
||||||
color: #1e293b;
|
width: 100%;
|
||||||
font-size: 15px;
|
height: 100%;
|
||||||
font-weight: 600;
|
object-fit: cover;
|
||||||
letter-spacing: 0.02em;
|
}
|
||||||
line-height: 1.45;
|
|
||||||
text-align: center;
|
.scene-loading-overlay__shade {
|
||||||
|
background: rgba(4, 7, 13, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-loading-overlay__logo {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: clamp(180px, 28vw, 320px);
|
||||||
|
max-height: min(38vh, 320px);
|
||||||
|
border-radius: 16px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-loading-overlay__footer {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 clamp(18px, 4vw, 56px) clamp(22px, 5vh, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-loading-overlay__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: "Nersans One", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: clamp(16px, 2.3vw, 30px);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scene-loading-overlay__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(8px, 1.2vw, 14px);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-loading-overlay__spinner {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: clamp(18px, 2.2vw, 30px);
|
||||||
|
height: clamp(18px, 2.2vw, 30px);
|
||||||
|
color: #ffffff;
|
||||||
|
animation: scene-loading-spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-loading-overlay__meta strong {
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.scene-loading-overlay__track {
|
.scene-loading-overlay__track {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 18px;
|
height: clamp(7px, 1vw, 12px);
|
||||||
background: #e2e8f0;
|
background: rgba(255, 255, 255, 0.22);
|
||||||
border-radius: 999px;
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.22);
|
||||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-loading-overlay__track span {
|
.scene-loading-overlay__track span {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #2563eb, #38bdf8);
|
background: #3b82f6;
|
||||||
border-radius: inherit;
|
|
||||||
transition: width 180ms ease;
|
transition: width 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-loading-overlay__track em {
|
@keyframes scene-loading-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mission notification */
|
||||||
|
.mission-notification {
|
||||||
|
position: fixed;
|
||||||
|
top: clamp(18px, 4vh, 42px);
|
||||||
|
left: clamp(18px, 4vw, 48px);
|
||||||
|
z-index: 20;
|
||||||
|
width: min(280px, calc(100vw - 36px));
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
filter: drop-shadow(0 0 12px rgba(96, 165, 250, 0.36));
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
transition:
|
||||||
|
opacity 420ms ease,
|
||||||
|
filter 420ms ease,
|
||||||
|
transform 420ms ease;
|
||||||
|
animation: mission-notification-enter 900ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-notification--hidden {
|
||||||
|
opacity: 0;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(96, 165, 250, 0.12));
|
||||||
|
transform: translate3d(-8px, -2px, 0) scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-notification::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
content: "";
|
||||||
place-items: center;
|
pointer-events: none;
|
||||||
color: #ffffff;
|
}
|
||||||
font-size: 11px;
|
|
||||||
font-style: normal;
|
.mission-notification::after {
|
||||||
font-weight: 700;
|
background: linear-gradient(
|
||||||
letter-spacing: 0.04em;
|
180deg,
|
||||||
line-height: 1;
|
transparent 0%,
|
||||||
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
|
rgba(96, 165, 250, 0.16) 48%,
|
||||||
|
transparent 52%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
background-size: 100% 10px;
|
||||||
|
opacity: 0.22;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 100% 69%, 88% 100%, 0 100%);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-notification__glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -14px;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 22% 22%,
|
||||||
|
rgba(96, 165, 250, 0.36),
|
||||||
|
transparent 58%
|
||||||
|
);
|
||||||
|
opacity: 0.7;
|
||||||
|
filter: blur(12px);
|
||||||
|
animation: mission-notification-glow 10s ease-in-out 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-notification__image-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 100% 69%, 88% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-notification__image-wrap::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -35%;
|
||||||
|
z-index: 2;
|
||||||
|
width: 28%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(191, 219, 254, 0.08) 18%,
|
||||||
|
rgba(125, 211, 252, 0.52) 50%,
|
||||||
|
rgba(191, 219, 254, 0.08) 82%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
content: "";
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: skewX(-16deg);
|
||||||
|
animation: mission-notification-scan 3.8s ease-in-out 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-notification__image {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
opacity: 0.92;
|
||||||
|
filter: sepia(0.08) saturate(1.18) hue-rotate(155deg) contrast(1.04)
|
||||||
|
brightness(1.03) blur(0.18px);
|
||||||
|
animation: mission-notification-flicker 10s ease-in-out 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mission-notification-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(-12px, -4px, 0) scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
12% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
18% {
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
26% {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
34% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
48%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mission-notification-flicker {
|
||||||
|
0%,
|
||||||
|
7%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.92;
|
||||||
|
filter: saturate(1) brightness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
1.5% {
|
||||||
|
opacity: 0.58;
|
||||||
|
filter: saturate(1.25) brightness(1.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
3% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
4.5% {
|
||||||
|
opacity: 0.74;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mission-notification-scan {
|
||||||
|
0%,
|
||||||
|
22% {
|
||||||
|
left: -35%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
32% {
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
52% {
|
||||||
|
left: 108%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
left: 108%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mission-notification-glow {
|
||||||
|
0%,
|
||||||
|
7%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
2.5% {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
4.5% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtitles */
|
/* Subtitles */
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
|
|||||||
mainState: "ebike",
|
mainState: "ebike",
|
||||||
intro: {
|
intro: {
|
||||||
...state.intro,
|
...state.intro,
|
||||||
|
currentStep: "completed",
|
||||||
hasCompleted: true,
|
hasCompleted: true,
|
||||||
isEbikeUnlocked: true,
|
isEbikeUnlocked: true,
|
||||||
},
|
},
|
||||||
@@ -255,7 +256,7 @@ function createInitialGameState(): GameState {
|
|||||||
currentSpeed: PLAYER_WALK_SPEED,
|
currentSpeed: PLAYER_WALK_SPEED,
|
||||||
},
|
},
|
||||||
intro: {
|
intro: {
|
||||||
currentStep: "intro",
|
currentStep: "loading-map",
|
||||||
dialogueAudio: null,
|
dialogueAudio: null,
|
||||||
hasCompleted: false,
|
hasCompleted: false,
|
||||||
isEbikeUnlocked: false,
|
isEbikeUnlocked: false,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { SiteStep } from "@/types/game";
|
||||||
|
|
||||||
|
interface SiteState {
|
||||||
|
currentStep: SiteStep;
|
||||||
|
selectedExperienceIndex: number | null;
|
||||||
|
selectedSituationIndex: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteActions {
|
||||||
|
setStep: (step: SiteStep) => void;
|
||||||
|
setSelectedExperienceIndex: (index: number) => void;
|
||||||
|
setSelectedSituationIndex: (index: number) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteStore = SiteState & SiteActions;
|
||||||
|
|
||||||
|
const initialState: SiteState = {
|
||||||
|
currentStep: "disclaimer",
|
||||||
|
selectedExperienceIndex: null,
|
||||||
|
selectedSituationIndex: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSiteStore = create<SiteStore>()((set) => ({
|
||||||
|
...initialState,
|
||||||
|
setStep: (step) => set({ currentStep: step }),
|
||||||
|
setSelectedExperienceIndex: (index) =>
|
||||||
|
set({ selectedExperienceIndex: index }),
|
||||||
|
setSelectedSituationIndex: (index) => set({ selectedSituationIndex: index }),
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}));
|
||||||
@@ -157,7 +157,7 @@ function CameraManager({
|
|||||||
const dataUrl = gl.domElement.toDataURL("image/png");
|
const dataUrl = gl.domElement.toDataURL("image/png");
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = dataUrl;
|
a.href = dataUrl;
|
||||||
a.download = "/assets/gps/map_background.png";
|
a.download = "map_background.png";
|
||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
+80
-6
@@ -1,19 +1,33 @@
|
|||||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||||
|
import { EbikeIntroSequence } from "@/components/game/EbikeIntroSequence";
|
||||||
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 {
|
||||||
|
FadeToVideoOverlay,
|
||||||
|
IntroDialogueOverlay,
|
||||||
|
IntroRevealOverlay,
|
||||||
|
IntroVideoPlayer,
|
||||||
|
} from "@/components/ui/intro";
|
||||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
||||||
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
|
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { World } from "@/world/World";
|
import { World } from "@/world/World";
|
||||||
|
|
||||||
export function HomePage(): React.JSX.Element {
|
const LOADING_TO_VIDEO_FADE_MS = 500;
|
||||||
|
|
||||||
|
export function HomePage(): React.JSX.Element | null {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||||
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
const dialogMessage = useGameStore(
|
const dialogMessage = useGameStore(
|
||||||
(state) => state.missionFlow.dialogMessage,
|
(state) => state.missionFlow.dialogMessage,
|
||||||
);
|
);
|
||||||
@@ -22,6 +36,12 @@ export function HomePage(): React.JSX.Element {
|
|||||||
INITIAL_SCENE_LOADING_STATE,
|
INITIAL_SCENE_LOADING_STATE,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasSiteBeenVisitedToday()) {
|
||||||
|
navigate({ to: "/site", replace: true });
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dialogMessage) return undefined;
|
if (!dialogMessage) return undefined;
|
||||||
|
|
||||||
@@ -50,6 +70,25 @@ export function HomePage(): React.JSX.Element {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introStep === "loading-map" && sceneLoadingState.status === "ready") {
|
||||||
|
AudioManager.getInstance().stopMusic();
|
||||||
|
setIntroStep("fade-to-video");
|
||||||
|
}
|
||||||
|
}, [introStep, sceneLoadingState.status, setIntroStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introStep !== "fade-to-video") return undefined;
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setIntroStep("video");
|
||||||
|
}, LOADING_TO_VIDEO_FADE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [introStep, setIntroStep]);
|
||||||
|
|
||||||
const handleCanvasCreated = useCallback(
|
const handleCanvasCreated = useCallback(
|
||||||
({ gl }: { gl: THREE.WebGLRenderer }) => {
|
({ gl }: { gl: THREE.WebGLRenderer }) => {
|
||||||
const canvas = gl.domElement;
|
const canvas = gl.domElement;
|
||||||
@@ -58,9 +97,16 @@ export function HomePage(): React.JSX.Element {
|
|||||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||||
gl.shadowMap.autoUpdate = true;
|
gl.shadowMap.autoUpdate = true;
|
||||||
|
|
||||||
|
// The browser hands us a WEBGL_lose_context extension we can use to
|
||||||
|
// ask the GPU to restore the context after a loss. Without this the
|
||||||
|
// page stays frozen on a black canvas until the user reloads.
|
||||||
|
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
|
||||||
|
|
||||||
const handleContextLost = (event: Event) => {
|
const handleContextLost = (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
logger.error("WebGL", "Context lost - GPU resources exhausted");
|
logger.error("WebGL", "Context lost - attempting auto-restore");
|
||||||
|
// Give the GPU a moment to free resources before asking it back.
|
||||||
|
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContextRestored = () => {
|
const handleContextRestored = () => {
|
||||||
@@ -76,6 +122,32 @@ export function HomePage(): React.JSX.Element {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Don't mount the Canvas until we know we will not redirect to /site.
|
||||||
|
// Without this guard the Canvas would mount, the effect above would fire
|
||||||
|
// navigate, and the Canvas would unmount mid-load — leaking GLTF requests
|
||||||
|
// and a WebGL context. The synchronous cookie check happens here AFTER
|
||||||
|
// all hooks (rules of hooks) but BEFORE any expensive render.
|
||||||
|
if (!hasSiteBeenVisitedToday()) return null;
|
||||||
|
|
||||||
|
const showFadeToVideoOverlay =
|
||||||
|
introStep === "fade-to-video" ||
|
||||||
|
(introStep === "loading-map" && sceneLoadingState.status === "ready");
|
||||||
|
|
||||||
|
const renderIntroOverlay = () => {
|
||||||
|
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
|
||||||
|
|
||||||
|
switch (introStep) {
|
||||||
|
case "video":
|
||||||
|
return <IntroVideoPlayer />;
|
||||||
|
case "dialogue-intro":
|
||||||
|
return <IntroDialogueOverlay />;
|
||||||
|
case "reveal":
|
||||||
|
return <IntroRevealOverlay />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HandTrackingProvider>
|
<HandTrackingProvider>
|
||||||
<Canvas
|
<Canvas
|
||||||
@@ -94,8 +166,6 @@ export function HomePage(): React.JSX.Element {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
<GameUI />
|
<GameUI />
|
||||||
<IntroUI />
|
|
||||||
<BienvenueDisplay />
|
|
||||||
{dialogMessage ? (
|
{dialogMessage ? (
|
||||||
<DialogMessage
|
<DialogMessage
|
||||||
message={dialogMessage}
|
message={dialogMessage}
|
||||||
@@ -103,7 +173,11 @@ export function HomePage(): React.JSX.Element {
|
|||||||
onClose={hideDialog}
|
onClose={hideDialog}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<SceneLoadingOverlay state={sceneLoadingState} />
|
{(introStep === "loading-map" || introStep === "fade-to-video") && (
|
||||||
|
<SceneLoadingOverlay state={sceneLoadingState} />
|
||||||
|
)}
|
||||||
|
{renderIntroOverlay()}
|
||||||
|
<EbikeIntroSequence />
|
||||||
</HandTrackingProvider>
|
</HandTrackingProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
|
||||||
|
import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
|
||||||
|
import { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
|
||||||
|
import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
|
||||||
|
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
||||||
|
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
||||||
|
import { SiteLayout } from "@/components/site/SiteLayout";
|
||||||
|
import { useIsMobile } from "@/hooks/ui/useIsMobile";
|
||||||
|
|
||||||
|
export function SitePage(): React.JSX.Element {
|
||||||
|
const currentStep = useSiteStore((state) => state.currentStep);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return <SiteMobileBlocker />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === "disclaimer") {
|
||||||
|
return <SiteDisclaimerScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
{currentStep === "welcome" && <SiteWelcomeScreen />}
|
||||||
|
{currentStep === "situation" && <SiteSituationScreen />}
|
||||||
|
{currentStep === "naming" && <SiteNamingScreen />}
|
||||||
|
{currentStep === "transition" && <SiteTransitionOverlay />}
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createRouter,
|
createRouter,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { HomePage } from "@/pages/page";
|
import { HomePage } from "@/pages/page";
|
||||||
|
import { SitePage } from "@/pages/site/page";
|
||||||
import { EditorPage } from "@/pages/editor/page";
|
import { EditorPage } from "@/pages/editor/page";
|
||||||
import { GalleryPage } from "@/pages/gallery/page";
|
import { GalleryPage } from "@/pages/gallery/page";
|
||||||
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
||||||
@@ -42,6 +43,12 @@ const indexRoute = createRoute({
|
|||||||
component: HomePage,
|
component: HomePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/site",
|
||||||
|
component: SitePage,
|
||||||
|
});
|
||||||
|
|
||||||
const editorRoute = createRoute({
|
const editorRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/editor",
|
path: "/editor",
|
||||||
@@ -102,6 +109,7 @@ const docsChildRoutes = [
|
|||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
|
siteRoute,
|
||||||
editorRoute,
|
editorRoute,
|
||||||
galleryRoute,
|
galleryRoute,
|
||||||
waypointRoute,
|
waypointRoute,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export interface DialogueDefinition {
|
|||||||
id: string;
|
id: string;
|
||||||
voice: DialogueVoiceId;
|
voice: DialogueVoiceId;
|
||||||
audio: string;
|
audio: string;
|
||||||
subtitleCueIndex: number;
|
subtitleCueIndex?: number;
|
||||||
|
subtitleCueIndices?: number[];
|
||||||
timecode?: number;
|
timecode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,3 +23,20 @@ export interface DialogueManifest {
|
|||||||
voices: DialogueVoice[];
|
voices: DialogueVoice[];
|
||||||
dialogues: DialogueDefinition[];
|
dialogues: DialogueDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDialogueCueIndices(dialogue: DialogueDefinition): number[] {
|
||||||
|
if (dialogue.subtitleCueIndices && dialogue.subtitleCueIndices.length > 0) {
|
||||||
|
return dialogue.subtitleCueIndices;
|
||||||
|
}
|
||||||
|
if (dialogue.subtitleCueIndex !== undefined) {
|
||||||
|
return [dialogue.subtitleCueIndex];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDialogueFirstCueIndex(
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
): number | undefined {
|
||||||
|
const indices = getDialogueCueIndices(dialogue);
|
||||||
|
return indices[0];
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ declare global {
|
|||||||
ebikeParkedPosition: Vector3Tuple | null;
|
ebikeParkedPosition: Vector3Tuple | null;
|
||||||
ebikeParkedRotation: number | null;
|
ebikeParkedRotation: number | null;
|
||||||
ebikeSteerFactor: number | undefined;
|
ebikeSteerFactor: number | undefined;
|
||||||
|
ebikeBreakdownActive: boolean | undefined;
|
||||||
|
ebikeDriveInputActive: boolean | undefined;
|
||||||
|
ebikeSpeedFactor: number | undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-19
@@ -1,24 +1,27 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
|
||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps for the /site onboarding page
|
||||||
|
*/
|
||||||
|
export type SiteStep =
|
||||||
|
| "disclaimer" // Écran 0: Avertissement (ordi recommandé, bonne connexion)
|
||||||
|
| "welcome" // Écran 1: Bienvenue à Altera
|
||||||
|
| "situation" // Écran 2: Quelle est votre situation
|
||||||
|
| "naming" // Écran 3: Quel est votre prénom (Danyl)
|
||||||
|
| "transition"; // Fondu noir + dialogue final
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps for the intro sequence (after /site, on / route)
|
||||||
|
*/
|
||||||
export type GameStep =
|
export type GameStep =
|
||||||
| "intro"
|
| "loading-map" // Chargement des assets
|
||||||
| "start-intro"
|
| "fade-to-video" // Fondu noir entre chargement et vidéo
|
||||||
| "naming"
|
| "video" // Vidéo intro.mp4
|
||||||
| "bienvenue"
|
| "dialogue-intro" // Dialogues post-vidéo (écran noir)
|
||||||
| "star-move"
|
| "reveal" // Fondu noir → jeu visible
|
||||||
| "mission2"
|
| "await-ebike-mount" // Attente interaction pour monter sur l'e-bike
|
||||||
| "searching"
|
| "ebike-intro-ride" // Courte conduite avant la panne
|
||||||
| "helped"
|
| "ebike-breakdown" // Panne + dialogue avant mission réparation
|
||||||
| "manipulation"
|
| "completed"; // Intro terminée
|
||||||
| "outOfFabrik";
|
|
||||||
|
|
||||||
export type MainGameState = "intro" | RepairMissionId | "outro";
|
export type MainGameState = "intro" | RepairMissionId | "outro";
|
||||||
|
|
||||||
export interface Zone {
|
|
||||||
id: string;
|
|
||||||
position: Vector3Tuple;
|
|
||||||
radius: number;
|
|
||||||
height: number;
|
|
||||||
targetStep: GameStep;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const COOKIE_NAME = "siteVisited";
|
||||||
|
const EXPIRY_HOURS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the site has been visited today (within 24 hours)
|
||||||
|
*/
|
||||||
|
export function hasSiteBeenVisitedToday(): boolean {
|
||||||
|
const cookies = document.cookie.split(";");
|
||||||
|
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
const [name, value] = cookie.trim().split("=");
|
||||||
|
if (name === COOKIE_NAME && value === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the site visited cookie with 24-hour expiration
|
||||||
|
*/
|
||||||
|
export function setSiteVisited(): void {
|
||||||
|
const expiryDate = new Date();
|
||||||
|
expiryDate.setTime(expiryDate.getTime() + EXPIRY_HOURS * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
document.cookie = `${COOKIE_NAME}=true; expires=${expiryDate.toUTCString()}; path=/; SameSite=Strict`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the site visited cookie (useful for debugging)
|
||||||
|
*/
|
||||||
|
export function clearSiteVisited(): void {
|
||||||
|
document.cookie = `${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
}
|
||||||
@@ -93,13 +93,26 @@ function parseDialogueDefinition(
|
|||||||
throw new Error(`Dialogue ${data.id} has an invalid audio path`);
|
throw new Error(`Dialogue ${data.id} has an invalid audio path`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Support both subtitleCueIndex (legacy) and subtitleCueIndices (new)
|
||||||
const subtitleCueIndex = data.subtitleCueIndex;
|
const subtitleCueIndex = data.subtitleCueIndex;
|
||||||
if (
|
const subtitleCueIndices = data.subtitleCueIndices;
|
||||||
typeof subtitleCueIndex !== "number" ||
|
|
||||||
!Number.isInteger(subtitleCueIndex) ||
|
const hasLegacyIndex =
|
||||||
subtitleCueIndex < 1
|
typeof subtitleCueIndex === "number" &&
|
||||||
) {
|
Number.isInteger(subtitleCueIndex) &&
|
||||||
throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`);
|
subtitleCueIndex >= 1;
|
||||||
|
|
||||||
|
const hasNewIndices =
|
||||||
|
Array.isArray(subtitleCueIndices) &&
|
||||||
|
subtitleCueIndices.length > 0 &&
|
||||||
|
subtitleCueIndices.every(
|
||||||
|
(idx) => typeof idx === "number" && Number.isInteger(idx) && idx >= 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasLegacyIndex && !hasNewIndices) {
|
||||||
|
throw new Error(
|
||||||
|
`Dialogue ${data.id} must have subtitleCueIndex or subtitleCueIndices`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timecode = data.timecode;
|
const timecode = data.timecode;
|
||||||
@@ -111,9 +124,14 @@ function parseDialogueDefinition(
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
voice: data.voice,
|
voice: data.voice,
|
||||||
audio: data.audio,
|
audio: data.audio,
|
||||||
subtitleCueIndex,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (hasNewIndices) {
|
||||||
|
dialogue.subtitleCueIndices = subtitleCueIndices as number[];
|
||||||
|
} else if (hasLegacyIndex) {
|
||||||
|
dialogue.subtitleCueIndex = subtitleCueIndex;
|
||||||
|
}
|
||||||
|
|
||||||
if (timecode !== undefined) dialogue.timecode = timecode;
|
if (timecode !== undefined) dialogue.timecode = timecode;
|
||||||
|
|
||||||
return dialogue;
|
return dialogue;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
DialogueManifest,
|
DialogueManifest,
|
||||||
DialogueVoice,
|
DialogueVoice,
|
||||||
} from "@/types/dialogues/dialogues";
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import { getDialogueCueIndices } from "@/types/dialogues/dialogues";
|
||||||
import type { SubtitleLanguage } from "@/types/settings/settings";
|
import type { SubtitleLanguage } from "@/types/settings/settings";
|
||||||
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
|
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
|
||||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||||
@@ -11,20 +12,40 @@ import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
|
|||||||
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
|
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
|
||||||
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
||||||
|
|
||||||
|
let manifestCache: DialogueManifest | null = null;
|
||||||
|
let manifestPromise: Promise<DialogueManifest | null> | null = null;
|
||||||
|
|
||||||
export interface DialogueSubtitleCue {
|
export interface DialogueSubtitleCue {
|
||||||
voice: DialogueVoice;
|
voice: DialogueVoice;
|
||||||
cue: SubtitleCue;
|
cue: SubtitleCue;
|
||||||
subtitlePath: string;
|
subtitlePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiple subtitle cues for a single dialogue
|
||||||
|
*/
|
||||||
|
export interface DialogueSubtitleCues {
|
||||||
|
voice: DialogueVoice;
|
||||||
|
cues: SubtitleCue[];
|
||||||
|
subtitlePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
||||||
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
if (manifestCache) return manifestCache;
|
||||||
|
if (manifestPromise) return manifestPromise;
|
||||||
|
|
||||||
if (!response.ok) {
|
manifestPromise = (async () => {
|
||||||
return null;
|
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||||
}
|
if (!response.ok) {
|
||||||
|
manifestPromise = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const manifest = parseDialogueManifest(await response.json());
|
||||||
|
manifestCache = manifest;
|
||||||
|
return manifest;
|
||||||
|
})();
|
||||||
|
|
||||||
return parseDialogueManifest(await response.json());
|
return manifestPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDialogueVoice(
|
function getDialogueVoice(
|
||||||
@@ -39,21 +60,40 @@ export async function loadDialogueSubtitleCue(
|
|||||||
dialogue: DialogueDefinition,
|
dialogue: DialogueDefinition,
|
||||||
language: SubtitleLanguage,
|
language: SubtitleLanguage,
|
||||||
): Promise<DialogueSubtitleCue | null> {
|
): Promise<DialogueSubtitleCue | null> {
|
||||||
|
const result = await loadDialogueSubtitleCues(manifest, dialogue, language);
|
||||||
|
const firstCue = result?.cues[0];
|
||||||
|
if (!result || !firstCue) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
voice: result.voice,
|
||||||
|
cue: firstCue,
|
||||||
|
subtitlePath: result.subtitlePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDialogueSubtitleCues(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
): Promise<DialogueSubtitleCues | null> {
|
||||||
const voice = getDialogueVoice(manifest, dialogue.voice);
|
const voice = getDialogueVoice(manifest, dialogue.voice);
|
||||||
if (!voice) return null;
|
if (!voice) return null;
|
||||||
|
|
||||||
const subtitles = await loadVoiceSubtitleCues(voice, language);
|
const subtitles = await loadVoiceSubtitleCues(voice, language);
|
||||||
if (!subtitles) return null;
|
if (!subtitles) return null;
|
||||||
|
|
||||||
const cue = subtitles.cues.find(
|
const cueIndices = getDialogueCueIndices(dialogue);
|
||||||
(item) => item.index === dialogue.subtitleCueIndex,
|
if (cueIndices.length === 0) return null;
|
||||||
);
|
|
||||||
|
|
||||||
if (!cue) return null;
|
const cues = cueIndices
|
||||||
|
.map((index) => subtitles.cues.find((item) => item.index === index))
|
||||||
|
.filter((cue): cue is SubtitleCue => cue !== undefined);
|
||||||
|
|
||||||
|
if (cues.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
voice,
|
voice,
|
||||||
cue,
|
cues,
|
||||||
subtitlePath: subtitles.path,
|
subtitlePath: subtitles.path,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
|||||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadDialogueSubtitleCue } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueSubtitleCues } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
|
||||||
|
|
||||||
interface QueuedDialogueRequest {
|
interface QueuedDialogueRequest {
|
||||||
manifest: DialogueManifest;
|
manifest: DialogueManifest;
|
||||||
@@ -15,6 +16,8 @@ const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
|
|||||||
const dialogueQueue: QueuedDialogueRequest[] = [];
|
const dialogueQueue: QueuedDialogueRequest[] = [];
|
||||||
let isDialogueQueuePlaying = false;
|
let isDialogueQueuePlaying = false;
|
||||||
|
|
||||||
|
let currentDialogueAudio: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
export function queueDialogueById(
|
export function queueDialogueById(
|
||||||
manifest: DialogueManifest,
|
manifest: DialogueManifest,
|
||||||
dialogueId: string,
|
dialogueId: string,
|
||||||
@@ -31,15 +34,26 @@ export function clearQueuedDialogues(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stopCurrentDialogue(): void {
|
||||||
|
if (currentDialogueAudio && !currentDialogueAudio.paused) {
|
||||||
|
currentDialogueAudio.pause();
|
||||||
|
currentDialogueAudio.currentTime = 0;
|
||||||
|
}
|
||||||
|
currentDialogueAudio = null;
|
||||||
|
useSubtitleStore.getState().clearActiveSubtitle();
|
||||||
|
}
|
||||||
|
|
||||||
export async function playDialogueById(
|
export async function playDialogueById(
|
||||||
manifest: DialogueManifest,
|
manifest: DialogueManifest,
|
||||||
dialogueId: string,
|
dialogueId: string,
|
||||||
): Promise<HTMLAudioElement | null> {
|
): Promise<HTMLAudioElement | null> {
|
||||||
|
stopCurrentDialogue();
|
||||||
|
|
||||||
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
|
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
|
||||||
if (!dialogue) return null;
|
if (!dialogue) return null;
|
||||||
|
|
||||||
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
|
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
|
||||||
const subtitle = await loadDialogueSubtitleCue(
|
const subtitleData = await loadDialogueSubtitleCues(
|
||||||
manifest,
|
manifest,
|
||||||
dialogue,
|
dialogue,
|
||||||
subtitleLanguage,
|
subtitleLanguage,
|
||||||
@@ -48,7 +62,11 @@ export async function playDialogueById(
|
|||||||
category: "dialogue",
|
category: "dialogue",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!subtitle) return audio;
|
currentDialogueAudio = audio;
|
||||||
|
|
||||||
|
if (!subtitleData || subtitleData.cues.length === 0) return audio;
|
||||||
|
|
||||||
|
const { voice, cues } = subtitleData;
|
||||||
|
|
||||||
const clearSubtitle = (): void => {
|
const clearSubtitle = (): void => {
|
||||||
useSubtitleStore.getState().clearActiveSubtitle();
|
useSubtitleStore.getState().clearActiveSubtitle();
|
||||||
@@ -60,18 +78,28 @@ export async function playDialogueById(
|
|||||||
audio.removeEventListener("ended", cleanup);
|
audio.removeEventListener("ended", cleanup);
|
||||||
audio.removeEventListener("pause", cleanup);
|
audio.removeEventListener("pause", cleanup);
|
||||||
clearSubtitle();
|
clearSubtitle();
|
||||||
|
if (currentDialogueAudio === audio) {
|
||||||
|
currentDialogueAudio = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findActiveCue = (currentTime: number): SubtitleCue | null => {
|
||||||
|
for (const cue of cues) {
|
||||||
|
if (currentTime >= cue.startTime && currentTime <= cue.endTime) {
|
||||||
|
return cue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncSubtitle = (): void => {
|
const syncSubtitle = (): void => {
|
||||||
const currentTime = audio.currentTime;
|
const currentTime = audio.currentTime;
|
||||||
const shouldShowSubtitle =
|
const activeCue = findActiveCue(currentTime);
|
||||||
currentTime >= subtitle.cue.startTime &&
|
|
||||||
currentTime <= subtitle.cue.endTime;
|
|
||||||
|
|
||||||
if (shouldShowSubtitle) {
|
if (activeCue) {
|
||||||
useSubtitleStore.getState().setActiveSubtitle({
|
useSubtitleStore.getState().setActiveSubtitle({
|
||||||
speaker: subtitle.voice.speaker,
|
speaker: voice.speaker,
|
||||||
text: subtitle.cue.text,
|
text: activeCue.text,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type ModelEntry = [modelName: string, modelUrl: string];
|
|||||||
|
|
||||||
let cachedSceneData: SceneData | null = null;
|
let cachedSceneData: SceneData | null = null;
|
||||||
let loadingPromise: Promise<SceneData | null> | null = null;
|
let loadingPromise: Promise<SceneData | null> | null = null;
|
||||||
|
const modelEntryCache = new Map<string, ModelEntry | null>();
|
||||||
|
|
||||||
export async function loadMapSceneData(): Promise<SceneData | null> {
|
export async function loadMapSceneData(): Promise<SceneData | null> {
|
||||||
if (cachedSceneData) {
|
if (cachedSceneData) {
|
||||||
@@ -223,24 +224,34 @@ async function loadMapModelUrls(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadModelEntry(modelName: string): Promise<ModelEntry | null> {
|
async function loadModelEntry(modelName: string): Promise<ModelEntry | null> {
|
||||||
for (const fileName of [...MODEL_FILE_NAMES, `${modelName}.gltf`]) {
|
if (modelEntryCache.has(modelName)) {
|
||||||
const modelUrl = `/models/${modelName}/${fileName}`;
|
return modelEntryCache.get(modelName) ?? null;
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(modelUrl, { method: "HEAD" });
|
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
|
||||||
if (response.ok && !contentType.includes(HTML_CONTENT_TYPE)) {
|
|
||||||
return [modelName, modelUrl];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("MapSceneData", "Failed to probe map model URL", {
|
|
||||||
modelName,
|
|
||||||
modelUrl,
|
|
||||||
error: error instanceof Error ? error : String(error),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
const modelUrls = [...MODEL_FILE_NAMES, `${modelName}.gltf`].map(
|
||||||
|
(fileName) => `/models/${modelName}/${fileName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
modelUrls.map(async (modelUrl) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(modelUrl, { method: "HEAD" });
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
return response.ok && !contentType.includes(HTML_CONTENT_TYPE);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("MapSceneData", "Failed to probe map model URL", {
|
||||||
|
modelName,
|
||||||
|
modelUrl,
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const modelUrl = modelUrls[results.findIndex(Boolean)] ?? null;
|
||||||
|
const entry = modelUrl ? ([modelName, modelUrl] satisfies ModelEntry) : null;
|
||||||
|
|
||||||
|
modelEntryCache.set(modelName, entry);
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,24 @@ import { SkyModel } from "@/components/three/world/SkyModel";
|
|||||||
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||||
import { FogSystem } from "@/world/fog/FogSystem";
|
import { FogSystem } from "@/world/fog/FogSystem";
|
||||||
import { GrassSystem } from "@/world/grass/GrassSystem";
|
import { GrassSystem } from "@/world/grass/GrassSystem";
|
||||||
|
import { SceneShadowWarmup } from "@/world/SceneShadowWarmup";
|
||||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||||
import { WaterSystem } from "@/world/water/WaterSystem";
|
import { WaterSystem } from "@/world/water/WaterSystem";
|
||||||
import { WorldPlane } from "@/world/WorldPlane";
|
import { WorldPlane } from "@/world/WorldPlane";
|
||||||
|
|
||||||
export function Environment(): React.JSX.Element {
|
interface ShadowWarmupConfig {
|
||||||
|
active: boolean;
|
||||||
|
onReady: () => void;
|
||||||
|
onStarted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnvironmentProps {
|
||||||
|
shadowWarmup?: ShadowWarmupConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Environment({
|
||||||
|
shadowWarmup,
|
||||||
|
}: EnvironmentProps): React.JSX.Element {
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
const groups = useMapPerformanceStore((state) => state.groups);
|
const groups = useMapPerformanceStore((state) => state.groups);
|
||||||
const models = useMapPerformanceStore((state) => state.models);
|
const models = useMapPerformanceStore((state) => state.models);
|
||||||
@@ -34,6 +47,13 @@ export function Environment(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FogSystem />
|
<FogSystem />
|
||||||
|
{shadowWarmup ? (
|
||||||
|
<SceneShadowWarmup
|
||||||
|
active={shadowWarmup.active}
|
||||||
|
onReady={shadowWarmup.onReady}
|
||||||
|
onStarted={shadowWarmup.onStarted}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{showSky ? (
|
{showSky ? (
|
||||||
<SkyModel
|
<SkyModel
|
||||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function GameMap({
|
|||||||
sceneData.mapNodes.length - visibleMapNodes.length;
|
sceneData.mapNodes.length - visibleMapNodes.length;
|
||||||
|
|
||||||
if (skippedMapNodeCount > 0) {
|
if (skippedMapNodeCount > 0) {
|
||||||
logger.warn("GameMap", "Lite map skipped heavy map nodes", {
|
logger.debug("GameMap", "Lite map skipped heavy map nodes", {
|
||||||
skippedMapNodeCount,
|
skippedMapNodeCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
|
|
||||||
const GAME_MUSIC_PATH = "/sounds/musique/test.mp3";
|
const GAME_MUSIC_PATH = "/sounds/musique/musique-jeu.mp3";
|
||||||
const GAME_MUSIC_VOLUME = 0.33;
|
const GAME_MUSIC_VOLUME = 0.33;
|
||||||
|
|
||||||
export function GameMusic(): null {
|
export function GameMusic(): null {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
|
|||||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||||
|
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||||
|
|
||||||
interface StageAnchorProps {
|
interface StageAnchorProps {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -81,7 +82,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||||
<Ebike position={[0, 10, 0]} />
|
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||||
const position = getRepairMissionPosition(mission, anchors);
|
const position = getRepairMissionPosition(mission, anchors);
|
||||||
if (!position) return null;
|
if (!position) return null;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
interface SceneShadowWarmupProps {
|
||||||
|
active: boolean;
|
||||||
|
onReady: () => void;
|
||||||
|
onStarted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markShadowLightForUpdate(object: THREE.Object3D): void {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
object instanceof THREE.DirectionalLight ||
|
||||||
|
object instanceof THREE.PointLight ||
|
||||||
|
object instanceof THREE.SpotLight
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!object.castShadow) return;
|
||||||
|
|
||||||
|
object.updateMatrixWorld(true);
|
||||||
|
object.shadow.camera.updateProjectionMatrix();
|
||||||
|
object.shadow.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceSceneShadowPass(
|
||||||
|
gl: THREE.WebGLRenderer,
|
||||||
|
scene: THREE.Scene,
|
||||||
|
): void {
|
||||||
|
gl.shadowMap.enabled = true;
|
||||||
|
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||||
|
gl.shadowMap.autoUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
|
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (object instanceof THREE.Mesh) {
|
||||||
|
object.updateMatrixWorld(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
markShadowLightForUpdate(object);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SceneShadowWarmup({
|
||||||
|
active,
|
||||||
|
onReady,
|
||||||
|
onStarted,
|
||||||
|
}: SceneShadowWarmupProps): null {
|
||||||
|
const gl = useThree((state) => state.gl);
|
||||||
|
const scene = useThree((state) => state.scene);
|
||||||
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
|
const isRunningRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
isRunningRef.current = false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRunningRef.current) return undefined;
|
||||||
|
|
||||||
|
isRunningRef.current = true;
|
||||||
|
onStarted();
|
||||||
|
forceSceneShadowPass(gl, scene);
|
||||||
|
invalidate();
|
||||||
|
|
||||||
|
let firstFrame = 0;
|
||||||
|
let secondFrame = 0;
|
||||||
|
|
||||||
|
firstFrame = window.requestAnimationFrame(() => {
|
||||||
|
forceSceneShadowPass(gl, scene);
|
||||||
|
invalidate();
|
||||||
|
|
||||||
|
secondFrame = window.requestAnimationFrame(() => {
|
||||||
|
forceSceneShadowPass(gl, scene);
|
||||||
|
invalidate();
|
||||||
|
onReady();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(firstFrame);
|
||||||
|
window.cancelAnimationFrame(secondFrame);
|
||||||
|
};
|
||||||
|
}, [active, gl, invalidate, onReady, onStarted, scene]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+11
-14
@@ -12,16 +12,9 @@ import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
|||||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { GameFlow } from "@/components/game/GameFlow";
|
|
||||||
import {
|
|
||||||
ZoneDebugVisuals,
|
|
||||||
ZoneDetection,
|
|
||||||
} from "@/components/zone/ZoneDetection";
|
|
||||||
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
||||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||||
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||||
import { PyloneDestroyed } from "@/components/three/interaction/PyloneDestroyed";
|
|
||||||
import { NPCHelper } from "@/components/three/interaction/NPCHelper";
|
|
||||||
import { Environment } from "@/world/Environment";
|
import { Environment } from "@/world/Environment";
|
||||||
import { GameCinematics } from "@/world/GameCinematics";
|
import { GameCinematics } from "@/world/GameCinematics";
|
||||||
import { GameDialogues } from "@/world/GameDialogues";
|
import { GameDialogues } from "@/world/GameDialogues";
|
||||||
@@ -54,6 +47,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
handleGameStageLoaded,
|
handleGameStageLoaded,
|
||||||
handleGameMapLoaded,
|
handleGameMapLoaded,
|
||||||
handleOctreeReady,
|
handleOctreeReady,
|
||||||
|
handleShadowWarmupReady,
|
||||||
|
handleShadowWarmupStarted,
|
||||||
|
shouldWarmUpShadows,
|
||||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||||
const playerSpawnPosition =
|
const playerSpawnPosition =
|
||||||
sceneMode === "game"
|
sceneMode === "game"
|
||||||
@@ -68,7 +64,13 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Environment />
|
<Environment
|
||||||
|
shadowWarmup={{
|
||||||
|
active: shouldWarmUpShadows,
|
||||||
|
onReady: handleShadowWarmupReady,
|
||||||
|
onStarted: handleShadowWarmupStarted,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Lighting />
|
<Lighting />
|
||||||
<DebugHelpers />
|
<DebugHelpers />
|
||||||
{showHandTrackingGloves ? (
|
{showHandTrackingGloves ? (
|
||||||
@@ -80,11 +82,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||||
{sceneMode === "game" ? (
|
{sceneMode === "game" ? (
|
||||||
<>
|
<>
|
||||||
<GameFlow />
|
|
||||||
<ZoneDetection />
|
|
||||||
<ZoneDebugVisuals />
|
|
||||||
<NPCHelper position={[1, 12, -55]} />
|
|
||||||
<PyloneDestroyed position={[1, 15, -45]} />
|
|
||||||
<GameMap
|
<GameMap
|
||||||
onLoaded={handleGameMapLoaded}
|
onLoaded={handleGameMapLoaded}
|
||||||
onLoadingStateChange={onLoadingStateChange}
|
onLoadingStateChange={onLoadingStateChange}
|
||||||
@@ -101,7 +98,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
<>
|
<>
|
||||||
<GameMusic />
|
<GameMusic />
|
||||||
{mainState === "outro" ? <GameCinematics /> : null}
|
{mainState === "outro" ? <GameCinematics /> : null}
|
||||||
<GameDialogues />
|
{mainState !== "intro" ? <GameDialogues /> : null}
|
||||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
height={4}
|
height={4}
|
||||||
startPos={{ x: 10, y: 0, z: -10 }}
|
startPos={{ x: 10, y: 0, z: -10 }}
|
||||||
destPos={{ x: -40, y: 0, z: 30 }}
|
destPos={{ x: -40, y: 0, z: 30 }}
|
||||||
mapImageUrl="/assets/gps/map_background.png"
|
mapImageUrl="/assets/world/gps/map_background.png"
|
||||||
worldBounds={{
|
worldBounds={{
|
||||||
minX: -166,
|
minX: -166,
|
||||||
maxX: 163,
|
maxX: 163,
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ import { InteractionManager } from "@/managers/InteractionManager";
|
|||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { EBIKE_CAMERA_TRANSFORM } from "@/data/ebike/ebikeConfig";
|
import {
|
||||||
|
EBIKE_ACCELERATION_DURATION_MS,
|
||||||
|
EBIKE_CAMERA_TRANSFORM,
|
||||||
|
EBIKE_DECELERATION_DURATION_MS,
|
||||||
|
EBIKE_MAX_SPEED,
|
||||||
|
} from "@/data/ebike/ebikeConfig";
|
||||||
|
|
||||||
/** Global window properties used for ebike communication */
|
/** Global window properties used for ebike communication */
|
||||||
interface EbikeGlobalState {
|
interface EbikeGlobalState {
|
||||||
@@ -39,6 +44,9 @@ interface EbikeGlobalState {
|
|||||||
ebikeVisualGroup?: React.RefObject<THREE.Group>;
|
ebikeVisualGroup?: React.RefObject<THREE.Group>;
|
||||||
playerPos?: Vector3Tuple;
|
playerPos?: Vector3Tuple;
|
||||||
ebikeAngle?: number;
|
ebikeAngle?: number;
|
||||||
|
ebikeBreakdownActive?: boolean;
|
||||||
|
ebikeDriveInputActive?: boolean;
|
||||||
|
ebikeSpeedFactor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -156,6 +164,7 @@ export function PlayerController({
|
|||||||
const movementModeRef = useRef(movementMode);
|
const movementModeRef = useRef(movementMode);
|
||||||
const prevMovementModeRef = useRef(movementMode);
|
const prevMovementModeRef = useRef(movementMode);
|
||||||
const ebikeAngle = useRef(0);
|
const ebikeAngle = useRef(0);
|
||||||
|
const ebikeSpeedFactor = useRef(0);
|
||||||
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -175,6 +184,7 @@ export function PlayerController({
|
|||||||
velocity.current.set(0, 0, 0);
|
velocity.current.set(0, 0, 0);
|
||||||
onFloor.current = false;
|
onFloor.current = false;
|
||||||
wantsJump.current = false;
|
wantsJump.current = false;
|
||||||
|
ebikeSpeedFactor.current = 0;
|
||||||
|
|
||||||
ebikeAngle.current = targetRot;
|
ebikeAngle.current = targetRot;
|
||||||
|
|
||||||
@@ -215,6 +225,7 @@ export function PlayerController({
|
|||||||
const shift = rightDir.multiplyScalar(3);
|
const shift = rightDir.multiplyScalar(3);
|
||||||
capsule.current.translate(shift);
|
capsule.current.translate(shift);
|
||||||
camera.position.copy(capsule.current.end);
|
camera.position.copy(capsule.current.end);
|
||||||
|
ebikeSpeedFactor.current = 0;
|
||||||
}
|
}
|
||||||
prevMovementModeRef.current = movementMode;
|
prevMovementModeRef.current = movementMode;
|
||||||
}, [movementMode, camera]);
|
}, [movementMode, camera]);
|
||||||
@@ -347,7 +358,10 @@ export function PlayerController({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (movementModeRef.current === "ebike") {
|
const isEbikeMounted = movementModeRef.current === "ebike";
|
||||||
|
const isEbikeBreakdown = window.ebikeBreakdownActive === true;
|
||||||
|
|
||||||
|
if (isEbikeMounted && !isEbikeBreakdown) {
|
||||||
const turnSpeed = 1.8;
|
const turnSpeed = 1.8;
|
||||||
if (keys.current.left) {
|
if (keys.current.left) {
|
||||||
ebikeAngle.current += turnSpeed * dt;
|
ebikeAngle.current += turnSpeed * dt;
|
||||||
@@ -365,19 +379,41 @@ export function PlayerController({
|
|||||||
}
|
}
|
||||||
|
|
||||||
_wishDir.set(0, 0, 0);
|
_wishDir.set(0, 0, 0);
|
||||||
if (!movementLocked) {
|
if (!movementLocked && !isEbikeBreakdown) {
|
||||||
if (keys.current.forward) _wishDir.add(_forward);
|
if (keys.current.forward) _wishDir.add(_forward);
|
||||||
if (keys.current.backward) _wishDir.sub(_forward);
|
if (keys.current.backward) _wishDir.sub(_forward);
|
||||||
if (movementModeRef.current !== "ebike") {
|
if (!isEbikeMounted) {
|
||||||
if (keys.current.left) _wishDir.sub(_right);
|
if (keys.current.left) _wishDir.sub(_right);
|
||||||
if (keys.current.right) _wishDir.add(_right);
|
if (keys.current.right) _wishDir.add(_right);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
||||||
|
|
||||||
|
if (isEbikeMounted) {
|
||||||
|
const isDriveInputActive = _wishDir.lengthSq() > 0 && !isEbikeBreakdown;
|
||||||
|
const durationMs = isDriveInputActive
|
||||||
|
? EBIKE_ACCELERATION_DURATION_MS
|
||||||
|
: EBIKE_DECELERATION_DURATION_MS;
|
||||||
|
const factorDelta = durationMs > 0 ? (dt * 1000) / durationMs : 1;
|
||||||
|
ebikeSpeedFactor.current = THREE.MathUtils.clamp(
|
||||||
|
ebikeSpeedFactor.current +
|
||||||
|
(isDriveInputActive ? factorDelta : -factorDelta),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
window.ebikeDriveInputActive = isDriveInputActive;
|
||||||
|
window.ebikeSpeedFactor = ebikeSpeedFactor.current;
|
||||||
|
} else {
|
||||||
|
window.ebikeDriveInputActive = false;
|
||||||
|
window.ebikeSpeedFactor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movementSpeed = isEbikeMounted
|
||||||
|
? EBIKE_MAX_SPEED * ebikeSpeedFactor.current
|
||||||
|
: currentSpeed;
|
||||||
const accel = onFloor.current
|
const accel = onFloor.current
|
||||||
? currentSpeed
|
? movementSpeed
|
||||||
: currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
|
: movementSpeed * PLAYER_AIR_CONTROL_FACTOR;
|
||||||
velocity.current.x +=
|
velocity.current.x +=
|
||||||
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
||||||
velocity.current.z +=
|
velocity.current.z +=
|
||||||
@@ -387,6 +423,18 @@ export function PlayerController({
|
|||||||
velocity.current.x *= damping;
|
velocity.current.x *= damping;
|
||||||
velocity.current.z *= damping;
|
velocity.current.z *= damping;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isEbikeMounted &&
|
||||||
|
isEbikeBreakdown &&
|
||||||
|
ebikeSpeedFactor.current <= 0.001 &&
|
||||||
|
Math.hypot(velocity.current.x, velocity.current.z) <= 0.05
|
||||||
|
) {
|
||||||
|
velocity.current.setX(0);
|
||||||
|
velocity.current.setZ(0);
|
||||||
|
useGameStore.getState().setPlayerMovementMode("walk");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (onFloor.current) {
|
if (onFloor.current) {
|
||||||
velocity.current.y = Math.max(0, velocity.current.y);
|
velocity.current.y = Math.max(0, velocity.current.y);
|
||||||
if (wantsJump.current) {
|
if (wantsJump.current) {
|
||||||
|
|||||||
Reference in New Issue
Block a user