21 Commits

Author SHA1 Message Date
math-pixel a397febd52 fix zoom
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-27 17:23:06 +02:00
math-pixel c15cad2ab0 fix zoom 2026-05-27 17:22:14 +02:00
math-pixel 011e7815a2 update gps 2026-05-27 17:15:08 +02:00
math-pixel 970253801a add map on bike 2026-05-22 18:28:05 +02:00
math-pixel 246da0019a add transparency gps 2026-05-20 16:56:01 +02:00
math-pixel 09a9471814 feature gps works 2026-05-20 15:29:23 +02:00
math-pixel 6e9318457a gps component 2026-05-20 14:45:40 +02:00
math-pixel 54a353de03 first implementation of pathfinding 2026-05-20 14:34:26 +02:00
math-pixel 4faa226326 working move kikle 2026-05-19 17:10:34 +02:00
math-pixel dd66966507 working move kikle 2026-05-19 17:04:01 +02:00
math-pixel 5893afe42a working move kikle 2026-05-19 16:34:48 +02:00
math-pixel 1ead7ab3a7 working move kikle 2026-05-19 16:17:02 +02:00
math-pixel 047c58678b working move kikle 2026-05-19 16:12:58 +02:00
math-pixel ed9051b0dc working move kikle 2026-05-19 16:10:57 +02:00
math-pixel 08be6bee48 add good inclinason cam 2026-05-19 15:54:40 +02:00
math-pixel ce0eb90321 inhance move 2026-05-19 15:50:11 +02:00
math-pixel 96d7ec7fc0 move forward cam 2026-05-19 15:36:50 +02:00
math-pixel 9ab4b4a002 first move with bike 2026-05-19 15:32:59 +02:00
math-pixel d13dd0fda0 wip bike movement 2026-05-17 12:30:40 +02:00
math-pixel fbedb90bca working bike 2026-05-17 08:15:16 +02:00
math-pixel cff7744ad9 wip 2026-05-17 07:41:29 +02:00
33 changed files with 5295 additions and 1203 deletions
+6 -9
View File
@@ -25,13 +25,12 @@ 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 |
| `/gallery` | 3D model gallery for browsing project assets |
| `/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 |
| `/docs` | In-app documentation index |
## Tech Stack
@@ -99,7 +98,6 @@ Useful local URLs:
```txt
http://localhost:5173/?debug
http://localhost:5173/editor
http://localhost:5173/gallery
http://localhost:5173/docs
```
@@ -150,7 +148,6 @@ 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
-46
View File
@@ -1,46 +0,0 @@
# 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.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
import { useEffect, useRef, useState, useMemo } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { animateCameraTransformTransition } from "@/world/GameCinematics";
import { useGameStore } from "@/managers/stores/useGameStore";
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
import type { Vector3Tuple } from "@/types/three/three";
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
export interface CameraTransform {
position: Vector3Tuple;
rotation: Vector3Tuple;
}
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
position: [-3.5, 6, 0],
rotation: [-10, -90, 0],
};
const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
position: [0, 1.5, -3],
rotation: [0, 0, 0],
};
interface EbikeProps {
position: Vector3Tuple;
}
export function Ebike({ position }: EbikeProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
scope: "Ebike",
position: position,
});
const model = useClonedObject(scene);
const movementMode = useGameStore((state) => state.player.movementMode);
const mainState = useGameStore((state) => state.mainState);
const camera = useThree((state) => state.camera);
// Map active mainState to target repair zone coordinate
const destPos = useMemo(() => {
switch (mainState) {
case "bike":
return { x: 8, y: 0, z: -6 };
case "pylone":
return { x: 64, y: 0, z: -66 };
case "ferme":
return { x: -24, y: 0, z: 42 };
default:
return undefined;
}
}, [mainState]);
// Throttled GPS start position to optimize pathfinding A* algorithm execution
const [gpsStartPos, setGpsStartPos] = useState<{ x: number; y: number; z: number }>({
x: position[0],
y: position[1],
z: position[2],
});
const lastGpsUpdatePos = useRef<THREE.Vector3>(new THREE.Vector3(...position));
const restingPosition = useRef<Vector3Tuple>([
position[0],
position[1] - PLAYER_EYE_HEIGHT,
position[2],
]);
const restingRotation = useRef<number>(0);
const forkRef = useRef<THREE.Object3D | null>(null);
useEffect(() => {
if (model) {
const fork = model.getObjectByName("fourche");
if (fork) {
forkRef.current = fork;
}
}
}, [model]);
useEffect(() => {
(window as any).ebikeVisualGroup = groupRef;
(window as any).ebikeParkedPosition = restingPosition.current;
(window as any).ebikeParkedRotation = restingRotation.current;
return () => {
(window as any).ebikeVisualGroup = null;
(window as any).ebikeParkedPosition = null;
(window as any).ebikeParkedRotation = null;
};
}, []);
useFrame((_, delta) => {
if (groupRef.current) {
if (movementMode === "ebike") {
restingPosition.current = [
groupRef.current.position.x,
groupRef.current.position.y,
groupRef.current.position.z,
];
restingRotation.current = groupRef.current.rotation.y;
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
const steerFactor = (window as any).ebikeSteerFactor || 0;
if (forkRef.current) {
// 15 degrees is 0.26 radians
const targetForkRotation = steerFactor * 0.26;
forkRef.current.rotation.z = THREE.MathUtils.lerp(
forkRef.current.rotation.z,
targetForkRotation,
12 * delta
);
}
// Throttled GPS start position update to prevent performance loss
const currentPos = groupRef.current.position;
if (currentPos.distanceTo(lastGpsUpdatePos.current) > 2.0) {
lastGpsUpdatePos.current.copy(currentPos);
setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z });
}
} else {
groupRef.current.position.set(...restingPosition.current);
groupRef.current.rotation.set(0, restingRotation.current, 0);
// Reset fork rotation when parked
if (forkRef.current) {
forkRef.current.rotation.z = 0;
}
}
(window as any).ebikeParkedPosition = restingPosition.current;
(window as any).ebikeParkedRotation = restingRotation.current;
}
});
const camPointPos: Vector3Tuple = [
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0],
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1],
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2],
];
const dropPointPos: Vector3Tuple = [
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
const handleInteract = (): void => {
if (movementMode === "walk") {
const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position);
cameraOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), restingRotation.current);
const targetCamPos: Vector3Tuple = [
restingPosition.current[0] + cameraOffset.x,
restingPosition.current[1] + cameraOffset.y,
restingPosition.current[2] + cameraOffset.z,
];
const targetRotation: Vector3Tuple = [
EBIKE_CAMERA_TRANSFORM.rotation[0],
EBIKE_CAMERA_TRANSFORM.rotation[1] + THREE.MathUtils.radToDeg(restingRotation.current),
EBIKE_CAMERA_TRANSFORM.rotation[2],
];
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
useGameStore.getState().setPlayerMovementMode("ebike");
});
} else {
const currentPos = new THREE.Vector3();
if (groupRef.current) {
groupRef.current.getWorldPosition(currentPos);
} else {
currentPos.set(...position);
}
const targetCamPos: Vector3Tuple = [
currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
// Get camera's current rotation in degrees so we keep the exact orientation during dismount
const currentEuler = new THREE.Euler().setFromQuaternion(camera.quaternion, "YXZ");
const targetRotation: Vector3Tuple = [
THREE.MathUtils.radToDeg(currentEuler.x),
THREE.MathUtils.radToDeg(currentEuler.y),
THREE.MathUtils.radToDeg(currentEuler.z),
];
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
useGameStore.getState().setPlayerMovementMode("walk");
});
}
};
const handleInteractRef = useRef(handleInteract);
handleInteractRef.current = handleInteract;
const debugRef = useRef({ showCameraPoints: true });
const debugActions = useRef({
toggleRide: () => {
handleInteractRef.current();
}
});
useDebugFolder("Ebike", (folder) => {
folder
.add(debugRef.current, "showCameraPoints")
.name("Show Camera Points")
.onChange((value: boolean) => {
debugRef.current.showCameraPoints = value;
});
folder
.add(debugActions.current, "toggleRide")
.name("Monter / Descendre");
});
return (
<>
<group ref={groupRef} position={position}>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
}
position={position}
radius={15}
onPress={handleInteract}
>
<mesh>
<boxGeometry args={[10, 13, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</InteractableObject>
{/* Dynamic 3D GPS Dashboard Screen */}
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
<EbikeGPSMap
width={0.8}
height={0.8}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
</group>
</group>
{debugRef.current.showCameraPoints && (
<>
<mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshStandardMaterial
color="yellow"
emissive="yellow"
emissiveIntensity={0.5}
/>
</mesh>
<mesh position={dropPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshStandardMaterial
color="cyan"
emissive="cyan"
emissiveIntensity={0.5}
/>
</mesh>
</>
)}
</>
);
}
+469
View File
@@ -0,0 +1,469 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import * as THREE from 'three';
import { findClosestWaypoint, findWaypointPath } from '@/pathfinding/WaypointAStar';
import type { Waypoint } from '@/pathfinding/types';
function computeImageSource(
img: HTMLImageElement | HTMLCanvasElement,
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
bounds: { minX: number; maxX: number; minZ: number; maxZ: number }
) {
const imgW = img.width;
const imgH = img.height;
const baseW = baseBounds.maxX - baseBounds.minX;
const baseH = baseBounds.maxZ - baseBounds.minZ;
if (baseW === 0 || baseH === 0) {
return { sx: 0, sy: 0, sW: imgW, sH: imgH };
}
const sx = ((bounds.minX - baseBounds.minX) / baseW) * imgW;
const sy = ((bounds.minZ - baseBounds.minZ) / baseH) * imgH;
const sW = ((bounds.maxX - bounds.minX) / baseW) * imgW;
const sH = ((bounds.maxZ - bounds.minZ) / baseH) * imgH;
return { sx, sy, sW, sH };
}
export interface EbikeGPSMapProps {
/**
* 3D world position of the player/bike (GPS start point)
* If omitted, snaps to [0,0,0]
*/
startPos?: { x: number; y: number; z: number } | undefined;
destPos?: { x: number; y: number; z: number } | undefined;
/**
* Optional custom URL to the map background texture.
* If not provided, renders a high-tech minimalist neon blueprint map dynamically.
*/
mapImageUrl?: string;
/**
* Optional explicit bounds for mapping coordinates.
* If omitted, bounds are calculated automatically to perfectly fit the road network!
*/
worldBounds?: {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
};
/**
* Width of the 3D plane mesh (default: 1)
*/
width?: number;
/**
* Height of the 3D plane mesh (default: 1)
*/
height?: number;
/**
* Optional world position for the GPS screen (defaults to origin)
*/
position?: [number, number, number];
/**
* Resolution of the offscreen canvas used for the map texture.
* Higher values yield sharper rendering at the cost of GPU memory.
* Default: 1024 (1024×1024 px)
*/
canvasSize?: number;
/**
* Zoom level applied to the map view.
* 1 = full world bounds, 2 = 2× zoom-in centred on the player, etc.
* Values < 1 zoom out beyond the calculated world bounds.
* Default: 1
*/
zoom?: number;
}
/**
* EbikeGPSMap
* A premium, state-of-the-art 3D GPS navigation screen for the Ebike.
* Loads the road network, runs A* pathfinding, and renders a glowing, animated
* orange path over a sleek high-tech map background.
*/
export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
startPos = { x: 0, y: 0, z: 0 },
destPos,
mapImageUrl,
worldBounds,
width = 1,
height = 1,
position = [0, 0, 0],
canvasSize = 1024,
zoom = 1,
}) => {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [mapImage, setMapImage] = useState<HTMLImageElement | HTMLCanvasElement | null>(null);
// Offscreen high-res canvas for crystal clear rendering
const [offscreenCanvas] = useState(() => {
const canvas = document.createElement('canvas');
canvas.width = canvasSize;
canvas.height = canvasSize;
return canvas;
});
// Resize the canvas whenever canvasSize changes
useEffect(() => {
offscreenCanvas.width = canvasSize;
offscreenCanvas.height = canvasSize;
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
}, [canvasSize, offscreenCanvas]);
const textureRef = useRef<THREE.CanvasTexture | null>(null);
const animTimeRef = useRef<number>(0);
// Load waypoints (localStorage with /roadNetwork.json fallback)
useEffect(() => {
const saved = localStorage.getItem('la-fabrik-waypoints');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
setWaypoints(parsed);
return;
}
} catch (e) {
console.error('[GPS Component] Error loading local storage waypoints', e);
}
}
// Fallback to static roadNetwork.json
fetch('/roadNetwork.json')
.then((res) => {
if (res.ok) return res.json();
throw new Error('Not found');
})
.then((data) => {
if (Array.isArray(data)) {
setWaypoints(data);
}
})
.catch((err) => {
console.log('[GPS Component] No default road network found.', err);
});
}, []);
// Pre-load background map image (standard HTML5 Image loader)
// Since the user's PNG is already transparent, we don't need fetch or pixel manipulation!
useEffect(() => {
if (!mapImageUrl) {
setMapImage(null);
return;
}
const img = new Image();
img.onload = () => {
setMapImage(img);
};
img.onerror = () => {
console.warn(`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`);
setMapImage(null);
};
img.src = mapImageUrl;
}, [mapImageUrl]);
// Determine grid boundaries (before zoom)
const baseBounds = useMemo(() => {
if (worldBounds) return worldBounds;
if (waypoints.length === 0) {
return { minX: -200, maxX: 200, minZ: -200, maxZ: 200 };
}
const xs = waypoints.map((w) => w.x);
const zs = waypoints.map((w) => w.z);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minZ = Math.min(...zs);
const maxZ = Math.max(...zs);
// Padding (15% to ensure full view breathing room)
const padX = (maxX - minX) * 0.15 || 40;
const padZ = (maxZ - minZ) * 0.15 || 40;
return {
minX: minX - padX,
maxX: maxX + padX,
minZ: minZ - padZ,
maxZ: maxZ + padZ,
};
}, [waypoints, worldBounds]);
// Apply zoom: shrink the view window around the player position
const bounds = useMemo(() => {
const clampedZoom = Math.max(0.1, zoom);
if (clampedZoom === 1) return baseBounds;
const centerX = startPos.x;
const centerZ = startPos.z;
const halfW = (baseBounds.maxX - baseBounds.minX) / 2 / clampedZoom;
const halfH = (baseBounds.maxZ - baseBounds.minZ) / 2 / clampedZoom;
return {
minX: centerX - halfW,
maxX: centerX + halfW,
minZ: centerZ - halfH,
maxZ: centerZ + halfH,
};
}, [baseBounds, zoom, startPos]);
// Snapped positions
const startPosSnapped = useMemo(() => {
if (waypoints.length === 0) return null;
return findClosestWaypoint(waypoints, startPos);
}, [waypoints, startPos]);
const destPosSnapped = useMemo(() => {
if (!destPos || waypoints.length === 0) return null;
return findClosestWaypoint(waypoints, destPos);
}, [waypoints, destPos]);
// Calculated active A* route
const activePath = useMemo(() => {
if (!startPosSnapped || !destPosSnapped || waypoints.length === 0) return [];
return findWaypointPath(waypoints, startPosSnapped, destPosSnapped);
}, [waypoints, startPosSnapped, destPosSnapped]);
// Translation helper: 3D world to Canvas pixels
const worldToCanvas = (wx: number, wz: number, canvasSize: number) => {
const { minX, maxX, minZ, maxZ } = bounds;
const px = ((wx - minX) / (maxX - minX)) * canvasSize;
const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize;
return { x: px, y: py };
};
// Draw loop
const draw = () => {
const canvas = offscreenCanvas;
const ctx = canvas.getContext('2d', { willReadFrequently: true, alpha: true });
if (!ctx) return;
const size = canvas.width;
ctx.clearRect(0, 0, size, size);
// 1. Draw Map Background (Image or premium blueprint vectors)
if (mapImage) {
const src = computeImageSource(mapImage, baseBounds, bounds);
const sx = Math.max(0, Math.min(mapImage.width, src.sx));
const sy = Math.max(0, Math.min(mapImage.height, src.sy));
const sW = Math.max(1, Math.min(mapImage.width - sx, src.sW));
const sH = Math.max(1, Math.min(mapImage.height - sy, src.sH));
ctx.drawImage(mapImage, sx, sy, sW, sH, 0, 0, size, size);
ctx.globalAlpha = 1.0;
} else {
// Dynamic Sci-fi background grid (Background is transparent!)
// Sci-fi subgrid
ctx.strokeStyle = 'rgba(30, 41, 59, 0.4)';
ctx.lineWidth = 1;
const step = size / 32;
for (let x = 0; x < size; x += step) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, size);
ctx.stroke();
}
for (let y = 0; y < size; y += step) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(size, y);
ctx.stroke();
}
// Aesthetic concentric radar topo-rings
ctx.strokeStyle = 'rgba(71, 85, 105, 0.06)';
ctx.lineWidth = 2;
for (let r = size / 6; r < size; r += size / 6) {
ctx.beginPath();
ctx.arc(size / 2, size / 2, r, 0, 2 * Math.PI);
ctx.stroke();
}
// Faint diagonal technical accents
ctx.strokeStyle = 'rgba(56, 189, 248, 0.03)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(size, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(size, 0);
ctx.lineTo(0, size);
ctx.stroke();
}
// 2. Draw Active Orange Glowing Path (Neon Highway effect)
if (activePath.length > 1) {
// Pass 1: Wide transparent orange bloom
ctx.beginPath();
let pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
ctx.moveTo(pt.x, pt.y);
for (let i = 1; i < activePath.length; i++) {
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = 'rgba(249, 115, 22, 0.2)'; // Faint bright orange
ctx.lineWidth = 20;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowBlur = 30;
ctx.shadowColor = '#f97316'; // Neon Orange
ctx.stroke();
// Pass 2: Saturated glow core
ctx.beginPath();
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
ctx.moveTo(pt.x, pt.y);
for (let i = 1; i < activePath.length; i++) {
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = '#f97316'; // Vibrant orange
ctx.lineWidth = 8;
ctx.shadowBlur = 12;
ctx.shadowColor = '#ea580c';
ctx.stroke();
// Pass 3: High-intensity white core
ctx.beginPath();
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
ctx.moveTo(pt.x, pt.y);
for (let i = 1; i < activePath.length; i++) {
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = '#fff7ed'; // Cream white
ctx.lineWidth = 3;
ctx.shadowBlur = 0; // Turn off shadows for the core
ctx.stroke();
// 3. Energy Particle Pulse animation tracing the road
const segments: { start: { x: number; y: number }; end: { x: number; y: number }; len: number }[] = [];
let totalLen = 0;
for (let i = 0; i < activePath.length - 1; i++) {
const p1 = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
const p2 = worldToCanvas(activePath[i + 1]!.x, activePath[i + 1]!.z, size);
const len = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
segments.push({ start: p1, end: p2, len });
totalLen += len;
}
if (totalLen > 0) {
const targetLen = totalLen * animTimeRef.current;
let currentLen = 0;
let dotPt = segments[0]!.start;
for (const seg of segments) {
if (currentLen + seg.len >= targetLen) {
const ratio = (targetLen - currentLen) / seg.len;
dotPt = {
x: seg.start.x + (seg.end.x - seg.start.x) * ratio,
y: seg.start.y + (seg.end.y - seg.start.y) * ratio,
};
break;
}
currentLen += seg.len;
}
// Draw multiple glowing pulses along the path
ctx.beginPath();
ctx.arc(dotPt.x, dotPt.y, 8, 0, 2 * Math.PI);
ctx.fillStyle = '#ffffff';
ctx.shadowBlur = 15;
ctx.shadowColor = '#f97316';
ctx.fill();
ctx.shadowBlur = 0;
}
}
// 4. Draw Snap Markers (Start and End)
if (destPosSnapped) {
const pt = worldToCanvas(destPosSnapped.x, destPosSnapped.z, size);
const pulseSize = 12 + Math.sin(Date.now() * 0.007) * 4;
// Pulse ring
ctx.beginPath();
ctx.arc(pt.x, pt.y, pulseSize, 0, 2 * Math.PI);
ctx.strokeStyle = 'rgba(249, 115, 22, 0.4)';
ctx.lineWidth = 3;
ctx.stroke();
// Solid target core
ctx.beginPath();
ctx.arc(pt.x, pt.y, 6, 0, 2 * Math.PI);
ctx.fillStyle = '#ea580c'; // Deep target orange
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
if (startPosSnapped) {
const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size);
// Start Marker (Player Arrow/Dot)
ctx.beginPath();
ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
ctx.fillStyle = '#0ea5e9'; // Cool cyberpunk sky blue
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.5;
ctx.fill();
ctx.stroke();
// Tech details
ctx.beginPath();
ctx.arc(pt.x, pt.y, 3, 0, 2 * Math.PI);
ctx.fillStyle = '#ffffff';
ctx.fill();
}
// 5. Update WebGL Texture
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
};
// 60 FPS animation ticker
useEffect(() => {
let animId: number;
const tick = () => {
animTimeRef.current += 0.004; // Slow, premium sweep speed
if (animTimeRef.current > 1) animTimeRef.current = 0;
draw();
animId = requestAnimationFrame(tick);
};
animId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animId);
}, [waypoints, startPos, destPos, bounds, mapImage]);
return (
<mesh castShadow receiveShadow position={position as any}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial toneMapped={false} transparent={true} opacity={1} depthWrite={false} side={THREE.DoubleSide}>
<canvasTexture
ref={textureRef}
attach="map"
image={offscreenCanvas}
format={THREE.RGBAFormat}
minFilter={THREE.LinearFilter}
magFilter={THREE.LinearFilter}
/>
</meshBasicMaterial>
</mesh>
);
};
+11 -79
View File
@@ -1,6 +1,6 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Component, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
@@ -8,16 +8,12 @@ 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 {
@@ -58,37 +54,23 @@ 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
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale}
unlit={unlit}
/>
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
) : null;
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
<SkyModelContent
materialSide={materialSide}
modelPath={modelPath}
scale={scale}
unlit={unlit}
/>
<SkyModelContent modelPath={modelPath} scale={scale} />
</SkyModelErrorBoundary>
);
}
function SkyModelContent({
materialSide,
modelPath,
scale,
unlit,
}: SkyModelContentProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
@@ -96,16 +78,7 @@ function SkyModelContent({
scope: "SkyModel",
scale,
});
const model = useMemo(
() => createSkyModel(scene, materialSide, unlit),
[materialSide, scene, unlit],
);
useEffect(() => {
return () => {
disposeSkyModelMaterials(model);
};
}, [model]);
const model = useMemo(() => createSkyModel(scene), [scene]);
useFrame(() => {
groupRef.current?.position.copy(camera.position);
@@ -123,11 +96,7 @@ function SkyModelContent({
);
}
function createSkyModel(
scene: THREE.Object3D,
materialSide: THREE.Side,
unlit: boolean,
): THREE.Object3D {
function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
const model = scene.clone(true);
model.traverse((object) => {
@@ -137,57 +106,20 @@ function createSkyModel(
if (!(object instanceof THREE.Mesh)) return;
object.material = Array.isArray(object.material)
? object.material.map((material) =>
createSkyMaterial(material, materialSide, unlit),
)
: createSkyMaterial(object.material, materialSide, unlit);
? object.material.map(createSkyMaterial)
: createSkyMaterial(object.material);
});
return model;
}
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;
function createSkyMaterial<T extends THREE.Material>(material: T): T {
const skyMaterial = material.clone();
skyMaterial.side = THREE.BackSide;
skyMaterial.depthTest = false;
skyMaterial.depthWrite = false;
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();
});
return skyMaterial as T;
}
useGLTF.preload("/models/skybox/skybox.gltf");
+1 -7
View File
@@ -109,12 +109,6 @@ export const docGroups: DocGroup[] = [
subtitle: "Components and usage",
meta: "15",
},
{
path: "/docs/gallery",
title: "Model Gallery",
subtitle: "Browsing 3D assets",
meta: "16",
},
],
},
{
@@ -124,7 +118,7 @@ export const docGroups: DocGroup[] = [
path: "/docs/code-review",
title: "Code Review Prep",
subtitle: "Presentation support",
meta: "17",
meta: "16",
},
],
},
-209
View File
@@ -1,209 +0,0 @@
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" },
];
+1
View File
@@ -4,6 +4,7 @@ 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
View File
@@ -30,277 +30,6 @@ 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;
+24
View File
@@ -7,8 +7,13 @@ 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 {
@@ -30,10 +35,16 @@ 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;
@@ -56,6 +67,7 @@ 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;
@@ -209,6 +221,10 @@ function createInitialGameState(): GameState {
dialogMessage: null,
playerName: "",
},
player: {
movementMode: "walk",
currentSpeed: PLAYER_WALK_SPEED,
},
intro: {
currentStep: "intro",
dialogueAudio: null,
@@ -249,6 +265,14 @@ 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 },
+251
View File
@@ -0,0 +1,251 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { MapControls, OrthographicCamera, useGLTF } from '@react-three/drei';
import * as THREE from 'three';
// ----------------------------------------------------------------------------
// 1. Terrain Scene
// ----------------------------------------------------------------------------
function TerrainScene() {
const { scene } = useGLTF('/models/terrain/terrain.glb');
return (
<group>
<ambientLight intensity={1.5} />
<directionalLight position={[10, 20, 10]} intensity={2} />
<primitive object={scene} />
</group>
);
}
// ----------------------------------------------------------------------------
// 2. Waypoint Overlay (Debug visualization)
// ----------------------------------------------------------------------------
function WaypointOverlay({ waypoints, visible }: { waypoints: any[], visible: boolean }) {
if (!visible) return null;
return (
<group>
{waypoints.map((w) => (
<mesh key={w.id} position={[w.x, w.y + 1, w.z]}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshBasicMaterial color="#10b981" />
</mesh>
))}
</group>
);
}
// ----------------------------------------------------------------------------
// 3. Camera Manager (Handles Orthographic Math & Downloads)
// ----------------------------------------------------------------------------
function CameraManager({
autoBounds,
boundsTextRef
}: {
autoBounds: any,
boundsTextRef: React.RefObject<HTMLPreElement | null>
}) {
const { camera, gl, scene } = useThree();
const controlsRef = useRef<any>(null);
// Apply Auto-Bounds function
useEffect(() => {
const applyAutoBounds = () => {
if (camera instanceof THREE.OrthographicCamera && autoBounds) {
const width = autoBounds.maxX - autoBounds.minX;
const height = autoBounds.maxZ - autoBounds.minZ;
const centerX = (autoBounds.minX + autoBounds.maxX) / 2;
const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2;
camera.position.set(centerX, 200, centerZ);
camera.left = -width / 2;
camera.right = width / 2;
camera.top = height / 2;
camera.bottom = -height / 2;
camera.zoom = 1;
camera.updateProjectionMatrix();
if (controlsRef.current) {
controlsRef.current.target.set(centerX, 0, centerZ);
controlsRef.current.update();
}
}
};
(window as any).applyAutoBounds = applyAutoBounds;
// Initial apply
applyAutoBounds();
return () => { delete (window as any).applyAutoBounds; };
}, [camera, autoBounds]);
// Track dynamic bounds without triggering React re-renders!
useFrame(() => {
if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) {
const width = (camera.right - camera.left) / camera.zoom;
const height = (camera.top - camera.bottom) / camera.zoom;
const minX = Math.round(camera.position.x - width / 2);
const maxX = Math.round(camera.position.x + width / 2);
const minZ = Math.round(camera.position.z - height / 2);
const maxZ = Math.round(camera.position.z + height / 2);
// Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!)
boundsTextRef.current.innerText = JSON.stringify({ minX, maxX, minZ, maxZ }, null, 2);
}
});
// Attach screenshot capture logic
useEffect(() => {
(window as any).downloadMapScreenshot = () => {
// Force an immediate render frame to ensure no UI overlays are missing
gl.render(scene, camera);
const dataUrl = gl.domElement.toDataURL("image/png");
const a = document.createElement("a");
a.href = dataUrl;
a.download = "map_background.png";
a.click();
};
return () => { delete (window as any).downloadMapScreenshot; };
}, [gl, camera, scene]);
return <MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} />;
}
// ----------------------------------------------------------------------------
// 4. Main Page Route Component
// ----------------------------------------------------------------------------
export function BackgroundMapPage() {
const [waypoints, setWaypoints] = useState<any[]>([]);
const [showWaypoints, setShowWaypoints] = useState(true);
const boundsTextRef = useRef<HTMLPreElement>(null);
// Load road network waypoints to compute perfect GPS bounds
useEffect(() => {
const saved = localStorage.getItem('la-fabrik-waypoints');
if (saved) {
setWaypoints(JSON.parse(saved));
} else {
fetch('/roadNetwork.json')
.then(res => res.json())
.then(data => setWaypoints(data))
.catch(() => { });
}
}, []);
// Compute exact bounds that the EbikeGPSMap will use by default
const autoBounds = useMemo(() => {
if (waypoints.length === 0) return null;
const xs = waypoints.map(w => w.x);
const zs = waypoints.map(w => w.z);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minZ = Math.min(...zs);
const maxZ = Math.max(...zs);
// CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE.
// If the camera is rectangular, the exported PNG will be distorted when drawn
// on the EbikeGPSMap's 1024x1024 canvas!
const width = maxX - minX;
const height = maxZ - minZ;
const maxDim = Math.max(width, height);
const centerX = (minX + maxX) / 2;
const centerZ = (minZ + maxZ) / 2;
const paddedDim = maxDim * 1.15 || 100;
return {
minX: centerX - paddedDim / 2,
maxX: centerX + paddedDim / 2,
minZ: centerZ - paddedDim / 2,
maxZ: centerZ + paddedDim / 2,
};
}, [waypoints]);
return (
<div style={{ width: '100vw', height: '100vh', background: '#050505', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/*
CRITICAL: The DOM element MUST be a perfect square so the resulting PNG
is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture!
*/}
<div style={{ width: 'min(100vw, 100vh)', height: 'min(100vw, 100vh)', background: '#000', position: 'relative' }}>
<Canvas gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}>
<OrthographicCamera makeDefault position={[0, 200, 0]} near={0.1} far={1000} />
<TerrainScene />
<WaypointOverlay waypoints={waypoints} visible={showWaypoints} />
<CameraManager autoBounds={autoBounds} boundsTextRef={boundsTextRef} />
</Canvas>
</div>
{/* Premium Glassmorphic UI Dashboard */}
<div style={{
position: 'absolute', top: 24, left: 24,
background: 'rgba(15, 23, 42, 0.85)', padding: 24,
borderRadius: 16, border: '1px solid #334155',
color: 'white', fontFamily: 'system-ui, sans-serif',
backdropFilter: 'blur(12px)', width: 360,
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5)'
}}>
<h2 style={{ margin: '0 0 16px 0', fontSize: '1.4rem', color: '#38bdf8' }}>GPS Map Generator</h2>
<p style={{ fontSize: '0.9rem', color: '#94a3b8', marginBottom: 20, lineHeight: 1.5 }}>
1. Cadrez votre carte (ou utilisez le <b>Cadrage Automatique</b>).<br />
2. Masquez les waypoints (fond visuel seul).<br />
3. Cliquez sur <b>Capturer la carte</b>.
</p>
<button
onClick={() => setShowWaypoints(!showWaypoints)}
style={{
width: '100%', padding: '12px', marginBottom: 12,
background: showWaypoints ? '#1e293b' : '#334155',
border: '1px solid #475569', color: 'white',
borderRadius: 8, cursor: 'pointer', fontWeight: 600,
transition: 'all 0.2s'
}}
>
{showWaypoints ? '👁️ Masquer Waypoints' : '👁️‍🗨️ Afficher Waypoints'}
</button>
<button
onClick={() => {
if ((window as any).applyAutoBounds) (window as any).applyAutoBounds();
}}
style={{
width: '100%', padding: '12px', marginBottom: 16,
background: '#1e293b', border: '1px solid #475569',
color: '#10b981', borderRadius: 8, cursor: 'pointer', fontWeight: 600,
transition: 'all 0.2s'
}}
>
🎯 Cadrage Automatique
</button>
<button
onClick={() => {
if ((window as any).downloadMapScreenshot) (window as any).downloadMapScreenshot();
}}
style={{
width: '100%', padding: '14px', background: '#0ea5e9',
border: 'none', color: 'white', borderRadius: 8,
cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem',
boxShadow: '0 4px 6px -1px rgba(14, 165, 233, 0.4)'
}}
>
📸 Capturer la carte (.png)
</button>
<div style={{ marginTop: 24, padding: 16, background: '#020617', borderRadius: 10, fontSize: '0.85rem' }}>
<div style={{ color: '#64748b', marginBottom: 8, fontWeight: 600 }}>Limites Actuelles (worldBounds):</div>
<pre ref={boundsTextRef} style={{ margin: 0, color: '#10b981', fontFamily: 'monospace' }}>
Calcul...
</pre>
<div style={{ color: '#ef4444', marginTop: 12, fontSize: '0.75rem', lineHeight: 1.4 }}>
*Si vous décadrez à la souris, vous devrez copier ces valeurs exactes dans la prop <code>worldBounds</code> de votre composant <b>EbikeGPSMap</b> !
<br /><br />
Astuce : Utilisez le <b>Cadrage Automatique</b> pour ne rien avoir à configurer.
</div>
</div>
</div>
</div>
);
}
-13
View File
@@ -1,13 +0,0 @@
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"
/>
);
}
-549
View File
@@ -1,549 +0,0 @@
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
+122
View File
@@ -0,0 +1,122 @@
import { Grid } from './Grid';
import type { GridNode, Position } from './types';
/**
* Calculates the octile heuristic distance between two nodes.
* Ideal for 8-directional grid movement.
*/
function getOctileDistance(nodeA: GridNode, nodeB: GridNode): number {
const dx = Math.abs(nodeA.x - nodeB.x);
const dy = Math.abs(nodeA.y - nodeB.y);
const D = 1; // Orthogonal movement cost
const D2 = 1.414; // Diagonal movement cost (approx Math.sqrt(2))
return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy);
}
/**
* Finds the shortest path between start and end positions on the grid.
* Returns an array of Positions representing the path, or an empty array if no path is found.
*/
export function findPath(
grid: Grid,
startPos: Position,
endPos: Position,
allowDiagonals: boolean = true
): Position[] {
grid.reset();
const startNode = grid.getNode(Math.floor(startPos.x), Math.floor(startPos.y));
const endNode = grid.getNode(Math.floor(endPos.x), Math.floor(endPos.y));
if (!startNode || !endNode) {
return [];
}
// If the destination node itself is blocked, we try to find the nearest walkable neighbor
if (!endNode.walkable) {
const endNeighbors = grid.getNeighbors(endNode, allowDiagonals);
if (endNeighbors.length === 0) {
return [];
}
// Set destination to the closest walkable neighbor
let closestNeighbor = endNeighbors[0]!;
let minDist = getOctileDistance(startNode, closestNeighbor);
for (let i = 1; i < endNeighbors.length; i++) {
const neighbor = endNeighbors[i]!;
const dist = getOctileDistance(startNode, neighbor);
if (dist < minDist) {
minDist = dist;
closestNeighbor = neighbor;
}
}
// Reroute to that walkable neighbor
return findPath(grid, startPos, { x: closestNeighbor.x, y: closestNeighbor.y }, allowDiagonals);
}
const openSet: GridNode[] = [startNode];
const closedSet = new Set<GridNode>();
startNode.g = 0;
startNode.h = getOctileDistance(startNode, endNode);
startNode.f = startNode.h;
while (openSet.length > 0) {
// Find the node in openSet with the lowest f value
let lowIndex = 0;
for (let i = 1; i < openSet.length; i++) {
const node = openSet[i]!;
const lowNode = openSet[lowIndex]!;
if (node.f < lowNode.f) {
lowIndex = i;
}
}
const currentNode = openSet[lowIndex]!;
// Check if we reached the destination
if (currentNode === endNode) {
const path: Position[] = [];
let temp: GridNode | null = currentNode;
while (temp !== null) {
path.push({ x: temp.x, y: temp.y });
temp = temp.parent;
}
return path.reverse();
}
// Remove currentNode from openSet and add to closedSet
openSet.splice(lowIndex, 1);
closedSet.add(currentNode);
const neighbors = grid.getNeighbors(currentNode, allowDiagonals);
for (const neighbor of neighbors) {
if (closedSet.has(neighbor)) {
continue;
}
// Calculate cost to move to this neighbor (1 for orthogonal, 1.414 for diagonal)
const isDiagonal = neighbor.x !== currentNode.x && neighbor.y !== currentNode.y;
const moveCost = isDiagonal ? 1.414 : 1;
const tentativeG = currentNode.g + moveCost;
let neighborInOpenSet = openSet.includes(neighbor);
if (!neighborInOpenSet || tentativeG < neighbor.g) {
neighbor.parent = currentNode;
neighbor.g = tentativeG;
neighbor.h = getOctileDistance(neighbor, endNode);
neighbor.f = neighbor.g + neighbor.h;
if (!neighborInOpenSet) {
openSet.push(neighbor);
}
}
}
}
// Return empty if no path is found
return [];
}
+207
View File
@@ -0,0 +1,207 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import * as THREE from 'three';
import { useGPS } from './useGPS';
import type { WorldBounds } from './useGPS';
// ==========================================
// 1. Premium 2D HUD GPS Overlay Component
// ==========================================
export interface GPSMinimapHUDProps {
bwMaskUrl: string;
colorMapUrl: string;
gridWidth: number;
gridHeight: number;
worldBounds: WorldBounds;
playerPos: { x: number; z: number };
destPos?: { x: number; z: number };
size?: number; // Size of HUD in pixels
}
/**
* A beautiful, glassmorphic 2D HUD overlay that renders the GPS Minimap
* in the corner of the screen.
*/
export const GPSMinimapHUD: React.FC<GPSMinimapHUDProps> = ({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
playerPos,
destPos,
size = 200,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const gpsOptions = useMemo(() => ({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
}), [bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds]);
const { calculateWorldPath, renderGPSToCanvas, loading, error } = useGPS(gpsOptions);
useEffect(() => {
if (loading || error || !canvasRef.current) return;
// Calculate A* path in world coordinates
const path = destPos ? calculateWorldPath(playerPos, destPos) : [];
// Render path onto HUD canvas
renderGPSToCanvas(canvasRef.current, path, playerPos, destPos, {
pathColor: '#3b82f6', // Premium vibrant blue
pathWidth: 5,
playerColor: '#ef4444', // Hot red for player
playerSize: 6,
destColor: '#10b981', // Emerald green for destination
destSize: 6,
});
}, [playerPos, destPos, loading, error, calculateWorldPath, renderGPSToCanvas]);
return (
<div style={hudStyles.container(size)}>
{loading && <div style={hudStyles.statusText}>Initializing GPS...</div>}
{error && <div style={{ ...hudStyles.statusText, color: '#ef4444' }}>GPS Error: {error}</div>}
{!loading && !error && (
<canvas
ref={canvasRef}
width={size * 2} // Double size for retina/high-DPI screens
height={size * 2}
style={hudStyles.canvas(size)}
/>
)}
</div>
);
};
// ==========================================
// 2. 3D Handlebar Screen Mesh Component (R3F)
// ==========================================
export interface GPSBikeScreenProps {
bwMaskUrl: string;
colorMapUrl: string;
gridWidth: number;
gridHeight: number;
worldBounds: WorldBounds;
playerPos: { x: number; z: number };
destPos?: { x: number; z: number };
width?: number; // 3D Plane Width
height?: number; // 3D Plane Height
}
/**
* A Three.js 3D plane mesh that renders the GPS dynamically as a CanvasTexture.
* This can be directly attached to the bike's handlebars in your 3D world.
*/
export const GPSBikeScreen: React.FC<GPSBikeScreenProps> = ({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
playerPos,
destPos,
width = 0.4,
height = 0.4,
}) => {
// Offscreen canvas to render the GPS texture onto
const [offscreenCanvas] = useState(() => {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
return canvas;
});
const textureRef = useRef<THREE.CanvasTexture | null>(null);
const gpsOptions = useMemo(() => ({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
}), [bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds]);
const { calculateWorldPath, renderGPSToCanvas, loading } = useGPS(gpsOptions);
useEffect(() => {
if (loading) return;
// Calculate A* path
const path = destPos ? calculateWorldPath(playerPos, destPos) : [];
// Render path onto our offscreen canvas
renderGPSToCanvas(offscreenCanvas, path, playerPos, destPos, {
pathColor: '#60a5fa', // Bright neon blue
pathWidth: 8,
playerColor: '#ff0055', // Neon pink-red for bike
playerSize: 10,
destColor: '#00ffcc', // Vibrant cyan for target
destSize: 10,
});
// Notify Three.js that the texture needs an update
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
}, [playerPos, destPos, loading, calculateWorldPath, renderGPSToCanvas, offscreenCanvas]);
return (
<mesh castShadow receiveShadow>
<planeGeometry args={[width, height]} />
<meshBasicMaterial toneMapped={false}>
<canvasTexture
ref={textureRef}
attach="map"
image={offscreenCanvas}
minFilter={THREE.LinearFilter}
magFilter={THREE.LinearFilter}
/>
</meshBasicMaterial>
</mesh>
);
};
// ==========================================
// Styles for HUD (Premium Glassmorphism)
// ==========================================
const hudStyles = {
container: (size: number): React.CSSProperties => ({
position: 'absolute',
bottom: '24px',
right: '24px',
width: `${size}px`,
height: `${size}px`,
borderRadius: '24px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.37), 0 0 15px rgba(59, 130, 246, 0.2)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
background: 'rgba(15, 23, 42, 0.6)', // Sleek dark slate
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
pointerEvents: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}),
canvas: (size: number): React.CSSProperties => ({
width: `${size}px`,
height: `${size}px`,
display: 'block',
}),
statusText: {
color: '#94a3b8',
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: '12px',
fontWeight: 500,
letterSpacing: '0.05em',
} as React.CSSProperties,
};
+100
View File
@@ -0,0 +1,100 @@
import type { GridNode } from './types';
export class Grid {
public width: number;
public height: number;
private nodes: GridNode[][];
constructor(walkableMatrix: boolean[][]) {
this.height = walkableMatrix.length;
this.width = this.height > 0 ? (walkableMatrix[0]?.length ?? 0) : 0;
this.nodes = [];
for (let y = 0; y < this.height; y++) {
const row: GridNode[] = [];
const sourceRow = walkableMatrix[y];
for (let x = 0; x < this.width; x++) {
row.push({
x,
y,
walkable: sourceRow ? (sourceRow[x] ?? false) : false,
g: 0,
h: 0,
f: 0,
parent: null,
});
}
this.nodes.push(row);
}
}
public getNode(x: number, y: number): GridNode | null {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
const row = this.nodes[y];
return row ? (row[x] ?? null) : null;
}
return null;
}
/**
* Resets g, h, f values and parents for all nodes in the grid,
* preparing it for a new A* calculation.
*/
public reset(): void {
for (let y = 0; y < this.height; y++) {
const row = this.nodes[y];
if (!row) continue;
for (let x = 0; x < this.width; x++) {
const node = row[x];
if (!node) continue;
node.g = 0;
node.h = 0;
node.f = 0;
node.parent = null;
}
}
}
/**
* Retrieves neighboring nodes. Supports 8-directional movement.
*/
public getNeighbors(node: GridNode, allowDiagonals: boolean = true): GridNode[] {
const neighbors: GridNode[] = [];
const { x, y } = node;
// Relative coordinates of 8 neighbors
const directions = [
{ dx: 0, dy: -1, isDiagonal: false }, // N
{ dx: 1, dy: 0, isDiagonal: false }, // E
{ dx: 0, dy: 1, isDiagonal: false }, // S
{ dx: -1, dy: 0, isDiagonal: false }, // W
];
if (allowDiagonals) {
directions.push(
{ dx: 1, dy: -1, isDiagonal: true }, // NE
{ dx: 1, dy: 1, isDiagonal: true }, // SE
{ dx: -1, dy: 1, isDiagonal: true }, // SW
{ dx: -1, dy: -1, isDiagonal: true } // NW
);
}
for (const dir of directions) {
const neighbor = this.getNode(x + dir.dx, y + dir.dy);
if (neighbor && neighbor.walkable) {
// Prevent corner cutting if both orthogonal neighbors are blocked
if (dir.isDiagonal) {
const ortho1 = this.getNode(x + dir.dx, y);
const ortho2 = this.getNode(x, y + dir.dy);
const isBlocked = (!ortho1 || !ortho1.walkable) && (!ortho2 || !ortho2.walkable);
if (isBlocked) {
continue; // Skip this diagonal neighbor to avoid squeezing through corners
}
}
neighbors.push(neighbor);
}
}
return neighbors;
}
}
+75
View File
@@ -0,0 +1,75 @@
import { Grid } from './Grid';
/**
* Loads an image from a URL.
*/
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous'; // Enable CORS just in case
img.onload = () => resolve(img);
img.onerror = (err) => reject(new Error(`Failed to load image at ${url}: ${err}`));
img.src = url;
});
}
/**
* Loads a B&W image and scales it to gridWidth x gridHeight.
* Higher dimensions = higher accuracy but slower pathfinding.
* Lower dimensions = extremely fast pathfinding.
*
* Walkable roads should be white (or light gray). Non-walkable areas should be black.
*
* @param imageUrl The path or URL of the B&W navigation mask.
* @param gridWidth The target width of our A* pathfinding grid.
* @param gridHeight The target height of our A* pathfinding grid.
* @param threshold Brightness threshold (0-255) above which a pixel is considered walkable (default: 128).
*/
export async function createGridFromImage(
imageUrl: string,
gridWidth: number,
gridHeight: number,
threshold: number = 128
): Promise<Grid> {
const img = await loadImage(imageUrl);
// Create an offscreen canvas to scale and analyze the image
const canvas = document.createElement('canvas');
canvas.width = gridWidth;
canvas.height = gridHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get 2D context for offscreen canvas');
}
// Draw and scale the image onto the canvas
ctx.drawImage(img, 0, 0, gridWidth, gridHeight);
// Retrieve pixel data
const imgData = ctx.getImageData(0, 0, gridWidth, gridHeight);
const data = imgData.data;
// Initialize a 2D boolean matrix representing the walkable grid
const walkableMatrix: boolean[][] = [];
for (let y = 0; y < gridHeight; y++) {
const row: boolean[] = [];
for (let x = 0; x < gridWidth; x++) {
// Each pixel has 4 channels: R, G, B, A
const index = (y * gridWidth + x) * 4;
const r = data[index] ?? 0;
const g = data[index + 1] ?? 0;
const b = data[index + 2] ?? 0;
// Calculate brightness (standard grayscale weighting)
const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
// If bright enough, it is a road (walkable)
row.push(brightness >= threshold);
}
walkableMatrix.push(row);
}
return new Grid(walkableMatrix);
}
+145
View File
@@ -0,0 +1,145 @@
import type { Waypoint, WaypointNode } from './types';
/**
* Calculates Euclidean 3D distance between two points.
*/
function getDistance3D(
posA: { x: number; y: number; z: number },
posB: { x: number; y: number; z: number }
): number {
return Math.sqrt(
Math.pow(posA.x - posB.x, 2) +
Math.pow(posA.y - posB.y, 2) +
Math.pow(posA.z - posB.z, 2)
);
}
/**
* Finds the closest Waypoint in a list to a target 3D world position.
*/
export function findClosestWaypoint(
waypoints: Waypoint[],
pos: { x: number; y: number; z: number }
): Waypoint | null {
if (waypoints.length === 0) return null;
let closest = waypoints[0]!;
let minDist = getDistance3D(closest, pos);
for (let i = 1; i < waypoints.length; i++) {
const wp = waypoints[i]!;
const dist = getDistance3D(wp, pos);
if (dist < minDist) {
minDist = dist;
closest = wp;
}
}
return closest;
}
/**
* Runs A* pathfinding on a network of 3D Waypoints.
*
* @param waypoints List of all waypoints in the road network.
* @param startWorldPos Player's current 3D world position.
* @param endWorldPos Targeted 3D world destination.
* @returns Array of Waypoints representing the path from start to end, or empty array if none found.
*/
export function findWaypointPath(
waypoints: Waypoint[],
startWorldPos: { x: number; y: number; z: number },
endWorldPos: { x: number; y: number; z: number }
): Waypoint[] {
if (waypoints.length === 0) return [];
// 1. Find the closest starting and ending waypoints in the network
const startWp = findClosestWaypoint(waypoints, startWorldPos);
const endWp = findClosestWaypoint(waypoints, endWorldPos);
if (!startWp || !endWp) return [];
if (startWp.id === endWp.id) return [startWp];
// 2. Map all waypoints to A* search nodes
const nodeMap = new Map<number, WaypointNode>();
waypoints.forEach((wp) => {
nodeMap.set(wp.id, {
...wp,
g: Infinity,
h: Infinity,
f: Infinity,
parent: null,
});
});
const startNode = nodeMap.get(startWp.id)!;
const endNode = nodeMap.get(endWp.id)!;
// 3. Initialize open and closed sets
const openSet: WaypointNode[] = [startNode];
const closedSet = new Set<number>(); // Set of waypoint IDs
startNode.g = 0;
startNode.h = getDistance3D(startNode, endNode);
startNode.f = startNode.h;
while (openSet.length > 0) {
// Find node with lowest f score
let lowIndex = 0;
for (let i = 1; i < openSet.length; i++) {
const node = openSet[i]!;
const lowNode = openSet[lowIndex]!;
if (node.f < lowNode.f) {
lowIndex = i;
}
}
const currentNode = openSet[lowIndex]!;
// Reached destination! Reconstruct the path
if (currentNode.id === endNode.id) {
const path: Waypoint[] = [];
let temp: WaypointNode | null = currentNode;
while (temp !== null) {
// Find corresponding raw Waypoint
const rawWp = waypoints.find((w) => w.id === temp!.id);
if (rawWp) {
path.push(rawWp);
}
temp = temp.parent;
}
return path.reverse();
}
// Move from open to closed set
openSet.splice(lowIndex, 1);
closedSet.add(currentNode.id);
// Process neighbors
for (const neighborId of currentNode.connections) {
if (closedSet.has(neighborId)) continue;
const neighborNode = nodeMap.get(neighborId);
if (!neighborNode) continue;
// Distance from currentNode to neighbor is physical 3D distance
const tentativeG = currentNode.g + getDistance3D(currentNode, neighborNode);
let neighborInOpenSet = openSet.some((node) => node.id === neighborId);
if (!neighborInOpenSet || tentativeG < neighborNode.g) {
neighborNode.parent = currentNode;
neighborNode.g = tentativeG;
neighborNode.h = getDistance3D(neighborNode, endNode);
neighborNode.f = neighborNode.g + neighborNode.h;
if (!neighborInOpenSet) {
openSet.push(neighborNode);
}
}
}
}
// No path found
return [];
}
+10
View File
@@ -0,0 +1,10 @@
export * from './types';
export * from './Grid';
export * from './AStar';
export * from './ImageToGrid';
export * from './useGPS';
export * from './GPSMinimap';
export * from './WaypointAStar';
export * from './useWaypointGPS';
+40
View File
@@ -0,0 +1,40 @@
export interface Position {
x: number;
y: number;
}
export interface GridNode {
x: number;
y: number;
walkable: boolean;
g: number;
h: number;
f: number;
parent: GridNode | null;
}
export interface GridSize {
width: number;
height: number;
}
export interface Waypoint {
id: number;
x: number;
y: number;
z: number;
connections: number[];
}
export interface WaypointNode {
id: number;
x: number;
y: number;
z: number;
connections: number[];
g: number;
h: number;
f: number;
parent: WaypointNode | null;
}
+243
View File
@@ -0,0 +1,243 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Grid } from './Grid';
import { createGridFromImage } from './ImageToGrid';
import { findPath } from './AStar';
import type { Position } from './types';
export interface WorldBounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
export interface UseGPSOptions {
bwMaskUrl: string;
colorMapUrl: string;
gridWidth: number; // The "width of the array pathfinding" (resolution scaling)
gridHeight: number; // The "height of the array pathfinding"
worldBounds: WorldBounds;
allowDiagonals?: boolean;
}
export function useGPS({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
allowDiagonals = true,
}: UseGPSOptions) {
const [grid, setGrid] = useState<Grid | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Cache the images so they don't reload every frame
const colorMapImgRef = useRef<HTMLImageElement | null>(null);
// Initialize the pathfinding grid
useEffect(() => {
let active = true;
setLoading(true);
setError(null);
async function initGrid() {
try {
const pathfindingGrid = await createGridFromImage(bwMaskUrl, gridWidth, gridHeight);
// Pre-load color map image for canvas drawing
const colorMapImg = new Image();
colorMapImg.crossOrigin = 'anonymous';
await new Promise((resolve, reject) => {
colorMapImg.onload = resolve;
colorMapImg.onerror = reject;
colorMapImg.src = colorMapUrl;
});
if (active) {
setGrid(pathfindingGrid);
colorMapImgRef.current = colorMapImg;
setLoading(false);
}
} catch (err: any) {
if (active) {
setError(err.message || 'Failed to initialize GPS system');
setLoading(false);
}
}
}
initGrid();
return () => {
active = false;
};
}, [bwMaskUrl, colorMapUrl, gridWidth, gridHeight]);
/**
* Translates 3D World coordinates (X, Z) into 2D Grid coordinates (col, row)
*/
const worldToGrid = useCallback(
(worldX: number, worldZ: number): Position => {
const { minX, maxX, minZ, maxZ } = worldBounds;
// Calculate percentages across the bounds
const pctX = (worldX - minX) / (maxX - minX);
const pctZ = (worldZ - minZ) / (maxZ - minZ);
// Map to grid dimensions
const gridX = Math.max(0, Math.min(gridWidth - 1, Math.floor(pctX * gridWidth)));
const gridY = Math.max(0, Math.min(gridHeight - 1, Math.floor(pctZ * gridHeight)));
return { x: gridX, y: gridY };
},
[worldBounds, gridWidth, gridHeight]
);
/**
* Translates 2D Grid coordinates (col, row) back into 3D World coordinates (X, Z)
*/
const gridToWorld = useCallback(
(gridX: number, gridY: number): { x: number; z: number } => {
const { minX, maxX, minZ, maxZ } = worldBounds;
const pctX = gridX / gridWidth;
const pctZ = gridY / gridHeight;
const worldX = minX + pctX * (maxX - minX);
const worldZ = minZ + pctZ * (maxZ - minZ);
return { x: worldX, z: worldZ };
},
[worldBounds, gridWidth, gridHeight]
);
/**
* Runs the A* calculation using 3D world coordinates.
* Returns path in 3D world space.
*/
const calculateWorldPath = useCallback(
(startWorld: { x: number; z: number }, endWorld: { x: number; z: number }): { x: number; z: number }[] => {
if (!grid) return [];
const startGrid = worldToGrid(startWorld.x, startWorld.z);
const endGrid = worldToGrid(endWorld.x, endWorld.z);
const gridPath = findPath(grid, startGrid, endGrid, allowDiagonals);
// Convert path coordinates back to 3D space
return gridPath.map((node) => gridToWorld(node.x, node.y));
},
[grid, worldToGrid, gridToWorld, allowDiagonals]
);
/**
* Updates an HTML5 `<canvas>` element with the background color map,
* a path line, and the player/destination indicators.
*/
const renderGPSToCanvas = useCallback(
(
canvas: HTMLCanvasElement,
path: { x: number; z: number }[],
playerWorldPos?: { x: number; z: number },
destWorldPos?: { x: number; z: number },
options: {
pathColor?: string;
pathWidth?: number;
playerColor?: string;
playerSize?: number;
destColor?: string;
destSize?: number;
} = {}
) => {
const ctx = canvas.getContext('2d');
if (!ctx || !colorMapImgRef.current) return;
const {
pathColor = '#3b82f6', // Premium blue
pathWidth = 6,
playerColor = '#ef4444', // Red dot for player
playerSize = 8,
destColor = '#10b981', // Green dot for flag
destSize = 8,
} = options;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
// 1. Draw background color map
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight);
// Helper: translate world coordinates to Canvas pixels
const worldToCanvas = (wx: number, wz: number): Position => {
const { minX, maxX, minZ, maxZ } = worldBounds;
const px = ((wx - minX) / (maxX - minX)) * canvasWidth;
const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight;
return { x: px, y: py };
};
// 2. Draw A* Path Line
if (path.length > 1) {
ctx.beginPath();
const startNode = path[0]!;
const startPt = worldToCanvas(startNode.x, startNode.z);
ctx.moveTo(startPt.x, startPt.y);
for (let i = 1; i < path.length; i++) {
const node = path[i]!;
const pt = worldToCanvas(node.x, node.z);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = pathColor;
ctx.lineWidth = pathWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Add a soft glow effect for premium feel
ctx.shadowBlur = 8;
ctx.shadowColor = pathColor;
ctx.stroke();
// Reset shadow for subsequent drawings
ctx.shadowBlur = 0;
}
// 3. Draw Destination Indicator
if (destWorldPos) {
const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z);
ctx.beginPath();
ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI);
ctx.fillStyle = destColor;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
// 4. Draw Player Indicator
if (playerWorldPos) {
const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z);
ctx.beginPath();
ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI);
ctx.fillStyle = playerColor;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
},
[worldBounds]
);
return {
grid,
loading,
error,
calculateWorldPath,
renderGPSToCanvas,
worldToGrid,
gridToWorld,
};
}
+212
View File
@@ -0,0 +1,212 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { findWaypointPath } from './WaypointAStar';
import type { Waypoint } from './types';
import type { WorldBounds } from './useGPS';
export interface UseWaypointGPSOptions {
roadNetworkUrl: string; // URL/Path to roadNetwork.json
colorMapUrl: string; // URL/Path to color_map.png
worldBounds: WorldBounds;
}
export function useWaypointGPS({
roadNetworkUrl,
colorMapUrl,
worldBounds,
}: UseWaypointGPSOptions) {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const colorMapImgRef = useRef<HTMLImageElement | null>(null);
// Load waypoint list and background color map image
useEffect(() => {
let active = true;
setLoading(true);
setError(null);
async function initGPS() {
try {
// 1. Fetch the road network JSON
const response = await fetch(roadNetworkUrl);
if (!response.ok) {
throw new Error(`Failed to load road network from ${roadNetworkUrl}`);
}
const data: Waypoint[] = await response.json();
// 2. Pre-load the color map image
const colorMapImg = new Image();
colorMapImg.crossOrigin = 'anonymous';
await new Promise((resolve, reject) => {
colorMapImg.onload = resolve;
colorMapImg.onerror = reject;
colorMapImg.src = colorMapUrl;
});
if (active) {
setWaypoints(data);
colorMapImgRef.current = colorMapImg;
setLoading(false);
}
} catch (err: any) {
if (active) {
setError(err.message || 'Failed to initialize Waypoint GPS');
setLoading(false);
}
}
}
initGPS();
return () => {
active = false;
};
}, [roadNetworkUrl, colorMapUrl]);
/**
* Calculates the shortest path between start and end world points.
*/
const calculateRoute = useCallback(
(
startWorld: { x: number; y: number; z: number },
endWorld: { x: number; y: number; z: number }
): Waypoint[] => {
if (waypoints.length === 0) return [];
return findWaypointPath(waypoints, startWorld, endWorld);
},
[waypoints]
);
/**
* Renders the road network path, player position, and waypoint target onto a canvas.
*/
const renderGPSToCanvas = useCallback(
(
canvas: HTMLCanvasElement,
path: Waypoint[],
playerWorldPos?: { x: number; y: number; z: number },
destWorldPos?: { x: number; y: number; z: number },
options: {
pathColor?: string;
pathWidth?: number;
playerColor?: string;
playerSize?: number;
destColor?: string;
destSize?: number;
showAllWaypoints?: boolean; // Debug mode
} = {}
) => {
const ctx = canvas.getContext('2d');
if (!ctx || !colorMapImgRef.current) return;
const {
pathColor = '#10b981', // Premium emerald green
pathWidth = 6,
playerColor = '#ff0055', // Neon pink-red for bike
playerSize = 8,
destColor = '#00ffcc', // Neon cyan for target
destSize = 8,
showAllWaypoints = false,
} = options;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
// 1. Draw color map background
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight);
// Helper: translate world coordinates (X, Z) to Canvas pixels (x, y)
const worldToCanvas = (wx: number, wz: number) => {
const { minX, maxX, minZ, maxZ } = worldBounds;
const px = ((wx - minX) / (maxX - minX)) * canvasWidth;
const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight;
return { x: px, y: py };
};
// 2. [Debug] Draw all network connections
if (showAllWaypoints && waypoints.length > 0) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 1.5;
const drawn = new Set<string>();
waypoints.forEach((wp) => {
const startPt = worldToCanvas(wp.x, wp.z);
wp.connections.forEach((connId) => {
const other = waypoints.find((w) => w.id === connId);
if (other) {
const key = wp.id < other.id ? `${wp.id}-${other.id}` : `${other.id}-${wp.id}`;
if (!drawn.has(key)) {
drawn.add(key);
const endPt = worldToCanvas(other.x, other.z);
ctx.beginPath();
ctx.moveTo(startPt.x, startPt.y);
ctx.lineTo(endPt.x, endPt.y);
ctx.stroke();
}
}
});
});
}
// 3. Draw calculated A* path line
if (path.length > 1) {
ctx.beginPath();
const startNode = path[0]!;
const startPt = worldToCanvas(startNode.x, startNode.z);
ctx.moveTo(startPt.x, startPt.y);
for (let i = 1; i < path.length; i++) {
const node = path[i]!;
const pt = worldToCanvas(node.x, node.z);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = pathColor;
ctx.lineWidth = pathWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Add soft premium path glow
ctx.shadowBlur = 8;
ctx.shadowColor = pathColor;
ctx.stroke();
ctx.shadowBlur = 0; // Reset
}
// 4. Draw Destination target
if (destWorldPos) {
const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z);
ctx.beginPath();
ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI);
ctx.fillStyle = destColor;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
// 5. Draw Player / Bike
if (playerWorldPos) {
const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z);
ctx.beginPath();
ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI);
ctx.fillStyle = playerColor;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
},
[worldBounds, waypoints]
);
return {
waypoints,
loading,
error,
calculateRoute,
renderGPSToCanvas,
};
}
+13 -7
View File
@@ -6,7 +6,8 @@ import {
} from "@tanstack/react-router";
import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page";
import { GalleryPage } from "@/pages/gallery/page";
import { WaypointEditorPage } from "@/pages/waypoint/page";
import { BackgroundMapPage } from "@/pages/backgroundmap/page";
import {
DocsAnimationRoute,
DocsAudioRoute,
@@ -14,7 +15,6 @@ import {
DocsCodeReviewRoute,
DocsEditorRoute,
DocsFeaturesRoute,
DocsGalleryRoute,
DocsHandTrackingRoute,
DocsInteractionRoute,
DocsLayoutRoute,
@@ -45,10 +45,16 @@ const editorRoute = createRoute({
component: EditorPage,
});
const galleryRoute = createRoute({
const waypointRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/gallery",
component: GalleryPage,
path: "/waypoint",
component: WaypointEditorPage,
});
const backgroundMapRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/backgroundmap",
component: BackgroundMapPage,
});
const docsRoute = createRoute({
@@ -74,7 +80,6 @@ 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({
@@ -87,7 +92,8 @@ const docsChildRoutes = [
const routeTree = rootRoute.addChildren([
indexRoute,
editorRoute,
galleryRoute,
waypointRoute,
backgroundMapRoute,
docsRoute.addChildren(docsChildRoutes),
]);
-5
View File
@@ -87,10 +87,6 @@ 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",
@@ -123,7 +119,6 @@ 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(
+121
View File
@@ -9,6 +9,7 @@ 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";
@@ -16,6 +17,11 @@ 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);
@@ -171,3 +177,118 @@ 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,
);
}
+2
View File
@@ -1,4 +1,5 @@
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";
@@ -56,6 +57,7 @@ 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}
+1
View File
@@ -29,6 +29,7 @@ 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;
+122 -1
View File
@@ -1,11 +1,13 @@
import type { ReactNode } from "react";
import { Component, useRef } from "react";
import { Component, useRef, useState, useEffect } 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,
@@ -84,11 +86,55 @@ class ModelPreviewErrorBoundary extends Component<
}
}
interface Waypoint {
id: number;
x: number;
y: number;
z: number;
connections: number[];
}
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
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}>
@@ -98,6 +144,45 @@ 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
@@ -151,6 +236,42 @@ 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}
+7 -1
View File
@@ -1,12 +1,18 @@
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 />;
}
+150 -6
View File
@@ -20,7 +20,6 @@ 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";
@@ -28,6 +27,7 @@ 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,6 +108,73 @@ 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));
@@ -220,6 +287,17 @@ 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) {
@@ -231,14 +309,16 @@ export function PlayerController({
if (!movementLocked) {
if (keys.current.forward) _wishDir.add(_forward);
if (keys.current.backward) _wishDir.sub(_forward);
if (keys.current.left) _wishDir.sub(_right);
if (keys.current.right) _wishDir.add(_right);
if (movementModeRef.current !== "ebike") {
if (keys.current.left) _wishDir.sub(_right);
if (keys.current.right) _wishDir.add(_right);
}
}
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
const accel = onFloor.current
? PLAYER_WALK_SPEED
: PLAYER_WALK_SPEED * PLAYER_AIR_CONTROL_FACTOR;
? currentSpeed
: currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
velocity.current.x +=
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
velocity.current.z +=
@@ -282,7 +362,71 @@ export function PlayerController({
}
}
camera.position.copy(capsule.current.end);
if (movementModeRef.current === "ebike") {
// Calculate dynamic steering factor
let targetSteer = 0;
if (keys.current.left) targetSteer = 1;
else if (keys.current.right) targetSteer = -1;
const currentSteer = (window as any).ebikeSteerFactor || 0;
const steerFactor = THREE.MathUtils.lerp(currentSteer, targetSteer, 8 * dt);
(window as any).ebikeSteerFactor = steerFactor;
// 1. Dynamic FOV stretch based on speed!
const speed = velocity.current.length();
const targetFov = 60 + Math.min(speed * 0.35, 9); // stretch FOV up to 9 degrees at high speed (halved by two)!
const perspectiveCam = camera as THREE.PerspectiveCamera;
perspectiveCam.fov = THREE.MathUtils.lerp(perspectiveCam.fov, targetFov, 6 * dt);
perspectiveCam.updateProjectionMatrix();
// 2. Camera lag & dynamic swing trailing
const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position);
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
// Swing camera to optimize the view for both left and right turns:
// Since the camera is on the left (X = -3.5), it naturally trails beautifully in right turns,
// but cuts forward in left turns. We compensate by pushing the camera backward (+Z) during left turns!
const swingX = -Math.abs(steerFactor) * 1.5;
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
cameraOffset.add(cameraSwing);
const targetCamPos = new THREE.Vector3()
.copy(capsule.current.end)
.add(cameraOffset);
// Smoothly lerp camera position to eliminate rigidity
camera.position.lerp(targetCamPos, 12 * dt);
// 3. Dynamic camera roll based on steering!
const pitchRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[0]);
const yawRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) + ebikeAngle.current;
// COMMENTED OUT: Camera roll/tilt during turns (keeping it flat)
// const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]) - steerFactor * 0.08;
const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]);
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
// 4. Synchronize visual e-bike position and apply leaning!
const ebikeVisual = (window as any).ebikeVisualGroup?.current;
if (ebikeVisual) {
ebikeVisual.position.set(
capsule.current.end.x,
capsule.current.end.y - PLAYER_EYE_HEIGHT,
capsule.current.end.z
);
// Lean (roll) the bike sideways in turns (up to 15 degrees)
const leanAngle = steerFactor * 0.26; // rotate in direction of turn!
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
}
} else {
camera.position.copy(capsule.current.end);
}
// Save player capsule end position and camera yaw globally so other components (like Ebike) can access it
(window as any).playerPos = [capsule.current.end.x, capsule.current.end.y, capsule.current.end.z];
(window as any).ebikeAngle = ebikeAngle.current;
});
return null;