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
🔍 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:
@@ -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]} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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,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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user