Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a397febd52 | |||
| c15cad2ab0 | |||
| 011e7815a2 | |||
| 970253801a | |||
| 246da0019a | |||
| 09a9471814 | |||
| 6e9318457a | |||
| 54a353de03 | |||
| 4faa226326 | |||
| dd66966507 | |||
| 5893afe42a | |||
| 1ead7ab3a7 | |||
| 047c58678b | |||
| ed9051b0dc | |||
| 08be6bee48 | |||
| ce0eb90321 | |||
| 96d7ec7fc0 | |||
| 9ab4b4a002 | |||
| d13dd0fda0 | |||
| fbedb90bca | |||
| cff7744ad9 |
@@ -1,226 +0,0 @@
|
|||||||
# Game States & Substates
|
|
||||||
|
|
||||||
Documentation technique pour le testing et debugging du flow de jeu.
|
|
||||||
|
|
||||||
## Vue d'ensemble
|
|
||||||
|
|
||||||
```
|
|
||||||
intro ──► bike ──► pylone ──► ferme ──► outro
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Main States
|
|
||||||
|
|
||||||
| State | Description |
|
|
||||||
| -------- | ------------------------------------------------------------------ |
|
|
||||||
| `intro` | Séquence d'introduction (cinématique, naming, premier déplacement) |
|
|
||||||
| `bike` | Mission de réparation du vélo |
|
|
||||||
| `pylone` | Quête du pylone (alert → searching → helped → manipulation) |
|
|
||||||
| `ferme` | Mission de réparation de la ferme |
|
|
||||||
| `outro` | Fin du jeu |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Intro (GameStep)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ intro.currentStep │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
intro ──► sequence_video ──► naming ──► start-move ──► bike
|
|
||||||
```
|
|
||||||
|
|
||||||
### Étapes
|
|
||||||
|
|
||||||
| Step | Trigger | Action | Passage vers |
|
|
||||||
| ---------------- | -------- | ---------------------------------------------- | ------------------------------------------------ |
|
|
||||||
| `intro` | Initial | État initial | Auto → `sequence_video` quand `sceneReady: true` |
|
|
||||||
| `sequence_video` | GameFlow | Déclenche la cinématique dans `GameCinematics` | Fin cinématique → `naming` |
|
|
||||||
| `naming` | IntroUI | Affiche l'input pour le prénom | Submit → `start-move` |
|
|
||||||
| `start-move` | GameFlow | Active le mouvement (`canMove: true`) | Via zone "fabrikExit" → `bike` |
|
|
||||||
|
|
||||||
### Transition vers bike
|
|
||||||
|
|
||||||
- **Trigger**: Zone `fabrikExit` dans `zones.ts`
|
|
||||||
- **Action**: `advanceGameState()` dans `ZoneDetection.tsx`
|
|
||||||
- **Résultat**: `mainState: "bike"`, `intro.hasCompleted: true`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bike (MissionStep)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ bike.currentStep (MissionStep) │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
locked ──► waiting ──► inspected ──► fragmented ──► scanning ──► repairing ──► reassembling ──► done
|
|
||||||
```
|
|
||||||
|
|
||||||
### Transition vers pylone
|
|
||||||
|
|
||||||
- **Trigger**: `bike.currentStep: "done"`
|
|
||||||
- **Action**: `completeBikeState()` dans useGameStore
|
|
||||||
- **Résultat**: `mainState: "pylone"`, `pylone.currentStep: "locked"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pylone (PyloneStep)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ pylone.currentStep (PyloneStep) │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
locked (bypass) ──► alert ──► searching ──► helped ──► manipulation ──► outro
|
|
||||||
```
|
|
||||||
|
|
||||||
### Étapes
|
|
||||||
|
|
||||||
| Step | Trigger | Action | Passage vers |
|
|
||||||
| -------------- | ---------------------------------- | ---------------------------------- | --------------------------------------------------------- |
|
|
||||||
| `locked` | Initial (après bike) | État initial | **Bypass automatique** → `alert` via `advanceGameState()` |
|
|
||||||
| `alert` | `advanceGameState()` | Affiche l'alerte (à implémenter) | Via `advanceGameState()` |
|
|
||||||
| `searching` | `advanceGameState()` | Déclenché par zone "searchingZone" | Via `advanceGameState()` |
|
|
||||||
| `helped` | Interaction avec `NPCHelper` | Dialogue avec le villageois | Via interaction 3D |
|
|
||||||
| `manipulation` | Interaction avec `PyloneDestroyed` | Interaction avec l'objet central | Via `advanceGameState()` → `outro` |
|
|
||||||
|
|
||||||
### Bypass automatique
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// useGameStore.ts - advancePyloneStep()
|
|
||||||
if (state.pylone.currentStep === "locked") {
|
|
||||||
return { pylone: { ...state.pylone, currentStep: "alert" } };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Transition vers outro
|
|
||||||
|
|
||||||
- **Trigger**: `pylone.currentStep: "manipulation"` + `advanceGameState()`
|
|
||||||
- **Action**: `advancePyloneStep()` détecte fin de la séquence
|
|
||||||
- **Résultat**: `mainState: "outro"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ferme (MissionStep)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ ferme.currentStep (MissionStep) │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
locked ──► waiting ──► inspected ──► fragmented ──► scanning ──► repairing ──► reassembling ──► done
|
|
||||||
```
|
|
||||||
|
|
||||||
### Transition vers outro
|
|
||||||
|
|
||||||
- **Trigger**: `ferme.currentStep: "done"`
|
|
||||||
- **Action**: `completeFermeState()` dans useGameStore
|
|
||||||
- **Résultat**: `mainState: "outro"`, `ferme.irrigationFixed: true`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Outro
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ outro.hasStarted │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
waiting ──► started
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Debug Panel
|
|
||||||
|
|
||||||
Le debug panel permet de tester toutes les transitions :
|
|
||||||
|
|
||||||
### Utilisation
|
|
||||||
|
|
||||||
1. Ouvrir le jeu en mode debug (`Debug: true` dans `Debug.ts`)
|
|
||||||
2. Le panneau "Game State" apparaît en bas à gauche
|
|
||||||
3. **Main state**: Sélectionner le state principal
|
|
||||||
4. **Sub state**: Sélectionner le sub-state
|
|
||||||
5. **Previous/Next step**: Avancer ou reculer d'un step
|
|
||||||
6. **Reset**: Remettre à l'état initial
|
|
||||||
|
|
||||||
### Raccourcis clavier
|
|
||||||
|
|
||||||
| Action | Clavier |
|
|
||||||
| ------- | ------------------ |
|
|
||||||
| Avancer | Debug panel button |
|
|
||||||
| Reculer | Debug panel button |
|
|
||||||
| Reset | Debug panel button |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comment tester chaque section
|
|
||||||
|
|
||||||
### Tester l'intro
|
|
||||||
|
|
||||||
1. Vérifier que `sceneReady: false` au démarrage
|
|
||||||
2. Attendre que le loader termine (`sceneReady: true`)
|
|
||||||
3. Vérifier `intro.currentStep: "intro"` → auto vers `sequence_video`
|
|
||||||
4. Si cinématique fonctionne : `sequence_video` → `naming`
|
|
||||||
5. Entrer un prénom : `naming` → `start-move`
|
|
||||||
6. Vérifier `canMove: true` après `start-move`
|
|
||||||
7. Entrer dans la zone `fabrikExit` → `mainState: "bike"`
|
|
||||||
|
|
||||||
### Tester bike
|
|
||||||
|
|
||||||
1. Via debug panel, avancer jusqu'à `done`
|
|
||||||
2. Vérifier `mainState: "pylone"`
|
|
||||||
|
|
||||||
### Tester pylone
|
|
||||||
|
|
||||||
1. Via debug panel, avancer (bypass `locked` → `alert`)
|
|
||||||
2. Vérifier `pylone.currentStep: "alert"`
|
|
||||||
3. Avancer : `alert` → `searching` → `helped` → `manipulation`
|
|
||||||
4. Après `manipulation`, vérifier `mainState: "outro"`
|
|
||||||
|
|
||||||
### Tester ferme
|
|
||||||
|
|
||||||
1. Via debug panel, avancer dans bike jusqu'à `done`
|
|
||||||
2. Vérifier `mainState: "pylone"` → `ferme` (après pylone)
|
|
||||||
3. Avancer jusqu'à `done`
|
|
||||||
4. Vérifier `mainState: "outro"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fichiers clés
|
|
||||||
|
|
||||||
| Fichier | Rôle |
|
|
||||||
| ------------------------------------------------- | ------------------------------------- |
|
|
||||||
| `src/managers/stores/useGameStore.ts` | Store Zustand avec toutes les actions |
|
|
||||||
| `src/types/game.ts` | Définition de `GameStep` |
|
|
||||||
| `src/types/gameplay/pylone.ts` | Définition de `PyloneStep` |
|
|
||||||
| `src/types/gameplay/repairMission.ts` | Définition de `MissionStep` |
|
|
||||||
| `src/components/game/GameFlow.tsx` | Logique de transition de l'intro |
|
|
||||||
| `src/components/zone/ZoneDetection.tsx` | Déclenchement des zones |
|
|
||||||
| `src/components/ui/debug/GameStateDebugPanel.tsx` | Outil de debug |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## État initial
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
mainState: "intro",
|
|
||||||
isCinematicPlaying: false,
|
|
||||||
sceneReady: false,
|
|
||||||
missionFlow: {
|
|
||||||
activityCity: true,
|
|
||||||
canMove: false,
|
|
||||||
dialogMessage: null,
|
|
||||||
playerName: "",
|
|
||||||
},
|
|
||||||
intro: { currentStep: "intro", dialogueAudio: null, hasCompleted: false, isBikeUnlocked: false },
|
|
||||||
bike: { currentStep: "locked", dialogueAudio: null, isRepaired: false },
|
|
||||||
pylone: { currentStep: "locked", dialogueAudio: null, isPowered: false },
|
|
||||||
ferme: { currentStep: "locked", dialogueAudio: null, irrigationFixed: false },
|
|
||||||
outro: { dialogueAudio: null, hasStarted: false },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,21 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cinematics": [
|
"cinematics": [
|
||||||
{
|
|
||||||
"id": "intro_sequence",
|
|
||||||
"trigger": "intro_sequence",
|
|
||||||
"cameraKeyframes": [
|
|
||||||
{ "time": 0, "position": [8, 5, 12], "target": [0, 2, 0] },
|
|
||||||
{ "time": 8, "position": [12, 4, -6], "target": [10, 1.4, -8] },
|
|
||||||
{ "time": 16, "position": [5, 6, -15], "target": [0, 3, -20] },
|
|
||||||
{ "time": 24, "position": [0, 8, -30], "target": [0, 0, -40] }
|
|
||||||
],
|
|
||||||
"dialogueCues": [
|
|
||||||
{ "time": 0, "dialogueId": "intro_welcome" },
|
|
||||||
{ "time": 8, "dialogueId": "intro_explanation" },
|
|
||||||
{ "time": 16, "dialogueId": "intro_mission" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "intro_overview",
|
"id": "intro_overview",
|
||||||
"timecode": 0,
|
"timecode": 0,
|
||||||
|
|||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
|||||||
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||||
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
|
||||||
|
|
||||||
|
export interface CameraTransform {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
rotation: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||||
|
position: [-3.5, 6, 0],
|
||||||
|
rotation: [-10, -90, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||||
|
position: [0, 1.5, -3],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EbikeProps {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
|
||||||
|
scope: "Ebike",
|
||||||
|
position: position,
|
||||||
|
});
|
||||||
|
const model = useClonedObject(scene);
|
||||||
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
|
// Map active mainState to target repair zone coordinate
|
||||||
|
const destPos = useMemo(() => {
|
||||||
|
switch (mainState) {
|
||||||
|
case "bike":
|
||||||
|
return { x: 8, y: 0, z: -6 };
|
||||||
|
case "pylone":
|
||||||
|
return { x: 64, y: 0, z: -66 };
|
||||||
|
case "ferme":
|
||||||
|
return { x: -24, y: 0, z: 42 };
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [mainState]);
|
||||||
|
|
||||||
|
// Throttled GPS start position to optimize pathfinding A* algorithm execution
|
||||||
|
const [gpsStartPos, setGpsStartPos] = useState<{ x: number; y: number; z: number }>({
|
||||||
|
x: position[0],
|
||||||
|
y: position[1],
|
||||||
|
z: position[2],
|
||||||
|
});
|
||||||
|
const lastGpsUpdatePos = useRef<THREE.Vector3>(new THREE.Vector3(...position));
|
||||||
|
|
||||||
|
const restingPosition = useRef<Vector3Tuple>([
|
||||||
|
position[0],
|
||||||
|
position[1] - PLAYER_EYE_HEIGHT,
|
||||||
|
position[2],
|
||||||
|
]);
|
||||||
|
const restingRotation = useRef<number>(0);
|
||||||
|
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (model) {
|
||||||
|
const fork = model.getObjectByName("fourche");
|
||||||
|
if (fork) {
|
||||||
|
forkRef.current = fork;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).ebikeVisualGroup = groupRef;
|
||||||
|
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||||
|
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||||
|
return () => {
|
||||||
|
(window as any).ebikeVisualGroup = null;
|
||||||
|
(window as any).ebikeParkedPosition = null;
|
||||||
|
(window as any).ebikeParkedRotation = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
if (movementMode === "ebike") {
|
||||||
|
restingPosition.current = [
|
||||||
|
groupRef.current.position.x,
|
||||||
|
groupRef.current.position.y,
|
||||||
|
groupRef.current.position.z,
|
||||||
|
];
|
||||||
|
restingRotation.current = groupRef.current.rotation.y;
|
||||||
|
|
||||||
|
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
|
||||||
|
const steerFactor = (window as any).ebikeSteerFactor || 0;
|
||||||
|
if (forkRef.current) {
|
||||||
|
// 15 degrees is 0.26 radians
|
||||||
|
const targetForkRotation = steerFactor * 0.26;
|
||||||
|
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||||
|
forkRef.current.rotation.z,
|
||||||
|
targetForkRotation,
|
||||||
|
12 * delta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttled GPS start position update to prevent performance loss
|
||||||
|
const currentPos = groupRef.current.position;
|
||||||
|
if (currentPos.distanceTo(lastGpsUpdatePos.current) > 2.0) {
|
||||||
|
lastGpsUpdatePos.current.copy(currentPos);
|
||||||
|
setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
groupRef.current.position.set(...restingPosition.current);
|
||||||
|
groupRef.current.rotation.set(0, restingRotation.current, 0);
|
||||||
|
|
||||||
|
// Reset fork rotation when parked
|
||||||
|
if (forkRef.current) {
|
||||||
|
forkRef.current.rotation.z = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||||
|
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const camPointPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0],
|
||||||
|
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1],
|
||||||
|
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
const dropPointPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||||
|
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
|
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleInteract = (): void => {
|
||||||
|
if (movementMode === "walk") {
|
||||||
|
const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position);
|
||||||
|
cameraOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), restingRotation.current);
|
||||||
|
|
||||||
|
const targetCamPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + cameraOffset.x,
|
||||||
|
restingPosition.current[1] + cameraOffset.y,
|
||||||
|
restingPosition.current[2] + cameraOffset.z,
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetRotation: Vector3Tuple = [
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[1] + THREE.MathUtils.radToDeg(restingRotation.current),
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||||
|
useGameStore.getState().setPlayerMovementMode("ebike");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const currentPos = new THREE.Vector3();
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.getWorldPosition(currentPos);
|
||||||
|
} else {
|
||||||
|
currentPos.set(...position);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCamPos: Vector3Tuple = [
|
||||||
|
currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||||
|
currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
|
currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get camera's current rotation in degrees so we keep the exact orientation during dismount
|
||||||
|
const currentEuler = new THREE.Euler().setFromQuaternion(camera.quaternion, "YXZ");
|
||||||
|
const targetRotation: Vector3Tuple = [
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.x),
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.y),
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.z),
|
||||||
|
];
|
||||||
|
|
||||||
|
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||||
|
useGameStore.getState().setPlayerMovementMode("walk");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInteractRef = useRef(handleInteract);
|
||||||
|
handleInteractRef.current = handleInteract;
|
||||||
|
|
||||||
|
const debugRef = useRef({ showCameraPoints: true });
|
||||||
|
const debugActions = useRef({
|
||||||
|
toggleRide: () => {
|
||||||
|
handleInteractRef.current();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useDebugFolder("Ebike", (folder) => {
|
||||||
|
folder
|
||||||
|
.add(debugRef.current, "showCameraPoints")
|
||||||
|
.name("Show Camera Points")
|
||||||
|
.onChange((value: boolean) => {
|
||||||
|
debugRef.current.showCameraPoints = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
folder
|
||||||
|
.add(debugActions.current, "toggleRide")
|
||||||
|
.name("Monter / Descendre");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<group ref={groupRef} position={position}>
|
||||||
|
<primitive object={model} />
|
||||||
|
<InteractableObject
|
||||||
|
kind="trigger"
|
||||||
|
label={
|
||||||
|
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
|
||||||
|
}
|
||||||
|
position={position}
|
||||||
|
radius={15}
|
||||||
|
onPress={handleInteract}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[10, 13, 2]} />
|
||||||
|
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</InteractableObject>
|
||||||
|
|
||||||
|
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||||
|
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||||
|
<EbikeGPSMap
|
||||||
|
width={0.8}
|
||||||
|
height={0.8}
|
||||||
|
startPos={gpsStartPos}
|
||||||
|
destPos={destPos}
|
||||||
|
mapImageUrl="/map_background.png"
|
||||||
|
worldBounds={{
|
||||||
|
minX: -166,
|
||||||
|
maxX: 163,
|
||||||
|
minZ: -142,
|
||||||
|
maxZ: 138,
|
||||||
|
}}
|
||||||
|
zoom={4}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{debugRef.current.showCameraPoints && (
|
||||||
|
<>
|
||||||
|
<mesh position={camPointPos}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="yellow"
|
||||||
|
emissive="yellow"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh position={dropPointPos}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="cyan"
|
||||||
|
emissive="cyan"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { findClosestWaypoint, findWaypointPath } from '@/pathfinding/WaypointAStar';
|
||||||
|
import type { Waypoint } from '@/pathfinding/types';
|
||||||
|
function computeImageSource(
|
||||||
|
img: HTMLImageElement | HTMLCanvasElement,
|
||||||
|
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||||
|
bounds: { minX: number; maxX: number; minZ: number; maxZ: number }
|
||||||
|
) {
|
||||||
|
const imgW = img.width;
|
||||||
|
const imgH = img.height;
|
||||||
|
|
||||||
|
const baseW = baseBounds.maxX - baseBounds.minX;
|
||||||
|
const baseH = baseBounds.maxZ - baseBounds.minZ;
|
||||||
|
|
||||||
|
if (baseW === 0 || baseH === 0) {
|
||||||
|
return { sx: 0, sy: 0, sW: imgW, sH: imgH };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sx = ((bounds.minX - baseBounds.minX) / baseW) * imgW;
|
||||||
|
const sy = ((bounds.minZ - baseBounds.minZ) / baseH) * imgH;
|
||||||
|
const sW = ((bounds.maxX - bounds.minX) / baseW) * imgW;
|
||||||
|
const sH = ((bounds.maxZ - bounds.minZ) / baseH) * imgH;
|
||||||
|
|
||||||
|
return { sx, sy, sW, sH };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EbikeGPSMapProps {
|
||||||
|
/**
|
||||||
|
* 3D world position of the player/bike (GPS start point)
|
||||||
|
* If omitted, snaps to [0,0,0]
|
||||||
|
*/
|
||||||
|
startPos?: { x: number; y: number; z: number } | undefined;
|
||||||
|
destPos?: { x: number; y: number; z: number } | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional custom URL to the map background texture.
|
||||||
|
* If not provided, renders a high-tech minimalist neon blueprint map dynamically.
|
||||||
|
*/
|
||||||
|
mapImageUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional explicit bounds for mapping coordinates.
|
||||||
|
* If omitted, bounds are calculated automatically to perfectly fit the road network!
|
||||||
|
*/
|
||||||
|
worldBounds?: {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minZ: number;
|
||||||
|
maxZ: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the 3D plane mesh (default: 1)
|
||||||
|
*/
|
||||||
|
width?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of the 3D plane mesh (default: 1)
|
||||||
|
*/
|
||||||
|
height?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional world position for the GPS screen (defaults to origin)
|
||||||
|
*/
|
||||||
|
position?: [number, number, number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolution of the offscreen canvas used for the map texture.
|
||||||
|
* Higher values yield sharper rendering at the cost of GPU memory.
|
||||||
|
* Default: 1024 (1024×1024 px)
|
||||||
|
*/
|
||||||
|
canvasSize?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom level applied to the map view.
|
||||||
|
* 1 = full world bounds, 2 = 2× zoom-in centred on the player, etc.
|
||||||
|
* Values < 1 zoom out beyond the calculated world bounds.
|
||||||
|
* Default: 1
|
||||||
|
*/
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EbikeGPSMap
|
||||||
|
* A premium, state-of-the-art 3D GPS navigation screen for the Ebike.
|
||||||
|
* Loads the road network, runs A* pathfinding, and renders a glowing, animated
|
||||||
|
* orange path over a sleek high-tech map background.
|
||||||
|
*/
|
||||||
|
export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||||
|
startPos = { x: 0, y: 0, z: 0 },
|
||||||
|
destPos,
|
||||||
|
mapImageUrl,
|
||||||
|
worldBounds,
|
||||||
|
width = 1,
|
||||||
|
height = 1,
|
||||||
|
position = [0, 0, 0],
|
||||||
|
canvasSize = 1024,
|
||||||
|
zoom = 1,
|
||||||
|
}) => {
|
||||||
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
|
const [mapImage, setMapImage] = useState<HTMLImageElement | HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
// Offscreen high-res canvas for crystal clear rendering
|
||||||
|
const [offscreenCanvas] = useState(() => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = canvasSize;
|
||||||
|
canvas.height = canvasSize;
|
||||||
|
return canvas;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize the canvas whenever canvasSize changes
|
||||||
|
useEffect(() => {
|
||||||
|
offscreenCanvas.width = canvasSize;
|
||||||
|
offscreenCanvas.height = canvasSize;
|
||||||
|
if (textureRef.current) {
|
||||||
|
textureRef.current.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [canvasSize, offscreenCanvas]);
|
||||||
|
|
||||||
|
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
||||||
|
const animTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Load waypoints (localStorage with /roadNetwork.json fallback)
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('la-fabrik-waypoints');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
setWaypoints(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[GPS Component] Error loading local storage waypoints', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to static roadNetwork.json
|
||||||
|
fetch('/roadNetwork.json')
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
throw new Error('Not found');
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setWaypoints(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('[GPS Component] No default road network found.', err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pre-load background map image (standard HTML5 Image loader)
|
||||||
|
// Since the user's PNG is already transparent, we don't need fetch or pixel manipulation!
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapImageUrl) {
|
||||||
|
setMapImage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setMapImage(img);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.warn(`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`);
|
||||||
|
setMapImage(null);
|
||||||
|
};
|
||||||
|
img.src = mapImageUrl;
|
||||||
|
}, [mapImageUrl]);
|
||||||
|
|
||||||
|
// Determine grid boundaries (before zoom)
|
||||||
|
const baseBounds = useMemo(() => {
|
||||||
|
if (worldBounds) return worldBounds;
|
||||||
|
|
||||||
|
if (waypoints.length === 0) {
|
||||||
|
return { minX: -200, maxX: 200, minZ: -200, maxZ: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const xs = waypoints.map((w) => w.x);
|
||||||
|
const zs = waypoints.map((w) => w.z);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const minZ = Math.min(...zs);
|
||||||
|
const maxZ = Math.max(...zs);
|
||||||
|
|
||||||
|
// Padding (15% to ensure full view breathing room)
|
||||||
|
const padX = (maxX - minX) * 0.15 || 40;
|
||||||
|
const padZ = (maxZ - minZ) * 0.15 || 40;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: minX - padX,
|
||||||
|
maxX: maxX + padX,
|
||||||
|
minZ: minZ - padZ,
|
||||||
|
maxZ: maxZ + padZ,
|
||||||
|
};
|
||||||
|
}, [waypoints, worldBounds]);
|
||||||
|
|
||||||
|
// Apply zoom: shrink the view window around the player position
|
||||||
|
const bounds = useMemo(() => {
|
||||||
|
const clampedZoom = Math.max(0.1, zoom);
|
||||||
|
if (clampedZoom === 1) return baseBounds;
|
||||||
|
|
||||||
|
const centerX = startPos.x;
|
||||||
|
const centerZ = startPos.z;
|
||||||
|
const halfW = (baseBounds.maxX - baseBounds.minX) / 2 / clampedZoom;
|
||||||
|
const halfH = (baseBounds.maxZ - baseBounds.minZ) / 2 / clampedZoom;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: centerX - halfW,
|
||||||
|
maxX: centerX + halfW,
|
||||||
|
minZ: centerZ - halfH,
|
||||||
|
maxZ: centerZ + halfH,
|
||||||
|
};
|
||||||
|
}, [baseBounds, zoom, startPos]);
|
||||||
|
|
||||||
|
// Snapped positions
|
||||||
|
const startPosSnapped = useMemo(() => {
|
||||||
|
if (waypoints.length === 0) return null;
|
||||||
|
return findClosestWaypoint(waypoints, startPos);
|
||||||
|
}, [waypoints, startPos]);
|
||||||
|
|
||||||
|
const destPosSnapped = useMemo(() => {
|
||||||
|
if (!destPos || waypoints.length === 0) return null;
|
||||||
|
return findClosestWaypoint(waypoints, destPos);
|
||||||
|
}, [waypoints, destPos]);
|
||||||
|
|
||||||
|
// Calculated active A* route
|
||||||
|
const activePath = useMemo(() => {
|
||||||
|
if (!startPosSnapped || !destPosSnapped || waypoints.length === 0) return [];
|
||||||
|
return findWaypointPath(waypoints, startPosSnapped, destPosSnapped);
|
||||||
|
}, [waypoints, startPosSnapped, destPosSnapped]);
|
||||||
|
|
||||||
|
// Translation helper: 3D world to Canvas pixels
|
||||||
|
const worldToCanvas = (wx: number, wz: number, canvasSize: number) => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = bounds;
|
||||||
|
const px = ((wx - minX) / (maxX - minX)) * canvasSize;
|
||||||
|
const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize;
|
||||||
|
return { x: px, y: py };
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Draw loop
|
||||||
|
const draw = () => {
|
||||||
|
const canvas = offscreenCanvas;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true, alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const size = canvas.width;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// 1. Draw Map Background (Image or premium blueprint vectors)
|
||||||
|
if (mapImage) {
|
||||||
|
const src = computeImageSource(mapImage, baseBounds, bounds);
|
||||||
|
const sx = Math.max(0, Math.min(mapImage.width, src.sx));
|
||||||
|
const sy = Math.max(0, Math.min(mapImage.height, src.sy));
|
||||||
|
const sW = Math.max(1, Math.min(mapImage.width - sx, src.sW));
|
||||||
|
const sH = Math.max(1, Math.min(mapImage.height - sy, src.sH));
|
||||||
|
|
||||||
|
ctx.drawImage(mapImage, sx, sy, sW, sH, 0, 0, size, size);
|
||||||
|
ctx.globalAlpha = 1.0;
|
||||||
|
} else {
|
||||||
|
// Dynamic Sci-fi background grid (Background is transparent!)
|
||||||
|
|
||||||
|
// Sci-fi subgrid
|
||||||
|
ctx.strokeStyle = 'rgba(30, 41, 59, 0.4)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const step = size / 32;
|
||||||
|
for (let x = 0; x < size; x += step) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, size);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 0; y < size; y += step) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(size, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aesthetic concentric radar topo-rings
|
||||||
|
ctx.strokeStyle = 'rgba(71, 85, 105, 0.06)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let r = size / 6; r < size; r += size / 6) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(size / 2, size / 2, r, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faint diagonal technical accents
|
||||||
|
ctx.strokeStyle = 'rgba(56, 189, 248, 0.03)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(size, size);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(size, 0);
|
||||||
|
ctx.lineTo(0, size);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Draw Active Orange Glowing Path (Neon Highway effect)
|
||||||
|
if (activePath.length > 1) {
|
||||||
|
// Pass 1: Wide transparent orange bloom
|
||||||
|
ctx.beginPath();
|
||||||
|
let pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = 'rgba(249, 115, 22, 0.2)'; // Faint bright orange
|
||||||
|
ctx.lineWidth = 20;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.shadowBlur = 30;
|
||||||
|
ctx.shadowColor = '#f97316'; // Neon Orange
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pass 2: Saturated glow core
|
||||||
|
ctx.beginPath();
|
||||||
|
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#f97316'; // Vibrant orange
|
||||||
|
ctx.lineWidth = 8;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.shadowColor = '#ea580c';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pass 3: High-intensity white core
|
||||||
|
ctx.beginPath();
|
||||||
|
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#fff7ed'; // Cream white
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.shadowBlur = 0; // Turn off shadows for the core
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 3. Energy Particle Pulse animation tracing the road
|
||||||
|
const segments: { start: { x: number; y: number }; end: { x: number; y: number }; len: number }[] = [];
|
||||||
|
let totalLen = 0;
|
||||||
|
for (let i = 0; i < activePath.length - 1; i++) {
|
||||||
|
const p1 = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
const p2 = worldToCanvas(activePath[i + 1]!.x, activePath[i + 1]!.z, size);
|
||||||
|
const len = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
|
||||||
|
segments.push({ start: p1, end: p2, len });
|
||||||
|
totalLen += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalLen > 0) {
|
||||||
|
const targetLen = totalLen * animTimeRef.current;
|
||||||
|
let currentLen = 0;
|
||||||
|
let dotPt = segments[0]!.start;
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (currentLen + seg.len >= targetLen) {
|
||||||
|
const ratio = (targetLen - currentLen) / seg.len;
|
||||||
|
dotPt = {
|
||||||
|
x: seg.start.x + (seg.end.x - seg.start.x) * ratio,
|
||||||
|
y: seg.start.y + (seg.end.y - seg.start.y) * ratio,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentLen += seg.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw multiple glowing pulses along the path
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(dotPt.x, dotPt.y, 8, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.shadowBlur = 15;
|
||||||
|
ctx.shadowColor = '#f97316';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Snap Markers (Start and End)
|
||||||
|
if (destPosSnapped) {
|
||||||
|
const pt = worldToCanvas(destPosSnapped.x, destPosSnapped.z, size);
|
||||||
|
const pulseSize = 12 + Math.sin(Date.now() * 0.007) * 4;
|
||||||
|
|
||||||
|
// Pulse ring
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, pulseSize, 0, 2 * Math.PI);
|
||||||
|
ctx.strokeStyle = 'rgba(249, 115, 22, 0.4)';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Solid target core
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 6, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = '#ea580c'; // Deep target orange
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startPosSnapped) {
|
||||||
|
const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size);
|
||||||
|
|
||||||
|
// Start Marker (Player Arrow/Dot)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = '#0ea5e9'; // Cool cyberpunk sky blue
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Tech details
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 3, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update WebGL Texture
|
||||||
|
if (textureRef.current) {
|
||||||
|
textureRef.current.needsUpdate = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 60 FPS animation ticker
|
||||||
|
useEffect(() => {
|
||||||
|
let animId: number;
|
||||||
|
const tick = () => {
|
||||||
|
animTimeRef.current += 0.004; // Slow, premium sweep speed
|
||||||
|
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
||||||
|
|
||||||
|
draw();
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
animId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(animId);
|
||||||
|
}, [waypoints, startPos, destPos, bounds, mapImage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh castShadow receiveShadow position={position as any}>
|
||||||
|
<planeGeometry args={[width, height]} />
|
||||||
|
<meshBasicMaterial toneMapped={false} transparent={true} opacity={1} depthWrite={false} side={THREE.DoubleSide}>
|
||||||
|
<canvasTexture
|
||||||
|
ref={textureRef}
|
||||||
|
attach="map"
|
||||||
|
image={offscreenCanvas}
|
||||||
|
format={THREE.RGBAFormat}
|
||||||
|
minFilter={THREE.LinearFilter}
|
||||||
|
magFilter={THREE.LinearFilter}
|
||||||
|
/>
|
||||||
|
</meshBasicMaterial>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,36 +1,64 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { AUDIO_PATHS } from "@/data/audioConfig";
|
||||||
|
|
||||||
export function GameFlow(): null {
|
export function GameFlow(): null {
|
||||||
const step = useGameStore((state) => state.intro.currentStep);
|
const step = useGameStore((state) => state.intro.currentStep);
|
||||||
const setStep = useGameStore((state) => state.setIntroStep);
|
const setStep = useGameStore((state) => state.setIntroStep);
|
||||||
const playVideo = useGameStore((state) => state.playVideo);
|
const setActivityCity = useGameStore((state) => state.setActivityCity);
|
||||||
const isCinematicPlaying = useGameStore((state) => state.isCinematicPlaying);
|
|
||||||
const sceneReady = useGameStore((state) => state.sceneReady);
|
|
||||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasInitialized.current && step === "intro" && sceneReady) {
|
if (!hasInitialized.current && step === "intro") {
|
||||||
hasInitialized.current = true;
|
hasInitialized.current = true;
|
||||||
setStep("sequence_video");
|
setStep("start-intro");
|
||||||
playVideo("/videos/intro.webm");
|
|
||||||
}
|
}
|
||||||
}, [step, setStep, sceneReady, playVideo]);
|
}, [step, setStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === "sequence_video" && !isCinematicPlaying) {
|
if (step === "start-intro") {
|
||||||
setStep("start-move");
|
const audio = AudioManager.getInstance();
|
||||||
}
|
audio.playSoundWithCallback(AUDIO_PATHS.intro, 0.5, () => {
|
||||||
}, [step, isCinematicPlaying, setStep]);
|
setStep("naming");
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {};
|
||||||
if (step === "start-move") {
|
}
|
||||||
setCanMove(true);
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [step, setCanMove]);
|
}, [step, setStep, setActivityCity, setCanMove]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { useFrame } from "@react-three/fiber";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { createNetShader } from "@/shaders/NetShader";
|
|
||||||
|
|
||||||
export function NetTest(): React.JSX.Element {
|
|
||||||
const materialRef = useRef<THREE.ShaderMaterial>(null);
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
|
||||||
if (materialRef.current) {
|
|
||||||
materialRef.current.uniforms.uTime.value += delta;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mesh position={[0, 2, -5]}>
|
|
||||||
<planeGeometry args={[2, 2, 1, 1]} />
|
|
||||||
<primitive
|
|
||||||
object={createNetShader()}
|
|
||||||
ref={materialRef}
|
|
||||||
attach="material"
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,17 +8,17 @@ interface NPCHelperProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element {
|
export function NPCHelper({ position }: NPCHelperProps): React.JSX.Element {
|
||||||
const step = useGameStore((state) => state.pylone.currentStep);
|
const step = useGameStore((state) => state.intro.currentStep);
|
||||||
const setPyloneStep = useGameStore((state) => state.setPyloneState);
|
const setStep = useGameStore((state) => state.setIntroStep);
|
||||||
const debug = Debug.getInstance();
|
const debug = Debug.getInstance();
|
||||||
|
|
||||||
const handlePress = (): void => {
|
const handlePress = (): void => {
|
||||||
if (step === "searching") {
|
if (step === "searching") {
|
||||||
setPyloneStep({ currentStep: "helped" });
|
setStep("helped");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldShow = step === "searching" || step === "helped" || debug.active;
|
const shouldShow = step === "searching" || debug.active;
|
||||||
|
|
||||||
if (!shouldShow) {
|
if (!shouldShow) {
|
||||||
return <></>;
|
return <></>;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ interface PyloneDestroyedProps {
|
|||||||
export function PyloneDestroyed({
|
export function PyloneDestroyed({
|
||||||
position,
|
position,
|
||||||
}: PyloneDestroyedProps): React.JSX.Element {
|
}: PyloneDestroyedProps): React.JSX.Element {
|
||||||
const step = useGameStore((state) => state.pylone.currentStep);
|
const step = useGameStore((state) => state.intro.currentStep);
|
||||||
const setPyloneStep = useGameStore((state) => state.setPyloneState);
|
const setStep = useGameStore((state) => state.setIntroStep);
|
||||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||||
const showDialog = useGameStore((state) => state.showDialog);
|
const showDialog = useGameStore((state) => state.showDialog);
|
||||||
const debug = Debug.getInstance();
|
const debug = Debug.getInstance();
|
||||||
@@ -19,7 +19,7 @@ export function PyloneDestroyed({
|
|||||||
const handlePress = (): void => {
|
const handlePress = (): void => {
|
||||||
if (step === "helped") {
|
if (step === "helped") {
|
||||||
setCanMove(false);
|
setCanMove(false);
|
||||||
setPyloneStep({ currentStep: "manipulation" });
|
setStep("manipulation");
|
||||||
} else if (step === "searching") {
|
} else if (step === "searching") {
|
||||||
showDialog(
|
showDialog(
|
||||||
"Cet objet est trop lourd pour le porter tout seul, trouve de l'aide",
|
"Cet objet est trop lourd pour le porter tout seul, trouve de l'aide",
|
||||||
|
|||||||
@@ -1,10 +1,106 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
|
||||||
|
export function IntroUI(): React.JSX.Element | null {
|
||||||
|
const step = useGameStore((state) => state.intro.currentStep);
|
||||||
|
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
||||||
|
const setStep = useGameStore((state) => state.setIntroStep);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
|
||||||
|
if (step !== "naming") return null;
|
||||||
|
|
||||||
|
const handleSubmit = (): void => {
|
||||||
|
if (inputValue.trim() === "") return;
|
||||||
|
|
||||||
|
setPlayerName(inputValue.trim());
|
||||||
|
setStep("bienvenue");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
padding: "2rem",
|
||||||
|
borderRadius: "12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1.5rem",
|
||||||
|
minWidth: "300px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quel est votre prenom ?
|
||||||
|
</h2>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Votre prenom"
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #444",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
color: "#fff",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={inputValue.trim() === ""}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: inputValue.trim() ? "#4a9" : "#444",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: inputValue.trim() ? "pointer" : "not-allowed",
|
||||||
|
transition: "background-color 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Valider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BienvenueDisplay(): React.JSX.Element | null {
|
export function BienvenueDisplay(): React.JSX.Element | null {
|
||||||
const step = useGameStore((state) => state.intro.currentStep);
|
const step = useGameStore((state) => state.intro.currentStep);
|
||||||
const playerName = useGameStore((state) => state.missionFlow.playerName);
|
const playerName = useGameStore((state) => state.missionFlow.playerName);
|
||||||
|
|
||||||
if (step !== "start-move") return null;
|
if (step !== "bienvenue") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
|
||||||
|
|
||||||
export function VideoPlayer(): null {
|
|
||||||
const currentVideo = useGameStore((state) => state.missionFlow.currentVideo);
|
|
||||||
const clearVideo = useGameStore((state) => state.clearVideo);
|
|
||||||
const setCinematicPlaying = useGameStore(
|
|
||||||
(state) => state.setCinematicPlaying,
|
|
||||||
);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentVideo) {
|
|
||||||
setCinematicPlaying(true);
|
|
||||||
}
|
|
||||||
}, [currentVideo, setCinematicPlaying]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && currentVideo) {
|
|
||||||
closeVideo();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [currentVideo]);
|
|
||||||
|
|
||||||
const closeVideo = () => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.pause();
|
|
||||||
}
|
|
||||||
clearVideo();
|
|
||||||
setCinematicPlaying(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
const handleEnded = () => {
|
|
||||||
closeVideo();
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener("ended", handleEnded);
|
|
||||||
return () => video.removeEventListener("ended", handleEnded);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!currentVideo) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: "100vw",
|
|
||||||
height: "100vh",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.95)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 9999,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={currentVideo}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
style={{
|
|
||||||
maxWidth: "100%",
|
|
||||||
maxHeight: "100%",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onClick={closeVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "@/managers/stores/useGameStore";
|
} from "@/managers/stores/useGameStore";
|
||||||
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
|
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
|
||||||
import { GAME_STEPS, type GameStep } from "@/types/game";
|
import { GAME_STEPS, type GameStep } from "@/types/game";
|
||||||
import { PYLONE_STEPS, type PyloneStep } from "@/types/gameplay/pylone";
|
|
||||||
|
|
||||||
const MAIN_STATES: MainGameState[] = [
|
const MAIN_STATES: MainGameState[] = [
|
||||||
"intro",
|
"intro",
|
||||||
@@ -55,11 +54,9 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
const subStateOptions =
|
const subStateOptions =
|
||||||
mainState === "intro"
|
mainState === "intro"
|
||||||
? GAME_STEPS
|
? GAME_STEPS
|
||||||
: mainState === "pylone"
|
: mainState === "outro"
|
||||||
? PYLONE_STEPS
|
? ["waiting", "started"]
|
||||||
: mainState === "outro"
|
: MISSION_STEPS;
|
||||||
? ["waiting", "started"]
|
|
||||||
: MISSION_STEPS;
|
|
||||||
|
|
||||||
function setSubState(nextSubState: string): void {
|
function setSubState(nextSubState: string): void {
|
||||||
if (mainState === "intro") {
|
if (mainState === "intro") {
|
||||||
@@ -67,11 +64,6 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainState === "pylone") {
|
|
||||||
setPyloneState({ currentStep: nextSubState as PyloneStep });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mainState === "outro") {
|
if (mainState === "outro") {
|
||||||
setOutroState({ hasStarted: nextSubState === "started" });
|
setOutroState({ hasStarted: nextSubState === "started" });
|
||||||
return;
|
return;
|
||||||
@@ -84,6 +76,11 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mainState === "pylone") {
|
||||||
|
setPyloneState({ currentStep: nextSubState });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mainState === "ferme") {
|
if (mainState === "ferme") {
|
||||||
setFermeState({ currentStep: nextSubState });
|
setFermeState({ currentStep: nextSubState });
|
||||||
return;
|
return;
|
||||||
@@ -98,6 +95,11 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextMainState === "pylone" && pyloneStep === "locked") {
|
||||||
|
setPyloneState({ currentStep: "waiting" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (nextMainState === "ferme" && fermeStep === "locked") {
|
if (nextMainState === "ferme" && fermeStep === "locked") {
|
||||||
setFermeState({ currentStep: "waiting" });
|
setFermeState({ currentStep: "waiting" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ export function ZoneDetection(): null {
|
|||||||
const triggeredZones = useRef<Set<string>>(new Set());
|
const triggeredZones = useRef<Set<string>>(new Set());
|
||||||
const debug = Debug.getInstance();
|
const debug = Debug.getInstance();
|
||||||
const step = useGameStore((state) => state.intro.currentStep);
|
const step = useGameStore((state) => state.intro.currentStep);
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
|
||||||
const setStep = useGameStore((state) => state.setIntroStep);
|
const setStep = useGameStore((state) => state.setIntroStep);
|
||||||
const setPyloneStep = useGameStore((state) => state.setPyloneState);
|
|
||||||
const advanceGameState = useGameStore((state) => state.advanceGameState);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!debug.active) return;
|
if (!debug.active) return;
|
||||||
@@ -68,11 +65,7 @@ export function ZoneDetection(): null {
|
|||||||
const distanceSq = _playerPos.distanceToSquared(_zonePos);
|
const distanceSq = _playerPos.distanceToSquared(_zonePos);
|
||||||
|
|
||||||
if (distanceSq <= zone.radius * zone.radius) {
|
if (distanceSq <= zone.radius * zone.radius) {
|
||||||
if (zone.targetStep === "bike" && mainState === "intro") {
|
setStep(zone.targetStep);
|
||||||
advanceGameState();
|
|
||||||
} else {
|
|
||||||
setStep(zone.targetStep);
|
|
||||||
}
|
|
||||||
triggeredZones.current.add(zone.id);
|
triggeredZones.current.add(zone.id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,51 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
pylone: {
|
||||||
|
id: "pylone",
|
||||||
|
label: "Power pylon",
|
||||||
|
description:
|
||||||
|
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||||
|
modelPath: "/models/pylone/model.gltf",
|
||||||
|
stageUiPath: "/assets/UI/centrale.webm",
|
||||||
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
|
case: DEFAULT_REPAIR_CASE,
|
||||||
|
reassemblySeconds: 1.8,
|
||||||
|
requiredReplacementPartId: "pylone-grid-relay-replacement",
|
||||||
|
scanPartSeconds: 1.4,
|
||||||
|
brokenParts: [
|
||||||
|
{
|
||||||
|
id: "pylone-grid-relay",
|
||||||
|
label: "Grid relay",
|
||||||
|
nodeName: "lampe",
|
||||||
|
placeholderName: "placeholder_1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pylone-damaged-panel",
|
||||||
|
label: "Damaged solar panel",
|
||||||
|
nodeName: "panneau2",
|
||||||
|
placeholderName: "placeholder_2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
replacementParts: [
|
||||||
|
{
|
||||||
|
id: "pylone-grid-relay-replacement",
|
||||||
|
label: "Replacement grid relay",
|
||||||
|
modelPath: "/models/pylone/model.gltf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pylone-stone-decoy",
|
||||||
|
label: "Stone counterweight",
|
||||||
|
modelPath: "/models/galet/model.gltf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pylone-cooling-decoy",
|
||||||
|
label: "Cooling core",
|
||||||
|
modelPath: "/models/refroidisseur/model.gltf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
ferme: {
|
ferme: {
|
||||||
id: "ferme",
|
id: "ferme",
|
||||||
label: "Vertical farm",
|
label: "Vertical farm",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const PLAYER_EYE_HEIGHT = 1.75;
|
|||||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||||
|
|
||||||
export const PLAYER_WALK_SPEED = 11;
|
export const PLAYER_WALK_SPEED = 11;
|
||||||
|
export const PLAYER_EBIKE_SPEED = 25;
|
||||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||||
export const PLAYER_JUMP_SPEED = 9;
|
export const PLAYER_JUMP_SPEED = 9;
|
||||||
export const PLAYER_GRAVITY = 30;
|
export const PLAYER_GRAVITY = 30;
|
||||||
@@ -11,5 +12,5 @@ export const PLAYER_MAX_DELTA = 0.05;
|
|||||||
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
|
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
|
||||||
export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
||||||
|
|
||||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [6.56,5,71.55];
|
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
|
||||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||||
|
|||||||
+12
-5
@@ -1,12 +1,19 @@
|
|||||||
import type { Zone, GameStep } from "@/types/game";
|
import type { Zone } from "@/types/game";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
export const ZONES: Zone[] = [
|
export const ZONES: Zone[] = [
|
||||||
{
|
{
|
||||||
id: "fabrikExit",
|
id: "fabrikExit",
|
||||||
position: [18.43,0,75.3] as Vector3Tuple,
|
position: [-5, 25, -15] as Vector3Tuple,
|
||||||
radius: 4,
|
radius: 10,
|
||||||
height: 10,
|
height: 20,
|
||||||
targetStep: "bike" as GameStep,
|
targetStep: "mission2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "searchingZone",
|
||||||
|
position: [-5, 25, -30] as Vector3Tuple,
|
||||||
|
radius: 10,
|
||||||
|
height: 20,
|
||||||
|
targetStep: "searching",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { useGameStore } from "@/managers/stores/useGameStore";
|
|||||||
import type { MissionStep } from "@/types/gameplay/repairMission";
|
import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
export function useRepairMovementLocked(): boolean {
|
export function useRepairMovementLocked(): boolean {
|
||||||
|
return false;
|
||||||
|
|
||||||
return useGameStore((state) => {
|
return useGameStore((state) => {
|
||||||
switch (state.mainState) {
|
switch (state.mainState) {
|
||||||
case "bike":
|
case "bike":
|
||||||
return isRepairMovementLocked(state.bike.currentStep);
|
return isRepairMovementLocked(state.bike.currentStep);
|
||||||
case "pylone":
|
case "pylone":
|
||||||
return state.pylone.currentStep === "manipulation";
|
return isRepairMovementLocked(state.pylone.currentStep);
|
||||||
case "ferme":
|
case "ferme":
|
||||||
return isRepairMovementLocked(state.ferme.currentStep);
|
return isRepairMovementLocked(state.ferme.currentStep);
|
||||||
case "intro":
|
case "intro":
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import {
|
|||||||
type MissionStep,
|
type MissionStep,
|
||||||
type RepairMissionId,
|
type RepairMissionId,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
import { type PyloneStep } from "@/types/gameplay/pylone";
|
import {
|
||||||
|
PLAYER_WALK_SPEED,
|
||||||
|
PLAYER_EBIKE_SPEED,
|
||||||
|
} from "@/data/player/playerConfig";
|
||||||
|
|
||||||
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
||||||
export type { MissionStep, RepairMissionId, PyloneStep };
|
export type PlayerMovementMode = "walk" | "ebike";
|
||||||
|
export type { MissionStep, RepairMissionId };
|
||||||
|
|
||||||
interface IntroState {
|
interface IntroState {
|
||||||
currentStep: GameStep;
|
currentStep: GameStep;
|
||||||
@@ -29,21 +33,23 @@ interface MissionFlowState {
|
|||||||
canMove: boolean;
|
canMove: boolean;
|
||||||
dialogMessage: string | null;
|
dialogMessage: string | null;
|
||||||
playerName: string;
|
playerName: string;
|
||||||
currentVideo: string | null;
|
}
|
||||||
|
|
||||||
|
interface PlayerState {
|
||||||
|
movementMode: PlayerMovementMode;
|
||||||
|
currentSpeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
mainState: MainGameState;
|
mainState: MainGameState;
|
||||||
isCinematicPlaying: boolean;
|
isCinematicPlaying: boolean;
|
||||||
sceneReady: boolean;
|
|
||||||
missionFlow: MissionFlowState;
|
missionFlow: MissionFlowState;
|
||||||
|
player: PlayerState;
|
||||||
intro: IntroState;
|
intro: IntroState;
|
||||||
bike: MissionState & {
|
bike: MissionState & {
|
||||||
isRepaired: boolean;
|
isRepaired: boolean;
|
||||||
};
|
};
|
||||||
pylone: {
|
pylone: MissionState & {
|
||||||
currentStep: PyloneStep;
|
|
||||||
dialogueAudio: string | null;
|
|
||||||
isPowered: boolean;
|
isPowered: boolean;
|
||||||
};
|
};
|
||||||
ferme: MissionState & {
|
ferme: MissionState & {
|
||||||
@@ -58,10 +64,10 @@ interface GameState {
|
|||||||
interface GameActions {
|
interface GameActions {
|
||||||
setMainState: (mainState: MainGameState) => void;
|
setMainState: (mainState: MainGameState) => void;
|
||||||
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
||||||
setSceneReady: (sceneReady: boolean) => void;
|
|
||||||
hideDialog: () => void;
|
hideDialog: () => void;
|
||||||
setActivityCity: (activityCity: boolean) => void;
|
setActivityCity: (activityCity: boolean) => void;
|
||||||
setCanMove: (canMove: boolean) => void;
|
setCanMove: (canMove: boolean) => void;
|
||||||
|
setPlayerMovementMode: (mode: PlayerMovementMode) => void;
|
||||||
setIntroStep: (step: GameStep) => void;
|
setIntroStep: (step: GameStep) => void;
|
||||||
setIntroState: (intro: Partial<IntroState>) => void;
|
setIntroState: (intro: Partial<IntroState>) => void;
|
||||||
setPlayerName: (playerName: string) => void;
|
setPlayerName: (playerName: string) => void;
|
||||||
@@ -72,6 +78,7 @@ interface GameActions {
|
|||||||
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
|
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
|
||||||
completeIntro: () => void;
|
completeIntro: () => void;
|
||||||
completeBike: () => void;
|
completeBike: () => void;
|
||||||
|
completePylone: () => void;
|
||||||
completeFerme: () => void;
|
completeFerme: () => void;
|
||||||
completeMission: (mission: RepairMissionId) => void;
|
completeMission: (mission: RepairMissionId) => void;
|
||||||
startOutro: () => void;
|
startOutro: () => void;
|
||||||
@@ -79,8 +86,6 @@ interface GameActions {
|
|||||||
rewindGameState: () => void;
|
rewindGameState: () => void;
|
||||||
resetGame: () => void;
|
resetGame: () => void;
|
||||||
showDialog: (dialogMessage: string) => void;
|
showDialog: (dialogMessage: string) => void;
|
||||||
playVideo: (videoSrc: string) => void;
|
|
||||||
clearVideo: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameStore = GameState & GameActions;
|
type GameStore = GameState & GameActions;
|
||||||
@@ -111,7 +116,22 @@ function completeBikeState(state: GameState): GameStateUpdate {
|
|||||||
},
|
},
|
||||||
pylone: {
|
pylone: {
|
||||||
...state.pylone,
|
...state.pylone,
|
||||||
currentStep: "locked",
|
currentStep: "waiting",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completePyloneState(state: GameState): GameStateUpdate {
|
||||||
|
return {
|
||||||
|
mainState: "ferme",
|
||||||
|
pylone: {
|
||||||
|
...state.pylone,
|
||||||
|
currentStep: "done",
|
||||||
|
isPowered: true,
|
||||||
|
},
|
||||||
|
ferme: {
|
||||||
|
...state.ferme,
|
||||||
|
currentStep: "waiting",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -151,48 +171,13 @@ function completeMissionState(
|
|||||||
switch (mission) {
|
switch (mission) {
|
||||||
case "bike":
|
case "bike":
|
||||||
return completeBikeState(state);
|
return completeBikeState(state);
|
||||||
|
case "pylone":
|
||||||
|
return completePyloneState(state);
|
||||||
case "ferme":
|
case "ferme":
|
||||||
return completeFermeState(state);
|
return completeFermeState(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNextPyloneStep(step: PyloneStep): PyloneStep {
|
|
||||||
switch (step) {
|
|
||||||
case "locked":
|
|
||||||
return "alert";
|
|
||||||
case "alert":
|
|
||||||
return "searching";
|
|
||||||
case "searching":
|
|
||||||
return "helped";
|
|
||||||
case "helped":
|
|
||||||
return "manipulation";
|
|
||||||
case "manipulation":
|
|
||||||
return "manipulation";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function advancePyloneStep(state: GameState): GameStateUpdate {
|
|
||||||
if (state.pylone.currentStep === "locked") {
|
|
||||||
return {
|
|
||||||
pylone: { ...state.pylone, currentStep: "alert" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStep = getNextPyloneStep(state.pylone.currentStep);
|
|
||||||
if (
|
|
||||||
nextStep === "manipulation" &&
|
|
||||||
state.pylone.currentStep === "manipulation"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
mainState: "outro",
|
|
||||||
pylone: { ...state.pylone, currentStep: "manipulation" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
pylone: { ...state.pylone, currentStep: nextStep },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function advanceRepairMissionState(
|
function advanceRepairMissionState(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
mission: RepairMissionId,
|
mission: RepairMissionId,
|
||||||
@@ -230,13 +215,15 @@ function createInitialGameState(): GameState {
|
|||||||
return {
|
return {
|
||||||
mainState: "intro",
|
mainState: "intro",
|
||||||
isCinematicPlaying: false,
|
isCinematicPlaying: false,
|
||||||
sceneReady: false,
|
|
||||||
missionFlow: {
|
missionFlow: {
|
||||||
activityCity: true,
|
activityCity: true,
|
||||||
canMove: false,
|
canMove: false,
|
||||||
dialogMessage: null,
|
dialogMessage: null,
|
||||||
playerName: "",
|
playerName: "",
|
||||||
currentVideo: null,
|
},
|
||||||
|
player: {
|
||||||
|
movementMode: "walk",
|
||||||
|
currentSpeed: PLAYER_WALK_SPEED,
|
||||||
},
|
},
|
||||||
intro: {
|
intro: {
|
||||||
currentStep: "intro",
|
currentStep: "intro",
|
||||||
@@ -270,7 +257,6 @@ export const useGameStore = create<GameStore>()((set) => ({
|
|||||||
...createInitialGameState(),
|
...createInitialGameState(),
|
||||||
setMainState: (mainState) => set({ mainState }),
|
setMainState: (mainState) => set({ mainState }),
|
||||||
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
|
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
|
||||||
setSceneReady: (sceneReady) => set({ sceneReady }),
|
|
||||||
hideDialog: () =>
|
hideDialog: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
missionFlow: { ...state.missionFlow, dialogMessage: null },
|
missionFlow: { ...state.missionFlow, dialogMessage: null },
|
||||||
@@ -279,6 +265,14 @@ export const useGameStore = create<GameStore>()((set) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
missionFlow: { ...state.missionFlow, activityCity },
|
missionFlow: { ...state.missionFlow, activityCity },
|
||||||
})),
|
})),
|
||||||
|
setPlayerMovementMode: (mode) =>
|
||||||
|
set((state) => ({
|
||||||
|
player: {
|
||||||
|
...state.player,
|
||||||
|
movementMode: mode,
|
||||||
|
currentSpeed: mode === "ebike" ? PLAYER_EBIKE_SPEED : PLAYER_WALK_SPEED,
|
||||||
|
},
|
||||||
|
})),
|
||||||
setCanMove: (canMove) =>
|
setCanMove: (canMove) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
missionFlow: { ...state.missionFlow, canMove },
|
missionFlow: { ...state.missionFlow, canMove },
|
||||||
@@ -303,6 +297,7 @@ export const useGameStore = create<GameStore>()((set) => ({
|
|||||||
set((state) => setMissionStepState(state, mission, step)),
|
set((state) => setMissionStepState(state, mission, step)),
|
||||||
completeIntro: () => set(completeIntroState),
|
completeIntro: () => set(completeIntroState),
|
||||||
completeBike: () => set((state) => completeMissionState(state, "bike")),
|
completeBike: () => set((state) => completeMissionState(state, "bike")),
|
||||||
|
completePylone: () => set((state) => completeMissionState(state, "pylone")),
|
||||||
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
|
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
|
||||||
completeMission: (mission) =>
|
completeMission: (mission) =>
|
||||||
set((state) => completeMissionState(state, mission)),
|
set((state) => completeMissionState(state, mission)),
|
||||||
@@ -310,19 +305,9 @@ export const useGameStore = create<GameStore>()((set) => ({
|
|||||||
advanceGameState: () =>
|
advanceGameState: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.mainState === "intro") {
|
if (state.mainState === "intro") {
|
||||||
if (state.intro.currentStep === "bike") {
|
|
||||||
return {
|
|
||||||
mainState: "bike",
|
|
||||||
intro: { ...state.intro, hasCompleted: true },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return completeIntroState(state);
|
return completeIntroState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.mainState === "pylone") {
|
|
||||||
return advancePyloneStep(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRepairMissionId(state.mainState)) {
|
if (isRepairMissionId(state.mainState)) {
|
||||||
return advanceRepairMissionState(state, state.mainState);
|
return advanceRepairMissionState(state, state.mainState);
|
||||||
}
|
}
|
||||||
@@ -346,12 +331,4 @@ export const useGameStore = create<GameStore>()((set) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
missionFlow: { ...state.missionFlow, dialogMessage },
|
missionFlow: { ...state.missionFlow, dialogMessage },
|
||||||
})),
|
})),
|
||||||
playVideo: (videoSrc) =>
|
|
||||||
set((state) => ({
|
|
||||||
missionFlow: { ...state.missionFlow, currentVideo: videoSrc },
|
|
||||||
})),
|
|
||||||
clearVideo: () =>
|
|
||||||
set((state) => ({
|
|
||||||
missionFlow: { ...state.missionFlow, currentVideo: null },
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { MapControls, OrthographicCamera, useGLTF } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 1. Terrain Scene
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function TerrainScene() {
|
||||||
|
const { scene } = useGLTF('/models/terrain/terrain.glb');
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<ambientLight intensity={1.5} />
|
||||||
|
<directionalLight position={[10, 20, 10]} intensity={2} />
|
||||||
|
<primitive object={scene} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 2. Waypoint Overlay (Debug visualization)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function WaypointOverlay({ waypoints, visible }: { waypoints: any[], visible: boolean }) {
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{waypoints.map((w) => (
|
||||||
|
<mesh key={w.id} position={[w.x, w.y + 1, w.z]}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#10b981" />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 3. Camera Manager (Handles Orthographic Math & Downloads)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function CameraManager({
|
||||||
|
autoBounds,
|
||||||
|
boundsTextRef
|
||||||
|
}: {
|
||||||
|
autoBounds: any,
|
||||||
|
boundsTextRef: React.RefObject<HTMLPreElement | null>
|
||||||
|
}) {
|
||||||
|
const { camera, gl, scene } = useThree();
|
||||||
|
const controlsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Apply Auto-Bounds function
|
||||||
|
useEffect(() => {
|
||||||
|
const applyAutoBounds = () => {
|
||||||
|
if (camera instanceof THREE.OrthographicCamera && autoBounds) {
|
||||||
|
const width = autoBounds.maxX - autoBounds.minX;
|
||||||
|
const height = autoBounds.maxZ - autoBounds.minZ;
|
||||||
|
const centerX = (autoBounds.minX + autoBounds.maxX) / 2;
|
||||||
|
const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2;
|
||||||
|
|
||||||
|
camera.position.set(centerX, 200, centerZ);
|
||||||
|
camera.left = -width / 2;
|
||||||
|
camera.right = width / 2;
|
||||||
|
camera.top = height / 2;
|
||||||
|
camera.bottom = -height / 2;
|
||||||
|
camera.zoom = 1;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
if (controlsRef.current) {
|
||||||
|
controlsRef.current.target.set(centerX, 0, centerZ);
|
||||||
|
controlsRef.current.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).applyAutoBounds = applyAutoBounds;
|
||||||
|
// Initial apply
|
||||||
|
applyAutoBounds();
|
||||||
|
|
||||||
|
return () => { delete (window as any).applyAutoBounds; };
|
||||||
|
}, [camera, autoBounds]);
|
||||||
|
|
||||||
|
// Track dynamic bounds without triggering React re-renders!
|
||||||
|
useFrame(() => {
|
||||||
|
if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) {
|
||||||
|
const width = (camera.right - camera.left) / camera.zoom;
|
||||||
|
const height = (camera.top - camera.bottom) / camera.zoom;
|
||||||
|
const minX = Math.round(camera.position.x - width / 2);
|
||||||
|
const maxX = Math.round(camera.position.x + width / 2);
|
||||||
|
const minZ = Math.round(camera.position.z - height / 2);
|
||||||
|
const maxZ = Math.round(camera.position.z + height / 2);
|
||||||
|
|
||||||
|
// Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!)
|
||||||
|
boundsTextRef.current.innerText = JSON.stringify({ minX, maxX, minZ, maxZ }, null, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach screenshot capture logic
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).downloadMapScreenshot = () => {
|
||||||
|
// Force an immediate render frame to ensure no UI overlays are missing
|
||||||
|
gl.render(scene, camera);
|
||||||
|
const dataUrl = gl.domElement.toDataURL("image/png");
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = dataUrl;
|
||||||
|
a.download = "map_background.png";
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
return () => { delete (window as any).downloadMapScreenshot; };
|
||||||
|
}, [gl, camera, scene]);
|
||||||
|
|
||||||
|
return <MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 4. Main Page Route Component
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
export function BackgroundMapPage() {
|
||||||
|
const [waypoints, setWaypoints] = useState<any[]>([]);
|
||||||
|
const [showWaypoints, setShowWaypoints] = useState(true);
|
||||||
|
const boundsTextRef = useRef<HTMLPreElement>(null);
|
||||||
|
|
||||||
|
// Load road network waypoints to compute perfect GPS bounds
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('la-fabrik-waypoints');
|
||||||
|
if (saved) {
|
||||||
|
setWaypoints(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
fetch('/roadNetwork.json')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setWaypoints(data))
|
||||||
|
.catch(() => { });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compute exact bounds that the EbikeGPSMap will use by default
|
||||||
|
const autoBounds = useMemo(() => {
|
||||||
|
if (waypoints.length === 0) return null;
|
||||||
|
const xs = waypoints.map(w => w.x);
|
||||||
|
const zs = waypoints.map(w => w.z);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const minZ = Math.min(...zs);
|
||||||
|
const maxZ = Math.max(...zs);
|
||||||
|
|
||||||
|
// CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE.
|
||||||
|
// If the camera is rectangular, the exported PNG will be distorted when drawn
|
||||||
|
// on the EbikeGPSMap's 1024x1024 canvas!
|
||||||
|
const width = maxX - minX;
|
||||||
|
const height = maxZ - minZ;
|
||||||
|
const maxDim = Math.max(width, height);
|
||||||
|
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerZ = (minZ + maxZ) / 2;
|
||||||
|
|
||||||
|
const paddedDim = maxDim * 1.15 || 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: centerX - paddedDim / 2,
|
||||||
|
maxX: centerX + paddedDim / 2,
|
||||||
|
minZ: centerZ - paddedDim / 2,
|
||||||
|
maxZ: centerZ + paddedDim / 2,
|
||||||
|
};
|
||||||
|
}, [waypoints]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100vw', height: '100vh', background: '#050505', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
{/*
|
||||||
|
CRITICAL: The DOM element MUST be a perfect square so the resulting PNG
|
||||||
|
is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture!
|
||||||
|
*/}
|
||||||
|
<div style={{ width: 'min(100vw, 100vh)', height: 'min(100vw, 100vh)', background: '#000', position: 'relative' }}>
|
||||||
|
<Canvas gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}>
|
||||||
|
<OrthographicCamera makeDefault position={[0, 200, 0]} near={0.1} far={1000} />
|
||||||
|
<TerrainScene />
|
||||||
|
<WaypointOverlay waypoints={waypoints} visible={showWaypoints} />
|
||||||
|
<CameraManager autoBounds={autoBounds} boundsTextRef={boundsTextRef} />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Premium Glassmorphic UI Dashboard */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 24, left: 24,
|
||||||
|
background: 'rgba(15, 23, 42, 0.85)', padding: 24,
|
||||||
|
borderRadius: 16, border: '1px solid #334155',
|
||||||
|
color: 'white', fontFamily: 'system-ui, sans-serif',
|
||||||
|
backdropFilter: 'blur(12px)', width: 360,
|
||||||
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5)'
|
||||||
|
}}>
|
||||||
|
<h2 style={{ margin: '0 0 16px 0', fontSize: '1.4rem', color: '#38bdf8' }}>GPS Map Generator</h2>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '0.9rem', color: '#94a3b8', marginBottom: 20, lineHeight: 1.5 }}>
|
||||||
|
1. Cadrez votre carte (ou utilisez le <b>Cadrage Automatique</b>).<br />
|
||||||
|
2. Masquez les waypoints (fond visuel seul).<br />
|
||||||
|
3. Cliquez sur <b>Capturer la carte</b>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWaypoints(!showWaypoints)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px', marginBottom: 12,
|
||||||
|
background: showWaypoints ? '#1e293b' : '#334155',
|
||||||
|
border: '1px solid #475569', color: 'white',
|
||||||
|
borderRadius: 8, cursor: 'pointer', fontWeight: 600,
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showWaypoints ? '👁️ Masquer Waypoints' : '👁️🗨️ Afficher Waypoints'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if ((window as any).applyAutoBounds) (window as any).applyAutoBounds();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '12px', marginBottom: 16,
|
||||||
|
background: '#1e293b', border: '1px solid #475569',
|
||||||
|
color: '#10b981', borderRadius: 8, cursor: 'pointer', fontWeight: 600,
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎯 Cadrage Automatique
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if ((window as any).downloadMapScreenshot) (window as any).downloadMapScreenshot();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '14px', background: '#0ea5e9',
|
||||||
|
border: 'none', color: 'white', borderRadius: 8,
|
||||||
|
cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(14, 165, 233, 0.4)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📸 Capturer la carte (.png)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, padding: 16, background: '#020617', borderRadius: 10, fontSize: '0.85rem' }}>
|
||||||
|
<div style={{ color: '#64748b', marginBottom: 8, fontWeight: 600 }}>Limites Actuelles (worldBounds):</div>
|
||||||
|
<pre ref={boundsTextRef} style={{ margin: 0, color: '#10b981', fontFamily: 'monospace' }}>
|
||||||
|
Calcul...
|
||||||
|
</pre>
|
||||||
|
<div style={{ color: '#ef4444', marginTop: 12, fontSize: '0.75rem', lineHeight: 1.4 }}>
|
||||||
|
*Si vous décadrez à la souris, vous devrez copier ces valeurs exactes dans la prop <code>worldBounds</code> de votre composant <b>EbikeGPSMap</b> !
|
||||||
|
<br /><br />
|
||||||
|
Astuce : Utilisez le <b>Cadrage Automatique</b> pour ne rien avoir à configurer.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-7
@@ -4,7 +4,7 @@ import * as THREE from "three";
|
|||||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||||
import { DialogMessage } from "@/components/ui/DialogMessage";
|
import { DialogMessage } from "@/components/ui/DialogMessage";
|
||||||
import { GameUI } from "@/components/ui/GameUI";
|
import { GameUI } from "@/components/ui/GameUI";
|
||||||
import { BienvenueDisplay } from "@/components/ui/IntroUI";
|
import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI";
|
||||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||||
@@ -19,7 +19,6 @@ export function HomePage(): React.JSX.Element {
|
|||||||
(state) => state.missionFlow.dialogMessage,
|
(state) => state.missionFlow.dialogMessage,
|
||||||
);
|
);
|
||||||
const hideDialog = useGameStore((state) => state.hideDialog);
|
const hideDialog = useGameStore((state) => state.hideDialog);
|
||||||
const setSceneReady = useGameStore((state) => state.setSceneReady);
|
|
||||||
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||||
INITIAL_SCENE_LOADING_STATE,
|
INITIAL_SCENE_LOADING_STATE,
|
||||||
);
|
);
|
||||||
@@ -43,17 +42,13 @@ export function HomePage(): React.JSX.Element {
|
|||||||
return currentState;
|
return currentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextState.status === "ready" && currentState.status !== "ready") {
|
|
||||||
setSceneReady(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextState,
|
...nextState,
|
||||||
progress: Math.max(currentState.progress, nextState.progress),
|
progress: Math.max(currentState.progress, nextState.progress),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setSceneReady],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,6 +63,7 @@ export function HomePage(): React.JSX.Element {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
<GameUI />
|
<GameUI />
|
||||||
|
<IntroUI />
|
||||||
<BienvenueDisplay />
|
<BienvenueDisplay />
|
||||||
{dialogMessage ? (
|
{dialogMessage ? (
|
||||||
<DialogMessage
|
<DialogMessage
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
import { Grid } from './Grid';
|
||||||
|
import type { GridNode, Position } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the octile heuristic distance between two nodes.
|
||||||
|
* Ideal for 8-directional grid movement.
|
||||||
|
*/
|
||||||
|
function getOctileDistance(nodeA: GridNode, nodeB: GridNode): number {
|
||||||
|
const dx = Math.abs(nodeA.x - nodeB.x);
|
||||||
|
const dy = Math.abs(nodeA.y - nodeB.y);
|
||||||
|
|
||||||
|
const D = 1; // Orthogonal movement cost
|
||||||
|
const D2 = 1.414; // Diagonal movement cost (approx Math.sqrt(2))
|
||||||
|
|
||||||
|
return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the shortest path between start and end positions on the grid.
|
||||||
|
* Returns an array of Positions representing the path, or an empty array if no path is found.
|
||||||
|
*/
|
||||||
|
export function findPath(
|
||||||
|
grid: Grid,
|
||||||
|
startPos: Position,
|
||||||
|
endPos: Position,
|
||||||
|
allowDiagonals: boolean = true
|
||||||
|
): Position[] {
|
||||||
|
grid.reset();
|
||||||
|
|
||||||
|
const startNode = grid.getNode(Math.floor(startPos.x), Math.floor(startPos.y));
|
||||||
|
const endNode = grid.getNode(Math.floor(endPos.x), Math.floor(endPos.y));
|
||||||
|
|
||||||
|
if (!startNode || !endNode) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the destination node itself is blocked, we try to find the nearest walkable neighbor
|
||||||
|
if (!endNode.walkable) {
|
||||||
|
const endNeighbors = grid.getNeighbors(endNode, allowDiagonals);
|
||||||
|
if (endNeighbors.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Set destination to the closest walkable neighbor
|
||||||
|
let closestNeighbor = endNeighbors[0]!;
|
||||||
|
let minDist = getOctileDistance(startNode, closestNeighbor);
|
||||||
|
for (let i = 1; i < endNeighbors.length; i++) {
|
||||||
|
const neighbor = endNeighbors[i]!;
|
||||||
|
const dist = getOctileDistance(startNode, neighbor);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closestNeighbor = neighbor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reroute to that walkable neighbor
|
||||||
|
return findPath(grid, startPos, { x: closestNeighbor.x, y: closestNeighbor.y }, allowDiagonals);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSet: GridNode[] = [startNode];
|
||||||
|
const closedSet = new Set<GridNode>();
|
||||||
|
|
||||||
|
startNode.g = 0;
|
||||||
|
startNode.h = getOctileDistance(startNode, endNode);
|
||||||
|
startNode.f = startNode.h;
|
||||||
|
|
||||||
|
while (openSet.length > 0) {
|
||||||
|
// Find the node in openSet with the lowest f value
|
||||||
|
let lowIndex = 0;
|
||||||
|
for (let i = 1; i < openSet.length; i++) {
|
||||||
|
const node = openSet[i]!;
|
||||||
|
const lowNode = openSet[lowIndex]!;
|
||||||
|
if (node.f < lowNode.f) {
|
||||||
|
lowIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNode = openSet[lowIndex]!;
|
||||||
|
|
||||||
|
// Check if we reached the destination
|
||||||
|
if (currentNode === endNode) {
|
||||||
|
const path: Position[] = [];
|
||||||
|
let temp: GridNode | null = currentNode;
|
||||||
|
while (temp !== null) {
|
||||||
|
path.push({ x: temp.x, y: temp.y });
|
||||||
|
temp = temp.parent;
|
||||||
|
}
|
||||||
|
return path.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove currentNode from openSet and add to closedSet
|
||||||
|
openSet.splice(lowIndex, 1);
|
||||||
|
closedSet.add(currentNode);
|
||||||
|
|
||||||
|
const neighbors = grid.getNeighbors(currentNode, allowDiagonals);
|
||||||
|
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
if (closedSet.has(neighbor)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate cost to move to this neighbor (1 for orthogonal, 1.414 for diagonal)
|
||||||
|
const isDiagonal = neighbor.x !== currentNode.x && neighbor.y !== currentNode.y;
|
||||||
|
const moveCost = isDiagonal ? 1.414 : 1;
|
||||||
|
const tentativeG = currentNode.g + moveCost;
|
||||||
|
|
||||||
|
let neighborInOpenSet = openSet.includes(neighbor);
|
||||||
|
|
||||||
|
if (!neighborInOpenSet || tentativeG < neighbor.g) {
|
||||||
|
neighbor.parent = currentNode;
|
||||||
|
neighbor.g = tentativeG;
|
||||||
|
neighbor.h = getOctileDistance(neighbor, endNode);
|
||||||
|
neighbor.f = neighbor.g + neighbor.h;
|
||||||
|
|
||||||
|
if (!neighborInOpenSet) {
|
||||||
|
openSet.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty if no path is found
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { useGPS } from './useGPS';
|
||||||
|
import type { WorldBounds } from './useGPS';
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. Premium 2D HUD GPS Overlay Component
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface GPSMinimapHUDProps {
|
||||||
|
bwMaskUrl: string;
|
||||||
|
colorMapUrl: string;
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
worldBounds: WorldBounds;
|
||||||
|
playerPos: { x: number; z: number };
|
||||||
|
destPos?: { x: number; z: number };
|
||||||
|
size?: number; // Size of HUD in pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A beautiful, glassmorphic 2D HUD overlay that renders the GPS Minimap
|
||||||
|
* in the corner of the screen.
|
||||||
|
*/
|
||||||
|
export const GPSMinimapHUD: React.FC<GPSMinimapHUDProps> = ({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
playerPos,
|
||||||
|
destPos,
|
||||||
|
size = 200,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const gpsOptions = useMemo(() => ({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
}), [bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds]);
|
||||||
|
|
||||||
|
const { calculateWorldPath, renderGPSToCanvas, loading, error } = useGPS(gpsOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || error || !canvasRef.current) return;
|
||||||
|
|
||||||
|
// Calculate A* path in world coordinates
|
||||||
|
const path = destPos ? calculateWorldPath(playerPos, destPos) : [];
|
||||||
|
|
||||||
|
// Render path onto HUD canvas
|
||||||
|
renderGPSToCanvas(canvasRef.current, path, playerPos, destPos, {
|
||||||
|
pathColor: '#3b82f6', // Premium vibrant blue
|
||||||
|
pathWidth: 5,
|
||||||
|
playerColor: '#ef4444', // Hot red for player
|
||||||
|
playerSize: 6,
|
||||||
|
destColor: '#10b981', // Emerald green for destination
|
||||||
|
destSize: 6,
|
||||||
|
});
|
||||||
|
}, [playerPos, destPos, loading, error, calculateWorldPath, renderGPSToCanvas]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={hudStyles.container(size)}>
|
||||||
|
{loading && <div style={hudStyles.statusText}>Initializing GPS...</div>}
|
||||||
|
{error && <div style={{ ...hudStyles.statusText, color: '#ef4444' }}>GPS Error: {error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={size * 2} // Double size for retina/high-DPI screens
|
||||||
|
height={size * 2}
|
||||||
|
style={hudStyles.canvas(size)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. 3D Handlebar Screen Mesh Component (R3F)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface GPSBikeScreenProps {
|
||||||
|
bwMaskUrl: string;
|
||||||
|
colorMapUrl: string;
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
worldBounds: WorldBounds;
|
||||||
|
playerPos: { x: number; z: number };
|
||||||
|
destPos?: { x: number; z: number };
|
||||||
|
width?: number; // 3D Plane Width
|
||||||
|
height?: number; // 3D Plane Height
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Three.js 3D plane mesh that renders the GPS dynamically as a CanvasTexture.
|
||||||
|
* This can be directly attached to the bike's handlebars in your 3D world.
|
||||||
|
*/
|
||||||
|
export const GPSBikeScreen: React.FC<GPSBikeScreenProps> = ({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
playerPos,
|
||||||
|
destPos,
|
||||||
|
width = 0.4,
|
||||||
|
height = 0.4,
|
||||||
|
}) => {
|
||||||
|
// Offscreen canvas to render the GPS texture onto
|
||||||
|
const [offscreenCanvas] = useState(() => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 512;
|
||||||
|
canvas.height = 512;
|
||||||
|
return canvas;
|
||||||
|
});
|
||||||
|
|
||||||
|
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
||||||
|
|
||||||
|
const gpsOptions = useMemo(() => ({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
}), [bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds]);
|
||||||
|
|
||||||
|
const { calculateWorldPath, renderGPSToCanvas, loading } = useGPS(gpsOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
// Calculate A* path
|
||||||
|
const path = destPos ? calculateWorldPath(playerPos, destPos) : [];
|
||||||
|
|
||||||
|
// Render path onto our offscreen canvas
|
||||||
|
renderGPSToCanvas(offscreenCanvas, path, playerPos, destPos, {
|
||||||
|
pathColor: '#60a5fa', // Bright neon blue
|
||||||
|
pathWidth: 8,
|
||||||
|
playerColor: '#ff0055', // Neon pink-red for bike
|
||||||
|
playerSize: 10,
|
||||||
|
destColor: '#00ffcc', // Vibrant cyan for target
|
||||||
|
destSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify Three.js that the texture needs an update
|
||||||
|
if (textureRef.current) {
|
||||||
|
textureRef.current.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [playerPos, destPos, loading, calculateWorldPath, renderGPSToCanvas, offscreenCanvas]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh castShadow receiveShadow>
|
||||||
|
<planeGeometry args={[width, height]} />
|
||||||
|
<meshBasicMaterial toneMapped={false}>
|
||||||
|
<canvasTexture
|
||||||
|
ref={textureRef}
|
||||||
|
attach="map"
|
||||||
|
image={offscreenCanvas}
|
||||||
|
minFilter={THREE.LinearFilter}
|
||||||
|
magFilter={THREE.LinearFilter}
|
||||||
|
/>
|
||||||
|
</meshBasicMaterial>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Styles for HUD (Premium Glassmorphism)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const hudStyles = {
|
||||||
|
container: (size: number): React.CSSProperties => ({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '24px',
|
||||||
|
right: '24px',
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
borderRadius: '24px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||||
|
boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.37), 0 0 15px rgba(59, 130, 246, 0.2)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
background: 'rgba(15, 23, 42, 0.6)', // Sleek dark slate
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}),
|
||||||
|
canvas: (size: number): React.CSSProperties => ({
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
display: 'block',
|
||||||
|
}),
|
||||||
|
statusText: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { GridNode } from './types';
|
||||||
|
|
||||||
|
export class Grid {
|
||||||
|
public width: number;
|
||||||
|
public height: number;
|
||||||
|
private nodes: GridNode[][];
|
||||||
|
|
||||||
|
constructor(walkableMatrix: boolean[][]) {
|
||||||
|
this.height = walkableMatrix.length;
|
||||||
|
this.width = this.height > 0 ? (walkableMatrix[0]?.length ?? 0) : 0;
|
||||||
|
this.nodes = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
const row: GridNode[] = [];
|
||||||
|
const sourceRow = walkableMatrix[y];
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
row.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
walkable: sourceRow ? (sourceRow[x] ?? false) : false,
|
||||||
|
g: 0,
|
||||||
|
h: 0,
|
||||||
|
f: 0,
|
||||||
|
parent: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.nodes.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNode(x: number, y: number): GridNode | null {
|
||||||
|
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
|
||||||
|
const row = this.nodes[y];
|
||||||
|
return row ? (row[x] ?? null) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets g, h, f values and parents for all nodes in the grid,
|
||||||
|
* preparing it for a new A* calculation.
|
||||||
|
*/
|
||||||
|
public reset(): void {
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
const row = this.nodes[y];
|
||||||
|
if (!row) continue;
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
const node = row[x];
|
||||||
|
if (!node) continue;
|
||||||
|
node.g = 0;
|
||||||
|
node.h = 0;
|
||||||
|
node.f = 0;
|
||||||
|
node.parent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves neighboring nodes. Supports 8-directional movement.
|
||||||
|
*/
|
||||||
|
public getNeighbors(node: GridNode, allowDiagonals: boolean = true): GridNode[] {
|
||||||
|
const neighbors: GridNode[] = [];
|
||||||
|
const { x, y } = node;
|
||||||
|
|
||||||
|
// Relative coordinates of 8 neighbors
|
||||||
|
const directions = [
|
||||||
|
{ dx: 0, dy: -1, isDiagonal: false }, // N
|
||||||
|
{ dx: 1, dy: 0, isDiagonal: false }, // E
|
||||||
|
{ dx: 0, dy: 1, isDiagonal: false }, // S
|
||||||
|
{ dx: -1, dy: 0, isDiagonal: false }, // W
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allowDiagonals) {
|
||||||
|
directions.push(
|
||||||
|
{ dx: 1, dy: -1, isDiagonal: true }, // NE
|
||||||
|
{ dx: 1, dy: 1, isDiagonal: true }, // SE
|
||||||
|
{ dx: -1, dy: 1, isDiagonal: true }, // SW
|
||||||
|
{ dx: -1, dy: -1, isDiagonal: true } // NW
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of directions) {
|
||||||
|
const neighbor = this.getNode(x + dir.dx, y + dir.dy);
|
||||||
|
if (neighbor && neighbor.walkable) {
|
||||||
|
// Prevent corner cutting if both orthogonal neighbors are blocked
|
||||||
|
if (dir.isDiagonal) {
|
||||||
|
const ortho1 = this.getNode(x + dir.dx, y);
|
||||||
|
const ortho2 = this.getNode(x, y + dir.dy);
|
||||||
|
const isBlocked = (!ortho1 || !ortho1.walkable) && (!ortho2 || !ortho2.walkable);
|
||||||
|
if (isBlocked) {
|
||||||
|
continue; // Skip this diagonal neighbor to avoid squeezing through corners
|
||||||
|
}
|
||||||
|
}
|
||||||
|
neighbors.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Grid } from './Grid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an image from a URL.
|
||||||
|
*/
|
||||||
|
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous'; // Enable CORS just in case
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = (err) => reject(new Error(`Failed to load image at ${url}: ${err}`));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a B&W image and scales it to gridWidth x gridHeight.
|
||||||
|
* Higher dimensions = higher accuracy but slower pathfinding.
|
||||||
|
* Lower dimensions = extremely fast pathfinding.
|
||||||
|
*
|
||||||
|
* Walkable roads should be white (or light gray). Non-walkable areas should be black.
|
||||||
|
*
|
||||||
|
* @param imageUrl The path or URL of the B&W navigation mask.
|
||||||
|
* @param gridWidth The target width of our A* pathfinding grid.
|
||||||
|
* @param gridHeight The target height of our A* pathfinding grid.
|
||||||
|
* @param threshold Brightness threshold (0-255) above which a pixel is considered walkable (default: 128).
|
||||||
|
*/
|
||||||
|
export async function createGridFromImage(
|
||||||
|
imageUrl: string,
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number,
|
||||||
|
threshold: number = 128
|
||||||
|
): Promise<Grid> {
|
||||||
|
const img = await loadImage(imageUrl);
|
||||||
|
|
||||||
|
// Create an offscreen canvas to scale and analyze the image
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = gridWidth;
|
||||||
|
canvas.height = gridHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Could not get 2D context for offscreen canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw and scale the image onto the canvas
|
||||||
|
ctx.drawImage(img, 0, 0, gridWidth, gridHeight);
|
||||||
|
|
||||||
|
// Retrieve pixel data
|
||||||
|
const imgData = ctx.getImageData(0, 0, gridWidth, gridHeight);
|
||||||
|
const data = imgData.data;
|
||||||
|
|
||||||
|
// Initialize a 2D boolean matrix representing the walkable grid
|
||||||
|
const walkableMatrix: boolean[][] = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < gridHeight; y++) {
|
||||||
|
const row: boolean[] = [];
|
||||||
|
for (let x = 0; x < gridWidth; x++) {
|
||||||
|
// Each pixel has 4 channels: R, G, B, A
|
||||||
|
const index = (y * gridWidth + x) * 4;
|
||||||
|
const r = data[index] ?? 0;
|
||||||
|
const g = data[index + 1] ?? 0;
|
||||||
|
const b = data[index + 2] ?? 0;
|
||||||
|
|
||||||
|
// Calculate brightness (standard grayscale weighting)
|
||||||
|
const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
|
||||||
|
// If bright enough, it is a road (walkable)
|
||||||
|
row.push(brightness >= threshold);
|
||||||
|
}
|
||||||
|
walkableMatrix.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Grid(walkableMatrix);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { Waypoint, WaypointNode } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates Euclidean 3D distance between two points.
|
||||||
|
*/
|
||||||
|
function getDistance3D(
|
||||||
|
posA: { x: number; y: number; z: number },
|
||||||
|
posB: { x: number; y: number; z: number }
|
||||||
|
): number {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.pow(posA.x - posB.x, 2) +
|
||||||
|
Math.pow(posA.y - posB.y, 2) +
|
||||||
|
Math.pow(posA.z - posB.z, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the closest Waypoint in a list to a target 3D world position.
|
||||||
|
*/
|
||||||
|
export function findClosestWaypoint(
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
pos: { x: number; y: number; z: number }
|
||||||
|
): Waypoint | null {
|
||||||
|
if (waypoints.length === 0) return null;
|
||||||
|
|
||||||
|
let closest = waypoints[0]!;
|
||||||
|
let minDist = getDistance3D(closest, pos);
|
||||||
|
|
||||||
|
for (let i = 1; i < waypoints.length; i++) {
|
||||||
|
const wp = waypoints[i]!;
|
||||||
|
const dist = getDistance3D(wp, pos);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closest = wp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs A* pathfinding on a network of 3D Waypoints.
|
||||||
|
*
|
||||||
|
* @param waypoints List of all waypoints in the road network.
|
||||||
|
* @param startWorldPos Player's current 3D world position.
|
||||||
|
* @param endWorldPos Targeted 3D world destination.
|
||||||
|
* @returns Array of Waypoints representing the path from start to end, or empty array if none found.
|
||||||
|
*/
|
||||||
|
export function findWaypointPath(
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
startWorldPos: { x: number; y: number; z: number },
|
||||||
|
endWorldPos: { x: number; y: number; z: number }
|
||||||
|
): Waypoint[] {
|
||||||
|
if (waypoints.length === 0) return [];
|
||||||
|
|
||||||
|
// 1. Find the closest starting and ending waypoints in the network
|
||||||
|
const startWp = findClosestWaypoint(waypoints, startWorldPos);
|
||||||
|
const endWp = findClosestWaypoint(waypoints, endWorldPos);
|
||||||
|
|
||||||
|
if (!startWp || !endWp) return [];
|
||||||
|
if (startWp.id === endWp.id) return [startWp];
|
||||||
|
|
||||||
|
// 2. Map all waypoints to A* search nodes
|
||||||
|
const nodeMap = new Map<number, WaypointNode>();
|
||||||
|
waypoints.forEach((wp) => {
|
||||||
|
nodeMap.set(wp.id, {
|
||||||
|
...wp,
|
||||||
|
g: Infinity,
|
||||||
|
h: Infinity,
|
||||||
|
f: Infinity,
|
||||||
|
parent: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const startNode = nodeMap.get(startWp.id)!;
|
||||||
|
const endNode = nodeMap.get(endWp.id)!;
|
||||||
|
|
||||||
|
// 3. Initialize open and closed sets
|
||||||
|
const openSet: WaypointNode[] = [startNode];
|
||||||
|
const closedSet = new Set<number>(); // Set of waypoint IDs
|
||||||
|
|
||||||
|
startNode.g = 0;
|
||||||
|
startNode.h = getDistance3D(startNode, endNode);
|
||||||
|
startNode.f = startNode.h;
|
||||||
|
|
||||||
|
while (openSet.length > 0) {
|
||||||
|
// Find node with lowest f score
|
||||||
|
let lowIndex = 0;
|
||||||
|
for (let i = 1; i < openSet.length; i++) {
|
||||||
|
const node = openSet[i]!;
|
||||||
|
const lowNode = openSet[lowIndex]!;
|
||||||
|
if (node.f < lowNode.f) {
|
||||||
|
lowIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNode = openSet[lowIndex]!;
|
||||||
|
|
||||||
|
// Reached destination! Reconstruct the path
|
||||||
|
if (currentNode.id === endNode.id) {
|
||||||
|
const path: Waypoint[] = [];
|
||||||
|
let temp: WaypointNode | null = currentNode;
|
||||||
|
while (temp !== null) {
|
||||||
|
// Find corresponding raw Waypoint
|
||||||
|
const rawWp = waypoints.find((w) => w.id === temp!.id);
|
||||||
|
if (rawWp) {
|
||||||
|
path.push(rawWp);
|
||||||
|
}
|
||||||
|
temp = temp.parent;
|
||||||
|
}
|
||||||
|
return path.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move from open to closed set
|
||||||
|
openSet.splice(lowIndex, 1);
|
||||||
|
closedSet.add(currentNode.id);
|
||||||
|
|
||||||
|
// Process neighbors
|
||||||
|
for (const neighborId of currentNode.connections) {
|
||||||
|
if (closedSet.has(neighborId)) continue;
|
||||||
|
|
||||||
|
const neighborNode = nodeMap.get(neighborId);
|
||||||
|
if (!neighborNode) continue;
|
||||||
|
|
||||||
|
// Distance from currentNode to neighbor is physical 3D distance
|
||||||
|
const tentativeG = currentNode.g + getDistance3D(currentNode, neighborNode);
|
||||||
|
|
||||||
|
let neighborInOpenSet = openSet.some((node) => node.id === neighborId);
|
||||||
|
|
||||||
|
if (!neighborInOpenSet || tentativeG < neighborNode.g) {
|
||||||
|
neighborNode.parent = currentNode;
|
||||||
|
neighborNode.g = tentativeG;
|
||||||
|
neighborNode.h = getDistance3D(neighborNode, endNode);
|
||||||
|
neighborNode.f = neighborNode.g + neighborNode.h;
|
||||||
|
|
||||||
|
if (!neighborInOpenSet) {
|
||||||
|
openSet.push(neighborNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No path found
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './Grid';
|
||||||
|
export * from './AStar';
|
||||||
|
export * from './ImageToGrid';
|
||||||
|
export * from './useGPS';
|
||||||
|
export * from './GPSMinimap';
|
||||||
|
export * from './WaypointAStar';
|
||||||
|
export * from './useWaypointGPS';
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridNode {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
walkable: boolean;
|
||||||
|
g: number;
|
||||||
|
h: number;
|
||||||
|
f: number;
|
||||||
|
parent: GridNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Waypoint {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
connections: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaypointNode {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
connections: number[];
|
||||||
|
g: number;
|
||||||
|
h: number;
|
||||||
|
f: number;
|
||||||
|
parent: WaypointNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Grid } from './Grid';
|
||||||
|
import { createGridFromImage } from './ImageToGrid';
|
||||||
|
import { findPath } from './AStar';
|
||||||
|
import type { Position } from './types';
|
||||||
|
|
||||||
|
export interface WorldBounds {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minZ: number;
|
||||||
|
maxZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseGPSOptions {
|
||||||
|
bwMaskUrl: string;
|
||||||
|
colorMapUrl: string;
|
||||||
|
gridWidth: number; // The "width of the array pathfinding" (resolution scaling)
|
||||||
|
gridHeight: number; // The "height of the array pathfinding"
|
||||||
|
worldBounds: WorldBounds;
|
||||||
|
allowDiagonals?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGPS({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
allowDiagonals = true,
|
||||||
|
}: UseGPSOptions) {
|
||||||
|
const [grid, setGrid] = useState<Grid | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Cache the images so they don't reload every frame
|
||||||
|
const colorMapImgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
// Initialize the pathfinding grid
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
async function initGrid() {
|
||||||
|
try {
|
||||||
|
const pathfindingGrid = await createGridFromImage(bwMaskUrl, gridWidth, gridHeight);
|
||||||
|
|
||||||
|
// Pre-load color map image for canvas drawing
|
||||||
|
const colorMapImg = new Image();
|
||||||
|
colorMapImg.crossOrigin = 'anonymous';
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
colorMapImg.onload = resolve;
|
||||||
|
colorMapImg.onerror = reject;
|
||||||
|
colorMapImg.src = colorMapUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
setGrid(pathfindingGrid);
|
||||||
|
colorMapImgRef.current = colorMapImg;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (active) {
|
||||||
|
setError(err.message || 'Failed to initialize GPS system');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initGrid();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [bwMaskUrl, colorMapUrl, gridWidth, gridHeight]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates 3D World coordinates (X, Z) into 2D Grid coordinates (col, row)
|
||||||
|
*/
|
||||||
|
const worldToGrid = useCallback(
|
||||||
|
(worldX: number, worldZ: number): Position => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = worldBounds;
|
||||||
|
|
||||||
|
// Calculate percentages across the bounds
|
||||||
|
const pctX = (worldX - minX) / (maxX - minX);
|
||||||
|
const pctZ = (worldZ - minZ) / (maxZ - minZ);
|
||||||
|
|
||||||
|
// Map to grid dimensions
|
||||||
|
const gridX = Math.max(0, Math.min(gridWidth - 1, Math.floor(pctX * gridWidth)));
|
||||||
|
const gridY = Math.max(0, Math.min(gridHeight - 1, Math.floor(pctZ * gridHeight)));
|
||||||
|
|
||||||
|
return { x: gridX, y: gridY };
|
||||||
|
},
|
||||||
|
[worldBounds, gridWidth, gridHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates 2D Grid coordinates (col, row) back into 3D World coordinates (X, Z)
|
||||||
|
*/
|
||||||
|
const gridToWorld = useCallback(
|
||||||
|
(gridX: number, gridY: number): { x: number; z: number } => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = worldBounds;
|
||||||
|
|
||||||
|
const pctX = gridX / gridWidth;
|
||||||
|
const pctZ = gridY / gridHeight;
|
||||||
|
|
||||||
|
const worldX = minX + pctX * (maxX - minX);
|
||||||
|
const worldZ = minZ + pctZ * (maxZ - minZ);
|
||||||
|
|
||||||
|
return { x: worldX, z: worldZ };
|
||||||
|
},
|
||||||
|
[worldBounds, gridWidth, gridHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the A* calculation using 3D world coordinates.
|
||||||
|
* Returns path in 3D world space.
|
||||||
|
*/
|
||||||
|
const calculateWorldPath = useCallback(
|
||||||
|
(startWorld: { x: number; z: number }, endWorld: { x: number; z: number }): { x: number; z: number }[] => {
|
||||||
|
if (!grid) return [];
|
||||||
|
|
||||||
|
const startGrid = worldToGrid(startWorld.x, startWorld.z);
|
||||||
|
const endGrid = worldToGrid(endWorld.x, endWorld.z);
|
||||||
|
|
||||||
|
const gridPath = findPath(grid, startGrid, endGrid, allowDiagonals);
|
||||||
|
|
||||||
|
// Convert path coordinates back to 3D space
|
||||||
|
return gridPath.map((node) => gridToWorld(node.x, node.y));
|
||||||
|
},
|
||||||
|
[grid, worldToGrid, gridToWorld, allowDiagonals]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an HTML5 `<canvas>` element with the background color map,
|
||||||
|
* a path line, and the player/destination indicators.
|
||||||
|
*/
|
||||||
|
const renderGPSToCanvas = useCallback(
|
||||||
|
(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
path: { x: number; z: number }[],
|
||||||
|
playerWorldPos?: { x: number; z: number },
|
||||||
|
destWorldPos?: { x: number; z: number },
|
||||||
|
options: {
|
||||||
|
pathColor?: string;
|
||||||
|
pathWidth?: number;
|
||||||
|
playerColor?: string;
|
||||||
|
playerSize?: number;
|
||||||
|
destColor?: string;
|
||||||
|
destSize?: number;
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx || !colorMapImgRef.current) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
pathColor = '#3b82f6', // Premium blue
|
||||||
|
pathWidth = 6,
|
||||||
|
playerColor = '#ef4444', // Red dot for player
|
||||||
|
playerSize = 8,
|
||||||
|
destColor = '#10b981', // Green dot for flag
|
||||||
|
destSize = 8,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const canvasWidth = canvas.width;
|
||||||
|
const canvasHeight = canvas.height;
|
||||||
|
|
||||||
|
// 1. Draw background color map
|
||||||
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight);
|
||||||
|
|
||||||
|
// Helper: translate world coordinates to Canvas pixels
|
||||||
|
const worldToCanvas = (wx: number, wz: number): Position => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = worldBounds;
|
||||||
|
const px = ((wx - minX) / (maxX - minX)) * canvasWidth;
|
||||||
|
const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight;
|
||||||
|
return { x: px, y: py };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Draw A* Path Line
|
||||||
|
if (path.length > 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const startNode = path[0]!;
|
||||||
|
const startPt = worldToCanvas(startNode.x, startNode.z);
|
||||||
|
ctx.moveTo(startPt.x, startPt.y);
|
||||||
|
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const node = path[i]!;
|
||||||
|
const pt = worldToCanvas(node.x, node.z);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = pathColor;
|
||||||
|
ctx.lineWidth = pathWidth;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
// Add a soft glow effect for premium feel
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.shadowColor = pathColor;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Reset shadow for subsequent drawings
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Draw Destination Indicator
|
||||||
|
if (destWorldPos) {
|
||||||
|
const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = destColor;
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Player Indicator
|
||||||
|
if (playerWorldPos) {
|
||||||
|
const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = playerColor;
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[worldBounds]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
grid,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
calculateWorldPath,
|
||||||
|
renderGPSToCanvas,
|
||||||
|
worldToGrid,
|
||||||
|
gridToWorld,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { findWaypointPath } from './WaypointAStar';
|
||||||
|
import type { Waypoint } from './types';
|
||||||
|
import type { WorldBounds } from './useGPS';
|
||||||
|
|
||||||
|
export interface UseWaypointGPSOptions {
|
||||||
|
roadNetworkUrl: string; // URL/Path to roadNetwork.json
|
||||||
|
colorMapUrl: string; // URL/Path to color_map.png
|
||||||
|
worldBounds: WorldBounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWaypointGPS({
|
||||||
|
roadNetworkUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
worldBounds,
|
||||||
|
}: UseWaypointGPSOptions) {
|
||||||
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const colorMapImgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
// Load waypoint list and background color map image
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
async function initGPS() {
|
||||||
|
try {
|
||||||
|
// 1. Fetch the road network JSON
|
||||||
|
const response = await fetch(roadNetworkUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load road network from ${roadNetworkUrl}`);
|
||||||
|
}
|
||||||
|
const data: Waypoint[] = await response.json();
|
||||||
|
|
||||||
|
// 2. Pre-load the color map image
|
||||||
|
const colorMapImg = new Image();
|
||||||
|
colorMapImg.crossOrigin = 'anonymous';
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
colorMapImg.onload = resolve;
|
||||||
|
colorMapImg.onerror = reject;
|
||||||
|
colorMapImg.src = colorMapUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
setWaypoints(data);
|
||||||
|
colorMapImgRef.current = colorMapImg;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (active) {
|
||||||
|
setError(err.message || 'Failed to initialize Waypoint GPS');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initGPS();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [roadNetworkUrl, colorMapUrl]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the shortest path between start and end world points.
|
||||||
|
*/
|
||||||
|
const calculateRoute = useCallback(
|
||||||
|
(
|
||||||
|
startWorld: { x: number; y: number; z: number },
|
||||||
|
endWorld: { x: number; y: number; z: number }
|
||||||
|
): Waypoint[] => {
|
||||||
|
if (waypoints.length === 0) return [];
|
||||||
|
return findWaypointPath(waypoints, startWorld, endWorld);
|
||||||
|
},
|
||||||
|
[waypoints]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the road network path, player position, and waypoint target onto a canvas.
|
||||||
|
*/
|
||||||
|
const renderGPSToCanvas = useCallback(
|
||||||
|
(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
path: Waypoint[],
|
||||||
|
playerWorldPos?: { x: number; y: number; z: number },
|
||||||
|
destWorldPos?: { x: number; y: number; z: number },
|
||||||
|
options: {
|
||||||
|
pathColor?: string;
|
||||||
|
pathWidth?: number;
|
||||||
|
playerColor?: string;
|
||||||
|
playerSize?: number;
|
||||||
|
destColor?: string;
|
||||||
|
destSize?: number;
|
||||||
|
showAllWaypoints?: boolean; // Debug mode
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx || !colorMapImgRef.current) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
pathColor = '#10b981', // Premium emerald green
|
||||||
|
pathWidth = 6,
|
||||||
|
playerColor = '#ff0055', // Neon pink-red for bike
|
||||||
|
playerSize = 8,
|
||||||
|
destColor = '#00ffcc', // Neon cyan for target
|
||||||
|
destSize = 8,
|
||||||
|
showAllWaypoints = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const canvasWidth = canvas.width;
|
||||||
|
const canvasHeight = canvas.height;
|
||||||
|
|
||||||
|
// 1. Draw color map background
|
||||||
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight);
|
||||||
|
|
||||||
|
// Helper: translate world coordinates (X, Z) to Canvas pixels (x, y)
|
||||||
|
const worldToCanvas = (wx: number, wz: number) => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = worldBounds;
|
||||||
|
const px = ((wx - minX) / (maxX - minX)) * canvasWidth;
|
||||||
|
const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight;
|
||||||
|
return { x: px, y: py };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. [Debug] Draw all network connections
|
||||||
|
if (showAllWaypoints && waypoints.length > 0) {
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
const drawn = new Set<string>();
|
||||||
|
|
||||||
|
waypoints.forEach((wp) => {
|
||||||
|
const startPt = worldToCanvas(wp.x, wp.z);
|
||||||
|
wp.connections.forEach((connId) => {
|
||||||
|
const other = waypoints.find((w) => w.id === connId);
|
||||||
|
if (other) {
|
||||||
|
const key = wp.id < other.id ? `${wp.id}-${other.id}` : `${other.id}-${wp.id}`;
|
||||||
|
if (!drawn.has(key)) {
|
||||||
|
drawn.add(key);
|
||||||
|
const endPt = worldToCanvas(other.x, other.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(startPt.x, startPt.y);
|
||||||
|
ctx.lineTo(endPt.x, endPt.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Draw calculated A* path line
|
||||||
|
if (path.length > 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const startNode = path[0]!;
|
||||||
|
const startPt = worldToCanvas(startNode.x, startNode.z);
|
||||||
|
ctx.moveTo(startPt.x, startPt.y);
|
||||||
|
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const node = path[i]!;
|
||||||
|
const pt = worldToCanvas(node.x, node.z);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = pathColor;
|
||||||
|
ctx.lineWidth = pathWidth;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
// Add soft premium path glow
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.shadowColor = pathColor;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0; // Reset
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Destination target
|
||||||
|
if (destWorldPos) {
|
||||||
|
const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = destColor;
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Draw Player / Bike
|
||||||
|
if (playerWorldPos) {
|
||||||
|
const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = playerColor;
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[worldBounds, waypoints]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
waypoints,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
calculateRoute,
|
||||||
|
renderGPSToCanvas,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { HomePage } from "@/pages/page";
|
import { HomePage } from "@/pages/page";
|
||||||
import { EditorPage } from "@/pages/editor/page";
|
import { EditorPage } from "@/pages/editor/page";
|
||||||
|
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
||||||
|
import { BackgroundMapPage } from "@/pages/backgroundmap/page";
|
||||||
import {
|
import {
|
||||||
DocsAnimationRoute,
|
DocsAnimationRoute,
|
||||||
DocsAudioRoute,
|
DocsAudioRoute,
|
||||||
@@ -43,6 +45,18 @@ const editorRoute = createRoute({
|
|||||||
component: EditorPage,
|
component: EditorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const waypointRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/waypoint",
|
||||||
|
component: WaypointEditorPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const backgroundMapRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/backgroundmap",
|
||||||
|
component: BackgroundMapPage,
|
||||||
|
});
|
||||||
|
|
||||||
const docsRoute = createRoute({
|
const docsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/docs",
|
path: "/docs",
|
||||||
@@ -78,6 +92,8 @@ const docsChildRoutes = [
|
|||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
editorRoute,
|
editorRoute,
|
||||||
|
waypointRoute,
|
||||||
|
backgroundMapRoute,
|
||||||
docsRoute.addChildren(docsChildRoutes),
|
docsRoute.addChildren(docsChildRoutes),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { ShaderMaterial } from "three";
|
|
||||||
|
|
||||||
export const createNetShader = (): ShaderMaterial => {
|
|
||||||
return new ShaderMaterial({
|
|
||||||
transparent: true,
|
|
||||||
depthWrite: false,
|
|
||||||
uniforms: {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uGridScale: { value: 11.3 },
|
|
||||||
uPincushionStrength: { value: 0.4 },
|
|
||||||
uBloomIntensity: { value: 0.8 },
|
|
||||||
uGridThickness: { value: 0.05 },
|
|
||||||
},
|
|
||||||
vertexShader: `
|
|
||||||
varying vec2 vUv;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vUv = uv;
|
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
fragmentShader: `
|
|
||||||
uniform float uTime;
|
|
||||||
uniform float uGridScale;
|
|
||||||
uniform float uPincushionStrength;
|
|
||||||
uniform float uBloomIntensity;
|
|
||||||
uniform float uGridThickness;
|
|
||||||
|
|
||||||
varying vec2 vUv;
|
|
||||||
|
|
||||||
vec2 applyPincushion(vec2 uv, float strength) {
|
|
||||||
vec2 center = uv - 0.5;
|
|
||||||
float dist = length(center);
|
|
||||||
float distortion = 1.0 + dist * dist * strength;
|
|
||||||
return center * distortion + 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
float grid(vec2 uv, float scale, float thickness) {
|
|
||||||
vec2 gridUV = fract(uv * scale);
|
|
||||||
|
|
||||||
float edgeX = step(gridUV.x, thickness) + step(1.0 - thickness, gridUV.x);
|
|
||||||
float edgeY = step(gridUV.y, thickness) + step(1.0 - thickness, gridUV.y);
|
|
||||||
|
|
||||||
float line = min(edgeX + edgeY, 1.0);
|
|
||||||
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vec2 uv = applyPincushion(vUv, uPincushionStrength);
|
|
||||||
|
|
||||||
float gridPattern = grid(uv, uGridScale, uGridThickness);
|
|
||||||
|
|
||||||
vec3 gridColor = vec3(1.0, 0.8, 0.2);
|
|
||||||
float bloom = gridPattern * uBloomIntensity;
|
|
||||||
vec3 col = gridColor * (0.3 + bloom);
|
|
||||||
|
|
||||||
gl_FragColor = vec4(col, gridPattern * 0.95);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -14,7 +14,6 @@ export interface CinematicDialogueCue {
|
|||||||
export interface CinematicDefinition {
|
export interface CinematicDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
timecode?: number;
|
timecode?: number;
|
||||||
trigger?: string;
|
|
||||||
cameraKeyframes: CinematicCameraKeyframe[];
|
cameraKeyframes: CinematicCameraKeyframe[];
|
||||||
dialogueCues?: CinematicDialogueCue[];
|
dialogueCues?: CinematicDialogueCue[];
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-4
@@ -1,12 +1,28 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
export type GameStep = "intro" | "sequence_video" | "start-move" | "bike";
|
export type GameStep =
|
||||||
|
| "intro"
|
||||||
|
| "start-intro"
|
||||||
|
| "naming"
|
||||||
|
| "bienvenue"
|
||||||
|
| "star-move"
|
||||||
|
| "mission2"
|
||||||
|
| "searching"
|
||||||
|
| "helped"
|
||||||
|
| "manipulation"
|
||||||
|
| "outOfFabrik";
|
||||||
|
|
||||||
export const GAME_STEPS: readonly GameStep[] = [
|
export const GAME_STEPS: readonly GameStep[] = [
|
||||||
"intro",
|
"intro",
|
||||||
"sequence_video",
|
"start-intro",
|
||||||
"start-move",
|
"naming",
|
||||||
"bike",
|
"bienvenue",
|
||||||
|
"star-move",
|
||||||
|
"mission2",
|
||||||
|
"searching",
|
||||||
|
"helped",
|
||||||
|
"manipulation",
|
||||||
|
"outOfFabrik",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export interface Zone {
|
export interface Zone {
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
export type PyloneStep =
|
|
||||||
| "locked"
|
|
||||||
| "alert"
|
|
||||||
| "searching"
|
|
||||||
| "helped"
|
|
||||||
| "manipulation";
|
|
||||||
|
|
||||||
export const PYLONE_STEPS: readonly PyloneStep[] = [
|
|
||||||
"locked",
|
|
||||||
"alert",
|
|
||||||
"searching",
|
|
||||||
"helped",
|
|
||||||
"manipulation",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function isPyloneStep(value: string): value is PyloneStep {
|
|
||||||
return PYLONE_STEPS.includes(value as PyloneStep);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type RepairMissionId = "bike" | "ferme";
|
export type RepairMissionId = "bike" | "pylone" | "ferme";
|
||||||
|
|
||||||
export type MissionStep =
|
export type MissionStep =
|
||||||
| "locked"
|
| "locked"
|
||||||
@@ -10,7 +10,7 @@ export type MissionStep =
|
|||||||
| "reassembling"
|
| "reassembling"
|
||||||
| "done";
|
| "done";
|
||||||
|
|
||||||
export const REPAIR_MISSION_IDS = ["bike", "ferme"] as const;
|
export const REPAIR_MISSION_IDS = ["bike", "pylone", "ferme"] as const;
|
||||||
|
|
||||||
export const MISSION_STEPS = [
|
export const MISSION_STEPS = [
|
||||||
"locked",
|
"locked",
|
||||||
|
|||||||
+121
-21
@@ -9,6 +9,7 @@ import type {
|
|||||||
CinematicManifest,
|
CinematicManifest,
|
||||||
} from "@/types/cinematics/cinematics";
|
} from "@/types/cinematics/cinematics";
|
||||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
@@ -16,11 +17,15 @@ import { queueDialogueById } from "@/utils/dialogues/playDialogue";
|
|||||||
|
|
||||||
export function GameCinematics(): null {
|
export function GameCinematics(): null {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalCamera(camera);
|
||||||
|
}, [camera]);
|
||||||
|
|
||||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||||
const [dialogueManifest, setDialogueManifest] =
|
const [dialogueManifest, setDialogueManifest] =
|
||||||
useState<DialogueManifest | null>(null);
|
useState<DialogueManifest | null>(null);
|
||||||
const playedCinematicsRef = useRef(new Set<string>());
|
const playedCinematicsRef = useRef(new Set<string>());
|
||||||
const triggeredCinematicsRef = useRef(new Set<string>());
|
|
||||||
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||||
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||||
const startedAtRef = useRef<number | null>(null);
|
const startedAtRef = useRef<number | null>(null);
|
||||||
@@ -65,25 +70,7 @@ export function GameCinematics(): null {
|
|||||||
|
|
||||||
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
|
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
|
||||||
|
|
||||||
const currentStep = useGameStore.getState().intro.currentStep;
|
|
||||||
|
|
||||||
manifest.cinematics.forEach((cinematic) => {
|
manifest.cinematics.forEach((cinematic) => {
|
||||||
if (cinematic.trigger) {
|
|
||||||
if (triggeredCinematicsRef.current.has(cinematic.id)) return;
|
|
||||||
if (currentStep !== cinematic.trigger) return;
|
|
||||||
if (cinematic.dialogueCues && !dialogueManifest) return;
|
|
||||||
|
|
||||||
triggeredCinematicsRef.current.add(cinematic.id);
|
|
||||||
playCinematic(camera, cinematic, timelineRef, {
|
|
||||||
dialogueManifest,
|
|
||||||
activeAudiosRef,
|
|
||||||
onComplete: () => {
|
|
||||||
triggeredCinematicsRef.current.delete(cinematic.id);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cinematic.timecode === undefined) return;
|
if (cinematic.timecode === undefined) return;
|
||||||
if (cinematic.timecode > elapsedTime) return;
|
if (cinematic.timecode > elapsedTime) return;
|
||||||
if (cinematic.dialogueCues && !dialogueManifest) return;
|
if (cinematic.dialogueCues && !dialogueManifest) return;
|
||||||
@@ -114,7 +101,6 @@ function playCinematic(
|
|||||||
dialogueOptions: {
|
dialogueOptions: {
|
||||||
dialogueManifest: DialogueManifest | null;
|
dialogueManifest: DialogueManifest | null;
|
||||||
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
|
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
|
||||||
onComplete?: () => void;
|
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const firstKeyframe = cinematic.cameraKeyframes[0];
|
const firstKeyframe = cinematic.cameraKeyframes[0];
|
||||||
@@ -133,7 +119,6 @@ function playCinematic(
|
|||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
timelineRef.current = null;
|
timelineRef.current = null;
|
||||||
useGameStore.getState().setCinematicPlaying(false);
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
dialogueOptions.onComplete?.();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,3 +177,118 @@ function playCinematic(
|
|||||||
|
|
||||||
timelineRef.current = timeline;
|
timelineRef.current = timeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cameraTransitionTimeline: gsap.core.Timeline | null = null;
|
||||||
|
let globalCamera: THREE.Camera | null = null;
|
||||||
|
|
||||||
|
export function setGlobalCamera(camera: THREE.Camera | null): void {
|
||||||
|
globalCamera = camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animateCameraTransition(
|
||||||
|
targetPosition: Vector3Tuple,
|
||||||
|
targetLookAt: Vector3Tuple,
|
||||||
|
duration: number = 1,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): void {
|
||||||
|
if (!globalCamera) {
|
||||||
|
logger.warn("GameCinematics", "Camera not found for transition");
|
||||||
|
onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = globalCamera;
|
||||||
|
|
||||||
|
cameraTransitionTimeline?.kill();
|
||||||
|
useGameStore.getState().setCinematicPlaying(true);
|
||||||
|
|
||||||
|
const target = new THREE.Vector3(...targetLookAt);
|
||||||
|
|
||||||
|
cameraTransitionTimeline = gsap.timeline({
|
||||||
|
onUpdate: () => camera.lookAt(target),
|
||||||
|
onComplete: () => {
|
||||||
|
cameraTransitionTimeline = null;
|
||||||
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(camera.position, {
|
||||||
|
x: targetPosition[0],
|
||||||
|
y: targetPosition[1],
|
||||||
|
z: targetPosition[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
x: targetLookAt[0],
|
||||||
|
y: targetLookAt[1],
|
||||||
|
z: targetLookAt[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animateCameraTransformTransition(
|
||||||
|
targetPosition: Vector3Tuple,
|
||||||
|
targetRotation: Vector3Tuple,
|
||||||
|
duration: number = 1,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): void {
|
||||||
|
if (!globalCamera) {
|
||||||
|
logger.warn("GameCinematics", "Camera not found for transition");
|
||||||
|
onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = globalCamera;
|
||||||
|
|
||||||
|
cameraTransitionTimeline?.kill();
|
||||||
|
useGameStore.getState().setCinematicPlaying(true);
|
||||||
|
|
||||||
|
// Convert target rotation in degrees to quaternion
|
||||||
|
const targetEuler = new THREE.Euler(
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[0]),
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[1]),
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[2]),
|
||||||
|
"YXZ"
|
||||||
|
);
|
||||||
|
const startQuaternion = camera.quaternion.clone();
|
||||||
|
const endQuaternion = new THREE.Quaternion().setFromEuler(targetEuler);
|
||||||
|
|
||||||
|
const transitionObj = { progress: 0 };
|
||||||
|
|
||||||
|
cameraTransitionTimeline = gsap.timeline({
|
||||||
|
onUpdate: () => {
|
||||||
|
camera.quaternion.copy(startQuaternion).slerp(endQuaternion, transitionObj.progress);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
cameraTransitionTimeline = null;
|
||||||
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(camera.position, {
|
||||||
|
x: targetPosition[0],
|
||||||
|
y: targetPosition[1],
|
||||||
|
z: targetPosition[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(
|
||||||
|
transitionObj,
|
||||||
|
{
|
||||||
|
progress: 1,
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||||
|
import { Ebike } from "@/components/ebike/Ebike";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
@@ -19,6 +20,10 @@ const GAME_REPAIR_ZONES = [
|
|||||||
mission: "bike",
|
mission: "bike",
|
||||||
position: [8, 0, -6],
|
position: [8, 0, -6],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
mission: "pylone",
|
||||||
|
position: [64, 0, -66],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
mission: "ferme",
|
mission: "ferme",
|
||||||
position: [-24, 0, 42],
|
position: [-24, 0, 42],
|
||||||
@@ -52,6 +57,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
{mainState === "intro" ? (
|
{mainState === "intro" ? (
|
||||||
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
|
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
|
||||||
) : null}
|
) : null}
|
||||||
|
<Ebike position={[0, 10, 0]} />
|
||||||
{GAME_REPAIR_ZONES.map((zone) => (
|
{GAME_REPAIR_ZONES.map((zone) => (
|
||||||
<RepairGame
|
<RepairGame
|
||||||
key={zone.mission}
|
key={zone.mission}
|
||||||
|
|||||||
+2
-7
@@ -28,9 +28,8 @@ import { GameMap } from "@/world/GameMap";
|
|||||||
import { GameStageContent } from "@/world/GameStageContent";
|
import { GameStageContent } from "@/world/GameStageContent";
|
||||||
import { Player } from "@/world/player/Player";
|
import { Player } from "@/world/player/Player";
|
||||||
import { TestMap } from "@/world/debug/TestMap";
|
import { TestMap } from "@/world/debug/TestMap";
|
||||||
import { NetTest } from "@/components/three/NetTest";
|
|
||||||
import { VideoPlayer } from "@/components/ui/VideoPlayer";
|
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
|
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||||
|
|
||||||
interface WorldProps {
|
interface WorldProps {
|
||||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||||
@@ -63,7 +62,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Environment />
|
<Environment />
|
||||||
<VideoPlayer />
|
|
||||||
<Lighting />
|
<Lighting />
|
||||||
<DebugHelpers />
|
<DebugHelpers />
|
||||||
{showHandTrackingGloves ? (
|
{showHandTrackingGloves ? (
|
||||||
@@ -101,10 +99,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<TestMap onOctreeReady={handleOctreeReady} />
|
||||||
<TestMap onOctreeReady={handleOctreeReady} />
|
|
||||||
<NetTest />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sceneMode !== "game" && spawnPlayer ? (
|
{sceneMode !== "game" && spawnPlayer ? (
|
||||||
|
|||||||
+122
-1
@@ -1,11 +1,13 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Component, useRef } from "react";
|
import { Component, useRef, useState, useEffect } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||||
|
import { Line } from "@react-three/drei";
|
||||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||||
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
||||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||||
|
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||||
import {
|
import {
|
||||||
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
|
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
|
||||||
TEST_SCENE_FLOOR_POSITION,
|
TEST_SCENE_FLOOR_POSITION,
|
||||||
@@ -84,11 +86,55 @@ class ModelPreviewErrorBoundary extends Component<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Waypoint {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
connections: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||||
const floorRef = useRef<THREE.Group>(null);
|
const floorRef = useRef<THREE.Group>(null);
|
||||||
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
|
|
||||||
useOctreeGraphNode(floorRef, onOctreeReady);
|
useOctreeGraphNode(floorRef, onOctreeReady);
|
||||||
|
|
||||||
|
// Load waypoints with double-safe fallback
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. Try localStorage
|
||||||
|
const saved = localStorage.getItem('la-fabrik-waypoints');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
console.log(`[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`);
|
||||||
|
setWaypoints(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse local storage waypoints", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try public/roadNetwork.json
|
||||||
|
console.log("[TestMap] Tentative de chargement depuis /roadNetwork.json...");
|
||||||
|
fetch('/roadNetwork.json')
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
throw new Error("Impossible de charger /roadNetwork.json");
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
console.log(`[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`);
|
||||||
|
setWaypoints(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<group ref={floorRef}>
|
<group ref={floorRef}>
|
||||||
@@ -98,6 +144,45 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
{/* Render Pathfinder Maps Waypoints & Routes visually */}
|
||||||
|
<group name="pathfinder-maps-visuals">
|
||||||
|
{/* Render Connection Lines */}
|
||||||
|
{waypoints.flatMap((wp) =>
|
||||||
|
wp.connections.map((connId) => {
|
||||||
|
const other = waypoints.find((w) => w.id === connId);
|
||||||
|
// Draw each line only once by enforcing wp.id < other.id
|
||||||
|
if (other && wp.id < other.id) {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={`route-${wp.id}-${other.id}`}
|
||||||
|
points={[
|
||||||
|
[wp.x, wp.y + 0.3, wp.z],
|
||||||
|
[other.x, other.y + 0.3, other.z]
|
||||||
|
]}
|
||||||
|
color="#10b981" // Beautiful emerald green
|
||||||
|
lineWidth={2.5}
|
||||||
|
transparent
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render Waypoint Spheres */}
|
||||||
|
{waypoints.map((wp) => (
|
||||||
|
<mesh key={`wp-sphere-${wp.id}`} position={[wp.x, wp.y + 0.3, wp.z]}>
|
||||||
|
<sphereGeometry args={[0.35, 16, 16]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#059669" // Deep emerald green
|
||||||
|
transparent
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
|
||||||
<Physics>
|
<Physics>
|
||||||
<RigidBody type="fixed">
|
<RigidBody type="fixed">
|
||||||
<CuboidCollider
|
<CuboidCollider
|
||||||
@@ -151,6 +236,42 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</Physics>
|
</Physics>
|
||||||
|
|
||||||
|
{/* Dynamic Futuristic 3D GPS Dashboard Preview */}
|
||||||
|
<group position={[0, 2.8, -4.8]} rotation={[0, 0, 0]}>
|
||||||
|
{/* Futuristic glowing screen frame (commented out to show true 3D transparency!) */}
|
||||||
|
{/*
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[4.2, 4.2, 0.1]} />
|
||||||
|
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} transparent opacity={0.4} />
|
||||||
|
</mesh>
|
||||||
|
*/}
|
||||||
|
{/* Glow accent border (commented out to remove any orange transparency tint!) */}
|
||||||
|
{/*
|
||||||
|
<mesh position={[0, 0, 0.01]}>
|
||||||
|
<boxGeometry args={[4.05, 4.05, 0.02]} />
|
||||||
|
<meshBasicMaterial color="#f97316" transparent opacity={0.1} />
|
||||||
|
</mesh>
|
||||||
|
*/}
|
||||||
|
{/* GPS Map screen plane */}
|
||||||
|
<group position={[0, 0, 0.06]}>
|
||||||
|
<EbikeGPSMap
|
||||||
|
width={4}
|
||||||
|
height={4}
|
||||||
|
startPos={{ x: 10, y: 0, z: -10 }}
|
||||||
|
destPos={{ x: -40, y: 0, z: 30 }}
|
||||||
|
mapImageUrl="/map_background.png"
|
||||||
|
worldBounds={{
|
||||||
|
"minX": -166,
|
||||||
|
"maxX": 163,
|
||||||
|
"minZ": -142,
|
||||||
|
"maxZ": 138
|
||||||
|
}}
|
||||||
|
zoom={1}
|
||||||
|
canvasSize={900}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
|
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
|
||||||
<AnimatedModel
|
<AnimatedModel
|
||||||
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
|
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
import { PointerLockControls } from "@react-three/drei";
|
import { PointerLockControls } from "@react-three/drei";
|
||||||
|
import { setGlobalCamera } from "@/world/GameCinematics";
|
||||||
|
|
||||||
export function PlayerCamera(): React.JSX.Element {
|
export function PlayerCamera(): React.JSX.Element {
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setGlobalCamera(camera);
|
||||||
return () => {
|
return () => {
|
||||||
|
setGlobalCamera(null);
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [camera]);
|
||||||
|
|
||||||
return <PointerLockControls />;
|
return <PointerLockControls />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
PLAYER_GRAVITY,
|
PLAYER_GRAVITY,
|
||||||
PLAYER_JUMP_SPEED,
|
PLAYER_JUMP_SPEED,
|
||||||
PLAYER_MAX_DELTA,
|
PLAYER_MAX_DELTA,
|
||||||
PLAYER_WALK_SPEED,
|
|
||||||
PLAYER_XZ_DAMPING_FACTOR,
|
PLAYER_XZ_DAMPING_FACTOR,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||||
@@ -28,6 +27,7 @@ import { InteractionManager } from "@/managers/InteractionManager";
|
|||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { EBIKE_CAMERA_TRANSFORM } from "@/components/ebike/Ebike";
|
||||||
|
|
||||||
type Keys = {
|
type Keys = {
|
||||||
forward: boolean;
|
forward: boolean;
|
||||||
@@ -108,6 +108,73 @@ export function PlayerController({
|
|||||||
const wantsJump = useRef(false);
|
const wantsJump = useRef(false);
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
const canMove = useGameStore((state) => state.missionFlow.canMove);
|
const canMove = useGameStore((state) => state.missionFlow.canMove);
|
||||||
|
const currentSpeed = useGameStore((state) => state.player.currentSpeed);
|
||||||
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const movementModeRef = useRef(movementMode);
|
||||||
|
const prevMovementModeRef = useRef(movementMode);
|
||||||
|
const ebikeAngle = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
movementModeRef.current = movementMode;
|
||||||
|
}, [movementMode]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (movementMode === "ebike") {
|
||||||
|
// Teleport player capsule to the bike's current parked position
|
||||||
|
const targetPos: Vector3Tuple = (window as any).ebikeParkedPosition || [0, 8.2, 0];
|
||||||
|
const targetRot: number = (window as any).ebikeParkedRotation || 0;
|
||||||
|
|
||||||
|
const headY = targetPos[1] + PLAYER_EYE_HEIGHT;
|
||||||
|
const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS;
|
||||||
|
|
||||||
|
capsule.current.start.set(
|
||||||
|
targetPos[0],
|
||||||
|
bottomY,
|
||||||
|
targetPos[2],
|
||||||
|
);
|
||||||
|
capsule.current.end.set(
|
||||||
|
targetPos[0],
|
||||||
|
headY,
|
||||||
|
targetPos[2],
|
||||||
|
);
|
||||||
|
velocity.current.set(0, 0, 0);
|
||||||
|
onFloor.current = false;
|
||||||
|
wantsJump.current = false;
|
||||||
|
|
||||||
|
// Initialize ebikeAngle to the bike's actual parked orientation!
|
||||||
|
ebikeAngle.current = targetRot;
|
||||||
|
|
||||||
|
// Position the camera exactly at the EBIKE_CAMERA_TRANSFORM offset rotated by targetRot
|
||||||
|
const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position);
|
||||||
|
cameraOffset.applyAxisAngle(_up, targetRot);
|
||||||
|
|
||||||
|
const camPos = new THREE.Vector3()
|
||||||
|
.copy(capsule.current.end)
|
||||||
|
.add(cameraOffset);
|
||||||
|
camera.position.copy(camPos);
|
||||||
|
|
||||||
|
// Set the camera's exact rotation according to EBIKE_CAMERA_TRANSFORM.rotation + targetRot
|
||||||
|
const pitchRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[0]);
|
||||||
|
const yawRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) + targetRot;
|
||||||
|
const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]);
|
||||||
|
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
|
||||||
|
} else if (movementMode === "walk" && prevMovementModeRef.current === "ebike") {
|
||||||
|
// Restore default walk FOV
|
||||||
|
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
||||||
|
perspectiveCam.fov = 60;
|
||||||
|
perspectiveCam.updateProjectionMatrix();
|
||||||
|
|
||||||
|
// Dismount! Teleport player capsule 3 units to the right
|
||||||
|
const rightDir = new THREE.Vector3();
|
||||||
|
camera.getWorldDirection(_forward);
|
||||||
|
_forward.setY(0).normalize();
|
||||||
|
rightDir.crossVectors(_forward, _up).normalize();
|
||||||
|
|
||||||
|
const shift = rightDir.multiplyScalar(3);
|
||||||
|
capsule.current.translate(shift);
|
||||||
|
camera.position.copy(capsule.current.end);
|
||||||
|
}
|
||||||
|
prevMovementModeRef.current = movementMode;
|
||||||
|
}, [movementMode, camera]);
|
||||||
|
|
||||||
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
||||||
|
|
||||||
@@ -220,6 +287,17 @@ export function PlayerController({
|
|||||||
|
|
||||||
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
||||||
|
|
||||||
|
// Rotate camera on Y-axis for ebike steering
|
||||||
|
if (movementModeRef.current === "ebike") {
|
||||||
|
const turnSpeed = 1.8; // radians per second
|
||||||
|
if (keys.current.left) {
|
||||||
|
ebikeAngle.current += turnSpeed * dt;
|
||||||
|
}
|
||||||
|
if (keys.current.right) {
|
||||||
|
ebikeAngle.current -= turnSpeed * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
camera.getWorldDirection(_forward);
|
camera.getWorldDirection(_forward);
|
||||||
_forward.setY(0);
|
_forward.setY(0);
|
||||||
if (_forward.lengthSq() > 0) {
|
if (_forward.lengthSq() > 0) {
|
||||||
@@ -231,14 +309,16 @@ export function PlayerController({
|
|||||||
if (!movementLocked) {
|
if (!movementLocked) {
|
||||||
if (keys.current.forward) _wishDir.add(_forward);
|
if (keys.current.forward) _wishDir.add(_forward);
|
||||||
if (keys.current.backward) _wishDir.sub(_forward);
|
if (keys.current.backward) _wishDir.sub(_forward);
|
||||||
if (keys.current.left) _wishDir.sub(_right);
|
if (movementModeRef.current !== "ebike") {
|
||||||
if (keys.current.right) _wishDir.add(_right);
|
if (keys.current.left) _wishDir.sub(_right);
|
||||||
|
if (keys.current.right) _wishDir.add(_right);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
||||||
|
|
||||||
const accel = onFloor.current
|
const accel = onFloor.current
|
||||||
? PLAYER_WALK_SPEED
|
? currentSpeed
|
||||||
: PLAYER_WALK_SPEED * PLAYER_AIR_CONTROL_FACTOR;
|
: currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
|
||||||
velocity.current.x +=
|
velocity.current.x +=
|
||||||
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
||||||
velocity.current.z +=
|
velocity.current.z +=
|
||||||
@@ -282,7 +362,71 @@ export function PlayerController({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
camera.position.copy(capsule.current.end);
|
if (movementModeRef.current === "ebike") {
|
||||||
|
// Calculate dynamic steering factor
|
||||||
|
let targetSteer = 0;
|
||||||
|
if (keys.current.left) targetSteer = 1;
|
||||||
|
else if (keys.current.right) targetSteer = -1;
|
||||||
|
|
||||||
|
const currentSteer = (window as any).ebikeSteerFactor || 0;
|
||||||
|
const steerFactor = THREE.MathUtils.lerp(currentSteer, targetSteer, 8 * dt);
|
||||||
|
(window as any).ebikeSteerFactor = steerFactor;
|
||||||
|
|
||||||
|
// 1. Dynamic FOV stretch based on speed!
|
||||||
|
const speed = velocity.current.length();
|
||||||
|
const targetFov = 60 + Math.min(speed * 0.35, 9); // stretch FOV up to 9 degrees at high speed (halved by two)!
|
||||||
|
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
||||||
|
perspectiveCam.fov = THREE.MathUtils.lerp(perspectiveCam.fov, targetFov, 6 * dt);
|
||||||
|
perspectiveCam.updateProjectionMatrix();
|
||||||
|
|
||||||
|
// 2. Camera lag & dynamic swing trailing
|
||||||
|
const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position);
|
||||||
|
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
|
||||||
|
|
||||||
|
// Swing camera to optimize the view for both left and right turns:
|
||||||
|
// Since the camera is on the left (X = -3.5), it naturally trails beautifully in right turns,
|
||||||
|
// but cuts forward in left turns. We compensate by pushing the camera backward (+Z) during left turns!
|
||||||
|
const swingX = -Math.abs(steerFactor) * 1.5;
|
||||||
|
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
|
||||||
|
|
||||||
|
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
|
||||||
|
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
|
||||||
|
cameraOffset.add(cameraSwing);
|
||||||
|
|
||||||
|
const targetCamPos = new THREE.Vector3()
|
||||||
|
.copy(capsule.current.end)
|
||||||
|
.add(cameraOffset);
|
||||||
|
|
||||||
|
// Smoothly lerp camera position to eliminate rigidity
|
||||||
|
camera.position.lerp(targetCamPos, 12 * dt);
|
||||||
|
|
||||||
|
// 3. Dynamic camera roll based on steering!
|
||||||
|
const pitchRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[0]);
|
||||||
|
const yawRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) + ebikeAngle.current;
|
||||||
|
// COMMENTED OUT: Camera roll/tilt during turns (keeping it flat)
|
||||||
|
// const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]) - steerFactor * 0.08;
|
||||||
|
const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]);
|
||||||
|
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
|
||||||
|
|
||||||
|
// 4. Synchronize visual e-bike position and apply leaning!
|
||||||
|
const ebikeVisual = (window as any).ebikeVisualGroup?.current;
|
||||||
|
if (ebikeVisual) {
|
||||||
|
ebikeVisual.position.set(
|
||||||
|
capsule.current.end.x,
|
||||||
|
capsule.current.end.y - PLAYER_EYE_HEIGHT,
|
||||||
|
capsule.current.end.z
|
||||||
|
);
|
||||||
|
// Lean (roll) the bike sideways in turns (up to 15 degrees)
|
||||||
|
const leanAngle = steerFactor * 0.26; // rotate in direction of turn!
|
||||||
|
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
camera.position.copy(capsule.current.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save player capsule end position and camera yaw globally so other components (like Ebike) can access it
|
||||||
|
(window as any).playerPos = [capsule.current.end.x, capsule.current.end.y, capsule.current.end.z];
|
||||||
|
(window as any).ebikeAngle = ebikeAngle.current;
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user