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