Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 054cb975da | |||
| cf71148935 | |||
| 1b2241df49 | |||
| d7351e5f37 | |||
| 6a412c7b00 | |||
| e9fb36f9dc | |||
| 36180279b2 | |||
| 626dc47bbe |
@@ -25,12 +25,13 @@ The current prototype puts the player in a repair-oriented world where they prog
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --------- | --------------------------------------------------- |
|
||||
| `/` | Playable 3D experience |
|
||||
| `/?debug` | Playable scene with debug GUI and overlays |
|
||||
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
|
||||
| `/docs` | In-app documentation index |
|
||||
| Route | Purpose |
|
||||
| ---------- | --------------------------------------------------- |
|
||||
| `/` | Playable 3D experience |
|
||||
| `/?debug` | Playable scene with debug GUI and overlays |
|
||||
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
|
||||
| `/gallery` | 3D model gallery for browsing project assets |
|
||||
| `/docs` | In-app documentation index |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -98,6 +99,7 @@ Useful local URLs:
|
||||
```txt
|
||||
http://localhost:5173/?debug
|
||||
http://localhost:5173/editor
|
||||
http://localhost:5173/gallery
|
||||
http://localhost:5173/docs
|
||||
```
|
||||
|
||||
@@ -148,6 +150,7 @@ WS ws://localhost:8000/ws
|
||||
| `docs/user/features.md` | Implemented feature inventory |
|
||||
| `docs/user/main-feature.md` | User-facing repair-game walkthrough |
|
||||
| `docs/user/editor.md` | Editor user guide |
|
||||
| `docs/user/gallery.md` | Model gallery user guide |
|
||||
| `docs/code-review-preparation.md` | French code-review preparation support |
|
||||
|
||||
## Current Caveats
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Galerie des modèles
|
||||
|
||||
La galerie est disponible sur `/gallery`. Elle permet de parcourir les modèles 3D présents dans `public/models/` sans lancer la boucle de gameplay principale.
|
||||
|
||||
## Objectif
|
||||
|
||||
Cette page sert à remercier et valoriser le travail des designers du projet La Fabrik. Chaque modèle est affiché dans un canvas dédié, avec la même skybox et le même lighting que l'expérience principale.
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Ouvrir `/gallery`.
|
||||
2. Utiliser les flèches en bas de l'écran pour passer au modèle précédent ou suivant.
|
||||
3. Tourner autour du modèle avec la souris ou le doigt.
|
||||
4. Utiliser le bouton de réglages à droite pour ouvrir ou fermer le panneau lumière.
|
||||
5. Lire le diagnostic texture discret pour savoir si le modèle chargé semble correct côté textures.
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
- La liste des modèles est déclarée dans `src/data/galleryModels.ts`.
|
||||
- Le viewer utilise `@react-three/fiber` et `@react-three/drei`.
|
||||
- `OrbitControls` permet de manipuler la caméra autour du modèle.
|
||||
- `Bounds` et `Center` recadrent automatiquement le modèle actif.
|
||||
- `SkyModel` réutilise la skybox du jeu, avec un matériau non éclairé uniquement dans la galerie pour éviter que certaines faces deviennent noires avec une caméra orbitale libre.
|
||||
- Les lumières reprennent les valeurs par défaut du jeu, puis peuvent être ajustées dans le panneau latéral.
|
||||
- `OrbitControls` autorise une orbite verticale complète pour inspecter le dessous des modèles.
|
||||
- Le viewer désactive les normal maps dans la preview pour limiter les coutures visibles sur certains exports découpés en plusieurs meshes.
|
||||
- Les animations GLTF présentes dans un modèle sont lancées automatiquement.
|
||||
- Un diagnostic simple inspecte les matériaux chargés pour signaler les textures absentes ou non exploitables.
|
||||
|
||||
## Ajouter un modèle
|
||||
|
||||
1. Ajouter le dossier du modèle dans `public/models/{nom}`.
|
||||
2. Vérifier que le modèle possède un fichier chargeable, par exemple `model.gltf`, `model.glb` ou un nom explicite comme `potager.gltf`.
|
||||
3. Ajouter une entrée dans `src/data/galleryModels.ts` avec un `id`, un `name` et un `path`.
|
||||
|
||||
Exemple :
|
||||
|
||||
```ts
|
||||
{ id: "nouveau-modele", name: "Nouveau modèle", path: "/models/nouveau-modele/model.gltf" }
|
||||
```
|
||||
|
||||
## Limites connues
|
||||
|
||||
- Le navigateur ne liste pas automatiquement les dossiers de `public/models/`, donc la liste reste déclarative.
|
||||
- Les modèles très lourds peuvent prendre du temps à charger.
|
||||
- La galerie est un viewer simple : elle ne remplace pas les outils d'inspection avancée comme Blender ou le viewer d'upload.
|
||||
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,280 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
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,6 +1,6 @@
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { Component, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
|
||||
@@ -8,12 +8,16 @@ interface SkyModelProps {
|
||||
modelPath: string;
|
||||
fallbackModelPath?: string | undefined;
|
||||
fallbackScale?: number | undefined;
|
||||
materialSide?: THREE.Side | undefined;
|
||||
scale?: number | undefined;
|
||||
unlit?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface SkyModelContentProps {
|
||||
materialSide: THREE.Side;
|
||||
modelPath: string;
|
||||
scale: number;
|
||||
unlit: boolean;
|
||||
}
|
||||
|
||||
interface SkyModelErrorBoundaryProps {
|
||||
@@ -54,23 +58,37 @@ class SkyModelErrorBoundary extends Component<
|
||||
export function SkyModel({
|
||||
fallbackModelPath,
|
||||
fallbackScale = SKY_MODEL_SCALE,
|
||||
materialSide = THREE.BackSide,
|
||||
modelPath,
|
||||
scale = SKY_MODEL_SCALE,
|
||||
unlit = false,
|
||||
}: SkyModelProps): React.JSX.Element {
|
||||
const fallback = fallbackModelPath ? (
|
||||
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
|
||||
<SkyModelContent
|
||||
materialSide={materialSide}
|
||||
modelPath={fallbackModelPath}
|
||||
scale={fallbackScale}
|
||||
unlit={unlit}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
||||
<SkyModelContent modelPath={modelPath} scale={scale} />
|
||||
<SkyModelContent
|
||||
materialSide={materialSide}
|
||||
modelPath={modelPath}
|
||||
scale={scale}
|
||||
unlit={unlit}
|
||||
/>
|
||||
</SkyModelErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function SkyModelContent({
|
||||
materialSide,
|
||||
modelPath,
|
||||
scale,
|
||||
unlit,
|
||||
}: SkyModelContentProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
@@ -78,7 +96,16 @@ function SkyModelContent({
|
||||
scope: "SkyModel",
|
||||
scale,
|
||||
});
|
||||
const model = useMemo(() => createSkyModel(scene), [scene]);
|
||||
const model = useMemo(
|
||||
() => createSkyModel(scene, materialSide, unlit),
|
||||
[materialSide, scene, unlit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeSkyModelMaterials(model);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
useFrame(() => {
|
||||
groupRef.current?.position.copy(camera.position);
|
||||
@@ -96,7 +123,11 @@ function SkyModelContent({
|
||||
);
|
||||
}
|
||||
|
||||
function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
|
||||
function createSkyModel(
|
||||
scene: THREE.Object3D,
|
||||
materialSide: THREE.Side,
|
||||
unlit: boolean,
|
||||
): THREE.Object3D {
|
||||
const model = scene.clone(true);
|
||||
|
||||
model.traverse((object) => {
|
||||
@@ -106,20 +137,57 @@ function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
object.material = Array.isArray(object.material)
|
||||
? object.material.map(createSkyMaterial)
|
||||
: createSkyMaterial(object.material);
|
||||
? object.material.map((material) =>
|
||||
createSkyMaterial(material, materialSide, unlit),
|
||||
)
|
||||
: createSkyMaterial(object.material, materialSide, unlit);
|
||||
});
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
function createSkyMaterial<T extends THREE.Material>(material: T): T {
|
||||
const skyMaterial = material.clone();
|
||||
skyMaterial.side = THREE.BackSide;
|
||||
function createSkyMaterial<T extends THREE.Material>(
|
||||
material: T,
|
||||
materialSide: THREE.Side,
|
||||
unlit: boolean,
|
||||
): THREE.Material {
|
||||
const skyMaterial = unlit
|
||||
? createUnlitSkyMaterial(material)
|
||||
: material.clone();
|
||||
skyMaterial.side = materialSide;
|
||||
skyMaterial.depthTest = false;
|
||||
skyMaterial.depthWrite = false;
|
||||
|
||||
return skyMaterial as T;
|
||||
return skyMaterial;
|
||||
}
|
||||
|
||||
function createUnlitSkyMaterial(
|
||||
material: THREE.Material,
|
||||
): THREE.MeshBasicMaterial {
|
||||
const sourceMaterial = material as THREE.MeshStandardMaterial;
|
||||
|
||||
return new THREE.MeshBasicMaterial({
|
||||
color: sourceMaterial.color?.clone() ?? new THREE.Color("#ffffff"),
|
||||
map: sourceMaterial.map ?? null,
|
||||
opacity: sourceMaterial.opacity,
|
||||
toneMapped: false,
|
||||
transparent: sourceMaterial.transparent,
|
||||
});
|
||||
}
|
||||
|
||||
function disposeSkyModelMaterials(model: THREE.Object3D): void {
|
||||
model.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
if (Array.isArray(object.material)) {
|
||||
for (const material of object.material) {
|
||||
material.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
object.material.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
useGLTF.preload("/models/skybox/skybox.gltf");
|
||||
|
||||
@@ -109,6 +109,12 @@ export const docGroups: DocGroup[] = [
|
||||
subtitle: "Components and usage",
|
||||
meta: "15",
|
||||
},
|
||||
{
|
||||
path: "/docs/gallery",
|
||||
title: "Model Gallery",
|
||||
subtitle: "Browsing 3D assets",
|
||||
meta: "16",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -118,7 +124,7 @@ export const docGroups: DocGroup[] = [
|
||||
path: "/docs/code-review",
|
||||
title: "Code Review Prep",
|
||||
subtitle: "Presentation support",
|
||||
meta: "16",
|
||||
meta: "17",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
export interface GalleryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const galleryModels: GalleryModel[] = [
|
||||
{ id: "arbre", name: "Arbre", path: "/models/arbre/model.gltf" },
|
||||
{
|
||||
id: "arbre-animated",
|
||||
name: "Arbre animé",
|
||||
path: "/models/arbre-animated/model.gltf",
|
||||
},
|
||||
{ id: "blocking", name: "Blocking", path: "/models/blocking/model.gltf" },
|
||||
{
|
||||
id: "boiteauxlettres",
|
||||
name: "Boîte aux lettres",
|
||||
path: "/models/boiteauxlettres/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "boiteimmeuble",
|
||||
name: "Boîte immeuble",
|
||||
path: "/models/boiteimmeuble/model.gltf",
|
||||
},
|
||||
{ id: "buisson", name: "Buisson", path: "/models/buisson/model.gltf" },
|
||||
{
|
||||
id: "buisson-animated",
|
||||
name: "Buisson animé",
|
||||
path: "/models/buisson-animated/model.gltf",
|
||||
},
|
||||
{ id: "cable1", name: "Câble 1", path: "/models/cable1/model.gltf" },
|
||||
{ id: "cable2", name: "Câble 2", path: "/models/cable2/model.gltf" },
|
||||
{
|
||||
id: "champdeble",
|
||||
name: "Champ de blé",
|
||||
path: "/models/champdeble/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champdeble-animated",
|
||||
name: "Champ de blé animé",
|
||||
path: "/models/champdeble-animated/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champdesoja",
|
||||
name: "Champ de soja",
|
||||
path: "/models/champdesoja/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champdesoja-animated",
|
||||
name: "Champ de soja animé",
|
||||
path: "/models/champdesoja-animated/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champsdetournesol",
|
||||
name: "Champ de tournesol",
|
||||
path: "/models/champsdetournesol/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "champsdetournesol-animated",
|
||||
name: "Champ de tournesol animé",
|
||||
path: "/models/champsdetournesol-animated/model.gltf",
|
||||
},
|
||||
{ id: "chemins", name: "Chemins", path: "/models/chemins/model.gltf" },
|
||||
{ id: "cloud", name: "Nuage", path: "/models/cloud/model.glb" },
|
||||
{
|
||||
id: "createurdepluie",
|
||||
name: "Créateur de pluie",
|
||||
path: "/models/createurdepluie/model.gltf",
|
||||
},
|
||||
{ id: "ebike", name: "E-bike", path: "/models/ebike/model.gltf" },
|
||||
{ id: "ecole", name: "École", path: "/models/ecole/model.gltf" },
|
||||
{ id: "elec", name: "Électricité", path: "/models/elec/model.gltf" },
|
||||
{
|
||||
id: "electricienne",
|
||||
name: "Électricienne",
|
||||
path: "/models/electricienne/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "entreetuyaux",
|
||||
name: "Entrée tuyaux",
|
||||
path: "/models/entreetuyaux/model.gltf",
|
||||
},
|
||||
{ id: "eolienne", name: "Éolienne", path: "/models/eolienne/model.gltf" },
|
||||
{
|
||||
id: "fermeverticale",
|
||||
name: "Ferme verticale",
|
||||
path: "/models/fermeverticale/model.gltf",
|
||||
},
|
||||
{ id: "fermier", name: "Fermier", path: "/models/fermier/model.gltf" },
|
||||
{
|
||||
id: "fermier-animated",
|
||||
name: "Fermier animé",
|
||||
path: "/models/fermier-animated/model.gltf",
|
||||
},
|
||||
{ id: "galet", name: "Galet", path: "/models/galet/model.gltf" },
|
||||
{ id: "gant_l", name: "Gant gauche", path: "/models/gant_l/model.gltf" },
|
||||
{
|
||||
id: "gant_l_pad",
|
||||
name: "Pad gant gauche",
|
||||
path: "/models/gant_l_pad/model.gltf",
|
||||
},
|
||||
{ id: "gant_r", name: "Gant droit", path: "/models/gant_r/model.gltf" },
|
||||
{
|
||||
id: "gant_r_pad",
|
||||
name: "Pad gant droit",
|
||||
path: "/models/gant_r_pad/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "generateur",
|
||||
name: "Générateur",
|
||||
path: "/models/generateur/model.gltf",
|
||||
},
|
||||
{ id: "gerant", name: "Gérant", path: "/models/gerant/model.gltf" },
|
||||
{
|
||||
id: "gerant-animated",
|
||||
name: "Gérant animé",
|
||||
path: "/models/gerant-animated/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "habitant1",
|
||||
name: "Habitant 1",
|
||||
path: "/models/habitant1/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "habitant1-animated",
|
||||
name: "Habitant 1 animé",
|
||||
path: "/models/habitant1-animated/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "habitant2",
|
||||
name: "Habitant 2",
|
||||
path: "/models/habitant2/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "habitant2-animated",
|
||||
name: "Habitant 2 animé",
|
||||
path: "/models/habitant2-animated/model.gltf",
|
||||
},
|
||||
{ id: "immeuble1", name: "Immeuble", path: "/models/immeuble1/model.gltf" },
|
||||
{ id: "lafabrik", name: "La Fabrik", path: "/models/lafabrik/model.gltf" },
|
||||
{ id: "maison1", name: "Maison", path: "/models/maison1/model.gltf" },
|
||||
{
|
||||
id: "packderelance",
|
||||
name: "Pack de relance",
|
||||
path: "/models/packderelance/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "panneauaffichage",
|
||||
name: "Panneau d'affichage",
|
||||
path: "/models/panneauaffichage/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "panneauclassique",
|
||||
name: "Panneau classique",
|
||||
path: "/models/panneauclassique/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "panneaufleche",
|
||||
name: "Panneau flèche",
|
||||
path: "/models/panneaufleche/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "panneausolaire",
|
||||
name: "Panneau solaire",
|
||||
path: "/models/panneausolaire/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "parcebike",
|
||||
name: "Parc e-bike",
|
||||
path: "/models/parcebike/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "persoprincipal",
|
||||
name: "Personnage principal",
|
||||
path: "/models/persoprincipal/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "persoprincipal-animated",
|
||||
name: "Personnage principal animé",
|
||||
path: "/models/persoprincipal-animated/model.gltf",
|
||||
},
|
||||
{ id: "potager", name: "Potager", path: "/models/potager/potager.gltf" },
|
||||
{ id: "puce", name: "Puce", path: "/models/puce/model.gltf" },
|
||||
{ id: "pylone", name: "Pylône", path: "/models/pylone/model.gltf" },
|
||||
{
|
||||
id: "refroidisseur",
|
||||
name: "Refroidisseur",
|
||||
path: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
{ id: "sapin", name: "Sapin", path: "/models/sapin/model.gltf" },
|
||||
{
|
||||
id: "sapin-animated",
|
||||
name: "Sapin animé",
|
||||
path: "/models/sapin-animated/model.gltf",
|
||||
},
|
||||
{ id: "talkie", name: "Talkie", path: "/models/talkie/model.gltf" },
|
||||
{ id: "terrain", name: "Terrain", path: "/models/terrain/model.gltf" },
|
||||
{
|
||||
id: "tuyauxlac",
|
||||
name: "Tuyaux lac",
|
||||
path: "/models/tuyauxlac/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "tuyauxpuzzle",
|
||||
name: "Tuyaux puzzle",
|
||||
path: "/models/tuyauxpuzzle/model.gltf",
|
||||
},
|
||||
{ id: "vase", name: "Vase", path: "/models/vase/model.gltf" },
|
||||
];
|
||||
@@ -4,7 +4,6 @@ export const PLAYER_EYE_HEIGHT = 1.75;
|
||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||
|
||||
export const PLAYER_WALK_SPEED = 11;
|
||||
export const PLAYER_EBIKE_SPEED = 25;
|
||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||
export const PLAYER_JUMP_SPEED = 9;
|
||||
export const PLAYER_GRAVITY = 30;
|
||||
|
||||
+271
@@ -30,6 +30,277 @@ canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Model gallery */
|
||||
.gallery-page {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #050505;
|
||||
color: #f4efe7;
|
||||
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
position: absolute;
|
||||
top: clamp(18px, 3vw, 34px);
|
||||
right: clamp(18px, 3vw, 38px);
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
color: #f4efe7;
|
||||
font-size: clamp(18px, 2vw, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gallery-canvas-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gallery-viewer-error {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
min-height: 360px;
|
||||
padding: 24px;
|
||||
color: #fecaca;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gallery-bottom-bar {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
bottom: clamp(18px, 4vw, 44px);
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(190px, 340px) 54px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 2px solid #d8d0c4;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
box-shadow: none;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #f4efe7;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button:hover,
|
||||
.gallery-bottom-bar button:focus-visible {
|
||||
background: #f4efe7;
|
||||
color: #050505;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gallery-model-info {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 54px;
|
||||
padding: 0 20px;
|
||||
border-right: 2px solid #d8d0c4;
|
||||
border-left: 2px solid #d8d0c4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gallery-model-info span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: #f4efe7;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gallery-model-info small {
|
||||
margin-top: 2px;
|
||||
color: #a9a196;
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gallery-texture-status {
|
||||
position: absolute;
|
||||
left: clamp(18px, 3vw, 38px);
|
||||
bottom: clamp(22px, 4vw, 50px);
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: min(320px, calc(100vw - 36px));
|
||||
padding: 10px 13px;
|
||||
border: 2px solid #d8d0c4;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
color: #d8d0c4;
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gallery-texture-status--ok {
|
||||
color: #d8d0c4;
|
||||
}
|
||||
|
||||
.gallery-texture-status--warning {
|
||||
color: #f4efe7;
|
||||
}
|
||||
|
||||
.gallery-texture-status--loading {
|
||||
color: #a9a196;
|
||||
}
|
||||
|
||||
.gallery-light-panel {
|
||||
position: absolute;
|
||||
top: 108px;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
transform: translateX(260px);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.gallery-light-panel.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.gallery-light-panel-toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 2px solid #d8d0c4;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
color: #f4efe7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-light-panel-toggle:hover,
|
||||
.gallery-light-panel-toggle:focus-visible {
|
||||
background: #f4efe7;
|
||||
color: #050505;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content {
|
||||
width: 236px;
|
||||
padding: 16px;
|
||||
border: 2px solid #d8d0c4;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content header span {
|
||||
color: #f4efe7;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content header button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #a9a196;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gallery-light-panel-content header button:hover,
|
||||
.gallery-light-panel-content header button:focus-visible {
|
||||
color: #f4efe7;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gallery-light-control {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.gallery-light-control span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #d8d0c4;
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gallery-light-control strong {
|
||||
color: #f4efe7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gallery-light-control input {
|
||||
width: 100%;
|
||||
accent-color: #f4efe7;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.gallery-title {
|
||||
right: 50%;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.gallery-bottom-bar {
|
||||
grid-template-columns: 48px minmax(150px, 1fr) 48px;
|
||||
width: calc(100vw - 36px);
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button,
|
||||
.gallery-model-info {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button {
|
||||
width: 48px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.gallery-texture-status {
|
||||
right: 50%;
|
||||
bottom: calc(clamp(18px, 4vw, 44px) + 66px);
|
||||
left: auto;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.gallery-light-panel {
|
||||
top: 78px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Docs layout */
|
||||
.docs-page {
|
||||
display: grid;
|
||||
|
||||
@@ -7,13 +7,8 @@ import {
|
||||
type MissionStep,
|
||||
type RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import {
|
||||
PLAYER_WALK_SPEED,
|
||||
PLAYER_EBIKE_SPEED,
|
||||
} from "@/data/player/playerConfig";
|
||||
|
||||
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
||||
export type PlayerMovementMode = "walk" | "ebike";
|
||||
export type { MissionStep, RepairMissionId };
|
||||
|
||||
interface IntroState {
|
||||
@@ -35,16 +30,10 @@ interface MissionFlowState {
|
||||
playerName: string;
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
movementMode: PlayerMovementMode;
|
||||
currentSpeed: number;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
mainState: MainGameState;
|
||||
isCinematicPlaying: boolean;
|
||||
missionFlow: MissionFlowState;
|
||||
player: PlayerState;
|
||||
intro: IntroState;
|
||||
bike: MissionState & {
|
||||
isRepaired: boolean;
|
||||
@@ -67,7 +56,6 @@ interface GameActions {
|
||||
hideDialog: () => void;
|
||||
setActivityCity: (activityCity: boolean) => void;
|
||||
setCanMove: (canMove: boolean) => void;
|
||||
setPlayerMovementMode: (mode: PlayerMovementMode) => void;
|
||||
setIntroStep: (step: GameStep) => void;
|
||||
setIntroState: (intro: Partial<IntroState>) => void;
|
||||
setPlayerName: (playerName: string) => void;
|
||||
@@ -221,10 +209,6 @@ function createInitialGameState(): GameState {
|
||||
dialogMessage: null,
|
||||
playerName: "",
|
||||
},
|
||||
player: {
|
||||
movementMode: "walk",
|
||||
currentSpeed: PLAYER_WALK_SPEED,
|
||||
},
|
||||
intro: {
|
||||
currentStep: "intro",
|
||||
dialogueAudio: null,
|
||||
@@ -265,14 +249,6 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, activityCity },
|
||||
})),
|
||||
setPlayerMovementMode: (mode) =>
|
||||
set((state) => ({
|
||||
player: {
|
||||
...state.player,
|
||||
movementMode: mode,
|
||||
currentSpeed: mode === "ebike" ? PLAYER_EBIKE_SPEED : PLAYER_WALK_SPEED,
|
||||
},
|
||||
})),
|
||||
setCanMove: (canMove) =>
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, canMove },
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import gallery from "../../../../docs/user/gallery.md?raw";
|
||||
import { DocsDocument } from "@/components/docs/DocsDocument";
|
||||
|
||||
export function DocsGalleryPage(): React.JSX.Element {
|
||||
return (
|
||||
<DocsDocument
|
||||
content={gallery}
|
||||
frContent={gallery}
|
||||
meta="16"
|
||||
title="Model Gallery"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import {
|
||||
Bounds,
|
||||
Center,
|
||||
OrbitControls,
|
||||
useAnimations,
|
||||
useGLTF,
|
||||
} from "@react-three/drei";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
SlidersHorizontal,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import * as THREE from "three";
|
||||
import { SkyModel } from "@/components/three/world/SkyModel";
|
||||
import { galleryModels, type GalleryModel } from "@/data/galleryModels";
|
||||
import {
|
||||
AMBIENT_LIGHT_COLOR,
|
||||
LIGHTING_DEFAULTS,
|
||||
SUN_LIGHT_COLOR,
|
||||
} from "@/data/world/lightingConfig";
|
||||
import {
|
||||
GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
|
||||
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
|
||||
GAME_SCENE_SKY_MODEL_PATH,
|
||||
GAME_SCENE_SKY_MODEL_SCALE,
|
||||
} from "@/data/world/environmentConfig";
|
||||
|
||||
interface GalleryModelProps {
|
||||
model: GalleryModel;
|
||||
}
|
||||
|
||||
interface GallerySceneProps extends GalleryModelProps {
|
||||
lighting: GalleryLightingConfig;
|
||||
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
|
||||
}
|
||||
|
||||
interface GalleryModelPreviewProps extends GalleryModelProps {
|
||||
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
|
||||
}
|
||||
|
||||
interface GalleryLightingConfig {
|
||||
ambientIntensity: number;
|
||||
sunIntensity: number;
|
||||
sunX: number;
|
||||
sunY: number;
|
||||
sunZ: number;
|
||||
}
|
||||
|
||||
interface GalleryLightControl {
|
||||
key: keyof GalleryLightingConfig;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
interface TextureDiagnostic {
|
||||
modelId: string | null;
|
||||
status: "loading" | "ok" | "warning";
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface GalleryModelScene extends THREE.Object3D {
|
||||
userData: THREE.Object3D["userData"] & {
|
||||
hiddenExportPlaneCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GalleryViewerErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
resetKey: string;
|
||||
}
|
||||
|
||||
interface GalleryViewerErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
const TEXTURE_SLOTS = [
|
||||
"map",
|
||||
"normalMap",
|
||||
"roughnessMap",
|
||||
"metalnessMap",
|
||||
"aoMap",
|
||||
"emissiveMap",
|
||||
"alphaMap",
|
||||
] as const;
|
||||
|
||||
const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
|
||||
modelId: null,
|
||||
status: "loading",
|
||||
summary: "Analyse des textures...",
|
||||
};
|
||||
|
||||
const GALLERY_LIGHT_CONTROLS: GalleryLightControl[] = [
|
||||
{ key: "ambientIntensity", label: "Ambiance", min: 0, max: 5, step: 0.1 },
|
||||
{ key: "sunIntensity", label: "Soleil", min: 0, max: 8, step: 0.1 },
|
||||
{ key: "sunX", label: "Soleil X", min: -100, max: 100, step: 1 },
|
||||
{ key: "sunY", label: "Soleil Y", min: -100, max: 150, step: 1 },
|
||||
{ key: "sunZ", label: "Soleil Z", min: -100, max: 100, step: 1 },
|
||||
];
|
||||
|
||||
class GalleryViewerErrorBoundary extends Component<
|
||||
GalleryViewerErrorBoundaryProps,
|
||||
GalleryViewerErrorBoundaryState
|
||||
> {
|
||||
constructor(props: GalleryViewerErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): GalleryViewerErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps: GalleryViewerErrorBoundaryProps): void {
|
||||
if (previousProps.resetKey !== this.props.resetKey && this.state.hasError) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="gallery-viewer-error" role="status">
|
||||
Ce modèle ne peut pas être affiché pour le moment.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function GalleryModelPreview({
|
||||
model,
|
||||
onTextureDiagnosticReady,
|
||||
}: GalleryModelPreviewProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { animations, scene } = useGLTF(model.path);
|
||||
const modelScene = useMemo(() => createGalleryModelScene(scene), [scene]);
|
||||
const { actions } = useAnimations(animations, groupRef);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeGalleryModelMaterials(modelScene);
|
||||
};
|
||||
}, [modelScene]);
|
||||
|
||||
useEffect(() => {
|
||||
onTextureDiagnosticReady(getTextureDiagnostic(model.id, modelScene));
|
||||
}, [model.id, modelScene, onTextureDiagnosticReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const animationActions = Object.values(actions).filter(
|
||||
(action): action is THREE.AnimationAction => Boolean(action),
|
||||
);
|
||||
|
||||
for (const action of animationActions) {
|
||||
action.reset().play();
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const action of animationActions) {
|
||||
action.stop();
|
||||
}
|
||||
};
|
||||
}, [actions]);
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<primitive object={modelScene} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D {
|
||||
const modelScene = scene.clone(true) as GalleryModelScene;
|
||||
const exportPlaneMeshes: THREE.Mesh[] = [];
|
||||
|
||||
modelScene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
if (isExportPlaneMesh(object)) {
|
||||
exportPlaneMeshes.push(object);
|
||||
return;
|
||||
}
|
||||
|
||||
object.material = Array.isArray(object.material)
|
||||
? object.material.map(createGalleryMaterial)
|
||||
: createGalleryMaterial(object.material);
|
||||
});
|
||||
|
||||
for (const mesh of exportPlaneMeshes) {
|
||||
mesh.parent?.remove(mesh);
|
||||
}
|
||||
|
||||
modelScene.userData.hiddenExportPlaneCount = exportPlaneMeshes.length;
|
||||
|
||||
return modelScene;
|
||||
}
|
||||
|
||||
function isExportPlaneMesh(mesh: THREE.Mesh): boolean {
|
||||
const name = mesh.name.toLowerCase();
|
||||
if (name !== "plan" && name !== "plane") return false;
|
||||
|
||||
mesh.geometry.computeBoundingBox();
|
||||
const boundingBox = mesh.geometry.boundingBox;
|
||||
if (!boundingBox) return false;
|
||||
|
||||
const size = new THREE.Vector3();
|
||||
boundingBox.getSize(size);
|
||||
|
||||
const dimensions = [size.x, size.y, size.z];
|
||||
const flatDimensions = dimensions.filter((dimension) => dimension <= 0.001);
|
||||
const largestDimension = Math.max(...dimensions);
|
||||
|
||||
return flatDimensions.length > 0 && largestDimension > 1;
|
||||
}
|
||||
|
||||
function createGalleryMaterial(material: THREE.Material): THREE.Material {
|
||||
const galleryMaterial = material.clone();
|
||||
const materialWithNormalMap = galleryMaterial as THREE.Material & {
|
||||
normalMap?: THREE.Texture | null;
|
||||
};
|
||||
|
||||
galleryMaterial.side = THREE.DoubleSide;
|
||||
|
||||
if (materialWithNormalMap.normalMap) {
|
||||
materialWithNormalMap.normalMap = null;
|
||||
galleryMaterial.needsUpdate = true;
|
||||
}
|
||||
|
||||
return galleryMaterial;
|
||||
}
|
||||
|
||||
function disposeGalleryModelMaterials(modelScene: THREE.Object3D): void {
|
||||
modelScene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
if (Array.isArray(object.material)) {
|
||||
for (const material of object.material) {
|
||||
material.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
object.material.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
function GalleryScene({
|
||||
lighting,
|
||||
model,
|
||||
onTextureDiagnosticReady,
|
||||
}: GallerySceneProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SkyModel
|
||||
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
||||
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
|
||||
materialSide={THREE.DoubleSide}
|
||||
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
||||
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
||||
unlit
|
||||
/>
|
||||
<GalleryLighting lighting={lighting} />
|
||||
<Bounds fit clip observe margin={1.35}>
|
||||
<Center>
|
||||
<GalleryModelPreview
|
||||
model={model}
|
||||
onTextureDiagnosticReady={onTextureDiagnosticReady}
|
||||
/>
|
||||
</Center>
|
||||
</Bounds>
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableDamping
|
||||
autoRotate
|
||||
autoRotateSpeed={0.5}
|
||||
minPolarAngle={0}
|
||||
maxPolarAngle={Math.PI}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryLighting({
|
||||
lighting,
|
||||
}: {
|
||||
lighting: GalleryLightingConfig;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<ambientLight
|
||||
intensity={lighting.ambientIntensity}
|
||||
color={AMBIENT_LIGHT_COLOR}
|
||||
/>
|
||||
<directionalLight
|
||||
position={[lighting.sunX, lighting.sunY, lighting.sunZ]}
|
||||
intensity={lighting.sunIntensity}
|
||||
color={SUN_LIGHT_COLOR}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextureStatusBadge({
|
||||
diagnostic,
|
||||
}: {
|
||||
diagnostic: TextureDiagnostic;
|
||||
}): React.JSX.Element {
|
||||
const hasWarning = diagnostic.status === "warning";
|
||||
const Icon = hasWarning ? TriangleAlert : CheckCircle2;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`gallery-texture-status gallery-texture-status--${diagnostic.status}`}
|
||||
>
|
||||
<Icon aria-hidden="true" size={15} strokeWidth={2.1} />
|
||||
<span>{diagnostic.summary}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryLightingPanel({
|
||||
lighting,
|
||||
onChange,
|
||||
onReset,
|
||||
onToggle,
|
||||
open,
|
||||
}: {
|
||||
lighting: GalleryLightingConfig;
|
||||
onChange: (key: keyof GalleryLightingConfig, value: number) => void;
|
||||
onReset: () => void;
|
||||
onToggle: () => void;
|
||||
open: boolean;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<aside className={`gallery-light-panel ${open ? "is-open" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="gallery-light-panel-toggle"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-label={
|
||||
open ? "Fermer les réglages lumière" : "Ouvrir les réglages lumière"
|
||||
}
|
||||
>
|
||||
<SlidersHorizontal aria-hidden="true" size={18} strokeWidth={1.8} />
|
||||
</button>
|
||||
<div className="gallery-light-panel-content" aria-hidden={!open}>
|
||||
<header>
|
||||
<span>LIGHTS</span>
|
||||
<button type="button" onClick={onReset}>
|
||||
Reset
|
||||
</button>
|
||||
</header>
|
||||
{GALLERY_LIGHT_CONTROLS.map((control) => (
|
||||
<label key={control.key} className="gallery-light-control">
|
||||
<span>
|
||||
{control.label}
|
||||
<strong>{lighting[control.key].toFixed(1)}</strong>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
value={lighting[control.key]}
|
||||
onChange={(event) =>
|
||||
onChange(control.key, Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function getTextureDiagnostic(
|
||||
modelId: string,
|
||||
modelScene: THREE.Object3D,
|
||||
): TextureDiagnostic {
|
||||
let textureCount = 0;
|
||||
let missingTextureImageCount = 0;
|
||||
const hiddenExportPlaneCount =
|
||||
(modelScene as GalleryModelScene).userData.hiddenExportPlaneCount ?? 0;
|
||||
|
||||
modelScene.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
const materials = Array.isArray(object.material)
|
||||
? object.material
|
||||
: [object.material];
|
||||
|
||||
for (const material of materials) {
|
||||
const materialRecord = material as unknown as Record<string, unknown>;
|
||||
|
||||
for (const textureSlot of TEXTURE_SLOTS) {
|
||||
const texture = materialRecord[textureSlot];
|
||||
if (!(texture instanceof THREE.Texture)) continue;
|
||||
|
||||
textureCount += 1;
|
||||
|
||||
if (!texture.image) {
|
||||
missingTextureImageCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (missingTextureImageCount > 0) {
|
||||
return {
|
||||
modelId,
|
||||
status: "warning",
|
||||
summary: `${missingTextureImageCount} texture(s) à vérifier`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hiddenExportPlaneCount > 0) {
|
||||
return {
|
||||
modelId,
|
||||
status: "warning",
|
||||
summary: `${hiddenExportPlaneCount} plan(s) d'export masqué(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (textureCount === 0) {
|
||||
return {
|
||||
modelId,
|
||||
status: "warning",
|
||||
summary: "Aucune texture détectée",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
modelId,
|
||||
status: "ok",
|
||||
summary: `${textureCount} texture(s) OK`,
|
||||
};
|
||||
}
|
||||
|
||||
export function GalleryPage(): React.JSX.Element {
|
||||
const [activeModelIndex, setActiveModelIndex] = useState(0);
|
||||
const [lightPanelOpen, setLightPanelOpen] = useState(false);
|
||||
const [lighting, setLighting] = useState<GalleryLightingConfig>({
|
||||
...LIGHTING_DEFAULTS,
|
||||
});
|
||||
const [textureDiagnostic, setTextureDiagnostic] = useState<TextureDiagnostic>(
|
||||
LOADING_TEXTURE_DIAGNOSTIC,
|
||||
);
|
||||
const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0]!;
|
||||
const modelCount = galleryModels.length;
|
||||
const activeTextureDiagnostic =
|
||||
textureDiagnostic.modelId === activeModel.id
|
||||
? textureDiagnostic
|
||||
: LOADING_TEXTURE_DIAGNOSTIC;
|
||||
|
||||
const goToPreviousModel = (): void => {
|
||||
setActiveModelIndex((currentIndex) =>
|
||||
currentIndex === 0 ? modelCount - 1 : currentIndex - 1,
|
||||
);
|
||||
};
|
||||
|
||||
const goToNextModel = (): void => {
|
||||
setActiveModelIndex((currentIndex) =>
|
||||
currentIndex === modelCount - 1 ? 0 : currentIndex + 1,
|
||||
);
|
||||
};
|
||||
|
||||
const handleLightChange = (
|
||||
key: keyof GalleryLightingConfig,
|
||||
value: number,
|
||||
): void => {
|
||||
setLighting((currentLighting) => ({
|
||||
...currentLighting,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetLighting = (): void => {
|
||||
setLighting({ ...LIGHTING_DEFAULTS });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="gallery-page">
|
||||
<h1 className="gallery-title">GALERIE</h1>
|
||||
|
||||
<div className="gallery-canvas-frame" aria-label="Viewer 3D">
|
||||
<GalleryViewerErrorBoundary resetKey={activeModel.id}>
|
||||
<Canvas camera={{ position: [3.5, 2.4, 4.5], fov: 45 }} dpr={[1, 2]}>
|
||||
<Suspense fallback={null}>
|
||||
<GalleryScene
|
||||
lighting={lighting}
|
||||
model={activeModel}
|
||||
onTextureDiagnosticReady={setTextureDiagnostic}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</GalleryViewerErrorBoundary>
|
||||
</div>
|
||||
|
||||
<nav className="gallery-bottom-bar" aria-label="Navigation des modèles">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToPreviousModel}
|
||||
aria-label="Modèle précédent"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={22} strokeWidth={1.8} />
|
||||
</button>
|
||||
<div className="gallery-model-info">
|
||||
<span>{activeModel.name}</span>
|
||||
<small>
|
||||
{activeModelIndex + 1} / {modelCount}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNextModel}
|
||||
aria-label="Modèle suivant"
|
||||
>
|
||||
<ArrowRight aria-hidden="true" size={22} strokeWidth={1.8} />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<TextureStatusBadge diagnostic={activeTextureDiagnostic} />
|
||||
<GalleryLightingPanel
|
||||
lighting={lighting}
|
||||
onChange={handleLightChange}
|
||||
onReset={resetLighting}
|
||||
onToggle={() => setLightPanelOpen((open) => !open)}
|
||||
open={lightPanelOpen}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
||||
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 [];
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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 [];
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export * from './types';
|
||||
export * from './Grid';
|
||||
export * from './AStar';
|
||||
export * from './ImageToGrid';
|
||||
export * from './useGPS';
|
||||
export * from './GPSMinimap';
|
||||
export * from './WaypointAStar';
|
||||
export * from './useWaypointGPS';
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
+7
-13
@@ -6,8 +6,7 @@ import {
|
||||
} from "@tanstack/react-router";
|
||||
import { HomePage } from "@/pages/page";
|
||||
import { EditorPage } from "@/pages/editor/page";
|
||||
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
||||
import { BackgroundMapPage } from "@/pages/backgroundmap/page";
|
||||
import { GalleryPage } from "@/pages/gallery/page";
|
||||
import {
|
||||
DocsAnimationRoute,
|
||||
DocsAudioRoute,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
DocsCodeReviewRoute,
|
||||
DocsEditorRoute,
|
||||
DocsFeaturesRoute,
|
||||
DocsGalleryRoute,
|
||||
DocsHandTrackingRoute,
|
||||
DocsInteractionRoute,
|
||||
DocsLayoutRoute,
|
||||
@@ -45,16 +45,10 @@ const editorRoute = createRoute({
|
||||
component: EditorPage,
|
||||
});
|
||||
|
||||
const waypointRoute = createRoute({
|
||||
const galleryRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/waypoint",
|
||||
component: WaypointEditorPage,
|
||||
});
|
||||
|
||||
const backgroundMapRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/backgroundmap",
|
||||
component: BackgroundMapPage,
|
||||
path: "/gallery",
|
||||
component: GalleryPage,
|
||||
});
|
||||
|
||||
const docsRoute = createRoute({
|
||||
@@ -80,6 +74,7 @@ const docsChildRoutes = [
|
||||
{ path: "main-feature", component: DocsMainFeatureRoute },
|
||||
{ path: "editor", component: DocsEditorRoute },
|
||||
{ path: "animation", component: DocsAnimationRoute },
|
||||
{ path: "gallery", component: DocsGalleryRoute },
|
||||
{ path: "code-review", component: DocsCodeReviewRoute },
|
||||
].map(({ path, component }) =>
|
||||
createRoute({
|
||||
@@ -92,8 +87,7 @@ const docsChildRoutes = [
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
editorRoute,
|
||||
waypointRoute,
|
||||
backgroundMapRoute,
|
||||
galleryRoute,
|
||||
docsRoute.addChildren(docsChildRoutes),
|
||||
]);
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ const LazyDocsAnimationPage = lazyNamed(
|
||||
() => import("@/pages/docs/animation/page"),
|
||||
"DocsAnimationPage",
|
||||
);
|
||||
const LazyDocsGalleryPage = lazyNamed(
|
||||
() => import("@/pages/docs/gallery/page"),
|
||||
"DocsGalleryPage",
|
||||
);
|
||||
const LazyDocsCodeReviewPage = lazyNamed(
|
||||
() => import("@/pages/docs/code-review/page"),
|
||||
"DocsCodeReviewPage",
|
||||
@@ -119,6 +123,7 @@ export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);
|
||||
export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage);
|
||||
export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage);
|
||||
export const DocsAnimationRoute = createDocsRoute(LazyDocsAnimationPage);
|
||||
export const DocsGalleryRoute = createDocsRoute(LazyDocsGalleryPage);
|
||||
export const DocsCodeReviewRoute = createDocsRoute(LazyDocsCodeReviewPage);
|
||||
export const DocsMissionFlowRoute = createDocsRoute(LazyDocsMissionFlowPage);
|
||||
export const DocsThreeDebuggingRoute = createDocsRoute(
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
CinematicManifest,
|
||||
} from "@/types/cinematics/cinematics";
|
||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
@@ -17,11 +16,6 @@ import { queueDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
export function GameCinematics(): null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalCamera(camera);
|
||||
}, [camera]);
|
||||
|
||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||
const [dialogueManifest, setDialogueManifest] =
|
||||
useState<DialogueManifest | null>(null);
|
||||
@@ -177,118 +171,3 @@ function playCinematic(
|
||||
|
||||
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,5 +1,4 @@
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { Ebike } from "@/components/ebike/Ebike";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
@@ -57,7 +56,6 @@ export function GameStageContent(): React.JSX.Element {
|
||||
{mainState === "intro" ? (
|
||||
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
|
||||
) : null}
|
||||
<Ebike position={[0, 10, 0]} />
|
||||
{GAME_REPAIR_ZONES.map((zone) => (
|
||||
<RepairGame
|
||||
key={zone.mission}
|
||||
|
||||
@@ -29,7 +29,6 @@ import { GameStageContent } from "@/world/GameStageContent";
|
||||
import { Player } from "@/world/player/Player";
|
||||
import { TestMap } from "@/world/debug/TestMap";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||
|
||||
interface WorldProps {
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
|
||||
+1
-122
@@ -1,13 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component, useRef, useState, useEffect } from "react";
|
||||
import { Component, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||
import { Line } from "@react-three/drei";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||
import {
|
||||
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
|
||||
TEST_SCENE_FLOOR_POSITION,
|
||||
@@ -86,55 +84,11 @@ class ModelPreviewErrorBoundary extends Component<
|
||||
}
|
||||
}
|
||||
|
||||
interface Waypoint {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
connections: number[];
|
||||
}
|
||||
|
||||
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
const floorRef = useRef<THREE.Group>(null);
|
||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<group ref={floorRef}>
|
||||
@@ -144,45 +98,6 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
</mesh>
|
||||
</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>
|
||||
<RigidBody type="fixed">
|
||||
<CuboidCollider
|
||||
@@ -236,42 +151,6 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
))}
|
||||
</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}>
|
||||
<AnimatedModel
|
||||
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { PointerLockControls } from "@react-three/drei";
|
||||
import { setGlobalCamera } from "@/world/GameCinematics";
|
||||
|
||||
export function PlayerCamera(): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalCamera(camera);
|
||||
return () => {
|
||||
setGlobalCamera(null);
|
||||
document.exitPointerLock();
|
||||
};
|
||||
}, [camera]);
|
||||
}, []);
|
||||
|
||||
return <PointerLockControls />;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PLAYER_GRAVITY,
|
||||
PLAYER_JUMP_SPEED,
|
||||
PLAYER_MAX_DELTA,
|
||||
PLAYER_WALK_SPEED,
|
||||
PLAYER_XZ_DAMPING_FACTOR,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
@@ -27,7 +28,6 @@ import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { EBIKE_CAMERA_TRANSFORM } from "@/components/ebike/Ebike";
|
||||
|
||||
type Keys = {
|
||||
forward: boolean;
|
||||
@@ -108,73 +108,6 @@ export function PlayerController({
|
||||
const wantsJump = useRef(false);
|
||||
const initializedRef = useRef(false);
|
||||
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));
|
||||
|
||||
@@ -287,17 +220,6 @@ export function PlayerController({
|
||||
|
||||
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);
|
||||
_forward.setY(0);
|
||||
if (_forward.lengthSq() > 0) {
|
||||
@@ -309,16 +231,14 @@ export function PlayerController({
|
||||
if (!movementLocked) {
|
||||
if (keys.current.forward) _wishDir.add(_forward);
|
||||
if (keys.current.backward) _wishDir.sub(_forward);
|
||||
if (movementModeRef.current !== "ebike") {
|
||||
if (keys.current.left) _wishDir.sub(_right);
|
||||
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();
|
||||
|
||||
const accel = onFloor.current
|
||||
? currentSpeed
|
||||
: currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
|
||||
? PLAYER_WALK_SPEED
|
||||
: PLAYER_WALK_SPEED * PLAYER_AIR_CONTROL_FACTOR;
|
||||
velocity.current.x +=
|
||||
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
||||
velocity.current.z +=
|
||||
@@ -362,71 +282,7 @@ export function PlayerController({
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
camera.position.copy(capsule.current.end);
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user