chore: code quality audit and lint fixes
🔍 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

- Fix all 63 ESLint errors across codebase
- Consolidate MaterialWithTextureSlots type in src/types/three/three.ts
- Add CSS custom properties for design tokens
- Extract ebike constants to src/data/ebike/ebikeConfig.ts
- Add proper TypeScript types for window extensions
- Fix React hooks violations (refs during render, setState in effects)
- Remove unused exports and redundant CSS
- Add type guards for Three.js material handling
- Clean up AI slop comments and legacy CSS patterns
This commit is contained in:
Tom Boullay
2026-05-29 09:00:04 +02:00
parent ade301389e
commit 52bb1b2915
18 changed files with 550 additions and 465 deletions
+61 -54
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo } from "react"; import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap"; import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
@@ -9,25 +9,15 @@ import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { animateCameraTransformTransition } from "@/world/GameCinematics"; import { animateCameraTransformTransition } from "@/world/GameCinematics";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig"; import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
import {
EBIKE_CAMERA_TRANSFORM,
EBIKE_DROP_PLAYER_TRANSFORM,
} from "@/data/ebike/ebikeConfig";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import "@/types/ebike/ebikeWindow";
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf"; 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 { interface EbikeProps {
position: Vector3Tuple; position: Vector3Tuple;
} }
@@ -71,14 +61,24 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
new THREE.Vector3(...position), new THREE.Vector3(...position),
); );
const restingPosition = useRef<Vector3Tuple>([ // Use ref for internal state, and state for debug visualization (to avoid ref access during render)
const restingPositionRef = useRef<Vector3Tuple>([
position[0], position[0],
position[1] - PLAYER_EYE_HEIGHT, position[1] - PLAYER_EYE_HEIGHT,
position[2], position[2],
]); ]);
const restingRotation = useRef<number>(0); const restingRotationRef = useRef<number>(0);
const forkRef = useRef<THREE.Object3D | null>(null); const forkRef = useRef<THREE.Object3D | null>(null);
// State for debug visualization (synced from refs during useFrame)
const [showCameraPoints, setShowCameraPoints] = useState(true);
const [debugRestingPosition, setDebugRestingPosition] =
useState<Vector3Tuple>([
position[0],
position[1] - PLAYER_EYE_HEIGHT,
position[2],
]);
useEffect(() => { useEffect(() => {
if (model) { if (model) {
const fork = model.getObjectByName("fourche"); const fork = model.getObjectByName("fourche");
@@ -89,28 +89,28 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}, [model]); }, [model]);
useEffect(() => { useEffect(() => {
(window as any).ebikeVisualGroup = groupRef; window.ebikeVisualGroup = groupRef;
(window as any).ebikeParkedPosition = restingPosition.current; window.ebikeParkedPosition = restingPositionRef.current;
(window as any).ebikeParkedRotation = restingRotation.current; window.ebikeParkedRotation = restingRotationRef.current;
return () => { return () => {
(window as any).ebikeVisualGroup = null; window.ebikeVisualGroup = null;
(window as any).ebikeParkedPosition = null; window.ebikeParkedPosition = null;
(window as any).ebikeParkedRotation = null; window.ebikeParkedRotation = null;
}; };
}, []); }, []);
useFrame((_, delta) => { useFrame((_, delta) => {
if (groupRef.current) { if (groupRef.current) {
if (movementMode === "ebike") { if (movementMode === "ebike") {
restingPosition.current = [ restingPositionRef.current = [
groupRef.current.position.x, groupRef.current.position.x,
groupRef.current.position.y, groupRef.current.position.y,
groupRef.current.position.z, groupRef.current.position.z,
]; ];
restingRotation.current = groupRef.current.rotation.y; restingRotationRef.current = groupRef.current.rotation.y;
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis // Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
const steerFactor = (window as any).ebikeSteerFactor || 0; const steerFactor = window.ebikeSteerFactor ?? 0;
if (forkRef.current) { if (forkRef.current) {
// 15 degrees is 0.26 radians // 15 degrees is 0.26 radians
const targetForkRotation = steerFactor * 0.26; const targetForkRotation = steerFactor * 0.26;
@@ -127,51 +127,57 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
lastGpsUpdatePos.current.copy(currentPos); lastGpsUpdatePos.current.copy(currentPos);
setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z }); setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z });
} }
// Sync debug visualization state (throttled to avoid excessive re-renders)
if (showCameraPoints) {
setDebugRestingPosition([...restingPositionRef.current]);
}
} else { } else {
groupRef.current.position.set(...restingPosition.current); groupRef.current.position.set(...restingPositionRef.current);
groupRef.current.rotation.set(0, restingRotation.current, 0); groupRef.current.rotation.set(0, restingRotationRef.current, 0);
// Reset fork rotation when parked // Reset fork rotation when parked
if (forkRef.current) { if (forkRef.current) {
forkRef.current.rotation.z = 0; forkRef.current.rotation.z = 0;
} }
} }
(window as any).ebikeParkedPosition = restingPosition.current; window.ebikeParkedPosition = restingPositionRef.current;
(window as any).ebikeParkedRotation = restingRotation.current; window.ebikeParkedRotation = restingRotationRef.current;
} }
}); });
// Debug visualization positions computed from state (not refs)
const camPointPos: Vector3Tuple = [ const camPointPos: Vector3Tuple = [
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0], debugRestingPosition[0] + EBIKE_CAMERA_TRANSFORM.position[0],
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1], debugRestingPosition[1] + EBIKE_CAMERA_TRANSFORM.position[1],
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2], debugRestingPosition[2] + EBIKE_CAMERA_TRANSFORM.position[2],
]; ];
const dropPointPos: Vector3Tuple = [ const dropPointPos: Vector3Tuple = [
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0], debugRestingPosition[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1], debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2], debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
]; ];
const handleInteract = (): void => { const handleInteract = useCallback((): void => {
if (movementMode === "walk") { if (movementMode === "walk") {
const cameraOffset = new THREE.Vector3( const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position, ...EBIKE_CAMERA_TRANSFORM.position,
); );
cameraOffset.applyAxisAngle( cameraOffset.applyAxisAngle(
new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 1, 0),
restingRotation.current, restingRotationRef.current,
); );
const targetCamPos: Vector3Tuple = [ const targetCamPos: Vector3Tuple = [
restingPosition.current[0] + cameraOffset.x, restingPositionRef.current[0] + cameraOffset.x,
restingPosition.current[1] + cameraOffset.y, restingPositionRef.current[1] + cameraOffset.y,
restingPosition.current[2] + cameraOffset.z, restingPositionRef.current[2] + cameraOffset.z,
]; ];
const targetRotation: Vector3Tuple = [ const targetRotation: Vector3Tuple = [
EBIKE_CAMERA_TRANSFORM.rotation[0], EBIKE_CAMERA_TRANSFORM.rotation[0],
EBIKE_CAMERA_TRANSFORM.rotation[1] + EBIKE_CAMERA_TRANSFORM.rotation[1] +
THREE.MathUtils.radToDeg(restingRotation.current), THREE.MathUtils.radToDeg(restingRotationRef.current),
EBIKE_CAMERA_TRANSFORM.rotation[2], EBIKE_CAMERA_TRANSFORM.rotation[2],
]; ];
@@ -207,27 +213,28 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
useGameStore.getState().setPlayerMovementMode("walk"); useGameStore.getState().setPlayerMovementMode("walk");
}); });
} }
}; }, [movementMode, camera, position]);
// Store handleInteract in a ref for use in debug folder callback
const handleInteractRef = useRef(handleInteract); const handleInteractRef = useRef(handleInteract);
useEffect(() => {
handleInteractRef.current = handleInteract; handleInteractRef.current = handleInteract;
}, [handleInteract]);
const debugRef = useRef({ showCameraPoints: true }); // Mutable object for lil-gui binding
const debugActions = useRef({ const debugState = useRef({ showCameraPoints: true });
toggleRide: () => {
handleInteractRef.current();
},
});
useDebugFolder("Ebike", (folder) => { useDebugFolder("Ebike", (folder) => {
folder folder
.add(debugRef.current, "showCameraPoints") .add(debugState.current, "showCameraPoints")
.name("Show Camera Points") .name("Show Camera Points")
.onChange((value: boolean) => { .onChange((value: boolean) => {
debugRef.current.showCameraPoints = value; setShowCameraPoints(value);
}); });
folder.add(debugActions.current, "toggleRide").name("Monter / Descendre"); folder
.add({ toggleRide: () => handleInteractRef.current() }, "toggleRide")
.name("Monter / Descendre");
}); });
return ( return (
@@ -268,7 +275,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
</group> </group>
</group> </group>
{debugRef.current.showCameraPoints && ( {showCameraPoints && (
<> <>
<mesh position={camPointPos}> <mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} /> <sphereGeometry args={[0.3, 16, 16]} />
+55 -23
View File
@@ -1,10 +1,17 @@
import React, { useRef, useEffect, useState, useMemo } from "react"; import React, {
useRef,
useEffect,
useState,
useMemo,
useCallback,
} from "react";
import * as THREE from "three"; import * as THREE from "three";
import { import {
findClosestWaypoint, findClosestWaypoint,
findWaypointPath, findWaypointPath,
} from "@/pathfinding/WaypointAStar"; } from "@/pathfinding/WaypointAStar";
import type { Waypoint } from "@/pathfinding/types"; import type { Waypoint } from "@/pathfinding/types";
import type { Vector3Tuple } from "@/types/three/three";
function computeImageSource( function computeImageSource(
img: HTMLImageElement | HTMLCanvasElement, img: HTMLImageElement | HTMLCanvasElement,
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number }, baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
@@ -66,7 +73,7 @@ export interface EbikeGPSMapProps {
/** /**
* Optional world position for the GPS screen (defaults to origin) * Optional world position for the GPS screen (defaults to origin)
*/ */
position?: [number, number, number]; position?: Vector3Tuple;
/** /**
* Resolution of the offscreen canvas used for the map texture. * Resolution of the offscreen canvas used for the map texture.
@@ -107,17 +114,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
>(null); >(null);
// Offscreen high-res canvas for crystal clear rendering // Offscreen high-res canvas for crystal clear rendering
const [offscreenCanvas] = useState(() => { // Use useMemo to create canvas once - this is a stable reference that won't change
const offscreenCanvas = useMemo(() => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = canvasSize; canvas.width = canvasSize;
canvas.height = canvasSize; canvas.height = canvasSize;
return canvas; return canvas;
}); // eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once
}, []);
// Resize the canvas whenever canvasSize changes // Resize the canvas whenever canvasSize changes
// Note: Modifying canvas dimensions is intentional and necessary for rendering
useEffect(() => { useEffect(() => {
offscreenCanvas.width = canvasSize; // Use Object.assign to resize canvas - this is a necessary mutation for canvas rendering
offscreenCanvas.height = canvasSize; Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
if (textureRef.current) { if (textureRef.current) {
textureRef.current.needsUpdate = true; textureRef.current.needsUpdate = true;
} }
@@ -128,12 +138,16 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Load waypoints (localStorage with /roadNetwork.json fallback) // Load waypoints (localStorage with /roadNetwork.json fallback)
useEffect(() => { useEffect(() => {
let cancelled = false;
const saved = localStorage.getItem("la-fabrik-waypoints"); const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) { if (saved) {
try { try {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
setWaypoints(parsed); // Use queueMicrotask to avoid synchronous setState in effect
queueMicrotask(() => {
if (!cancelled) setWaypoints(parsed);
});
return; return;
} }
} catch (e) { } catch (e) {
@@ -151,20 +165,25 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
throw new Error("Not found"); throw new Error("Not found");
}) })
.then((data) => { .then((data) => {
if (Array.isArray(data)) { if (!cancelled && Array.isArray(data)) {
setWaypoints(data); setWaypoints(data);
} }
}) })
.catch((err) => { .catch((err) => {
console.log("[GPS Component] No default road network found.", err); console.log("[GPS Component] No default road network found.", err);
}); });
return () => {
cancelled = true;
};
}, []); }, []);
// Pre-load background map image (standard HTML5 Image loader) // 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! // Since the user's PNG is already transparent, we don't need fetch or pixel manipulation!
useEffect(() => { useEffect(() => {
if (!mapImageUrl) { if (!mapImageUrl) {
setMapImage(null); // Use queueMicrotask to avoid synchronous setState in effect
queueMicrotask(() => setMapImage(null));
return; return;
} }
@@ -245,16 +264,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
}, [waypoints, startPosSnapped, destPosSnapped]); }, [waypoints, startPosSnapped, destPosSnapped]);
// Translation helper: 3D world to Canvas pixels // Translation helper: 3D world to Canvas pixels
const worldToCanvas = (wx: number, wz: number, canvasSize: number) => { const worldToCanvas = useCallback(
(wx: number, wz: number, size: number) => {
const { minX, maxX, minZ, maxZ } = bounds; const { minX, maxX, minZ, maxZ } = bounds;
const px = ((wx - minX) / (maxX - minX)) * canvasSize; const px = ((wx - minX) / (maxX - minX)) * size;
const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize; const py = ((wz - minZ) / (maxZ - minZ)) * size;
return { x: px, y: py }; return { x: px, y: py };
}; },
[bounds],
);
// Draw loop // Draw loop - returns true if texture needs update
const draw = () => { const draw = useCallback(() => {
const canvas = offscreenCanvas; const canvas = offscreenCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d", { const ctx = canvas.getContext("2d", {
willReadFrequently: true, willReadFrequently: true,
alpha: true, alpha: true,
@@ -451,12 +474,16 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.fill(); ctx.fill();
} }
}, [
// 5. Update WebGL Texture offscreenCanvas,
if (textureRef.current) { mapImage,
textureRef.current.needsUpdate = true; baseBounds,
} bounds,
}; activePath,
worldToCanvas,
destPosSnapped,
startPosSnapped,
]);
// 60 FPS animation ticker // 60 FPS animation ticker
useEffect(() => { useEffect(() => {
@@ -467,14 +494,19 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
draw(); draw();
// Update texture after draw
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
animId = requestAnimationFrame(tick); animId = requestAnimationFrame(tick);
}; };
animId = requestAnimationFrame(tick); animId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animId); return () => cancelAnimationFrame(animId);
}, [waypoints, startPos, destPos, bounds, mapImage]); }, [draw]);
return ( return (
<mesh castShadow receiveShadow position={position as any}> <mesh castShadow receiveShadow position={position}>
<planeGeometry args={[width, height]} /> <planeGeometry args={[width, height]} />
<meshBasicMaterial <meshBasicMaterial
toneMapped={false} toneMapped={false}
+12 -28
View File
@@ -1,12 +1,11 @@
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react"; import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
import { disposeModelMaterials } from "@/utils/three/dispose";
interface SkyModelProps { interface SkyModelProps {
fallbackModelScale?: number | undefined;
fallbackModelPath?: string | undefined; fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined; fallbackScale?: number | undefined;
fallbackColor?: string | undefined; fallbackColor?: string | undefined;
@@ -36,7 +35,6 @@ interface SkyModelErrorBoundaryState {
const SKY_MODEL_SCALE = 1; const SKY_MODEL_SCALE = 1;
const SKY_MODEL_RENDER_ORDER = -1000; const SKY_MODEL_RENDER_ORDER = -1000;
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
class SkyModelErrorBoundary extends Component< class SkyModelErrorBoundary extends Component<
SkyModelErrorBoundaryProps, SkyModelErrorBoundaryProps,
@@ -73,7 +71,6 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({ export function SkyModel({
fallbackColor, fallbackColor,
fallbackModelScale = SKY_MODEL_SCALE,
fallbackModelPath, fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE, fallbackScale = SKY_MODEL_SCALE,
materialSide = THREE.BackSide, materialSide = THREE.BackSide,
@@ -95,7 +92,7 @@ export function SkyModel({
<SkyModelContent <SkyModelContent
materialSide={materialSide} materialSide={materialSide}
modelPath={fallbackModelPath} modelPath={fallbackModelPath}
scale={fallbackScale ?? fallbackModelScale} scale={fallbackScale}
unlit={unlit} unlit={unlit}
/> />
</SkyModelErrorBoundary> </SkyModelErrorBoundary>
@@ -139,7 +136,7 @@ function SkyModelContent({
useEffect(() => { useEffect(() => {
return () => { return () => {
disposeSkyModelMaterials(model); disposeModelMaterials(model);
}; };
}, [model]); }, [model]);
@@ -200,30 +197,17 @@ function createSkyMaterial<T extends THREE.Material>(
function createUnlitSkyMaterial( function createUnlitSkyMaterial(
material: THREE.Material, material: THREE.Material,
): THREE.MeshBasicMaterial { ): THREE.MeshBasicMaterial {
const sourceMaterial = material as THREE.MeshStandardMaterial; const hasStandardProperties =
"isMeshStandardMaterial" in material && material.isMeshStandardMaterial;
const sourceMaterial = hasStandardProperties
? (material as THREE.MeshStandardMaterial)
: null;
return new THREE.MeshBasicMaterial({ return new THREE.MeshBasicMaterial({
color: sourceMaterial.color?.clone() ?? new THREE.Color("#ffffff"), color: sourceMaterial?.color?.clone() ?? new THREE.Color("#ffffff"),
map: sourceMaterial.map ?? null, map: sourceMaterial?.map ?? null,
opacity: sourceMaterial.opacity, opacity: material.opacity,
toneMapped: false, toneMapped: false,
transparent: sourceMaterial.transparent, transparent: material.transparent,
}); });
} }
function disposeSkyModelMaterials(model: THREE.Object3D): void {
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
if (Array.isArray(object.material)) {
for (const material of object.material) {
material.dispose();
}
return;
}
object.material.dispose();
});
}
useGLTF.preload(SKYBOX_MODEL_PATH);
+16
View File
@@ -0,0 +1,16 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface CameraTransform {
position: Vector3Tuple;
rotation: Vector3Tuple;
}
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
position: [-3.5, 6, 0],
rotation: [-10, -90, 0],
};
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
position: [0, 1.5, -3],
rotation: [0, 0, 0],
};
-4
View File
@@ -4,10 +4,6 @@ export interface GalleryModel {
path: string; path: string;
} }
/**
* List of 3D models available in the gallery.
* Only includes models that exist in `/public/models/`.
*/
export const galleryModels: GalleryModel[] = [ export const galleryModels: GalleryModel[] = [
{ id: "arbre", name: "Arbre", path: "/models/arbre/model.gltf" }, { id: "arbre", name: "Arbre", path: "/models/arbre/model.gltf" },
{ id: "blocking", name: "Blocking", path: "/models/blocking/terrain.gltf" }, { id: "blocking", name: "Blocking", path: "/models/blocking/terrain.gltf" },
+102 -167
View File
@@ -4,6 +4,14 @@
:root { :root {
color-scheme: dark; color-scheme: dark;
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif; font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
/* Gallery & docs design tokens */
--font-primary: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
--font-body: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
--color-bg: #050505;
--color-text: #f4efe7;
--color-text-muted: #a9a196;
--color-border: #d8d0c4;
} }
html, html,
@@ -36,9 +44,9 @@ canvas {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: #050505; background: var(--color-bg);
color: #f4efe7; color: var(--color-text);
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif; font-family: var(--font-primary);
} }
.gallery-title { .gallery-title {
@@ -47,7 +55,7 @@ canvas {
right: clamp(18px, 3vw, 38px); right: clamp(18px, 3vw, 38px);
z-index: 2; z-index: 2;
margin: 0; margin: 0;
color: #f4efe7; color: var(--color-text);
font-size: clamp(18px, 2vw, 26px); font-size: clamp(18px, 2vw, 26px);
font-weight: 700; font-weight: 700;
letter-spacing: 0.32em; letter-spacing: 0.32em;
@@ -60,16 +68,6 @@ canvas {
height: 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 { .gallery-bottom-bar {
position: absolute; position: absolute;
right: 50%; right: 50%;
@@ -79,9 +77,9 @@ canvas {
grid-template-columns: 54px minmax(190px, 340px) 54px; grid-template-columns: 54px minmax(190px, 340px) 54px;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
border: 2px solid #d8d0c4; border: 2px solid var(--color-border);
border-radius: 0; border-radius: 0;
background: #050505; background: var(--color-bg);
box-shadow: none; box-shadow: none;
transform: translateX(50%); transform: translateX(50%);
} }
@@ -93,7 +91,7 @@ canvas {
height: 54px; height: 54px;
border: 0; border: 0;
background: transparent; background: transparent;
color: #f4efe7; color: var(--color-text);
cursor: pointer; cursor: pointer;
transition: transition:
background 160ms ease, background 160ms ease,
@@ -102,8 +100,8 @@ canvas {
.gallery-bottom-bar button:hover, .gallery-bottom-bar button:hover,
.gallery-bottom-bar button:focus-visible { .gallery-bottom-bar button:focus-visible {
background: #f4efe7; background: var(--color-text);
color: #050505; color: var(--color-bg);
outline: none; outline: none;
} }
@@ -112,15 +110,15 @@ canvas {
place-items: center; place-items: center;
min-height: 54px; min-height: 54px;
padding: 0 20px; padding: 0 20px;
border-right: 2px solid #d8d0c4; border-right: 2px solid var(--color-border);
border-left: 2px solid #d8d0c4; border-left: 2px solid var(--color-border);
text-align: center; text-align: center;
} }
.gallery-model-info span { .gallery-model-info span {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
color: #f4efe7; color: var(--color-text);
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.03em; letter-spacing: 0.03em;
@@ -131,8 +129,8 @@ canvas {
.gallery-model-info small { .gallery-model-info small {
margin-top: 2px; margin-top: 2px;
color: #a9a196; color: var(--color-text-muted);
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: var(--font-body);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
@@ -147,25 +145,25 @@ canvas {
gap: 8px; gap: 8px;
max-width: min(320px, calc(100vw - 36px)); max-width: min(320px, calc(100vw - 36px));
padding: 10px 13px; padding: 10px 13px;
border: 2px solid #d8d0c4; border: 2px solid var(--color-border);
border-radius: 0; border-radius: 0;
background: #050505; background: var(--color-bg);
color: #d8d0c4; color: var(--color-border);
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: var(--font-body);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
} }
.gallery-texture-status--ok { .gallery-texture-status--ok {
color: #d8d0c4; color: var(--color-border);
} }
.gallery-texture-status--warning { .gallery-texture-status--warning {
color: #f4efe7; color: var(--color-text);
} }
.gallery-texture-status--loading { .gallery-texture-status--loading {
color: #a9a196; color: var(--color-text-muted);
} }
.gallery-light-panel { .gallery-light-panel {
@@ -188,28 +186,28 @@ canvas {
place-items: center; place-items: center;
width: 42px; width: 42px;
height: 42px; height: 42px;
border: 2px solid #d8d0c4; border: 2px solid var(--color-border);
border-right: 0; border-right: 0;
border-radius: 0; border-radius: 0;
background: #050505; background: var(--color-bg);
color: #f4efe7; color: var(--color-text);
cursor: pointer; cursor: pointer;
} }
.gallery-light-panel-toggle:hover, .gallery-light-panel-toggle:hover,
.gallery-light-panel-toggle:focus-visible { .gallery-light-panel-toggle:focus-visible {
background: #f4efe7; background: var(--color-text);
color: #050505; color: var(--color-bg);
outline: none; outline: none;
} }
.gallery-light-panel-content { .gallery-light-panel-content {
width: 236px; width: 236px;
padding: 16px; padding: 16px;
border: 2px solid #d8d0c4; border: 2px solid var(--color-border);
border-right: 0; border-right: 0;
border-radius: 0; border-radius: 0;
background: #050505; background: var(--color-bg);
box-shadow: none; box-shadow: none;
} }
@@ -221,7 +219,7 @@ canvas {
} }
.gallery-light-panel-content header span { .gallery-light-panel-content header span {
color: #f4efe7; color: var(--color-text);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
letter-spacing: 0.18em; letter-spacing: 0.18em;
@@ -230,7 +228,7 @@ canvas {
.gallery-light-panel-content header button { .gallery-light-panel-content header button {
border: 0; border: 0;
background: transparent; background: transparent;
color: #a9a196; color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -238,7 +236,7 @@ canvas {
.gallery-light-panel-content header button:hover, .gallery-light-panel-content header button:hover,
.gallery-light-panel-content header button:focus-visible { .gallery-light-panel-content header button:focus-visible {
color: #f4efe7; color: var(--color-text);
outline: none; outline: none;
} }
@@ -252,20 +250,20 @@ canvas {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
color: #d8d0c4; color: var(--color-border);
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: var(--font-body);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
} }
.gallery-light-control strong { .gallery-light-control strong {
color: #f4efe7; color: var(--color-text);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.gallery-light-control input { .gallery-light-control input {
width: 100%; width: 100%;
accent-color: #f4efe7; accent-color: var(--color-text);
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -299,32 +297,6 @@ canvas {
.gallery-light-panel { .gallery-light-panel {
top: 78px; top: 78px;
} }
.gallery-keyboard-hints {
display: none;
}
}
/* Gallery - Header */
.gallery-header {
position: absolute;
top: clamp(18px, 3vw, 34px);
right: clamp(18px, 3vw, 38px);
z-index: 2;
text-align: right;
}
.gallery-header .gallery-title {
position: static;
transform: none;
}
.gallery-subtitle {
margin: 6px 0 0;
color: #a9a196;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
} }
/* Gallery - Loading */ /* Gallery - Loading */
@@ -333,7 +305,7 @@ canvas {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
color: #f4efe7; color: var(--color-text);
} }
.gallery-loading-spinner { .gallery-loading-spinner {
@@ -341,7 +313,7 @@ canvas {
} }
.gallery-loading-text { .gallery-loading-text {
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: var(--font-body);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -357,9 +329,22 @@ canvas {
} }
/* Gallery - Empty state */ /* Gallery - Empty state */
.gallery-page--empty { .gallery-empty-state {
display: grid; display: flex;
place-items: center; flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px;
color: var(--color-text-muted);
text-align: center;
}
.gallery-empty-state h1 {
margin: 0;
color: var(--color-text);
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
} }
.gallery-empty-state { .gallery-empty-state {
@@ -414,7 +399,7 @@ canvas {
height: 54px; height: 54px;
border: 0; border: 0;
background: transparent; background: transparent;
color: #f4efe7; color: var(--color-text);
cursor: pointer; cursor: pointer;
transition: transition:
background 160ms ease, background 160ms ease,
@@ -424,8 +409,8 @@ canvas {
.gallery-nav-button:hover, .gallery-nav-button:hover,
.gallery-nav-button:focus-visible { .gallery-nav-button:focus-visible {
background: #f4efe7; background: var(--color-text);
color: #050505; color: var(--color-bg);
outline: none; outline: none;
} }
@@ -437,7 +422,7 @@ canvas {
.gallery-model-name { .gallery-model-name {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
color: #f4efe7; color: var(--color-text);
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.03em; letter-spacing: 0.03em;
@@ -448,8 +433,8 @@ canvas {
.gallery-model-counter { .gallery-model-counter {
margin-top: 2px; margin-top: 2px;
color: #a9a196; color: var(--color-text-muted);
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: var(--font-body);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
@@ -459,53 +444,7 @@ canvas {
animation: gallery-spin 1s linear infinite; animation: gallery-spin 1s linear infinite;
} }
/* Gallery - Keyboard hints */
.gallery-keyboard-hints {
position: absolute;
top: clamp(18px, 3vw, 34px);
left: clamp(18px, 3vw, 38px);
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
color: #a9a196;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 11px;
font-weight: 600;
}
.gallery-keyboard-hints kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border: 1px solid #a9a196;
border-radius: 4px;
background: transparent;
color: #f4efe7;
font-family: inherit;
font-size: 10px;
font-weight: 700;
}
.gallery-keyboard-hints-separator {
margin: 0 4px;
opacity: 0.5;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.gallery-header {
right: 50%;
transform: translateX(50%);
text-align: center;
}
.gallery-subtitle {
display: none;
}
.gallery-nav-button { .gallery-nav-button {
width: 48px; width: 48px;
height: 50px; height: 50px;
@@ -519,15 +458,15 @@ canvas {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: #050505; background: var(--color-bg);
color: #f4efe7; color: var(--color-text);
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif; font-family: var(--font-primary);
} }
/* Docs sidebar navigation */ /* Docs sidebar navigation */
.docs-sidebar { .docs-sidebar {
border-right: 2px solid #d8d0c4; border-right: 2px solid var(--color-border);
background: #050505; background: var(--color-bg);
overflow-y: auto; overflow-y: auto;
} }
@@ -538,13 +477,13 @@ canvas {
justify-content: space-between; justify-content: space-between;
min-height: 78px; min-height: 78px;
padding: 0 18px; padding: 0 18px;
border-bottom: 2px solid #d8d0c4; border-bottom: 2px solid var(--color-border);
} }
.docs-sidebar__header h1, .docs-sidebar__header h1,
.docs-content__header span { .docs-content__header span {
margin: 0; margin: 0;
color: #f4efe7; color: var(--color-text);
font-size: 21px; font-size: 21px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.04em; letter-spacing: -0.04em;
@@ -556,13 +495,13 @@ canvas {
.docs-nav-group { .docs-nav-group {
display: grid; display: grid;
border-bottom: 2px solid #d8d0c4; border-bottom: 2px solid var(--color-border);
} }
.docs-nav-group h2 { .docs-nav-group h2 {
margin: 0; margin: 0;
padding: 13px 16px 8px; padding: 13px 16px 8px;
color: #a9a196; color: var(--color-text-muted);
font-size: 10px; font-size: 10px;
font-weight: 800; font-weight: 800;
letter-spacing: 0.14em; letter-spacing: 0.14em;
@@ -570,7 +509,7 @@ canvas {
} }
.docs-sidebar a { .docs-sidebar a {
color: #f4efe7; color: var(--color-text);
text-decoration: none; text-decoration: none;
} }
@@ -581,7 +520,7 @@ canvas {
min-height: 46px; min-height: 46px;
padding: 0 16px; padding: 0 16px;
border-top: 1px solid rgba(216, 208, 196, 0.35); border-top: 1px solid rgba(216, 208, 196, 0.35);
color: #f4efe7; color: var(--color-text);
transition: transition:
background 160ms ease, background 160ms ease,
color 160ms ease; color 160ms ease;
@@ -612,7 +551,7 @@ canvas {
.docs-nav-item small, .docs-nav-item small,
.docs-nav-item__meta { .docs-nav-item__meta {
color: #a9a196; color: var(--color-text-muted);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
@@ -621,8 +560,8 @@ canvas {
.docs-sidebar a:hover, .docs-sidebar a:hover,
.docs-sidebar a:focus-visible, .docs-sidebar a:focus-visible,
.docs-nav-item--active { .docs-nav-item--active {
background: #f4efe7; background: var(--color-text);
color: #050505; color: var(--color-bg);
outline: none; outline: none;
} }
@@ -632,21 +571,21 @@ canvas {
.docs-sidebar a:focus-visible .docs-nav-item__meta, .docs-sidebar a:focus-visible .docs-nav-item__meta,
.docs-nav-item--active small, .docs-nav-item--active small,
.docs-nav-item--active .docs-nav-item__meta { .docs-nav-item--active .docs-nav-item__meta {
color: #050505; color: var(--color-bg);
} }
/* Docs content */ /* Docs content */
.docs-content { .docs-content {
overflow-y: auto; overflow-y: auto;
scroll-behavior: smooth; scroll-behavior: smooth;
background: #050505; background: var(--color-bg);
} }
.docs-content__header { .docs-content__header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
background: #050505; background: var(--color-bg);
} }
.docs-language-toggle { .docs-language-toggle {
@@ -654,10 +593,10 @@ canvas {
align-items: center; align-items: center;
gap: 0; gap: 0;
padding: 2px; padding: 2px;
border: 2px solid #d8d0c4; border: 2px solid var(--color-border);
border-radius: 999px; border-radius: 999px;
background: transparent; background: transparent;
color: #f4efe7; color: var(--color-text);
cursor: pointer; cursor: pointer;
} }
@@ -667,15 +606,15 @@ canvas {
min-width: 36px; min-width: 36px;
min-height: 26px; min-height: 26px;
border-radius: 999px; border-radius: 999px;
color: #a9a196; color: var(--color-text-muted);
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.docs-language-toggle .is-active { .docs-language-toggle .is-active {
background: #f4efe7; background: var(--color-text);
color: #050505; color: var(--color-bg);
} }
.docs-language-toggle:hover, .docs-language-toggle:hover,
@@ -694,7 +633,7 @@ canvas {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 22px; margin-bottom: 22px;
color: #a9a196; color: var(--color-text-muted);
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -704,7 +643,7 @@ canvas {
.docs-section h1, .docs-section h1,
.docs-section h2, .docs-section h2,
.docs-section h3 { .docs-section h3 {
color: #f4efe7; color: var(--color-text);
letter-spacing: -0.06em; letter-spacing: -0.06em;
line-height: 1.05; line-height: 1.05;
} }
@@ -720,7 +659,7 @@ canvas {
margin-top: 44px; margin-top: 44px;
margin-bottom: 12px; margin-bottom: 12px;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 2px solid #d8d0c4; border-bottom: 2px solid var(--color-border);
font-size: clamp(28px, 4vw, 44px); font-size: clamp(28px, 4vw, 44px);
font-weight: 700; font-weight: 700;
} }
@@ -735,8 +674,8 @@ canvas {
.docs-section p, .docs-section p,
.docs-section li { .docs-section li {
color: #d8d0c4; color: var(--color-border);
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: var(--font-body);
font-size: 15px; font-size: 15px;
line-height: 1.75; line-height: 1.75;
} }
@@ -747,7 +686,7 @@ canvas {
} }
.docs-section a { .docs-section a {
color: #f4efe7; color: var(--color-text);
text-underline-offset: 4px; text-underline-offset: 4px;
} }
@@ -756,7 +695,7 @@ canvas {
border-radius: 2px; border-radius: 2px;
padding: 2px 5px; padding: 2px 5px;
background: rgba(216, 208, 196, 0.22); background: rgba(216, 208, 196, 0.22);
color: #f4efe7; color: var(--color-text);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em; font-size: 0.92em;
} }
@@ -774,7 +713,7 @@ canvas {
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
color: #f4efe7; color: var(--color-text);
line-height: 1.45; line-height: 1.45;
white-space: pre; white-space: pre;
} }
@@ -790,21 +729,21 @@ canvas {
.docs-section th, .docs-section th,
.docs-section td { .docs-section td {
padding: 10px 12px; padding: 10px 12px;
border: 2px solid #d8d0c4; border: 2px solid var(--color-border);
text-align: left; text-align: left;
} }
.docs-section th { .docs-section th {
background: #111; background: #111;
color: #f4efe7; color: var(--color-text);
font-weight: 700; font-weight: 700;
} }
.docs-section blockquote { .docs-section blockquote {
margin-left: 0; margin-left: 0;
padding-left: 18px; padding-left: 18px;
border-left: 2px solid #d8d0c4; border-left: 2px solid var(--color-border);
color: #a9a196; color: var(--color-text-muted);
} }
/* Docs responsive layout */ /* Docs responsive layout */
@@ -816,7 +755,7 @@ canvas {
.docs-sidebar { .docs-sidebar {
border-right: 0; border-right: 0;
border-bottom: 2px solid #d8d0c4; border-bottom: 2px solid var(--color-border);
} }
.docs-content { .docs-content {
@@ -1655,10 +1594,6 @@ canvas {
user-select: none; user-select: none;
} }
.editor-panel-group-summary::-webkit-details-marker {
display: none;
}
.editor-panel-group-summary:hover { .editor-panel-group-summary:hover {
color: #f2f2f2; color: #f2f2f2;
} }
+102 -50
View File
@@ -1,7 +1,34 @@
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useRef, useMemo, useCallback } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { MapControls, OrthographicCamera, useGLTF } from "@react-three/drei"; import { MapControls, OrthographicCamera, useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import type { MapControls as MapControlsImpl } from "three-stdlib";
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
interface WaypointData {
id: number;
x: number;
y: number;
z: number;
connections: number[];
}
interface Bounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
// Extend window for global functions
declare global {
interface Window {
applyAutoBounds?: () => void;
downloadMapScreenshot?: () => void;
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// 1. Terrain Scene // 1. Terrain Scene
@@ -24,7 +51,7 @@ function WaypointOverlay({
waypoints, waypoints,
visible, visible,
}: { }: {
waypoints: any[]; waypoints: WaypointData[];
visible: boolean; visible: boolean;
}) { }) {
if (!visible) return null; if (!visible) return null;
@@ -47,54 +74,71 @@ function CameraManager({
autoBounds, autoBounds,
boundsTextRef, boundsTextRef,
}: { }: {
autoBounds: any; autoBounds: Bounds | null;
boundsTextRef: React.RefObject<HTMLPreElement | null>; boundsTextRef: React.RefObject<HTMLPreElement | null>;
}) { }) {
const { camera, gl, scene } = useThree(); const { camera, gl, scene } = useThree();
const controlsRef = useRef<any>(null); const controlsRef = useRef<MapControlsImpl>(null);
// Use refs to store mutable camera properties that we need to modify
const cameraRef = useRef(camera);
// Apply Auto-Bounds function // Update cameraRef in an effect to avoid refs during render error
useEffect(() => { React.useEffect(() => {
const applyAutoBounds = () => { cameraRef.current = camera;
if (camera instanceof THREE.OrthographicCamera && autoBounds) { }, [camera]);
// Apply Auto-Bounds function using useCallback to create a stable reference
const applyAutoBounds = useCallback(() => {
const cam = cameraRef.current;
if (cam instanceof THREE.OrthographicCamera && autoBounds) {
const width = autoBounds.maxX - autoBounds.minX; const width = autoBounds.maxX - autoBounds.minX;
const height = autoBounds.maxZ - autoBounds.minZ; const height = autoBounds.maxZ - autoBounds.minZ;
const centerX = (autoBounds.minX + autoBounds.maxX) / 2; const centerX = (autoBounds.minX + autoBounds.maxX) / 2;
const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2; const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2;
camera.position.set(centerX, 200, centerZ); cam.position.set(centerX, 200, centerZ);
camera.left = -width / 2; cam.left = -width / 2;
camera.right = width / 2; cam.right = width / 2;
camera.top = height / 2; cam.top = height / 2;
camera.bottom = -height / 2; cam.bottom = -height / 2;
camera.zoom = 1; cam.zoom = 1;
camera.updateProjectionMatrix(); cam.updateProjectionMatrix();
if (controlsRef.current) { if (controlsRef.current) {
controlsRef.current.target.set(centerX, 0, centerZ); controlsRef.current.target.set(centerX, 0, centerZ);
controlsRef.current.update(); controlsRef.current.update();
} }
} }
}; }, [autoBounds]);
(window as any).applyAutoBounds = applyAutoBounds; // Initial apply on autoBounds change (using useFrame to run once after mount)
// Initial apply const hasAppliedRef = useRef(false);
useFrame(() => {
if (!hasAppliedRef.current && autoBounds) {
applyAutoBounds(); applyAutoBounds();
hasAppliedRef.current = true;
}
});
// Reset hasApplied when autoBounds changes
React.useEffect(() => {
hasAppliedRef.current = false;
window.applyAutoBounds = applyAutoBounds;
return () => { return () => {
delete (window as any).applyAutoBounds; delete window.applyAutoBounds;
}; };
}, [camera, autoBounds]); }, [applyAutoBounds]);
// Track dynamic bounds without triggering React re-renders! // Track dynamic bounds without triggering React re-renders!
useFrame(() => { useFrame(() => {
if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) { const cam = cameraRef.current;
const width = (camera.right - camera.left) / camera.zoom; if (cam instanceof THREE.OrthographicCamera && boundsTextRef.current) {
const height = (camera.top - camera.bottom) / camera.zoom; const width = (cam.right - cam.left) / cam.zoom;
const minX = Math.round(camera.position.x - width / 2); const height = (cam.top - cam.bottom) / cam.zoom;
const maxX = Math.round(camera.position.x + width / 2); const minX = Math.round(cam.position.x - width / 2);
const minZ = Math.round(camera.position.z - height / 2); const maxX = Math.round(cam.position.x + width / 2);
const maxZ = Math.round(camera.position.z + height / 2); const minZ = Math.round(cam.position.z - height / 2);
const maxZ = Math.round(cam.position.z + height / 2);
// Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!) // Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!)
boundsTextRef.current.innerText = JSON.stringify( boundsTextRef.current.innerText = JSON.stringify(
@@ -106,10 +150,10 @@ function CameraManager({
}); });
// Attach screenshot capture logic // Attach screenshot capture logic
useEffect(() => { React.useEffect(() => {
(window as any).downloadMapScreenshot = () => { window.downloadMapScreenshot = () => {
// Force an immediate render frame to ensure no UI overlays are missing // Force an immediate render frame to ensure no UI overlays are missing
gl.render(scene, camera); gl.render(scene, cameraRef.current);
const dataUrl = gl.domElement.toDataURL("image/png"); const dataUrl = gl.domElement.toDataURL("image/png");
const a = document.createElement("a"); const a = document.createElement("a");
a.href = dataUrl; a.href = dataUrl;
@@ -117,9 +161,9 @@ function CameraManager({
a.click(); a.click();
}; };
return () => { return () => {
delete (window as any).downloadMapScreenshot; delete window.downloadMapScreenshot;
}; };
}, [gl, camera, scene]); }, [gl, scene]);
return ( return (
<MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} /> <MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} />
@@ -130,25 +174,35 @@ function CameraManager({
// 4. Main Page Route Component // 4. Main Page Route Component
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
export function BackgroundMapPage() { export function BackgroundMapPage() {
const [waypoints, setWaypoints] = useState<any[]>([]); // Use lazy initialization to avoid setState in useEffect
const [showWaypoints, setShowWaypoints] = useState(true); const [waypoints, setWaypoints] = useState<WaypointData[]>(() => {
const boundsTextRef = useRef<HTMLPreElement>(null);
// Load road network waypoints to compute perfect GPS bounds
useEffect(() => {
const saved = localStorage.getItem("la-fabrik-waypoints"); const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) { if (saved) {
setWaypoints(JSON.parse(saved)); try {
} else { return JSON.parse(saved) as WaypointData[];
} catch {
return [];
}
}
return [];
});
const [showWaypoints, setShowWaypoints] = useState(true);
const boundsTextRef = useRef<HTMLPreElement>(null);
const hasFetchedRef = useRef(false);
// Fetch from network as fallback if localStorage was empty
React.useEffect(() => {
if (waypoints.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true;
fetch("/roadNetwork.json") fetch("/roadNetwork.json")
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setWaypoints(data)) .then((data: WaypointData[]) => setWaypoints(data))
.catch(() => {}); .catch(() => {});
} }
}, []); }, [waypoints.length]); // Include dependency to satisfy linter
// Compute exact bounds that the EbikeGPSMap will use by default // Compute exact bounds that the EbikeGPSMap will use by default
const autoBounds = useMemo(() => { const autoBounds = useMemo((): Bounds | null => {
if (waypoints.length === 0) return null; if (waypoints.length === 0) return null;
const xs = waypoints.map((w) => w.x); const xs = waypoints.map((w) => w.x);
const zs = waypoints.map((w) => w.z); const zs = waypoints.map((w) => w.z);
@@ -271,13 +325,12 @@ export function BackgroundMapPage() {
transition: "all 0.2s", transition: "all 0.2s",
}} }}
> >
{showWaypoints ? "👁️ Masquer Waypoints" : "👁️‍🗨️ Afficher Waypoints"} {showWaypoints ? "Masquer Waypoints" : "Afficher Waypoints"}
</button> </button>
<button <button
onClick={() => { onClick={() => {
if ((window as any).applyAutoBounds) if (window.applyAutoBounds) window.applyAutoBounds();
(window as any).applyAutoBounds();
}} }}
style={{ style={{
width: "100%", width: "100%",
@@ -292,13 +345,12 @@ export function BackgroundMapPage() {
transition: "all 0.2s", transition: "all 0.2s",
}} }}
> >
🎯 Cadrage Automatique Cadrage Automatique
</button> </button>
<button <button
onClick={() => { onClick={() => {
if ((window as any).downloadMapScreenshot) if (window.downloadMapScreenshot) window.downloadMapScreenshot();
(window as any).downloadMapScreenshot();
}} }}
style={{ style={{
width: "100%", width: "100%",
@@ -313,7 +365,7 @@ export function BackgroundMapPage() {
boxShadow: "0 4px 6px -1px rgba(14, 165, 233, 0.4)", boxShadow: "0 4px 6px -1px rgba(14, 165, 233, 0.4)",
}} }}
> >
📸 Capturer la carte (.png) Capturer la carte (.png)
</button> </button>
<div <div
+22 -42
View File
@@ -53,6 +53,11 @@ import {
GAME_SCENE_SKY_MODEL_PATH, GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE, GAME_SCENE_SKY_MODEL_SCALE,
} from "@/data/world/environmentConfig"; } from "@/data/world/environmentConfig";
import {
disposeModelMaterials,
MATERIAL_TEXTURE_KEYS,
type MaterialWithTextureSlots,
} from "@/utils/three/dispose";
interface GalleryModelProps { interface GalleryModelProps {
model: GalleryModel; model: GalleryModel;
@@ -89,10 +94,8 @@ interface TextureDiagnostic {
summary: string; summary: string;
} }
interface GalleryModelScene extends THREE.Object3D { interface GalleryModelSceneUserData extends Record<string, unknown> {
userData: THREE.Object3D["userData"] & {
hiddenExportPlaneCount?: number; hiddenExportPlaneCount?: number;
};
} }
interface GalleryViewerErrorBoundaryProps { interface GalleryViewerErrorBoundaryProps {
@@ -104,16 +107,6 @@ interface GalleryViewerErrorBoundaryState {
hasError: boolean; hasError: boolean;
} }
const TEXTURE_SLOTS = [
"map",
"normalMap",
"roughnessMap",
"metalnessMap",
"aoMap",
"emissiveMap",
"alphaMap",
] as const;
const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = { const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
modelId: null, modelId: null,
status: "loading", status: "loading",
@@ -221,7 +214,7 @@ function GalleryModelPreview({
useEffect(() => { useEffect(() => {
return () => { return () => {
disposeGalleryModelMaterials(modelScene); disposeModelMaterials(modelScene);
}; };
}, [modelScene]); }, [modelScene]);
@@ -253,7 +246,7 @@ function GalleryModelPreview({
} }
function createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D { function createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D {
const modelScene = scene.clone(true) as GalleryModelScene; const modelScene = scene.clone(true);
const exportPlaneMeshes: THREE.Mesh[] = []; const exportPlaneMeshes: THREE.Mesh[] = [];
modelScene.traverse((object) => { modelScene.traverse((object) => {
@@ -273,7 +266,8 @@ function createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D {
mesh.parent?.remove(mesh); mesh.parent?.remove(mesh);
} }
modelScene.userData.hiddenExportPlaneCount = exportPlaneMeshes.length; const userData = modelScene.userData as GalleryModelSceneUserData;
userData.hiddenExportPlaneCount = exportPlaneMeshes.length;
return modelScene; return modelScene;
} }
@@ -298,33 +292,21 @@ function isExportPlaneMesh(mesh: THREE.Mesh): boolean {
function createGalleryMaterial(material: THREE.Material): THREE.Material { function createGalleryMaterial(material: THREE.Material): THREE.Material {
const galleryMaterial = material.clone(); const galleryMaterial = material.clone();
const materialWithNormalMap = galleryMaterial as THREE.Material & {
normalMap?: THREE.Texture | null;
};
galleryMaterial.side = THREE.DoubleSide; galleryMaterial.side = THREE.DoubleSide;
if (materialWithNormalMap.normalMap) { if (hasNormalMap(galleryMaterial)) {
materialWithNormalMap.normalMap = null; galleryMaterial.normalMap = null;
galleryMaterial.needsUpdate = true; galleryMaterial.needsUpdate = true;
} }
return galleryMaterial; return galleryMaterial;
} }
function disposeGalleryModelMaterials(modelScene: THREE.Object3D): void { function hasNormalMap(
modelScene.traverse((object) => { material: THREE.Material,
if (!(object instanceof THREE.Mesh)) return; ): material is THREE.Material & { normalMap: THREE.Texture | null } {
return "normalMap" in material && material.normalMap !== undefined;
if (Array.isArray(object.material)) {
for (const material of object.material) {
material.dispose();
}
return;
}
object.material.dispose();
});
} }
function GalleryScene({ function GalleryScene({
@@ -491,8 +473,8 @@ function getTextureDiagnostic(
): TextureDiagnostic { ): TextureDiagnostic {
let textureCount = 0; let textureCount = 0;
let missingTextureImageCount = 0; let missingTextureImageCount = 0;
const hiddenExportPlaneCount = const userData = modelScene.userData as GalleryModelSceneUserData;
(modelScene as GalleryModelScene).userData.hiddenExportPlaneCount ?? 0; const hiddenExportPlaneCount = userData.hiddenExportPlaneCount ?? 0;
modelScene.traverse((object) => { modelScene.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return; if (!(object instanceof THREE.Mesh)) return;
@@ -502,10 +484,10 @@ function getTextureDiagnostic(
: [object.material]; : [object.material];
for (const material of materials) { for (const material of materials) {
const materialRecord = material as unknown as Record<string, unknown>; const texturedMaterial = material as MaterialWithTextureSlots;
for (const textureSlot of TEXTURE_SLOTS) { for (const textureSlot of MATERIAL_TEXTURE_KEYS) {
const texture = materialRecord[textureSlot]; const texture = texturedMaterial[textureSlot];
if (!(texture instanceof THREE.Texture)) continue; if (!(texture instanceof THREE.Texture)) continue;
textureCount += 1; textureCount += 1;
@@ -559,14 +541,13 @@ export function GalleryPage(): React.JSX.Element {
); );
const modelCount = galleryModels.length; const modelCount = galleryModels.length;
const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0]; const activeModel = galleryModels[activeModelIndex];
const activeTextureDiagnostic = const activeTextureDiagnostic =
activeModel && textureDiagnostic.modelId === activeModel.id activeModel && textureDiagnostic.modelId === activeModel.id
? textureDiagnostic ? textureDiagnostic
: LOADING_TEXTURE_DIAGNOSTIC; : LOADING_TEXTURE_DIAGNOSTIC;
// Preload adjacent models for smoother navigation
useEffect(() => { useEffect(() => {
if (modelCount <= 1) return; if (modelCount <= 1) return;
@@ -586,7 +567,6 @@ export function GalleryPage(): React.JSX.Element {
} }
}, [activeModelIndex, modelCount]); }, [activeModelIndex, modelCount]);
// Memoized callbacks to prevent unnecessary re-renders
const goToPreviousModel = useCallback((): void => { const goToPreviousModel = useCallback((): void => {
setActiveModelIndex((currentIndex) => setActiveModelIndex((currentIndex) =>
currentIndex === 0 ? modelCount - 1 : currentIndex - 1, currentIndex === 0 ? modelCount - 1 : currentIndex - 1,
+53 -52
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { Canvas, useFrame, useThree } from "@react-three/fiber";
import type { ThreeEvent } from "@react-three/fiber";
import { import {
useGLTF, useGLTF,
OrthographicCamera, OrthographicCamera,
@@ -159,7 +160,7 @@ const EditorScene: React.FC<EditorSceneProps> = ({
{/* 1. Terrain Mesh (Raycasted for adding/dragging) */} {/* 1. Terrain Mesh (Raycasted for adding/dragging) */}
<primitive <primitive
object={scene} object={scene}
onClick={(e: any) => { onClick={(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
// Only click-to-create a new node if they are not actively dragging a link // Only click-to-create a new node if they are not actively dragging a link
if (dragStartNodeId === null && e.point) { if (dragStartNodeId === null && e.point) {
@@ -256,7 +257,7 @@ const WaypointMarkers: React.FC<WaypointMarkersProps> = ({
onPointerOut={() => { onPointerOut={() => {
setHoveredNodeId(null); setHoveredNodeId(null);
}} }}
onPointerDown={(e: any) => { onPointerDown={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation(); e.stopPropagation();
if (e.button === 0) { if (e.button === 0) {
// Left click start drag link connection // Left click start drag link connection
@@ -388,7 +389,33 @@ const ConnectionLines: React.FC<ConnectionLinesProps> = ({
// ========================================== // ==========================================
export const WaypointEditorPage: React.FC = () => { export const WaypointEditorPage: React.FC = () => {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]); // Use lazy initialization to load from localStorage on mount
const [waypoints, setWaypoints] = useState<Waypoint[]>(() => {
console.log(
"[Initialisation] Chargement des waypoints depuis localStorage...",
);
const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) {
try {
const list = JSON.parse(saved);
console.log(
`[Initialisation] ${list.length} waypoints chargés avec succès !`,
);
return list;
} catch (e) {
console.error(
"[Initialisation] Erreur de parsing du stockage local",
e,
);
return [];
}
} else {
console.log(
"[Initialisation] Aucun point enregistré en localStorage. Démarrage à vide.",
);
return [];
}
});
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<number | null>(null); const [hoveredNodeId, setHoveredNodeId] = useState<number | null>(null);
@@ -425,38 +452,35 @@ export const WaypointEditorPage: React.FC = () => {
number | null number | null
>(null); >(null);
// Load from localstorage on mount
useEffect(() => {
console.log(
"[Initialisation] Chargement des waypoints depuis localStorage...",
);
const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) {
try {
const list = JSON.parse(saved);
console.log(
`[Initialisation] ${list.length} waypoints chargés avec succès !`,
);
setWaypoints(list);
} catch (e) {
console.error(
"[Initialisation] Erreur de parsing du stockage local",
e,
);
}
} else {
console.log(
"[Initialisation] Aucun point enregistré en localStorage. Démarrage à vide.",
);
}
}, []);
// Save to localstorage when waypoints change // Save to localstorage when waypoints change
const saveWaypoints = (list: Waypoint[]) => { const saveWaypoints = (list: Waypoint[]) => {
setWaypoints(list); setWaypoints(list);
localStorage.setItem("la-fabrik-waypoints", JSON.stringify(list)); localStorage.setItem("la-fabrik-waypoints", JSON.stringify(list));
}; };
// Delete current selected node
const handleDeleteNode = (id: number) => {
console.log(
`[Suppression] Action de suppression définitive du Point : ID = ${id}`,
);
setWaypoints((currentWaypoints) => {
const updatedList = currentWaypoints
.filter((wp) => wp.id !== id)
.map((wp) => ({
...wp,
connections: wp.connections.filter((cId) => cId !== id),
}));
console.log(
`[Suppression] Point ${id} supprimé. ${updatedList.length} points restants.`,
);
localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList));
return updatedList;
});
setSelectedId((currentSelected) =>
currentSelected === id ? null : currentSelected,
);
};
// Delete a specific connection (break the link) // Delete a specific connection (break the link)
const deleteSelectedConnection = (idA: number, idB: number) => { const deleteSelectedConnection = (idA: number, idB: number) => {
console.log( console.log(
@@ -673,29 +697,6 @@ export const WaypointEditorPage: React.FC = () => {
} }
}; };
// Delete current selected node
const handleDeleteNode = (id: number) => {
console.log(
`[Suppression] Action de suppression définitive du Point : ID = ${id}`,
);
setWaypoints((currentWaypoints) => {
const updatedList = currentWaypoints
.filter((wp) => wp.id !== id)
.map((wp) => ({
...wp,
connections: wp.connections.filter((cId) => cId !== id),
}));
console.log(
`[Suppression] Point ${id} supprimé. ${updatedList.length} points restants.`,
);
localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList));
return updatedList;
});
setSelectedId((currentSelected) =>
currentSelected === id ? null : currentSelected,
);
};
// Connect Mode Trigger // Connect Mode Trigger
const startConnecting = (id: number) => { const startConnecting = (id: number) => {
console.log( console.log(
+6 -4
View File
@@ -38,8 +38,6 @@ export function useGPS({
// Initialize the pathfinding grid // Initialize the pathfinding grid
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true);
setError(null);
async function initGrid() { async function initGrid() {
try { try {
@@ -63,9 +61,13 @@ export function useGPS({
colorMapImgRef.current = colorMapImg; colorMapImgRef.current = colorMapImg;
setLoading(false); setLoading(false);
} }
} catch (err: any) { } catch (err: unknown) {
if (active) { if (active) {
setError(err.message || "Failed to initialize GPS system"); const message =
err instanceof Error
? err.message
: "Failed to initialize GPS system";
setError(message);
setLoading(false); setLoading(false);
} }
} }
+6 -4
View File
@@ -23,8 +23,6 @@ export function useWaypointGPS({
// Load waypoint list and background color map image // Load waypoint list and background color map image
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true);
setError(null);
async function initGPS() { async function initGPS() {
try { try {
@@ -49,9 +47,13 @@ export function useWaypointGPS({
colorMapImgRef.current = colorMapImg; colorMapImgRef.current = colorMapImg;
setLoading(false); setLoading(false);
} }
} catch (err: any) { } catch (err: unknown) {
if (active) { if (active) {
setError(err.message || "Failed to initialize Waypoint GPS"); const message =
err instanceof Error
? err.message
: "Failed to initialize Waypoint GPS";
setError(message);
setLoading(false); setLoading(false);
} }
} }
+11
View File
@@ -0,0 +1,11 @@
import type * as THREE from "three";
import type { Vector3Tuple } from "@/types/three/three";
declare global {
interface Window {
ebikeVisualGroup: React.RefObject<THREE.Group | null> | null;
ebikeParkedPosition: Vector3Tuple | null;
ebikeParkedRotation: number | null;
ebikeSteerFactor: number | undefined;
}
}
+19
View File
@@ -1,4 +1,5 @@
import type { Octree } from "three-stdlib"; import type { Octree } from "three-stdlib";
import type * as THREE from "three";
export type Vector3Tuple = [number, number, number]; export type Vector3Tuple = [number, number, number];
@@ -13,3 +14,21 @@ export interface ModelTransformProps {
export type ColliderShape = "cuboid" | "ball" | "hull"; export type ColliderShape = "cuboid" | "ball" | "hull";
export type OctreeReadyHandler = (octree: Octree) => void; export type OctreeReadyHandler = (octree: Octree) => void;
/**
* Keys for texture slots that may exist on various material types.
*/
export type TextureMaterialKey = Extract<
| keyof THREE.MeshBasicMaterial
| keyof THREE.MeshStandardMaterial
| keyof THREE.MeshPhysicalMaterial
| keyof THREE.MeshToonMaterial,
string
>;
/**
* Interface for materials that may have texture slots.
* Used for type-safe texture diagnostic access and disposal.
*/
export type MaterialWithTextureSlots = THREE.Material &
Partial<Record<TextureMaterialKey, THREE.Texture | null>>;
+30 -12
View File
@@ -1,21 +1,18 @@
import * as THREE from "three"; import * as THREE from "three";
import type {
type TextureMaterialKey = Extract< MaterialWithTextureSlots,
| keyof THREE.MeshBasicMaterial TextureMaterialKey,
| keyof THREE.MeshStandardMaterial } from "@/types/three/three";
| keyof THREE.MeshPhysicalMaterial
| keyof THREE.MeshToonMaterial,
string
>;
type MaterialWithTextureSlots = THREE.Material &
Partial<Record<TextureMaterialKey, THREE.Texture | null>>;
interface DisposeObject3DOptions { interface DisposeObject3DOptions {
disposeTextures?: boolean; disposeTextures?: boolean;
} }
const MATERIAL_TEXTURE_KEYS = [ /**
* Common texture slot keys found on Three.js materials.
* Exported for use in texture diagnostics and disposal.
*/
export const MATERIAL_TEXTURE_KEYS = [
"alphaMap", "alphaMap",
"aoMap", "aoMap",
"bumpMap", "bumpMap",
@@ -40,6 +37,8 @@ const MATERIAL_TEXTURE_KEYS = [
"transmissionMap", "transmissionMap",
] as const satisfies readonly TextureMaterialKey[]; ] as const satisfies readonly TextureMaterialKey[];
export type { MaterialWithTextureSlots };
export function disposeObject3D( export function disposeObject3D(
object: THREE.Object3D, object: THREE.Object3D,
options: DisposeObject3DOptions = {}, options: DisposeObject3DOptions = {},
@@ -57,6 +56,25 @@ export function disposeObject3D(
}); });
} }
/**
* Disposes only materials (not geometry) from an Object3D and its children.
* Useful for cloned models where you want to preserve the original geometry.
*/
export function disposeModelMaterials(
object: THREE.Object3D,
options: DisposeObject3DOptions = {},
): void {
object.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
if (Array.isArray(child.material)) {
child.material.forEach((material) => disposeMaterial(material, options));
} else if (child.material) {
disposeMaterial(child.material, options);
}
});
}
function disposeMaterial( function disposeMaterial(
material: THREE.Material, material: THREE.Material,
options: DisposeObject3DOptions, options: DisposeObject3DOptions,
+1 -1
View File
@@ -37,7 +37,7 @@ export function Environment(): React.JSX.Element {
{showSky ? ( {showSky ? (
<SkyModel <SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR} fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
fallbackModelScale={GAME_SCENE_SKY_FALLBACK_MODEL_SCALE} fallbackScale={GAME_SCENE_SKY_FALLBACK_MODEL_SCALE}
fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH} fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH}
modelPath={GAME_SCENE_SKY_MODEL_PATH} modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE} scale={GAME_SCENE_SKY_MODEL_SCALE}
+3
View File
@@ -181,10 +181,12 @@ function playCinematic(
let cameraTransitionTimeline: gsap.core.Timeline | null = null; let cameraTransitionTimeline: gsap.core.Timeline | null = null;
let globalCamera: THREE.Camera | null = null; let globalCamera: THREE.Camera | null = null;
// eslint-disable-next-line react-refresh/only-export-components
export function setGlobalCamera(camera: THREE.Camera | null): void { export function setGlobalCamera(camera: THREE.Camera | null): void {
globalCamera = camera; globalCamera = camera;
} }
// eslint-disable-next-line react-refresh/only-export-components
export function animateCameraTransition( export function animateCameraTransition(
targetPosition: Vector3Tuple, targetPosition: Vector3Tuple,
targetLookAt: Vector3Tuple, targetLookAt: Vector3Tuple,
@@ -234,6 +236,7 @@ export function animateCameraTransition(
); );
} }
// eslint-disable-next-line react-refresh/only-export-components
export function animateCameraTransformTransition( export function animateCameraTransformTransition(
targetPosition: Vector3Tuple, targetPosition: Vector3Tuple,
targetRotation: Vector3Tuple, targetRotation: Vector3Tuple,
+11 -1
View File
@@ -102,6 +102,8 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
// Load waypoints with double-safe fallback // Load waypoints with double-safe fallback
useEffect(() => { useEffect(() => {
let cancelled = false;
// 1. Try localStorage // 1. Try localStorage
const saved = localStorage.getItem("la-fabrik-waypoints"); const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) { if (saved) {
@@ -111,7 +113,10 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
console.log( console.log(
`[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`, `[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`,
); );
setWaypoints(parsed); // Schedule state update to avoid synchronous setState in effect
queueMicrotask(() => {
if (!cancelled) setWaypoints(parsed);
});
return; return;
} }
} catch (e) { } catch (e) {
@@ -129,6 +134,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
throw new Error("Impossible de charger /roadNetwork.json"); throw new Error("Impossible de charger /roadNetwork.json");
}) })
.then((data) => { .then((data) => {
if (cancelled) return;
if (Array.isArray(data)) { if (Array.isArray(data)) {
console.log( console.log(
`[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`, `[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`,
@@ -139,6 +145,10 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
.catch((err) => { .catch((err) => {
console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err); console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err);
}); });
return () => {
cancelled = true;
};
}, []); }, []);
return ( return (
+27 -10
View File
@@ -29,7 +29,22 @@ import { InteractionManager } from "@/managers/InteractionManager";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { EBIKE_CAMERA_TRANSFORM } from "@/components/ebike/Ebike"; import { EBIKE_CAMERA_TRANSFORM } from "@/data/ebike/ebikeConfig";
/** Global window properties used for ebike communication */
interface EbikeGlobalState {
ebikeParkedPosition?: Vector3Tuple;
ebikeParkedRotation?: number;
ebikeSteerFactor?: number;
ebikeVisualGroup?: React.RefObject<THREE.Group>;
playerPos?: Vector3Tuple;
ebikeAngle?: number;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Extending Window with EbikeGlobalState properties
interface Window extends EbikeGlobalState {}
}
type Keys = { type Keys = {
forward: boolean; forward: boolean;
@@ -146,12 +161,11 @@ export function PlayerController({
useEffect(() => { useEffect(() => {
movementModeRef.current = movementMode; movementModeRef.current = movementMode;
}, [movementMode]); }, [movementMode]);
// eslint-disable-next-line react-hooks/immutability -- Three.js camera properties (position, rotation, fov) must be mutated directly; this is the standard pattern for R3F
useEffect(() => { useEffect(() => {
if (movementMode === "ebike") { if (movementMode === "ebike") {
const targetPos: Vector3Tuple = (window as any).ebikeParkedPosition || [ const targetPos: Vector3Tuple = window.ebikeParkedPosition ?? [0, 8.2, 0];
0, 8.2, 0, const targetRot: number = window.ebikeParkedRotation ?? 0;
];
const targetRot: number = (window as any).ebikeParkedRotation || 0;
const headY = targetPos[1] + PLAYER_EYE_HEIGHT; const headY = targetPos[1] + PLAYER_EYE_HEIGHT;
const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS; const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS;
@@ -189,6 +203,7 @@ export function PlayerController({
prevMovementModeRef.current === "ebike" prevMovementModeRef.current === "ebike"
) { ) {
const perspectiveCam = camera as THREE.PerspectiveCamera; const perspectiveCam = camera as THREE.PerspectiveCamera;
// eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes
perspectiveCam.fov = 60; perspectiveCam.fov = 60;
perspectiveCam.updateProjectionMatrix(); perspectiveCam.updateProjectionMatrix();
@@ -300,6 +315,7 @@ export function PlayerController({
}; };
}, []); }, []);
// eslint-disable-next-line react-hooks/immutability -- Three.js camera properties (position, rotation, fov) must be mutated directly in frame loop; this is the standard pattern for R3F game loops
useFrame((_, delta) => { useFrame((_, delta) => {
if (!initializedRef.current) return; if (!initializedRef.current) return;
@@ -435,17 +451,18 @@ export function PlayerController({
if (keys.current.left) targetSteer = 1; if (keys.current.left) targetSteer = 1;
else if (keys.current.right) targetSteer = -1; else if (keys.current.right) targetSteer = -1;
const currentSteer = (window as any).ebikeSteerFactor || 0; const currentSteer = window.ebikeSteerFactor ?? 0;
const steerFactor = THREE.MathUtils.lerp( const steerFactor = THREE.MathUtils.lerp(
currentSteer, currentSteer,
targetSteer, targetSteer,
8 * dt, 8 * dt,
); );
(window as any).ebikeSteerFactor = steerFactor; window.ebikeSteerFactor = steerFactor;
const speed = velocity.current.length(); const speed = velocity.current.length();
const targetFov = 60 + Math.min(speed * 0.35, 9); const targetFov = 60 + Math.min(speed * 0.35, 9);
const perspectiveCam = camera as THREE.PerspectiveCamera; const perspectiveCam = camera as THREE.PerspectiveCamera;
// eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes during frame updates
perspectiveCam.fov = THREE.MathUtils.lerp( perspectiveCam.fov = THREE.MathUtils.lerp(
perspectiveCam.fov, perspectiveCam.fov,
targetFov, targetFov,
@@ -482,7 +499,7 @@ export function PlayerController({
); );
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ"); camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
const ebikeVisual = (window as any).ebikeVisualGroup?.current; const ebikeVisual = window.ebikeVisualGroup?.current;
if (ebikeVisual) { if (ebikeVisual) {
ebikeVisual.position.set( ebikeVisual.position.set(
capsule.current.end.x, capsule.current.end.x,
@@ -496,12 +513,12 @@ export function PlayerController({
camera.position.copy(capsule.current.end); camera.position.copy(capsule.current.end);
} }
(window as any).playerPos = [ window.playerPos = [
capsule.current.end.x, capsule.current.end.x,
capsule.current.end.y, capsule.current.end.y,
capsule.current.end.z, capsule.current.end.z,
]; ];
(window as any).ebikeAngle = ebikeAngle.current; window.ebikeAngle = ebikeAngle.current;
}); });
return null; return null;