Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e073fc375b | |||
| bff8a16290 | |||
| a3f611e227 | |||
| b578e68c2e | |||
| 7c691a8044 | |||
| f24704091a | |||
| e6bfcbe960 | |||
| 0fa7a82175 | |||
| 82dc47a296 | |||
| 970adf4853 | |||
| 07b09c22af | |||
| 0f6860f1ae | |||
| 6ae21a2427 | |||
| 29342d796c | |||
| 60e3c92511 | |||
| 02c1fb33d0 | |||
| ce5dc8ada0 | |||
| a2cff0567e | |||
| 8cfee1ac93 | |||
| 4c5e2ed945 | |||
| 345d49f485 |
@@ -112,7 +112,7 @@ npm run format:check
|
||||
npm run build
|
||||
```
|
||||
|
||||
Regenerate runtime map data after editing `public/map_raw.json`:
|
||||
Regenerate runtime map data after editing `public/map_raw.json` that came from the hierachy node of the model Blocking.gltf:
|
||||
|
||||
```bash
|
||||
npm run map:transform
|
||||
|
||||
Binary file not shown.
@@ -72,14 +72,23 @@ It tracks:
|
||||
- `gameMapLoaded`: map data and visible map nodes settled
|
||||
- `gameStageLoaded`: Rapier gameplay stage mounted
|
||||
- `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
|
||||
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:
|
||||
|
||||
```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.
Binary file not shown.
BIN
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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"type": "Object3D",
|
||||
@@ -37602,23 +37394,6 @@
|
||||
"rotation": [0, 0, 0],
|
||||
"scale": [1, 1, 1],
|
||||
"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",
|
||||
"type": "Object3D",
|
||||
@@ -40477,14 +40252,14 @@
|
||||
"name": "lafabrik",
|
||||
"type": "Object3D",
|
||||
"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],
|
||||
"children": [
|
||||
{
|
||||
"name": "lafabrik",
|
||||
"type": "Mesh",
|
||||
"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]
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -31,14 +31,13 @@
|
||||
"id": "narrateur_bienvenueaaltera",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3",
|
||||
"subtitleCueIndex": 1,
|
||||
"timecode": 0
|
||||
"subtitleCueIndex": 1
|
||||
},
|
||||
{
|
||||
"id": "narrateur_intro_prenom",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_intro_prenom.mp3",
|
||||
"subtitleCueIndex": 2
|
||||
"subtitleCueIndices": [1, 2]
|
||||
},
|
||||
{
|
||||
"id": "narrateur_intro_apresprenom",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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.
|
||||
|
||||
2
|
||||
00:00:00,000 --> 00:00:11,592
|
||||
00:00:09,000 --> 00:00:11,592
|
||||
Avant de commencer, comment tu t'appelles ?
|
||||
|
||||
3
|
||||
|
||||
@@ -6,12 +6,14 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
|
||||
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||
import {
|
||||
EBIKE_CAMERA_TRANSFORM,
|
||||
EBIKE_DROP_PLAYER_TRANSFORM,
|
||||
EBIKE_WORLD_ROTATION_Y,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import "@/types/ebike/ebikeWindow";
|
||||
@@ -31,7 +33,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
const model = useClonedObject(scene);
|
||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||
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 updateEbikeSounds = useEbikeSounds();
|
||||
|
||||
// Map active mainState to target repair zone coordinate
|
||||
const destPos = useMemo(() => {
|
||||
@@ -67,7 +72,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
position[1] - PLAYER_EYE_HEIGHT,
|
||||
position[2],
|
||||
]);
|
||||
const restingRotationRef = useRef<number>(0);
|
||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||
|
||||
// State for debug visualization (synced from refs during useFrame)
|
||||
@@ -102,6 +107,12 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
useFrame((_, delta) => {
|
||||
if (groupRef.current) {
|
||||
if (movementMode === "ebike") {
|
||||
updateEbikeSounds({
|
||||
mounted: true,
|
||||
driving: window.ebikeDriveInputActive === true,
|
||||
breakdown: window.ebikeBreakdownActive === true,
|
||||
});
|
||||
|
||||
restingPositionRef.current = [
|
||||
groupRef.current.position.x,
|
||||
groupRef.current.position.y,
|
||||
@@ -133,6 +144,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
setDebugRestingPosition([...restingPositionRef.current]);
|
||||
}
|
||||
} else {
|
||||
updateEbikeSounds({ mounted: false, driving: false, breakdown: false });
|
||||
groupRef.current.position.set(...restingPositionRef.current);
|
||||
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
|
||||
|
||||
@@ -159,7 +171,14 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
];
|
||||
|
||||
const handleInteract = useCallback((): void => {
|
||||
if (window.ebikeBreakdownActive === true) return;
|
||||
|
||||
if (movementMode === "walk") {
|
||||
if (mainState === "ebike" && ebikeStep === "waiting") {
|
||||
setMissionStep("ebike", "inspected");
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraOffset = new THREE.Vector3(
|
||||
...EBIKE_CAMERA_TRANSFORM.position,
|
||||
);
|
||||
@@ -213,7 +232,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
useGameStore.getState().setPlayerMovementMode("walk");
|
||||
});
|
||||
}
|
||||
}, [movementMode, camera, position]);
|
||||
}, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]);
|
||||
|
||||
// Store handleInteract in a ref for use in debug folder callback
|
||||
const handleInteractRef = useRef(handleInteract);
|
||||
@@ -239,12 +258,20 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<group ref={groupRef} position={position}>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||
>
|
||||
<primitive object={model} />
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
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}
|
||||
radius={15}
|
||||
@@ -263,7 +290,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
height={0.8}
|
||||
startPos={gpsStartPos}
|
||||
destPos={destPos}
|
||||
mapImageUrl="/assets/gps/map_background.png"
|
||||
mapImageUrl="/assets/world/gps/map_background.png"
|
||||
worldBounds={{
|
||||
minX: -166,
|
||||
maxX: 163,
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
DialogueSpeaker,
|
||||
DialogueVoiceId,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import {
|
||||
getDialogueCueIndices,
|
||||
getDialogueFirstCueIndex,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||
@@ -34,7 +38,7 @@ function getNextCueIndex(
|
||||
): number {
|
||||
const cueIndexes = manifest.dialogues
|
||||
.filter((dialogue) => dialogue.voice === voice)
|
||||
.map((dialogue) => dialogue.subtitleCueIndex);
|
||||
.flatMap((dialogue) => getDialogueCueIndices(dialogue));
|
||||
|
||||
return Math.max(0, ...cueIndexes) + 1;
|
||||
}
|
||||
@@ -93,12 +97,15 @@ async function createFrenchSrtCue(
|
||||
manifest: DialogueManifest,
|
||||
dialogue: DialogueDefinition,
|
||||
): Promise<void> {
|
||||
const firstCueIndex = getDialogueFirstCueIndex(dialogue);
|
||||
if (firstCueIndex === undefined) return;
|
||||
|
||||
const srtPath = getFrenchSrtPath(dialogue.voice);
|
||||
const response = await fetch(srtPath);
|
||||
const content = response.ok ? await response.text() : "";
|
||||
const nextContent = appendSrtCueIfMissing(
|
||||
content,
|
||||
dialogue.subtitleCueIndex,
|
||||
firstCueIndex,
|
||||
getVoiceSpeaker(manifest, dialogue.voice),
|
||||
);
|
||||
|
||||
@@ -122,7 +129,8 @@ function getManifestErrors(manifest: DialogueManifest | null): string[] {
|
||||
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.`);
|
||||
}
|
||||
|
||||
@@ -160,9 +168,18 @@ function getPatchedDialogue(
|
||||
id: patch.id ?? dialogue.id,
|
||||
voice: patch.voice ?? dialogue.voice,
|
||||
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 (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
|
||||
} else if (dialogue.timecode !== undefined) {
|
||||
@@ -252,8 +269,9 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
||||
|
||||
try {
|
||||
await createFrenchSrtCue(nextManifest, dialogue);
|
||||
const cueIndex = getDialogueFirstCueIndex(dialogue) ?? "?";
|
||||
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) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
@@ -333,12 +351,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
||||
async function handleCreateFrenchSrtCue(): Promise<void> {
|
||||
if (!manifest || !selectedDialogue) return;
|
||||
|
||||
const cueIndex = getDialogueFirstCueIndex(selectedDialogue) ?? "?";
|
||||
setIsCreatingSrtCue(true);
|
||||
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`);
|
||||
setStatus(`Creation de la cue FR ${cueIndex}...`);
|
||||
|
||||
try {
|
||||
await createFrenchSrtCue(manifest, selectedDialogue);
|
||||
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`);
|
||||
setStatus(`Cue FR ${cueIndex} prete.`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
@@ -478,7 +497,7 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={selectedDialogue.subtitleCueIndex}
|
||||
value={getDialogueFirstCueIndex(selectedDialogue) ?? ""}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue({
|
||||
subtitleCueIndex: Math.max(1, Number(event.target.value)),
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
DialogueSpeaker,
|
||||
DialogueVoiceId,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import {
|
||||
getDialogueCueIndices,
|
||||
getDialogueFirstCueIndex,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import {
|
||||
@@ -181,7 +185,7 @@ function getExpectedCueIndexes(
|
||||
voice: DialogueVoiceId,
|
||||
): number[] {
|
||||
return getExpectedDialogues(manifest, voice)
|
||||
.map((dialogue) => dialogue.subtitleCueIndex)
|
||||
.flatMap((dialogue) => getDialogueCueIndices(dialogue))
|
||||
.filter(
|
||||
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
|
||||
)
|
||||
@@ -196,7 +200,11 @@ function getExpectedDialogues(
|
||||
|
||||
return [...manifest.dialogues]
|
||||
.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(
|
||||
@@ -577,7 +585,7 @@ export function EditorSrtPanel(): React.JSX.Element {
|
||||
)}
|
||||
{expectedDialogues.map((dialogue) => (
|
||||
<option key={dialogue.id} value={dialogue.id}>
|
||||
Cue {dialogue.subtitleCueIndex} - {dialogue.id}
|
||||
Cue {getDialogueFirstCueIndex(dialogue) ?? "?"} - {dialogue.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -585,7 +593,7 @@ export function EditorSrtPanel(): React.JSX.Element {
|
||||
|
||||
{selectedDialogue && (
|
||||
<div className="editor-srt-audio-card">
|
||||
<span>Cue {selectedDialogue.subtitleCueIndex}</span>
|
||||
<span>Cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"}</span>
|
||||
<strong>{selectedDialogue.id}</strong>
|
||||
<audio
|
||||
key={selectedDialogue.audio}
|
||||
@@ -609,39 +617,52 @@ export function EditorSrtPanel(): React.JSX.Element {
|
||||
<div className="editor-srt-time-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start")
|
||||
disabled={
|
||||
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||
}
|
||||
onClick={() => {
|
||||
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||
if (cueIndex !== undefined)
|
||||
handleSetCueTime(cueIndex, "start");
|
||||
}}
|
||||
>
|
||||
Set start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end")
|
||||
disabled={
|
||||
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||
}
|
||||
onClick={() => {
|
||||
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||
if (cueIndex !== undefined) handleSetCueTime(cueIndex, "end");
|
||||
}}
|
||||
>
|
||||
Set end
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleNudgeCue(
|
||||
selectedDialogue.subtitleCueIndex,
|
||||
-CUE_NUDGE_SECONDS,
|
||||
)
|
||||
disabled={
|
||||
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||
}
|
||||
onClick={() => {
|
||||
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||
if (cueIndex !== undefined)
|
||||
handleNudgeCue(cueIndex, -CUE_NUDGE_SECONDS);
|
||||
}}
|
||||
>
|
||||
-100ms
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleNudgeCue(
|
||||
selectedDialogue.subtitleCueIndex,
|
||||
CUE_NUDGE_SECONDS,
|
||||
)
|
||||
disabled={
|
||||
getDialogueFirstCueIndex(selectedDialogue) === undefined
|
||||
}
|
||||
onClick={() => {
|
||||
const cueIndex = getDialogueFirstCueIndex(selectedDialogue);
|
||||
if (cueIndex !== undefined)
|
||||
handleNudgeCue(cueIndex, CUE_NUDGE_SECONDS);
|
||||
}}
|
||||
>
|
||||
+100ms
|
||||
</button>
|
||||
@@ -649,9 +670,15 @@ export function EditorSrtPanel(): React.JSX.Element {
|
||||
<button
|
||||
className="editor-srt-jump-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>
|
||||
</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} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{step === "waiting" ? (
|
||||
{step === "waiting" && mission !== "ebike" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
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";
|
||||
|
||||
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 {
|
||||
state: SceneLoadingState;
|
||||
}
|
||||
@@ -15,11 +23,47 @@ export function SceneLoadingOverlay({
|
||||
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="scene-loading-overlay__content">
|
||||
<strong>{state.currentStep}</strong>
|
||||
<img
|
||||
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">
|
||||
<span style={{ width: `${progress}%` }} />
|
||||
<em>{progress}%</em>
|
||||
</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 const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||
|
||||
@@ -14,3 +14,22 @@ export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||
position: [0, 1.5, -3],
|
||||
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",
|
||||
"start-intro",
|
||||
/**
|
||||
* Steps for the /site onboarding page
|
||||
*/
|
||||
export const SITE_STEPS: readonly SiteStep[] = [
|
||||
"welcome",
|
||||
"situation",
|
||||
"naming",
|
||||
"bienvenue",
|
||||
"star-move",
|
||||
"mission2",
|
||||
"searching",
|
||||
"helped",
|
||||
"manipulation",
|
||||
"outOfFabrik",
|
||||
"transition",
|
||||
];
|
||||
|
||||
/**
|
||||
* Steps for the intro sequence (after /site, on / route)
|
||||
*/
|
||||
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[] = [
|
||||
@@ -21,9 +33,14 @@ export const MAIN_GAME_STATES: readonly MainGameState[] = [
|
||||
"outro",
|
||||
] as const;
|
||||
|
||||
const SITE_STEP_VALUES: ReadonlySet<string> = new Set(SITE_STEPS);
|
||||
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
|
||||
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 {
|
||||
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,
|
||||
RepairMissionTriggerConfig,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||
|
||||
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||
Record<RepairMissionId, string>
|
||||
@@ -10,9 +11,7 @@ export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||
pylon: "repair:pylon",
|
||||
};
|
||||
|
||||
const EBIKE_REPAIR_POSITION = [
|
||||
42.2399, 4.5484, 34.6468,
|
||||
] as const satisfies Vector3Tuple;
|
||||
const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
|
||||
|
||||
const REPAIR_MISSION_POSITIONS = {
|
||||
ebike: EBIKE_REPAIR_POSITION,
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
|
||||
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
|
||||
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
|
||||
const REPAIR_INTERACT_UI_PATH = "/assets/world/UI/interagir.webm";
|
||||
const REPAIR_BROKEN_UI_PATH = "/assets/world/UI/cassé.webm";
|
||||
|
||||
const DEFAULT_REPAIR_CASE = {
|
||||
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",
|
||||
modelPath: "/models/ebike/model.gltf",
|
||||
modelScale: 0.3,
|
||||
stageUiPath: "/assets/UI/ebike.webm",
|
||||
stageUiPath: "/assets/world/UI/ebike.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
description:
|
||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
stageUiPath: "/assets/UI/centrale.webm",
|
||||
stageUiPath: "/assets/world/UI/centrale.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
description:
|
||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
stageUiPath: "/assets/UI/laferme.webm",
|
||||
stageUiPath: "/assets/world/UI/laferme.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
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_CAPSULE_RADIUS = 0.35;
|
||||
|
||||
export const PLAYER_WALK_SPEED = 11;
|
||||
export const PLAYER_EBIKE_SPEED = 25;
|
||||
export const PLAYER_WALK_SPEED = 5;
|
||||
export const PLAYER_EBIKE_SPEED = 20;
|
||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||
export const PLAYER_JUMP_SPEED = 9;
|
||||
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_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];
|
||||
|
||||
@@ -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",
|
||||
label: "Gerant",
|
||||
modelPath: "/models/gerant-animated/model.gltf",
|
||||
position: [45.2, 0, 45.5],
|
||||
rotation: [0, -1.55, 0],
|
||||
position: [59.5, 0, 64.64],
|
||||
rotation: [0, 2.41, 0],
|
||||
scale: [1, 1, 1],
|
||||
animations: ["idle", "walk"],
|
||||
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 {
|
||||
octree: Octree | null;
|
||||
gameplayReady: boolean;
|
||||
shouldWarmUpShadows: boolean;
|
||||
showGameStage: boolean;
|
||||
handleGameStageLoaded: () => void;
|
||||
handleGameMapLoaded: () => void;
|
||||
handleOctreeReady: (octree: Octree) => void;
|
||||
handleShadowWarmupReady: () => void;
|
||||
handleShadowWarmupStarted: () => void;
|
||||
}
|
||||
|
||||
export function useWorldSceneLoading({
|
||||
@@ -24,13 +27,19 @@ export function useWorldSceneLoading({
|
||||
const [octree, setOctree] = useState<Octree | null>(null);
|
||||
const [gameMapLoaded, setGameMapLoaded] = useState(false);
|
||||
const [gameStageLoaded, setGameStageLoaded] = useState(false);
|
||||
const [shadowsReady, setShadowsReady] = useState(false);
|
||||
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 =
|
||||
(sceneMode === "game" && gameplayReady) ||
|
||||
(sceneMode === "physics" && octree !== null);
|
||||
|
||||
const handleGameMapLoaded = useCallback(() => {
|
||||
setShadowsReady(false);
|
||||
setGameMapLoaded(true);
|
||||
}, []);
|
||||
|
||||
@@ -45,6 +54,7 @@ export function useWorldSceneLoading({
|
||||
|
||||
const handleOctreeReady = useCallback(
|
||||
(nextOctree: Octree) => {
|
||||
setShadowsReady(false);
|
||||
setOctree(nextOctree);
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Collision prête",
|
||||
@@ -55,6 +65,23 @@ export function useWorldSceneLoading({
|
||||
[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(() => {
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Initialisation du jeu",
|
||||
@@ -88,9 +115,12 @@ export function useWorldSceneLoading({
|
||||
return {
|
||||
octree,
|
||||
gameplayReady,
|
||||
shouldWarmUpShadows,
|
||||
showGameStage,
|
||||
handleGameStageLoaded,
|
||||
handleGameMapLoaded,
|
||||
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");
|
||||
|
||||
@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 */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -34,6 +44,18 @@ select {
|
||||
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 {
|
||||
display: block;
|
||||
}
|
||||
@@ -851,72 +873,301 @@ canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
background: #04070d;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 640ms ease;
|
||||
transition: opacity 500ms ease;
|
||||
}
|
||||
|
||||
.scene-loading-overlay--ready {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scene-loading-overlay__content {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 18px;
|
||||
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__background,
|
||||
.scene-loading-overlay__shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.scene-loading-overlay strong {
|
||||
color: #1e293b;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
.scene-loading-overlay__background {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04);
|
||||
height: clamp(7px, 1vw, 12px);
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.scene-loading-overlay__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #38bdf8);
|
||||
border-radius: inherit;
|
||||
background: #3b82f6;
|
||||
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;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mission-notification::after {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
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 */
|
||||
|
||||
@@ -121,6 +121,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
|
||||
mainState: "ebike",
|
||||
intro: {
|
||||
...state.intro,
|
||||
currentStep: "completed",
|
||||
hasCompleted: true,
|
||||
isEbikeUnlocked: true,
|
||||
},
|
||||
@@ -255,7 +256,7 @@ function createInitialGameState(): GameState {
|
||||
currentSpeed: PLAYER_WALK_SPEED,
|
||||
},
|
||||
intro: {
|
||||
currentStep: "intro",
|
||||
currentStep: "loading-map",
|
||||
dialogueAudio: null,
|
||||
hasCompleted: 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 a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = "/assets/gps/map_background.png";
|
||||
a.download = "map_background.png";
|
||||
a.click();
|
||||
};
|
||||
return () => {
|
||||
|
||||
+80
-6
@@ -1,19 +1,33 @@
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||
import { EbikeIntroSequence } from "@/components/game/EbikeIntroSequence";
|
||||
import { DialogMessage } from "@/components/ui/DialogMessage";
|
||||
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 { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
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(
|
||||
(state) => state.missionFlow.dialogMessage,
|
||||
);
|
||||
@@ -22,6 +36,12 @@ export function HomePage(): React.JSX.Element {
|
||||
INITIAL_SCENE_LOADING_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSiteBeenVisitedToday()) {
|
||||
navigate({ to: "/site", replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
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(
|
||||
({ gl }: { gl: THREE.WebGLRenderer }) => {
|
||||
const canvas = gl.domElement;
|
||||
@@ -58,9 +97,16 @@ export function HomePage(): React.JSX.Element {
|
||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||
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) => {
|
||||
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 = () => {
|
||||
@@ -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 (
|
||||
<HandTrackingProvider>
|
||||
<Canvas
|
||||
@@ -94,8 +166,6 @@ export function HomePage(): React.JSX.Element {
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
<GameUI />
|
||||
<IntroUI />
|
||||
<BienvenueDisplay />
|
||||
{dialogMessage ? (
|
||||
<DialogMessage
|
||||
message={dialogMessage}
|
||||
@@ -103,7 +173,11 @@ export function HomePage(): React.JSX.Element {
|
||||
onClose={hideDialog}
|
||||
/>
|
||||
) : null}
|
||||
<SceneLoadingOverlay state={sceneLoadingState} />
|
||||
{(introStep === "loading-map" || introStep === "fade-to-video") && (
|
||||
<SceneLoadingOverlay state={sceneLoadingState} />
|
||||
)}
|
||||
{renderIntroOverlay()}
|
||||
<EbikeIntroSequence />
|
||||
</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,
|
||||
} from "@tanstack/react-router";
|
||||
import { HomePage } from "@/pages/page";
|
||||
import { SitePage } from "@/pages/site/page";
|
||||
import { EditorPage } from "@/pages/editor/page";
|
||||
import { GalleryPage } from "@/pages/gallery/page";
|
||||
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
||||
@@ -42,6 +43,12 @@ const indexRoute = createRoute({
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
const siteRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/site",
|
||||
component: SitePage,
|
||||
});
|
||||
|
||||
const editorRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/editor",
|
||||
@@ -102,6 +109,7 @@ const docsChildRoutes = [
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
siteRoute,
|
||||
editorRoute,
|
||||
galleryRoute,
|
||||
waypointRoute,
|
||||
|
||||
@@ -13,7 +13,8 @@ export interface DialogueDefinition {
|
||||
id: string;
|
||||
voice: DialogueVoiceId;
|
||||
audio: string;
|
||||
subtitleCueIndex: number;
|
||||
subtitleCueIndex?: number;
|
||||
subtitleCueIndices?: number[];
|
||||
timecode?: number;
|
||||
}
|
||||
|
||||
@@ -22,3 +23,20 @@ export interface DialogueManifest {
|
||||
voices: DialogueVoice[];
|
||||
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;
|
||||
ebikeParkedRotation: number | null;
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
| "intro"
|
||||
| "start-intro"
|
||||
| "naming"
|
||||
| "bienvenue"
|
||||
| "star-move"
|
||||
| "mission2"
|
||||
| "searching"
|
||||
| "helped"
|
||||
| "manipulation"
|
||||
| "outOfFabrik";
|
||||
| "loading-map" // Chargement des assets
|
||||
| "fade-to-video" // Fondu noir entre chargement et vidéo
|
||||
| "video" // Vidéo intro.mp4
|
||||
| "dialogue-intro" // Dialogues post-vidéo (écran noir)
|
||||
| "reveal" // Fondu noir → jeu visible
|
||||
| "await-ebike-mount" // Attente interaction pour monter sur l'e-bike
|
||||
| "ebike-intro-ride" // Courte conduite avant la panne
|
||||
| "ebike-breakdown" // Panne + dialogue avant mission réparation
|
||||
| "completed"; // Intro terminée
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
// Support both subtitleCueIndex (legacy) and subtitleCueIndices (new)
|
||||
const subtitleCueIndex = data.subtitleCueIndex;
|
||||
if (
|
||||
typeof subtitleCueIndex !== "number" ||
|
||||
!Number.isInteger(subtitleCueIndex) ||
|
||||
subtitleCueIndex < 1
|
||||
) {
|
||||
throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`);
|
||||
const subtitleCueIndices = data.subtitleCueIndices;
|
||||
|
||||
const hasLegacyIndex =
|
||||
typeof subtitleCueIndex === "number" &&
|
||||
Number.isInteger(subtitleCueIndex) &&
|
||||
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;
|
||||
@@ -111,9 +124,14 @@ function parseDialogueDefinition(
|
||||
id: data.id,
|
||||
voice: data.voice,
|
||||
audio: data.audio,
|
||||
subtitleCueIndex,
|
||||
};
|
||||
|
||||
if (hasNewIndices) {
|
||||
dialogue.subtitleCueIndices = subtitleCueIndices as number[];
|
||||
} else if (hasLegacyIndex) {
|
||||
dialogue.subtitleCueIndex = subtitleCueIndex;
|
||||
}
|
||||
|
||||
if (timecode !== undefined) dialogue.timecode = timecode;
|
||||
|
||||
return dialogue;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DialogueManifest,
|
||||
DialogueVoice,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import { getDialogueCueIndices } from "@/types/dialogues/dialogues";
|
||||
import type { SubtitleLanguage } from "@/types/settings/settings";
|
||||
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
|
||||
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 DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
||||
|
||||
let manifestCache: DialogueManifest | null = null;
|
||||
let manifestPromise: Promise<DialogueManifest | null> | null = null;
|
||||
|
||||
export interface DialogueSubtitleCue {
|
||||
voice: DialogueVoice;
|
||||
cue: SubtitleCue;
|
||||
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> {
|
||||
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||
if (manifestCache) return manifestCache;
|
||||
if (manifestPromise) return manifestPromise;
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
manifestPromise = (async () => {
|
||||
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(
|
||||
@@ -39,21 +60,40 @@ export async function loadDialogueSubtitleCue(
|
||||
dialogue: DialogueDefinition,
|
||||
language: SubtitleLanguage,
|
||||
): 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);
|
||||
if (!voice) return null;
|
||||
|
||||
const subtitles = await loadVoiceSubtitleCues(voice, language);
|
||||
if (!subtitles) return null;
|
||||
|
||||
const cue = subtitles.cues.find(
|
||||
(item) => item.index === dialogue.subtitleCueIndex,
|
||||
);
|
||||
const cueIndices = getDialogueCueIndices(dialogue);
|
||||
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 {
|
||||
voice,
|
||||
cue,
|
||||
cues,
|
||||
subtitlePath: subtitles.path,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
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 {
|
||||
manifest: DialogueManifest;
|
||||
@@ -15,6 +16,8 @@ const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
|
||||
const dialogueQueue: QueuedDialogueRequest[] = [];
|
||||
let isDialogueQueuePlaying = false;
|
||||
|
||||
let currentDialogueAudio: HTMLAudioElement | null = null;
|
||||
|
||||
export function queueDialogueById(
|
||||
manifest: DialogueManifest,
|
||||
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(
|
||||
manifest: DialogueManifest,
|
||||
dialogueId: string,
|
||||
): Promise<HTMLAudioElement | null> {
|
||||
stopCurrentDialogue();
|
||||
|
||||
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
|
||||
if (!dialogue) return null;
|
||||
|
||||
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
|
||||
const subtitle = await loadDialogueSubtitleCue(
|
||||
const subtitleData = await loadDialogueSubtitleCues(
|
||||
manifest,
|
||||
dialogue,
|
||||
subtitleLanguage,
|
||||
@@ -48,7 +62,11 @@ export async function playDialogueById(
|
||||
category: "dialogue",
|
||||
});
|
||||
|
||||
if (!subtitle) return audio;
|
||||
currentDialogueAudio = audio;
|
||||
|
||||
if (!subtitleData || subtitleData.cues.length === 0) return audio;
|
||||
|
||||
const { voice, cues } = subtitleData;
|
||||
|
||||
const clearSubtitle = (): void => {
|
||||
useSubtitleStore.getState().clearActiveSubtitle();
|
||||
@@ -60,18 +78,28 @@ export async function playDialogueById(
|
||||
audio.removeEventListener("ended", cleanup);
|
||||
audio.removeEventListener("pause", cleanup);
|
||||
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 currentTime = audio.currentTime;
|
||||
const shouldShowSubtitle =
|
||||
currentTime >= subtitle.cue.startTime &&
|
||||
currentTime <= subtitle.cue.endTime;
|
||||
const activeCue = findActiveCue(currentTime);
|
||||
|
||||
if (shouldShowSubtitle) {
|
||||
if (activeCue) {
|
||||
useSubtitleStore.getState().setActiveSubtitle({
|
||||
speaker: subtitle.voice.speaker,
|
||||
text: subtitle.cue.text,
|
||||
speaker: voice.speaker,
|
||||
text: activeCue.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type ModelEntry = [modelName: string, modelUrl: string];
|
||||
|
||||
let cachedSceneData: SceneData | null = null;
|
||||
let loadingPromise: Promise<SceneData | null> | null = null;
|
||||
const modelEntryCache = new Map<string, ModelEntry | null>();
|
||||
|
||||
export async function loadMapSceneData(): Promise<SceneData | null> {
|
||||
if (cachedSceneData) {
|
||||
@@ -223,24 +224,34 @@ async function loadMapModelUrls(
|
||||
}
|
||||
|
||||
async function loadModelEntry(modelName: string): Promise<ModelEntry | null> {
|
||||
for (const fileName of [...MODEL_FILE_NAMES, `${modelName}.gltf`]) {
|
||||
const modelUrl = `/models/${modelName}/${fileName}`;
|
||||
|
||||
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;
|
||||
}
|
||||
if (modelEntryCache.has(modelName)) {
|
||||
return modelEntryCache.get(modelName) ?? null;
|
||||
}
|
||||
|
||||
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 { FogSystem } from "@/world/fog/FogSystem";
|
||||
import { GrassSystem } from "@/world/grass/GrassSystem";
|
||||
import { SceneShadowWarmup } from "@/world/SceneShadowWarmup";
|
||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||
import { WaterSystem } from "@/world/water/WaterSystem";
|
||||
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 groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
@@ -34,6 +47,13 @@ export function Environment(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<FogSystem />
|
||||
{shadowWarmup ? (
|
||||
<SceneShadowWarmup
|
||||
active={shadowWarmup.active}
|
||||
onReady={shadowWarmup.onReady}
|
||||
onStarted={shadowWarmup.onStarted}
|
||||
/>
|
||||
) : null}
|
||||
{showSky ? (
|
||||
<SkyModel
|
||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||
|
||||
@@ -175,7 +175,7 @@ export function GameMap({
|
||||
sceneData.mapNodes.length - visibleMapNodes.length;
|
||||
|
||||
if (skippedMapNodeCount > 0) {
|
||||
logger.warn("GameMap", "Lite map skipped heavy map nodes", {
|
||||
logger.debug("GameMap", "Lite map skipped heavy map nodes", {
|
||||
skippedMapNodeCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
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;
|
||||
|
||||
export function GameMusic(): null {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
|
||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||
|
||||
interface StageAnchorProps {
|
||||
color: string;
|
||||
@@ -81,7 +82,7 @@ export function GameStageContent(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||
<Ebike position={[0, 10, 0]} />
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||
const position = getRepairMissionPosition(mission, anchors);
|
||||
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 { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||
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 { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||
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 { GameCinematics } from "@/world/GameCinematics";
|
||||
import { GameDialogues } from "@/world/GameDialogues";
|
||||
@@ -54,6 +47,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
handleGameStageLoaded,
|
||||
handleGameMapLoaded,
|
||||
handleOctreeReady,
|
||||
handleShadowWarmupReady,
|
||||
handleShadowWarmupStarted,
|
||||
shouldWarmUpShadows,
|
||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||
const playerSpawnPosition =
|
||||
sceneMode === "game"
|
||||
@@ -68,7 +64,13 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Environment />
|
||||
<Environment
|
||||
shadowWarmup={{
|
||||
active: shouldWarmUpShadows,
|
||||
onReady: handleShadowWarmupReady,
|
||||
onStarted: handleShadowWarmupStarted,
|
||||
}}
|
||||
/>
|
||||
<Lighting />
|
||||
<DebugHelpers />
|
||||
{showHandTrackingGloves ? (
|
||||
@@ -80,11 +82,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||
{sceneMode === "game" ? (
|
||||
<>
|
||||
<GameFlow />
|
||||
<ZoneDetection />
|
||||
<ZoneDebugVisuals />
|
||||
<NPCHelper position={[1, 12, -55]} />
|
||||
<PyloneDestroyed position={[1, 15, -45]} />
|
||||
<GameMap
|
||||
onLoaded={handleGameMapLoaded}
|
||||
onLoadingStateChange={onLoadingStateChange}
|
||||
@@ -101,7 +98,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
<>
|
||||
<GameMusic />
|
||||
{mainState === "outro" ? <GameCinematics /> : null}
|
||||
<GameDialogues />
|
||||
{mainState !== "intro" ? <GameDialogues /> : null}
|
||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -275,7 +275,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
height={4}
|
||||
startPos={{ x: 10, y: 0, z: -10 }}
|
||||
destPos={{ x: -40, y: 0, z: 30 }}
|
||||
mapImageUrl="/assets/gps/map_background.png"
|
||||
mapImageUrl="/assets/world/gps/map_background.png"
|
||||
worldBounds={{
|
||||
minX: -166,
|
||||
maxX: 163,
|
||||
|
||||
@@ -29,7 +29,12 @@ import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
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 */
|
||||
interface EbikeGlobalState {
|
||||
@@ -39,6 +44,9 @@ interface EbikeGlobalState {
|
||||
ebikeVisualGroup?: React.RefObject<THREE.Group>;
|
||||
playerPos?: Vector3Tuple;
|
||||
ebikeAngle?: number;
|
||||
ebikeBreakdownActive?: boolean;
|
||||
ebikeDriveInputActive?: boolean;
|
||||
ebikeSpeedFactor?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -156,6 +164,7 @@ export function PlayerController({
|
||||
const movementModeRef = useRef(movementMode);
|
||||
const prevMovementModeRef = useRef(movementMode);
|
||||
const ebikeAngle = useRef(0);
|
||||
const ebikeSpeedFactor = useRef(0);
|
||||
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -175,6 +184,7 @@ export function PlayerController({
|
||||
velocity.current.set(0, 0, 0);
|
||||
onFloor.current = false;
|
||||
wantsJump.current = false;
|
||||
ebikeSpeedFactor.current = 0;
|
||||
|
||||
ebikeAngle.current = targetRot;
|
||||
|
||||
@@ -215,6 +225,7 @@ export function PlayerController({
|
||||
const shift = rightDir.multiplyScalar(3);
|
||||
capsule.current.translate(shift);
|
||||
camera.position.copy(capsule.current.end);
|
||||
ebikeSpeedFactor.current = 0;
|
||||
}
|
||||
prevMovementModeRef.current = movementMode;
|
||||
}, [movementMode, camera]);
|
||||
@@ -347,7 +358,10 @@ export function PlayerController({
|
||||
return;
|
||||
}
|
||||
|
||||
if (movementModeRef.current === "ebike") {
|
||||
const isEbikeMounted = movementModeRef.current === "ebike";
|
||||
const isEbikeBreakdown = window.ebikeBreakdownActive === true;
|
||||
|
||||
if (isEbikeMounted && !isEbikeBreakdown) {
|
||||
const turnSpeed = 1.8;
|
||||
if (keys.current.left) {
|
||||
ebikeAngle.current += turnSpeed * dt;
|
||||
@@ -365,19 +379,41 @@ export function PlayerController({
|
||||
}
|
||||
|
||||
_wishDir.set(0, 0, 0);
|
||||
if (!movementLocked) {
|
||||
if (!movementLocked && !isEbikeBreakdown) {
|
||||
if (keys.current.forward) _wishDir.add(_forward);
|
||||
if (keys.current.backward) _wishDir.sub(_forward);
|
||||
if (movementModeRef.current !== "ebike") {
|
||||
if (!isEbikeMounted) {
|
||||
if (keys.current.left) _wishDir.sub(_right);
|
||||
if (keys.current.right) _wishDir.add(_right);
|
||||
}
|
||||
}
|
||||
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
|
||||
? currentSpeed
|
||||
: currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
|
||||
? movementSpeed
|
||||
: movementSpeed * PLAYER_AIR_CONTROL_FACTOR;
|
||||
velocity.current.x +=
|
||||
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
||||
velocity.current.z +=
|
||||
@@ -387,6 +423,18 @@ export function PlayerController({
|
||||
velocity.current.x *= 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) {
|
||||
velocity.current.y = Math.max(0, velocity.current.y);
|
||||
if (wantsJump.current) {
|
||||
|
||||
Reference in New Issue
Block a user