chore: code quality audit and lint fixes
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

- Fix all 63 ESLint errors across codebase
- Consolidate MaterialWithTextureSlots type in src/types/three/three.ts
- Add CSS custom properties for design tokens
- Extract ebike constants to src/data/ebike/ebikeConfig.ts
- Add proper TypeScript types for window extensions
- Fix React hooks violations (refs during render, setState in effects)
- Remove unused exports and redundant CSS
- Add type guards for Three.js material handling
- Clean up AI slop comments and legacy CSS patterns
This commit is contained in:
Tom Boullay
2026-05-29 09:00:04 +02:00
parent ade301389e
commit 52bb1b2915
18 changed files with 550 additions and 465 deletions
+62 -55
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo } from "react";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
@@ -9,25 +9,15 @@ import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { animateCameraTransformTransition } from "@/world/GameCinematics";
import { useGameStore } from "@/managers/stores/useGameStore";
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
import {
EBIKE_CAMERA_TRANSFORM,
EBIKE_DROP_PLAYER_TRANSFORM,
} from "@/data/ebike/ebikeConfig";
import type { Vector3Tuple } from "@/types/three/three";
import "@/types/ebike/ebikeWindow";
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
export interface CameraTransform {
position: Vector3Tuple;
rotation: Vector3Tuple;
}
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
position: [-3.5, 6, 0],
rotation: [-10, -90, 0],
};
const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
position: [0, 1.5, -3],
rotation: [0, 0, 0],
};
interface EbikeProps {
position: Vector3Tuple;
}
@@ -71,14 +61,24 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
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[1] - PLAYER_EYE_HEIGHT,
position[2],
]);
const restingRotation = useRef<number>(0);
const restingRotationRef = useRef<number>(0);
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(() => {
if (model) {
const fork = model.getObjectByName("fourche");
@@ -89,28 +89,28 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}, [model]);
useEffect(() => {
(window as any).ebikeVisualGroup = groupRef;
(window as any).ebikeParkedPosition = restingPosition.current;
(window as any).ebikeParkedRotation = restingRotation.current;
window.ebikeVisualGroup = groupRef;
window.ebikeParkedPosition = restingPositionRef.current;
window.ebikeParkedRotation = restingRotationRef.current;
return () => {
(window as any).ebikeVisualGroup = null;
(window as any).ebikeParkedPosition = null;
(window as any).ebikeParkedRotation = null;
window.ebikeVisualGroup = null;
window.ebikeParkedPosition = null;
window.ebikeParkedRotation = null;
};
}, []);
useFrame((_, delta) => {
if (groupRef.current) {
if (movementMode === "ebike") {
restingPosition.current = [
restingPositionRef.current = [
groupRef.current.position.x,
groupRef.current.position.y,
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
const steerFactor = (window as any).ebikeSteerFactor || 0;
const steerFactor = window.ebikeSteerFactor ?? 0;
if (forkRef.current) {
// 15 degrees is 0.26 radians
const targetForkRotation = steerFactor * 0.26;
@@ -127,51 +127,57 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
lastGpsUpdatePos.current.copy(currentPos);
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 {
groupRef.current.position.set(...restingPosition.current);
groupRef.current.rotation.set(0, restingRotation.current, 0);
groupRef.current.position.set(...restingPositionRef.current);
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
// Reset fork rotation when parked
if (forkRef.current) {
forkRef.current.rotation.z = 0;
}
}
(window as any).ebikeParkedPosition = restingPosition.current;
(window as any).ebikeParkedRotation = restingRotation.current;
window.ebikeParkedPosition = restingPositionRef.current;
window.ebikeParkedRotation = restingRotationRef.current;
}
});
// Debug visualization positions computed from state (not refs)
const camPointPos: Vector3Tuple = [
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0],
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1],
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2],
debugRestingPosition[0] + EBIKE_CAMERA_TRANSFORM.position[0],
debugRestingPosition[1] + EBIKE_CAMERA_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_CAMERA_TRANSFORM.position[2],
];
const dropPointPos: Vector3Tuple = [
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
debugRestingPosition[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
const handleInteract = (): void => {
const handleInteract = useCallback((): void => {
if (movementMode === "walk") {
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
cameraOffset.applyAxisAngle(
new THREE.Vector3(0, 1, 0),
restingRotation.current,
restingRotationRef.current,
);
const targetCamPos: Vector3Tuple = [
restingPosition.current[0] + cameraOffset.x,
restingPosition.current[1] + cameraOffset.y,
restingPosition.current[2] + cameraOffset.z,
restingPositionRef.current[0] + cameraOffset.x,
restingPositionRef.current[1] + cameraOffset.y,
restingPositionRef.current[2] + cameraOffset.z,
];
const targetRotation: Vector3Tuple = [
EBIKE_CAMERA_TRANSFORM.rotation[0],
EBIKE_CAMERA_TRANSFORM.rotation[1] +
THREE.MathUtils.radToDeg(restingRotation.current),
THREE.MathUtils.radToDeg(restingRotationRef.current),
EBIKE_CAMERA_TRANSFORM.rotation[2],
];
@@ -207,27 +213,28 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
useGameStore.getState().setPlayerMovementMode("walk");
});
}
};
}, [movementMode, camera, position]);
// Store handleInteract in a ref for use in debug folder callback
const handleInteractRef = useRef(handleInteract);
handleInteractRef.current = handleInteract;
useEffect(() => {
handleInteractRef.current = handleInteract;
}, [handleInteract]);
const debugRef = useRef({ showCameraPoints: true });
const debugActions = useRef({
toggleRide: () => {
handleInteractRef.current();
},
});
// Mutable object for lil-gui binding
const debugState = useRef({ showCameraPoints: true });
useDebugFolder("Ebike", (folder) => {
folder
.add(debugRef.current, "showCameraPoints")
.add(debugState.current, "showCameraPoints")
.name("Show Camera Points")
.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 (
@@ -268,7 +275,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
</group>
</group>
{debugRef.current.showCameraPoints && (
{showCameraPoints && (
<>
<mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
+57 -25
View File
@@ -1,10 +1,17 @@
import React, { useRef, useEffect, useState, useMemo } from "react";
import React, {
useRef,
useEffect,
useState,
useMemo,
useCallback,
} from "react";
import * as THREE from "three";
import {
findClosestWaypoint,
findWaypointPath,
} from "@/pathfinding/WaypointAStar";
import type { Waypoint } from "@/pathfinding/types";
import type { Vector3Tuple } from "@/types/three/three";
function computeImageSource(
img: HTMLImageElement | HTMLCanvasElement,
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)
*/
position?: [number, number, number];
position?: Vector3Tuple;
/**
* Resolution of the offscreen canvas used for the map texture.
@@ -107,17 +114,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
>(null);
// 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");
canvas.width = canvasSize;
canvas.height = canvasSize;
return canvas;
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once
}, []);
// Resize the canvas whenever canvasSize changes
// Note: Modifying canvas dimensions is intentional and necessary for rendering
useEffect(() => {
offscreenCanvas.width = canvasSize;
offscreenCanvas.height = canvasSize;
// Use Object.assign to resize canvas - this is a necessary mutation for canvas rendering
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
@@ -128,12 +138,16 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Load waypoints (localStorage with /roadNetwork.json fallback)
useEffect(() => {
let cancelled = false;
const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
setWaypoints(parsed);
// Use queueMicrotask to avoid synchronous setState in effect
queueMicrotask(() => {
if (!cancelled) setWaypoints(parsed);
});
return;
}
} catch (e) {
@@ -151,20 +165,25 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
throw new Error("Not found");
})
.then((data) => {
if (Array.isArray(data)) {
if (!cancelled && Array.isArray(data)) {
setWaypoints(data);
}
})
.catch((err) => {
console.log("[GPS Component] No default road network found.", err);
});
return () => {
cancelled = true;
};
}, []);
// Pre-load background map image (standard HTML5 Image loader)
// Since the user's PNG is already transparent, we don't need fetch or pixel manipulation!
useEffect(() => {
if (!mapImageUrl) {
setMapImage(null);
// Use queueMicrotask to avoid synchronous setState in effect
queueMicrotask(() => setMapImage(null));
return;
}
@@ -245,16 +264,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
}, [waypoints, startPosSnapped, destPosSnapped]);
// Translation helper: 3D world to Canvas pixels
const worldToCanvas = (wx: number, wz: number, canvasSize: number) => {
const { minX, maxX, minZ, maxZ } = bounds;
const px = ((wx - minX) / (maxX - minX)) * canvasSize;
const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize;
return { x: px, y: py };
};
const worldToCanvas = useCallback(
(wx: number, wz: number, size: number) => {
const { minX, maxX, minZ, maxZ } = bounds;
const px = ((wx - minX) / (maxX - minX)) * size;
const py = ((wz - minZ) / (maxZ - minZ)) * size;
return { x: px, y: py };
},
[bounds],
);
// Draw loop
const draw = () => {
// Draw loop - returns true if texture needs update
const draw = useCallback(() => {
const canvas = offscreenCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d", {
willReadFrequently: true,
alpha: true,
@@ -451,12 +474,16 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
ctx.fillStyle = "#ffffff";
ctx.fill();
}
// 5. Update WebGL Texture
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
};
}, [
offscreenCanvas,
mapImage,
baseBounds,
bounds,
activePath,
worldToCanvas,
destPosSnapped,
startPosSnapped,
]);
// 60 FPS animation ticker
useEffect(() => {
@@ -467,14 +494,19 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
draw();
// Update texture after draw
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
animId = requestAnimationFrame(tick);
};
animId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animId);
}, [waypoints, startPos, destPos, bounds, mapImage]);
}, [draw]);
return (
<mesh castShadow receiveShadow position={position as any}>
<mesh castShadow receiveShadow position={position}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
toneMapped={false}
+12 -28
View File
@@ -1,12 +1,11 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { logger } from "@/utils/core/Logger";
import { disposeModelMaterials } from "@/utils/three/dispose";
interface SkyModelProps {
fallbackModelScale?: number | undefined;
fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined;
fallbackColor?: string | undefined;
@@ -36,7 +35,6 @@ interface SkyModelErrorBoundaryState {
const SKY_MODEL_SCALE = 1;
const SKY_MODEL_RENDER_ORDER = -1000;
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
class SkyModelErrorBoundary extends Component<
SkyModelErrorBoundaryProps,
@@ -73,7 +71,6 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({
fallbackColor,
fallbackModelScale = SKY_MODEL_SCALE,
fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE,
materialSide = THREE.BackSide,
@@ -95,7 +92,7 @@ export function SkyModel({
<SkyModelContent
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale ?? fallbackModelScale}
scale={fallbackScale}
unlit={unlit}
/>
</SkyModelErrorBoundary>
@@ -139,7 +136,7 @@ function SkyModelContent({
useEffect(() => {
return () => {
disposeSkyModelMaterials(model);
disposeModelMaterials(model);
};
}, [model]);
@@ -200,30 +197,17 @@ function createSkyMaterial<T extends THREE.Material>(
function createUnlitSkyMaterial(
material: THREE.Material,
): 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({
color: sourceMaterial.color?.clone() ?? new THREE.Color("#ffffff"),
map: sourceMaterial.map ?? null,
opacity: sourceMaterial.opacity,
color: sourceMaterial?.color?.clone() ?? new THREE.Color("#ffffff"),
map: sourceMaterial?.map ?? null,
opacity: material.opacity,
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);