Feat/map-environment #6

Merged
math-pixel merged 116 commits from feat/map-environment into develop 2026-05-29 00:00:51 +00:00
27 changed files with 5329 additions and 13 deletions
Showing only changes of commit 89044a18ec - Show all commits
Binary file not shown.
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
import { useEffect, useRef, useState, useMemo } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { animateCameraTransformTransition } from "@/world/GameCinematics";
import { useGameStore } from "@/managers/stores/useGameStore";
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
import type { Vector3Tuple } from "@/types/three/three";
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
export interface CameraTransform {
position: Vector3Tuple;
rotation: Vector3Tuple;
}
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
position: [-3.5, 6, 0],
rotation: [-10, -90, 0],
};
const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
position: [0, 1.5, -3],
rotation: [0, 0, 0],
};
interface EbikeProps {
position: Vector3Tuple;
}
export function Ebike({ position }: EbikeProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
scope: "Ebike",
position: position,
});
const model = useClonedObject(scene);
const movementMode = useGameStore((state) => state.player.movementMode);
const mainState = useGameStore((state) => state.mainState);
const camera = useThree((state) => state.camera);
// Map active mainState to target repair zone coordinate
const destPos = useMemo(() => {
switch (mainState) {
case "ebike":
return { x: 8, y: 0, z: -6 };
case "pylon":
return { x: 64, y: 0, z: -66 };
case "farm":
return { x: -24, y: 0, z: 42 };
default:
return undefined;
}
}, [mainState]);
// Throttled GPS start position to optimize pathfinding A* algorithm execution
const [gpsStartPos, setGpsStartPos] = useState<{
x: number;
y: number;
z: number;
}>({
x: position[0],
y: position[1],
z: position[2],
});
const lastGpsUpdatePos = useRef<THREE.Vector3>(
new THREE.Vector3(...position),
);
const restingPosition = useRef<Vector3Tuple>([
position[0],
position[1] - PLAYER_EYE_HEIGHT,
position[2],
]);
const restingRotation = useRef<number>(0);
const forkRef = useRef<THREE.Object3D | null>(null);
useEffect(() => {
if (model) {
const fork = model.getObjectByName("fourche");
if (fork) {
forkRef.current = fork;
}
}
}, [model]);
useEffect(() => {
(window as any).ebikeVisualGroup = groupRef;
(window as any).ebikeParkedPosition = restingPosition.current;
(window as any).ebikeParkedRotation = restingRotation.current;
return () => {
(window as any).ebikeVisualGroup = null;
(window as any).ebikeParkedPosition = null;
(window as any).ebikeParkedRotation = null;
};
}, []);
useFrame((_, delta) => {
if (groupRef.current) {
if (movementMode === "ebike") {
restingPosition.current = [
groupRef.current.position.x,
groupRef.current.position.y,
groupRef.current.position.z,
];
restingRotation.current = groupRef.current.rotation.y;
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
const steerFactor = (window as any).ebikeSteerFactor || 0;
if (forkRef.current) {
// 15 degrees is 0.26 radians
const targetForkRotation = steerFactor * 0.26;
forkRef.current.rotation.z = THREE.MathUtils.lerp(
forkRef.current.rotation.z,
targetForkRotation,
12 * delta,
);
}
// Throttled GPS start position update to prevent performance loss
const currentPos = groupRef.current.position;
if (currentPos.distanceTo(lastGpsUpdatePos.current) > 2.0) {
lastGpsUpdatePos.current.copy(currentPos);
setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z });
}
} else {
groupRef.current.position.set(...restingPosition.current);
groupRef.current.rotation.set(0, restingRotation.current, 0);
// Reset fork rotation when parked
if (forkRef.current) {
forkRef.current.rotation.z = 0;
}
}
(window as any).ebikeParkedPosition = restingPosition.current;
(window as any).ebikeParkedRotation = restingRotation.current;
}
});
const camPointPos: Vector3Tuple = [
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0],
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1],
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2],
];
const dropPointPos: Vector3Tuple = [
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
const handleInteract = (): void => {
if (movementMode === "walk") {
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
cameraOffset.applyAxisAngle(
new THREE.Vector3(0, 1, 0),
restingRotation.current,
);
const targetCamPos: Vector3Tuple = [
restingPosition.current[0] + cameraOffset.x,
restingPosition.current[1] + cameraOffset.y,
restingPosition.current[2] + cameraOffset.z,
];
const targetRotation: Vector3Tuple = [
EBIKE_CAMERA_TRANSFORM.rotation[0],
EBIKE_CAMERA_TRANSFORM.rotation[1] +
THREE.MathUtils.radToDeg(restingRotation.current),
EBIKE_CAMERA_TRANSFORM.rotation[2],
];
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
useGameStore.getState().setPlayerMovementMode("ebike");
});
} else {
const currentPos = new THREE.Vector3();
if (groupRef.current) {
groupRef.current.getWorldPosition(currentPos);
} else {
currentPos.set(...position);
}
const targetCamPos: Vector3Tuple = [
currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
// Get camera's current rotation in degrees so we keep the exact orientation during dismount
const currentEuler = new THREE.Euler().setFromQuaternion(
camera.quaternion,
"YXZ",
);
const targetRotation: Vector3Tuple = [
THREE.MathUtils.radToDeg(currentEuler.x),
THREE.MathUtils.radToDeg(currentEuler.y),
THREE.MathUtils.radToDeg(currentEuler.z),
];
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
useGameStore.getState().setPlayerMovementMode("walk");
});
}
};
const handleInteractRef = useRef(handleInteract);
handleInteractRef.current = handleInteract;
const debugRef = useRef({ showCameraPoints: true });
const debugActions = useRef({
toggleRide: () => {
handleInteractRef.current();
},
});
useDebugFolder("Ebike", (folder) => {
folder
.add(debugRef.current, "showCameraPoints")
.name("Show Camera Points")
.onChange((value: boolean) => {
debugRef.current.showCameraPoints = value;
});
folder.add(debugActions.current, "toggleRide").name("Monter / Descendre");
});
return (
<>
<group ref={groupRef} position={position}>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
}
position={position}
radius={15}
onPress={handleInteract}
>
<mesh>
<boxGeometry args={[10, 13, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</InteractableObject>
{/* Dynamic 3D GPS Dashboard Screen */}
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
<EbikeGPSMap
width={0.8}
height={0.8}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/assets/gps/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
</group>
</group>
{debugRef.current.showCameraPoints && (
<>
<mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshStandardMaterial
color="yellow"
emissive="yellow"
emissiveIntensity={0.5}
/>
</mesh>
<mesh position={dropPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshStandardMaterial
color="cyan"
emissive="cyan"
emissiveIntensity={0.5}
/>
</mesh>
</>
)}
</>
);
}
+497
View File
@@ -0,0 +1,497 @@
import React, { useRef, useEffect, useState, useMemo } from "react";
import * as THREE from "three";
import {
findClosestWaypoint,
findWaypointPath,
} from "@/pathfinding/WaypointAStar";
import type { Waypoint } from "@/pathfinding/types";
function computeImageSource(
img: HTMLImageElement | HTMLCanvasElement,
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
bounds: { minX: number; maxX: number; minZ: number; maxZ: number },
) {
const imgW = img.width;
const imgH = img.height;
const baseW = baseBounds.maxX - baseBounds.minX;
const baseH = baseBounds.maxZ - baseBounds.minZ;
if (baseW === 0 || baseH === 0) {
return { sx: 0, sy: 0, sW: imgW, sH: imgH };
}
const sx = ((bounds.minX - baseBounds.minX) / baseW) * imgW;
const sy = ((bounds.minZ - baseBounds.minZ) / baseH) * imgH;
const sW = ((bounds.maxX - bounds.minX) / baseW) * imgW;
const sH = ((bounds.maxZ - bounds.minZ) / baseH) * imgH;
return { sx, sy, sW, sH };
}
export interface EbikeGPSMapProps {
/**
* 3D world position of the player/bike (GPS start point)
* If omitted, snaps to [0,0,0]
*/
startPos?: { x: number; y: number; z: number } | undefined;
destPos?: { x: number; y: number; z: number } | undefined;
/**
* Optional custom URL to the map background texture.
* If not provided, renders a high-tech minimalist neon blueprint map dynamically.
*/
mapImageUrl?: string;
/**
* Optional explicit bounds for mapping coordinates.
* If omitted, bounds are calculated automatically to perfectly fit the road network!
*/
worldBounds?: {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
};
/**
* Width of the 3D plane mesh (default: 1)
*/
width?: number;
/**
* Height of the 3D plane mesh (default: 1)
*/
height?: number;
/**
* Optional world position for the GPS screen (defaults to origin)
*/
position?: [number, number, number];
/**
* Resolution of the offscreen canvas used for the map texture.
* Higher values yield sharper rendering at the cost of GPU memory.
* Default: 1024 (1024×1024 px)
*/
canvasSize?: number;
/**
* Zoom level applied to the map view.
* 1 = full world bounds, 2 = 2× zoom-in centred on the player, etc.
* Values < 1 zoom out beyond the calculated world bounds.
* Default: 1
*/
zoom?: number;
}
/**
* EbikeGPSMap
* A premium, state-of-the-art 3D GPS navigation screen for the Ebike.
* Loads the road network, runs A* pathfinding, and renders a glowing, animated
* orange path over a sleek high-tech map background.
*/
export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
startPos = { x: 0, y: 0, z: 0 },
destPos,
mapImageUrl,
worldBounds,
width = 1,
height = 1,
position = [0, 0, 0],
canvasSize = 1024,
zoom = 1,
}) => {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [mapImage, setMapImage] = useState<
HTMLImageElement | HTMLCanvasElement | null
>(null);
// Offscreen high-res canvas for crystal clear rendering
const [offscreenCanvas] = useState(() => {
const canvas = document.createElement("canvas");
canvas.width = canvasSize;
canvas.height = canvasSize;
return canvas;
});
// Resize the canvas whenever canvasSize changes
useEffect(() => {
offscreenCanvas.width = canvasSize;
offscreenCanvas.height = canvasSize;
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
}, [canvasSize, offscreenCanvas]);
const textureRef = useRef<THREE.CanvasTexture | null>(null);
const animTimeRef = useRef<number>(0);
// Load waypoints (localStorage with /roadNetwork.json fallback)
useEffect(() => {
const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
setWaypoints(parsed);
return;
}
} catch (e) {
console.error(
"[GPS Component] Error loading local storage waypoints",
e,
);
}
}
// Fallback to static roadNetwork.json
fetch("/roadNetwork.json")
.then((res) => {
if (res.ok) return res.json();
throw new Error("Not found");
})
.then((data) => {
if (Array.isArray(data)) {
setWaypoints(data);
}
})
.catch((err) => {
console.log("[GPS Component] No default road network found.", err);
});
}, []);
// Pre-load background map image (standard HTML5 Image loader)
// Since the user's PNG is already transparent, we don't need fetch or pixel manipulation!
useEffect(() => {
if (!mapImageUrl) {
setMapImage(null);
return;
}
const img = new Image();
img.onload = () => {
setMapImage(img);
};
img.onerror = () => {
console.warn(
`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`,
);
setMapImage(null);
};
img.src = mapImageUrl;
}, [mapImageUrl]);
// Determine grid boundaries (before zoom)
const baseBounds = useMemo(() => {
if (worldBounds) return worldBounds;
if (waypoints.length === 0) {
return { minX: -200, maxX: 200, minZ: -200, maxZ: 200 };
}
const xs = waypoints.map((w) => w.x);
const zs = waypoints.map((w) => w.z);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minZ = Math.min(...zs);
const maxZ = Math.max(...zs);
// Padding (15% to ensure full view breathing room)
const padX = (maxX - minX) * 0.15 || 40;
const padZ = (maxZ - minZ) * 0.15 || 40;
return {
minX: minX - padX,
maxX: maxX + padX,
minZ: minZ - padZ,
maxZ: maxZ + padZ,
};
}, [waypoints, worldBounds]);
// Apply zoom: shrink the view window around the player position
const bounds = useMemo(() => {
const clampedZoom = Math.max(0.1, zoom);
if (clampedZoom === 1) return baseBounds;
const centerX = startPos.x;
const centerZ = startPos.z;
const halfW = (baseBounds.maxX - baseBounds.minX) / 2 / clampedZoom;
const halfH = (baseBounds.maxZ - baseBounds.minZ) / 2 / clampedZoom;
return {
minX: centerX - halfW,
maxX: centerX + halfW,
minZ: centerZ - halfH,
maxZ: centerZ + halfH,
};
}, [baseBounds, zoom, startPos]);
// Snapped positions
const startPosSnapped = useMemo(() => {
if (waypoints.length === 0) return null;
return findClosestWaypoint(waypoints, startPos);
}, [waypoints, startPos]);
const destPosSnapped = useMemo(() => {
if (!destPos || waypoints.length === 0) return null;
return findClosestWaypoint(waypoints, destPos);
}, [waypoints, destPos]);
// Calculated active A* route
const activePath = useMemo(() => {
if (!startPosSnapped || !destPosSnapped || waypoints.length === 0)
return [];
return findWaypointPath(waypoints, startPosSnapped, destPosSnapped);
}, [waypoints, startPosSnapped, destPosSnapped]);
// Translation helper: 3D world to Canvas pixels
const worldToCanvas = (wx: number, wz: number, canvasSize: number) => {
const { minX, maxX, minZ, maxZ } = bounds;
const px = ((wx - minX) / (maxX - minX)) * canvasSize;
const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize;
return { x: px, y: py };
};
// Draw loop
const draw = () => {
const canvas = offscreenCanvas;
const ctx = canvas.getContext("2d", {
willReadFrequently: true,
alpha: true,
});
if (!ctx) return;
const size = canvas.width;
ctx.clearRect(0, 0, size, size);
// 1. Draw Map Background (Image or premium blueprint vectors)
if (mapImage) {
const src = computeImageSource(mapImage, baseBounds, bounds);
const sx = Math.max(0, Math.min(mapImage.width, src.sx));
const sy = Math.max(0, Math.min(mapImage.height, src.sy));
const sW = Math.max(1, Math.min(mapImage.width - sx, src.sW));
const sH = Math.max(1, Math.min(mapImage.height - sy, src.sH));
ctx.drawImage(mapImage, sx, sy, sW, sH, 0, 0, size, size);
ctx.globalAlpha = 1.0;
} else {
// Dynamic Sci-fi background grid (Background is transparent!)
// Sci-fi subgrid
ctx.strokeStyle = "rgba(30, 41, 59, 0.4)";
ctx.lineWidth = 1;
const step = size / 32;
for (let x = 0; x < size; x += step) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, size);
ctx.stroke();
}
for (let y = 0; y < size; y += step) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(size, y);
ctx.stroke();
}
// Aesthetic concentric radar topo-rings
ctx.strokeStyle = "rgba(71, 85, 105, 0.06)";
ctx.lineWidth = 2;
for (let r = size / 6; r < size; r += size / 6) {
ctx.beginPath();
ctx.arc(size / 2, size / 2, r, 0, 2 * Math.PI);
ctx.stroke();
}
// Faint diagonal technical accents
ctx.strokeStyle = "rgba(56, 189, 248, 0.03)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(size, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(size, 0);
ctx.lineTo(0, size);
ctx.stroke();
}
// 2. Draw Active Orange Glowing Path (Neon Highway effect)
if (activePath.length > 1) {
// Pass 1: Wide transparent orange bloom
ctx.beginPath();
let pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
ctx.moveTo(pt.x, pt.y);
for (let i = 1; i < activePath.length; i++) {
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = "rgba(249, 115, 22, 0.2)"; // Faint bright orange
ctx.lineWidth = 20;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.shadowBlur = 30;
ctx.shadowColor = "#f97316"; // Neon Orange
ctx.stroke();
// Pass 2: Saturated glow core
ctx.beginPath();
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
ctx.moveTo(pt.x, pt.y);
for (let i = 1; i < activePath.length; i++) {
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = "#f97316"; // Vibrant orange
ctx.lineWidth = 8;
ctx.shadowBlur = 12;
ctx.shadowColor = "#ea580c";
ctx.stroke();
// Pass 3: High-intensity white core
ctx.beginPath();
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
ctx.moveTo(pt.x, pt.y);
for (let i = 1; i < activePath.length; i++) {
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = "#fff7ed"; // Cream white
ctx.lineWidth = 3;
ctx.shadowBlur = 0; // Turn off shadows for the core
ctx.stroke();
// 3. Energy Particle Pulse animation tracing the road
const segments: {
start: { x: number; y: number };
end: { x: number; y: number };
len: number;
}[] = [];
let totalLen = 0;
for (let i = 0; i < activePath.length - 1; i++) {
const p1 = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
const p2 = worldToCanvas(
activePath[i + 1]!.x,
activePath[i + 1]!.z,
size,
);
const len = Math.sqrt(
Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2),
);
segments.push({ start: p1, end: p2, len });
totalLen += len;
}
if (totalLen > 0) {
const targetLen = totalLen * animTimeRef.current;
let currentLen = 0;
let dotPt = segments[0]!.start;
for (const seg of segments) {
if (currentLen + seg.len >= targetLen) {
const ratio = (targetLen - currentLen) / seg.len;
dotPt = {
x: seg.start.x + (seg.end.x - seg.start.x) * ratio,
y: seg.start.y + (seg.end.y - seg.start.y) * ratio,
};
break;
}
currentLen += seg.len;
}
// Draw multiple glowing pulses along the path
ctx.beginPath();
ctx.arc(dotPt.x, dotPt.y, 8, 0, 2 * Math.PI);
ctx.fillStyle = "#ffffff";
ctx.shadowBlur = 15;
ctx.shadowColor = "#f97316";
ctx.fill();
ctx.shadowBlur = 0;
}
}
// 4. Draw Snap Markers (Start and End)
if (destPosSnapped) {
const pt = worldToCanvas(destPosSnapped.x, destPosSnapped.z, size);
const pulseSize = 12 + Math.sin(Date.now() * 0.007) * 4;
// Pulse ring
ctx.beginPath();
ctx.arc(pt.x, pt.y, pulseSize, 0, 2 * Math.PI);
ctx.strokeStyle = "rgba(249, 115, 22, 0.4)";
ctx.lineWidth = 3;
ctx.stroke();
// Solid target core
ctx.beginPath();
ctx.arc(pt.x, pt.y, 6, 0, 2 * Math.PI);
ctx.fillStyle = "#ea580c"; // Deep target orange
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
if (startPosSnapped) {
const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size);
// Start Marker (Player Arrow/Dot)
ctx.beginPath();
ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
ctx.fillStyle = "#0ea5e9"; // Cool cyberpunk sky blue
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2.5;
ctx.fill();
ctx.stroke();
// Tech details
ctx.beginPath();
ctx.arc(pt.x, pt.y, 3, 0, 2 * Math.PI);
ctx.fillStyle = "#ffffff";
ctx.fill();
}
// 5. Update WebGL Texture
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
};
// 60 FPS animation ticker
useEffect(() => {
let animId: number;
const tick = () => {
animTimeRef.current += 0.004; // Slow, premium sweep speed
if (animTimeRef.current > 1) animTimeRef.current = 0;
draw();
animId = requestAnimationFrame(tick);
};
animId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animId);
}, [waypoints, startPos, destPos, bounds, mapImage]);
return (
<mesh castShadow receiveShadow position={position as any}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
toneMapped={false}
transparent={true}
opacity={1}
depthWrite={false}
side={THREE.DoubleSide}
>
<canvasTexture
ref={textureRef}
attach="map"
image={offscreenCanvas}
format={THREE.RGBAFormat}
minFilter={THREE.LinearFilter}
magFilter={THREE.LinearFilter}
/>
</meshBasicMaterial>
</mesh>
);
};
+24
View File
@@ -0,0 +1,24 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { createNetShader } from "@/shaders/NetShader";
export function NetTest(): React.JSX.Element {
const materialRef = useRef<THREE.ShaderMaterial>(null);
useFrame((_, delta) => {
const timeUniform = materialRef.current?.uniforms.uTime;
if (timeUniform) timeUniform.value += delta;
});
return (
<mesh position={[0, 2, -3]} rotation={[0, 0, 0]}>
<planeGeometry args={[2, 2, 1, 1]} />
<primitive
object={createNetShader()}
ref={materialRef}
attach="material"
/>
</mesh>
);
}
+5 -2
View File
@@ -1,4 +1,5 @@
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0]; export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200]; export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
@@ -23,7 +24,7 @@ export const TEST_SCENE_TRIGGER_METALNESS = 0.5;
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65; export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045; export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
export const TEST_SCENE_REPAIR_ZONES = [ export const GAME_REPAIR_ZONES = [
{ {
mission: "ebike", mission: "ebike",
label: "E-bike", label: "E-bike",
@@ -43,8 +44,10 @@ export const TEST_SCENE_REPAIR_ZONES = [
position: [12, 0, -12], position: [12, 0, -12],
}, },
] as const satisfies readonly { ] as const satisfies readonly {
mission: "ebike" | "pylon" | "farm"; mission: RepairMissionId;
label: string; label: string;
color: string; color: string;
position: Vector3Tuple; position: Vector3Tuple;
}[]; }[];
export const TEST_SCENE_REPAIR_ZONES = GAME_REPAIR_ZONES;
+1
View File
@@ -4,6 +4,7 @@ export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35; export const PLAYER_CAPSULE_RADIUS = 0.35;
export const PLAYER_WALK_SPEED = 11; export const PLAYER_WALK_SPEED = 11;
export const PLAYER_EBIKE_SPEED = 25;
export const PLAYER_AIR_CONTROL_FACTOR = 0.35; export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
export const PLAYER_JUMP_SPEED = 9; export const PLAYER_JUMP_SPEED = 9;
export const PLAYER_GRAVITY = 30; export const PLAYER_GRAVITY = 30;
+44
View File
@@ -6,6 +6,10 @@ import {
isMissionStep, isMissionStep,
isRepairMissionId, isRepairMissionId,
} from "@/data/gameplay/repairMissionState"; } from "@/data/gameplay/repairMissionState";
import {
PLAYER_EBIKE_SPEED,
PLAYER_WALK_SPEED,
} from "@/data/player/playerConfig";
import type { GameStep, MainGameState } from "@/types/game"; import type { GameStep, MainGameState } from "@/types/game";
import { import {
type MissionStep, type MissionStep,
@@ -18,6 +22,7 @@ import {
} from "@/utils/debug/debugGameStateCookie"; } from "@/utils/debug/debugGameStateCookie";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
export type PlayerMovementMode = "walk" | "ebike";
export type { MissionStep, RepairMissionId }; export type { MissionStep, RepairMissionId };
interface IntroState { interface IntroState {
@@ -43,6 +48,7 @@ export interface GameState {
mainState: MainGameState; mainState: MainGameState;
isCinematicPlaying: boolean; isCinematicPlaying: boolean;
missionFlow: MissionFlowState; missionFlow: MissionFlowState;
player: PlayerState;
intro: IntroState; intro: IntroState;
ebike: MissionState & { ebike: MissionState & {
isRepaired: boolean; isRepaired: boolean;
@@ -59,12 +65,18 @@ export interface GameState {
}; };
} }
interface PlayerState {
movementMode: PlayerMovementMode;
currentSpeed: number;
}
interface GameActions { interface GameActions {
setMainState: (mainState: MainGameState) => void; setMainState: (mainState: MainGameState) => void;
setCinematicPlaying: (isCinematicPlaying: boolean) => void; setCinematicPlaying: (isCinematicPlaying: boolean) => void;
hideDialog: () => void; hideDialog: () => void;
setActivityCity: (activityCity: boolean) => void; setActivityCity: (activityCity: boolean) => void;
setCanMove: (canMove: boolean) => void; setCanMove: (canMove: boolean) => void;
setPlayerMovementMode: (mode: PlayerMovementMode) => void;
setIntroStep: (step: GameStep) => void; setIntroStep: (step: GameStep) => void;
setIntroState: (intro: Partial<IntroState>) => void; setIntroState: (intro: Partial<IntroState>) => void;
setPlayerName: (playerName: string) => void; setPlayerName: (playerName: string) => void;
@@ -100,6 +112,10 @@ function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean"; return typeof value === "boolean";
} }
function isPlayerMovementMode(value: unknown): value is PlayerMovementMode {
return value === "walk" || value === "ebike";
}
function completeIntroState(state: GameState): GameStateUpdate { function completeIntroState(state: GameState): GameStateUpdate {
return { return {
mainState: "ebike", mainState: "ebike",
@@ -234,6 +250,10 @@ function createInitialGameState(): GameState {
dialogMessage: null, dialogMessage: null,
playerName: "", playerName: "",
}, },
player: {
movementMode: "walk",
currentSpeed: PLAYER_WALK_SPEED,
},
intro: { intro: {
currentStep: "intro", currentStep: "intro",
dialogueAudio: null, dialogueAudio: null,
@@ -319,6 +339,20 @@ function hydrateMissionFlowState(
}; };
} }
function hydratePlayerState(initial: PlayerState, value: unknown): PlayerState {
if (!isRecord(value)) return initial;
return {
movementMode: isPlayerMovementMode(value.movementMode)
? value.movementMode
: initial.movementMode,
currentSpeed:
typeof value.currentSpeed === "number"
? value.currentSpeed
: initial.currentSpeed,
};
}
function hydrateDebugGameState(initial: GameState, value: unknown): GameState { function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
if (!isRecord(value)) return initial; if (!isRecord(value)) return initial;
@@ -338,6 +372,7 @@ function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
initial.missionFlow, initial.missionFlow,
value.missionFlow, value.missionFlow,
), ),
player: hydratePlayerState(initial.player, value.player),
intro: hydrateIntroState(initial.intro, value.intro), intro: hydrateIntroState(initial.intro, value.intro),
ebike: { ebike: {
...ebike, ...ebike,
@@ -385,6 +420,7 @@ function pickGameState(state: GameStore): GameState {
mainState: state.mainState, mainState: state.mainState,
isCinematicPlaying: state.isCinematicPlaying, isCinematicPlaying: state.isCinematicPlaying,
missionFlow: state.missionFlow, missionFlow: state.missionFlow,
player: state.player,
intro: state.intro, intro: state.intro,
ebike: state.ebike, ebike: state.ebike,
pylon: state.pylon, pylon: state.pylon,
@@ -405,6 +441,14 @@ export const useGameStore = create<GameStore>()((set) => ({
set((state) => ({ set((state) => ({
missionFlow: { ...state.missionFlow, activityCity }, missionFlow: { ...state.missionFlow, activityCity },
})), })),
setPlayerMovementMode: (mode) =>
set((state) => ({
player: {
...state.player,
movementMode: mode,
currentSpeed: mode === "ebike" ? PLAYER_EBIKE_SPEED : PLAYER_WALK_SPEED,
},
})),
setCanMove: (canMove) => setCanMove: (canMove) =>
set((state) => ({ set((state) => ({
missionFlow: { ...state.missionFlow, canMove }, missionFlow: { ...state.missionFlow, canMove },
+357
View File
@@ -0,0 +1,357 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { MapControls, OrthographicCamera, useGLTF } from "@react-three/drei";
import * as THREE from "three";
// ----------------------------------------------------------------------------
// 1. Terrain Scene
// ----------------------------------------------------------------------------
function TerrainScene() {
const { scene } = useGLTF("/models/terrain/terrain.glb");
return (
<group>
<ambientLight intensity={1.5} />
<directionalLight position={[10, 20, 10]} intensity={2} />
<primitive object={scene} />
</group>
);
}
// ----------------------------------------------------------------------------
// 2. Waypoint Overlay (Debug visualization)
// ----------------------------------------------------------------------------
function WaypointOverlay({
waypoints,
visible,
}: {
waypoints: any[];
visible: boolean;
}) {
if (!visible) return null;
return (
<group>
{waypoints.map((w) => (
<mesh key={w.id} position={[w.x, w.y + 1, w.z]}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshBasicMaterial color="#10b981" />
</mesh>
))}
</group>
);
}
// ----------------------------------------------------------------------------
// 3. Camera Manager (Handles Orthographic Math & Downloads)
// ----------------------------------------------------------------------------
function CameraManager({
autoBounds,
boundsTextRef,
}: {
autoBounds: any;
boundsTextRef: React.RefObject<HTMLPreElement | null>;
}) {
const { camera, gl, scene } = useThree();
const controlsRef = useRef<any>(null);
// Apply Auto-Bounds function
useEffect(() => {
const applyAutoBounds = () => {
if (camera instanceof THREE.OrthographicCamera && autoBounds) {
const width = autoBounds.maxX - autoBounds.minX;
const height = autoBounds.maxZ - autoBounds.minZ;
const centerX = (autoBounds.minX + autoBounds.maxX) / 2;
const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2;
camera.position.set(centerX, 200, centerZ);
camera.left = -width / 2;
camera.right = width / 2;
camera.top = height / 2;
camera.bottom = -height / 2;
camera.zoom = 1;
camera.updateProjectionMatrix();
if (controlsRef.current) {
controlsRef.current.target.set(centerX, 0, centerZ);
controlsRef.current.update();
}
}
};
(window as any).applyAutoBounds = applyAutoBounds;
// Initial apply
applyAutoBounds();
return () => {
delete (window as any).applyAutoBounds;
};
}, [camera, autoBounds]);
// Track dynamic bounds without triggering React re-renders!
useFrame(() => {
if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) {
const width = (camera.right - camera.left) / camera.zoom;
const height = (camera.top - camera.bottom) / camera.zoom;
const minX = Math.round(camera.position.x - width / 2);
const maxX = Math.round(camera.position.x + width / 2);
const minZ = Math.round(camera.position.z - height / 2);
const maxZ = Math.round(camera.position.z + height / 2);
// Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!)
boundsTextRef.current.innerText = JSON.stringify(
{ minX, maxX, minZ, maxZ },
null,
2,
);
}
});
// Attach screenshot capture logic
useEffect(() => {
(window as any).downloadMapScreenshot = () => {
// Force an immediate render frame to ensure no UI overlays are missing
gl.render(scene, camera);
const dataUrl = gl.domElement.toDataURL("image/png");
const a = document.createElement("a");
a.href = dataUrl;
a.download = "/assets/gps/map_background.png";
a.click();
};
return () => {
delete (window as any).downloadMapScreenshot;
};
}, [gl, camera, scene]);
return (
<MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} />
);
}
// ----------------------------------------------------------------------------
// 4. Main Page Route Component
// ----------------------------------------------------------------------------
export function BackgroundMapPage() {
const [waypoints, setWaypoints] = useState<any[]>([]);
const [showWaypoints, setShowWaypoints] = useState(true);
const boundsTextRef = useRef<HTMLPreElement>(null);
// Load road network waypoints to compute perfect GPS bounds
useEffect(() => {
const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) {
setWaypoints(JSON.parse(saved));
} else {
fetch("/roadNetwork.json")
.then((res) => res.json())
.then((data) => setWaypoints(data))
.catch(() => {});
}
}, []);
// Compute exact bounds that the EbikeGPSMap will use by default
const autoBounds = useMemo(() => {
if (waypoints.length === 0) return null;
const xs = waypoints.map((w) => w.x);
const zs = waypoints.map((w) => w.z);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minZ = Math.min(...zs);
const maxZ = Math.max(...zs);
// CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE.
// If the camera is rectangular, the exported PNG will be distorted when drawn
// on the EbikeGPSMap's 1024x1024 canvas!
const width = maxX - minX;
const height = maxZ - minZ;
const maxDim = Math.max(width, height);
const centerX = (minX + maxX) / 2;
const centerZ = (minZ + maxZ) / 2;
const paddedDim = maxDim * 1.15 || 100;
return {
minX: centerX - paddedDim / 2,
maxX: centerX + paddedDim / 2,
minZ: centerZ - paddedDim / 2,
maxZ: centerZ + paddedDim / 2,
};
}, [waypoints]);
return (
<div
style={{
width: "100vw",
height: "100vh",
background: "#050505",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{/*
CRITICAL: The DOM element MUST be a perfect square so the resulting PNG
is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture!
*/}
<div
style={{
width: "min(100vw, 100vh)",
height: "min(100vw, 100vh)",
background: "#000",
position: "relative",
}}
>
<Canvas
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}
>
<OrthographicCamera
makeDefault
position={[0, 200, 0]}
near={0.1}
far={1000}
/>
<TerrainScene />
<WaypointOverlay waypoints={waypoints} visible={showWaypoints} />
<CameraManager
autoBounds={autoBounds}
boundsTextRef={boundsTextRef}
/>
</Canvas>
</div>
{/* Premium Glassmorphic UI Dashboard */}
<div
style={{
position: "absolute",
top: 24,
left: 24,
background: "rgba(15, 23, 42, 0.85)",
padding: 24,
borderRadius: 16,
border: "1px solid #334155",
color: "white",
fontFamily: "system-ui, sans-serif",
backdropFilter: "blur(12px)",
width: 360,
boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.5)",
}}
>
<h2
style={{ margin: "0 0 16px 0", fontSize: "1.4rem", color: "#38bdf8" }}
>
GPS Map Generator
</h2>
<p
style={{
fontSize: "0.9rem",
color: "#94a3b8",
marginBottom: 20,
lineHeight: 1.5,
}}
>
1. Cadrez votre carte (ou utilisez le <b>Cadrage Automatique</b>).
<br />
2. Masquez les waypoints (fond visuel seul).
<br />
3. Cliquez sur <b>Capturer la carte</b>.
</p>
<button
onClick={() => setShowWaypoints(!showWaypoints)}
style={{
width: "100%",
padding: "12px",
marginBottom: 12,
background: showWaypoints ? "#1e293b" : "#334155",
border: "1px solid #475569",
color: "white",
borderRadius: 8,
cursor: "pointer",
fontWeight: 600,
transition: "all 0.2s",
}}
>
{showWaypoints ? "👁️ Masquer Waypoints" : "👁️‍🗨️ Afficher Waypoints"}
</button>
<button
onClick={() => {
if ((window as any).applyAutoBounds)
(window as any).applyAutoBounds();
}}
style={{
width: "100%",
padding: "12px",
marginBottom: 16,
background: "#1e293b",
border: "1px solid #475569",
color: "#10b981",
borderRadius: 8,
cursor: "pointer",
fontWeight: 600,
transition: "all 0.2s",
}}
>
🎯 Cadrage Automatique
</button>
<button
onClick={() => {
if ((window as any).downloadMapScreenshot)
(window as any).downloadMapScreenshot();
}}
style={{
width: "100%",
padding: "14px",
background: "#0ea5e9",
border: "none",
color: "white",
borderRadius: 8,
cursor: "pointer",
fontWeight: "bold",
fontSize: "1rem",
boxShadow: "0 4px 6px -1px rgba(14, 165, 233, 0.4)",
}}
>
📸 Capturer la carte (.png)
</button>
<div
style={{
marginTop: 24,
padding: 16,
background: "#020617",
borderRadius: 10,
fontSize: "0.85rem",
}}
>
<div style={{ color: "#64748b", marginBottom: 8, fontWeight: 600 }}>
Limites Actuelles (worldBounds):
</div>
<pre
ref={boundsTextRef}
style={{ margin: 0, color: "#10b981", fontFamily: "monospace" }}
>
Calcul...
</pre>
<div
style={{
color: "#ef4444",
marginTop: 12,
fontSize: "0.75rem",
lineHeight: 1.4,
}}
>
*Si vous décadrez à la souris, vous devrez copier ces valeurs
exactes dans la prop <code>worldBounds</code> de votre composant{" "}
<b>EbikeGPSMap</b> !
<br />
<br />
Astuce : Utilisez le <b>Cadrage Automatique</b> pour ne rien avoir à
configurer.
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
import { Grid } from "./Grid";
import type { GridNode, Position } from "./types";
/**
* Calculates the octile heuristic distance between two nodes.
* Ideal for 8-directional grid movement.
*/
function getOctileDistance(nodeA: GridNode, nodeB: GridNode): number {
const dx = Math.abs(nodeA.x - nodeB.x);
const dy = Math.abs(nodeA.y - nodeB.y);
const D = 1; // Orthogonal movement cost
const D2 = 1.414; // Diagonal movement cost (approx Math.sqrt(2))
return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy);
}
/**
* Finds the shortest path between start and end positions on the grid.
* Returns an array of Positions representing the path, or an empty array if no path is found.
*/
export function findPath(
grid: Grid,
startPos: Position,
endPos: Position,
allowDiagonals: boolean = true,
): Position[] {
grid.reset();
const startNode = grid.getNode(
Math.floor(startPos.x),
Math.floor(startPos.y),
);
const endNode = grid.getNode(Math.floor(endPos.x), Math.floor(endPos.y));
if (!startNode || !endNode) {
return [];
}
// If the destination node itself is blocked, we try to find the nearest walkable neighbor
if (!endNode.walkable) {
const endNeighbors = grid.getNeighbors(endNode, allowDiagonals);
if (endNeighbors.length === 0) {
return [];
}
// Set destination to the closest walkable neighbor
let closestNeighbor = endNeighbors[0]!;
let minDist = getOctileDistance(startNode, closestNeighbor);
for (let i = 1; i < endNeighbors.length; i++) {
const neighbor = endNeighbors[i]!;
const dist = getOctileDistance(startNode, neighbor);
if (dist < minDist) {
minDist = dist;
closestNeighbor = neighbor;
}
}
// Reroute to that walkable neighbor
return findPath(
grid,
startPos,
{ x: closestNeighbor.x, y: closestNeighbor.y },
allowDiagonals,
);
}
const openSet: GridNode[] = [startNode];
const closedSet = new Set<GridNode>();
startNode.g = 0;
startNode.h = getOctileDistance(startNode, endNode);
startNode.f = startNode.h;
while (openSet.length > 0) {
// Find the node in openSet with the lowest f value
let lowIndex = 0;
for (let i = 1; i < openSet.length; i++) {
const node = openSet[i]!;
const lowNode = openSet[lowIndex]!;
if (node.f < lowNode.f) {
lowIndex = i;
}
}
const currentNode = openSet[lowIndex]!;
// Check if we reached the destination
if (currentNode === endNode) {
const path: Position[] = [];
let temp: GridNode | null = currentNode;
while (temp !== null) {
path.push({ x: temp.x, y: temp.y });
temp = temp.parent;
}
return path.reverse();
}
// Remove currentNode from openSet and add to closedSet
openSet.splice(lowIndex, 1);
closedSet.add(currentNode);
const neighbors = grid.getNeighbors(currentNode, allowDiagonals);
for (const neighbor of neighbors) {
if (closedSet.has(neighbor)) {
continue;
}
// Calculate cost to move to this neighbor (1 for orthogonal, 1.414 for diagonal)
const isDiagonal =
neighbor.x !== currentNode.x && neighbor.y !== currentNode.y;
const moveCost = isDiagonal ? 1.414 : 1;
const tentativeG = currentNode.g + moveCost;
const neighborInOpenSet = openSet.includes(neighbor);
if (!neighborInOpenSet || tentativeG < neighbor.g) {
neighbor.parent = currentNode;
neighbor.g = tentativeG;
neighbor.h = getOctileDistance(neighbor, endNode);
neighbor.f = neighbor.g + neighbor.h;
if (!neighborInOpenSet) {
openSet.push(neighbor);
}
}
}
}
// Return empty if no path is found
return [];
}
+234
View File
@@ -0,0 +1,234 @@
import React, { useRef, useEffect, useState, useMemo } from "react";
import * as THREE from "three";
import { useGPS } from "./useGPS";
import type { WorldBounds } from "./useGPS";
// ==========================================
// 1. Premium 2D HUD GPS Overlay Component
// ==========================================
export interface GPSMinimapHUDProps {
bwMaskUrl: string;
colorMapUrl: string;
gridWidth: number;
gridHeight: number;
worldBounds: WorldBounds;
playerPos: { x: number; z: number };
destPos?: { x: number; z: number };
size?: number; // Size of HUD in pixels
}
/**
* A beautiful, glassmorphic 2D HUD overlay that renders the GPS Minimap
* in the corner of the screen.
*/
export const GPSMinimapHUD: React.FC<GPSMinimapHUDProps> = ({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
playerPos,
destPos,
size = 200,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const gpsOptions = useMemo(
() => ({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
}),
[bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds],
);
const { calculateWorldPath, renderGPSToCanvas, loading, error } =
useGPS(gpsOptions);
useEffect(() => {
if (loading || error || !canvasRef.current) return;
// Calculate A* path in world coordinates
const path = destPos ? calculateWorldPath(playerPos, destPos) : [];
// Render path onto HUD canvas
renderGPSToCanvas(canvasRef.current, path, playerPos, destPos, {
pathColor: "#3b82f6", // Premium vibrant blue
pathWidth: 5,
playerColor: "#ef4444", // Hot red for player
playerSize: 6,
destColor: "#10b981", // Emerald green for destination
destSize: 6,
});
}, [
playerPos,
destPos,
loading,
error,
calculateWorldPath,
renderGPSToCanvas,
]);
return (
<div style={hudStyles.container(size)}>
{loading && <div style={hudStyles.statusText}>Initializing GPS...</div>}
{error && (
<div style={{ ...hudStyles.statusText, color: "#ef4444" }}>
GPS Error: {error}
</div>
)}
{!loading && !error && (
<canvas
ref={canvasRef}
width={size * 2} // Double size for retina/high-DPI screens
height={size * 2}
style={hudStyles.canvas(size)}
/>
)}
</div>
);
};
// ==========================================
// 2. 3D Handlebar Screen Mesh Component (R3F)
// ==========================================
export interface GPSBikeScreenProps {
bwMaskUrl: string;
colorMapUrl: string;
gridWidth: number;
gridHeight: number;
worldBounds: WorldBounds;
playerPos: { x: number; z: number };
destPos?: { x: number; z: number };
width?: number; // 3D Plane Width
height?: number; // 3D Plane Height
}
/**
* A Three.js 3D plane mesh that renders the GPS dynamically as a CanvasTexture.
* This can be directly attached to the bike's handlebars in your 3D world.
*/
export const GPSBikeScreen: React.FC<GPSBikeScreenProps> = ({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
playerPos,
destPos,
width = 0.4,
height = 0.4,
}) => {
// Offscreen canvas to render the GPS texture onto
const [offscreenCanvas] = useState(() => {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 512;
return canvas;
});
const textureRef = useRef<THREE.CanvasTexture | null>(null);
const gpsOptions = useMemo(
() => ({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
}),
[bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds],
);
const { calculateWorldPath, renderGPSToCanvas, loading } = useGPS(gpsOptions);
useEffect(() => {
if (loading) return;
// Calculate A* path
const path = destPos ? calculateWorldPath(playerPos, destPos) : [];
// Render path onto our offscreen canvas
renderGPSToCanvas(offscreenCanvas, path, playerPos, destPos, {
pathColor: "#60a5fa", // Bright neon blue
pathWidth: 8,
playerColor: "#ff0055", // Neon pink-red for bike
playerSize: 10,
destColor: "#00ffcc", // Vibrant cyan for target
destSize: 10,
});
// Notify Three.js that the texture needs an update
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
}, [
playerPos,
destPos,
loading,
calculateWorldPath,
renderGPSToCanvas,
offscreenCanvas,
]);
return (
<mesh castShadow receiveShadow>
<planeGeometry args={[width, height]} />
<meshBasicMaterial toneMapped={false}>
<canvasTexture
ref={textureRef}
attach="map"
image={offscreenCanvas}
minFilter={THREE.LinearFilter}
magFilter={THREE.LinearFilter}
/>
</meshBasicMaterial>
</mesh>
);
};
// ==========================================
// Styles for HUD (Premium Glassmorphism)
// ==========================================
const hudStyles = {
container: (size: number): React.CSSProperties => ({
position: "absolute",
bottom: "24px",
right: "24px",
width: `${size}px`,
height: `${size}px`,
borderRadius: "24px",
overflow: "hidden",
border: "1px solid rgba(255, 255, 255, 0.15)",
boxShadow:
"0 8px 32px 0 rgba(0, 0, 0, 0.37), 0 0 15px rgba(59, 130, 246, 0.2)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
background: "rgba(15, 23, 42, 0.6)", // Sleek dark slate
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
pointerEvents: "none",
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}),
canvas: (size: number): React.CSSProperties => ({
width: `${size}px`,
height: `${size}px`,
display: "block",
}),
statusText: {
color: "#94a3b8",
fontFamily:
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: "12px",
fontWeight: 500,
letterSpacing: "0.05em",
} as React.CSSProperties,
};
+104
View File
@@ -0,0 +1,104 @@
import type { GridNode } from "./types";
export class Grid {
public width: number;
public height: number;
private nodes: GridNode[][];
constructor(walkableMatrix: boolean[][]) {
this.height = walkableMatrix.length;
this.width = this.height > 0 ? (walkableMatrix[0]?.length ?? 0) : 0;
this.nodes = [];
for (let y = 0; y < this.height; y++) {
const row: GridNode[] = [];
const sourceRow = walkableMatrix[y];
for (let x = 0; x < this.width; x++) {
row.push({
x,
y,
walkable: sourceRow ? (sourceRow[x] ?? false) : false,
g: 0,
h: 0,
f: 0,
parent: null,
});
}
this.nodes.push(row);
}
}
public getNode(x: number, y: number): GridNode | null {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
const row = this.nodes[y];
return row ? (row[x] ?? null) : null;
}
return null;
}
/**
* Resets g, h, f values and parents for all nodes in the grid,
* preparing it for a new A* calculation.
*/
public reset(): void {
for (let y = 0; y < this.height; y++) {
const row = this.nodes[y];
if (!row) continue;
for (let x = 0; x < this.width; x++) {
const node = row[x];
if (!node) continue;
node.g = 0;
node.h = 0;
node.f = 0;
node.parent = null;
}
}
}
/**
* Retrieves neighboring nodes. Supports 8-directional movement.
*/
public getNeighbors(
node: GridNode,
allowDiagonals: boolean = true,
): GridNode[] {
const neighbors: GridNode[] = [];
const { x, y } = node;
// Relative coordinates of 8 neighbors
const directions = [
{ dx: 0, dy: -1, isDiagonal: false }, // N
{ dx: 1, dy: 0, isDiagonal: false }, // E
{ dx: 0, dy: 1, isDiagonal: false }, // S
{ dx: -1, dy: 0, isDiagonal: false }, // W
];
if (allowDiagonals) {
directions.push(
{ dx: 1, dy: -1, isDiagonal: true }, // NE
{ dx: 1, dy: 1, isDiagonal: true }, // SE
{ dx: -1, dy: 1, isDiagonal: true }, // SW
{ dx: -1, dy: -1, isDiagonal: true }, // NW
);
}
for (const dir of directions) {
const neighbor = this.getNode(x + dir.dx, y + dir.dy);
if (neighbor && neighbor.walkable) {
// Prevent corner cutting if both orthogonal neighbors are blocked
if (dir.isDiagonal) {
const ortho1 = this.getNode(x + dir.dx, y);
const ortho2 = this.getNode(x, y + dir.dy);
const isBlocked =
(!ortho1 || !ortho1.walkable) && (!ortho2 || !ortho2.walkable);
if (isBlocked) {
continue; // Skip this diagonal neighbor to avoid squeezing through corners
}
}
neighbors.push(neighbor);
}
}
return neighbors;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { Grid } from "./Grid";
/**
* Loads an image from a URL.
*/
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous"; // Enable CORS just in case
img.onload = () => resolve(img);
img.onerror = (err) =>
reject(new Error(`Failed to load image at ${url}: ${err}`));
img.src = url;
});
}
/**
* Loads a B&W image and scales it to gridWidth x gridHeight.
* Higher dimensions = higher accuracy but slower pathfinding.
* Lower dimensions = extremely fast pathfinding.
*
* Walkable roads should be white (or light gray). Non-walkable areas should be black.
*
* @param imageUrl The path or URL of the B&W navigation mask.
* @param gridWidth The target width of our A* pathfinding grid.
* @param gridHeight The target height of our A* pathfinding grid.
* @param threshold Brightness threshold (0-255) above which a pixel is considered walkable (default: 128).
*/
export async function createGridFromImage(
imageUrl: string,
gridWidth: number,
gridHeight: number,
threshold: number = 128,
): Promise<Grid> {
const img = await loadImage(imageUrl);
// Create an offscreen canvas to scale and analyze the image
const canvas = document.createElement("canvas");
canvas.width = gridWidth;
canvas.height = gridHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not get 2D context for offscreen canvas");
}
// Draw and scale the image onto the canvas
ctx.drawImage(img, 0, 0, gridWidth, gridHeight);
// Retrieve pixel data
const imgData = ctx.getImageData(0, 0, gridWidth, gridHeight);
const data = imgData.data;
// Initialize a 2D boolean matrix representing the walkable grid
const walkableMatrix: boolean[][] = [];
for (let y = 0; y < gridHeight; y++) {
const row: boolean[] = [];
for (let x = 0; x < gridWidth; x++) {
// Each pixel has 4 channels: R, G, B, A
const index = (y * gridWidth + x) * 4;
const r = data[index] ?? 0;
const g = data[index + 1] ?? 0;
const b = data[index + 2] ?? 0;
// Calculate brightness (standard grayscale weighting)
const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
// If bright enough, it is a road (walkable)
row.push(brightness >= threshold);
}
walkableMatrix.push(row);
}
return new Grid(walkableMatrix);
}
+146
View File
@@ -0,0 +1,146 @@
import type { Waypoint, WaypointNode } from "./types";
/**
* Calculates Euclidean 3D distance between two points.
*/
function getDistance3D(
posA: { x: number; y: number; z: number },
posB: { x: number; y: number; z: number },
): number {
return Math.sqrt(
Math.pow(posA.x - posB.x, 2) +
Math.pow(posA.y - posB.y, 2) +
Math.pow(posA.z - posB.z, 2),
);
}
/**
* Finds the closest Waypoint in a list to a target 3D world position.
*/
export function findClosestWaypoint(
waypoints: Waypoint[],
pos: { x: number; y: number; z: number },
): Waypoint | null {
if (waypoints.length === 0) return null;
let closest = waypoints[0]!;
let minDist = getDistance3D(closest, pos);
for (let i = 1; i < waypoints.length; i++) {
const wp = waypoints[i]!;
const dist = getDistance3D(wp, pos);
if (dist < minDist) {
minDist = dist;
closest = wp;
}
}
return closest;
}
/**
* Runs A* pathfinding on a network of 3D Waypoints.
*
* @param waypoints List of all waypoints in the road network.
* @param startWorldPos Player's current 3D world position.
* @param endWorldPos Targeted 3D world destination.
* @returns Array of Waypoints representing the path from start to end, or empty array if none found.
*/
export function findWaypointPath(
waypoints: Waypoint[],
startWorldPos: { x: number; y: number; z: number },
endWorldPos: { x: number; y: number; z: number },
): Waypoint[] {
if (waypoints.length === 0) return [];
// 1. Find the closest starting and ending waypoints in the network
const startWp = findClosestWaypoint(waypoints, startWorldPos);
const endWp = findClosestWaypoint(waypoints, endWorldPos);
if (!startWp || !endWp) return [];
if (startWp.id === endWp.id) return [startWp];
// 2. Map all waypoints to A* search nodes
const nodeMap = new Map<number, WaypointNode>();
waypoints.forEach((wp) => {
nodeMap.set(wp.id, {
...wp,
g: Infinity,
h: Infinity,
f: Infinity,
parent: null,
});
});
const startNode = nodeMap.get(startWp.id)!;
const endNode = nodeMap.get(endWp.id)!;
// 3. Initialize open and closed sets
const openSet: WaypointNode[] = [startNode];
const closedSet = new Set<number>(); // Set of waypoint IDs
startNode.g = 0;
startNode.h = getDistance3D(startNode, endNode);
startNode.f = startNode.h;
while (openSet.length > 0) {
// Find node with lowest f score
let lowIndex = 0;
for (let i = 1; i < openSet.length; i++) {
const node = openSet[i]!;
const lowNode = openSet[lowIndex]!;
if (node.f < lowNode.f) {
lowIndex = i;
}
}
const currentNode = openSet[lowIndex]!;
// Reached destination! Reconstruct the path
if (currentNode.id === endNode.id) {
const path: Waypoint[] = [];
let temp: WaypointNode | null = currentNode;
while (temp !== null) {
// Find corresponding raw Waypoint
const rawWp = waypoints.find((w) => w.id === temp!.id);
if (rawWp) {
path.push(rawWp);
}
temp = temp.parent;
}
return path.reverse();
}
// Move from open to closed set
openSet.splice(lowIndex, 1);
closedSet.add(currentNode.id);
// Process neighbors
for (const neighborId of currentNode.connections) {
if (closedSet.has(neighborId)) continue;
const neighborNode = nodeMap.get(neighborId);
if (!neighborNode) continue;
// Distance from currentNode to neighbor is physical 3D distance
const tentativeG =
currentNode.g + getDistance3D(currentNode, neighborNode);
const neighborInOpenSet = openSet.some((node) => node.id === neighborId);
if (!neighborInOpenSet || tentativeG < neighborNode.g) {
neighborNode.parent = currentNode;
neighborNode.g = tentativeG;
neighborNode.h = getDistance3D(neighborNode, endNode);
neighborNode.f = neighborNode.g + neighborNode.h;
if (!neighborInOpenSet) {
openSet.push(neighborNode);
}
}
}
}
// No path found
return [];
}
+8
View File
@@ -0,0 +1,8 @@
export * from "./types";
export * from "./Grid";
export * from "./AStar";
export * from "./ImageToGrid";
export * from "./useGPS";
export * from "./GPSMinimap";
export * from "./WaypointAStar";
export * from "./useWaypointGPS";
+39
View File
@@ -0,0 +1,39 @@
export interface Position {
x: number;
y: number;
}
export interface GridNode {
x: number;
y: number;
walkable: boolean;
g: number;
h: number;
f: number;
parent: GridNode | null;
}
export interface GridSize {
width: number;
height: number;
}
export interface Waypoint {
id: number;
x: number;
y: number;
z: number;
connections: number[];
}
export interface WaypointNode {
id: number;
x: number;
y: number;
z: number;
connections: number[];
g: number;
h: number;
f: number;
parent: WaypointNode | null;
}
+256
View File
@@ -0,0 +1,256 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Grid } from "./Grid";
import { createGridFromImage } from "./ImageToGrid";
import { findPath } from "./AStar";
import type { Position } from "./types";
export interface WorldBounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
export interface UseGPSOptions {
bwMaskUrl: string;
colorMapUrl: string;
gridWidth: number; // The "width of the array pathfinding" (resolution scaling)
gridHeight: number; // The "height of the array pathfinding"
worldBounds: WorldBounds;
allowDiagonals?: boolean;
}
export function useGPS({
bwMaskUrl,
colorMapUrl,
gridWidth,
gridHeight,
worldBounds,
allowDiagonals = true,
}: UseGPSOptions) {
const [grid, setGrid] = useState<Grid | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Cache the images so they don't reload every frame
const colorMapImgRef = useRef<HTMLImageElement | null>(null);
// Initialize the pathfinding grid
useEffect(() => {
let active = true;
setLoading(true);
setError(null);
async function initGrid() {
try {
const pathfindingGrid = await createGridFromImage(
bwMaskUrl,
gridWidth,
gridHeight,
);
// Pre-load color map image for canvas drawing
const colorMapImg = new Image();
colorMapImg.crossOrigin = "anonymous";
await new Promise((resolve, reject) => {
colorMapImg.onload = resolve;
colorMapImg.onerror = reject;
colorMapImg.src = colorMapUrl;
});
if (active) {
setGrid(pathfindingGrid);
colorMapImgRef.current = colorMapImg;
setLoading(false);
}
} catch (err: any) {
if (active) {
setError(err.message || "Failed to initialize GPS system");
setLoading(false);
}
}
}
initGrid();
return () => {
active = false;
};
}, [bwMaskUrl, colorMapUrl, gridWidth, gridHeight]);
/**
* Translates 3D World coordinates (X, Z) into 2D Grid coordinates (col, row)
*/
const worldToGrid = useCallback(
(worldX: number, worldZ: number): Position => {
const { minX, maxX, minZ, maxZ } = worldBounds;
// Calculate percentages across the bounds
const pctX = (worldX - minX) / (maxX - minX);
const pctZ = (worldZ - minZ) / (maxZ - minZ);
// Map to grid dimensions
const gridX = Math.max(
0,
Math.min(gridWidth - 1, Math.floor(pctX * gridWidth)),
);
const gridY = Math.max(
0,
Math.min(gridHeight - 1, Math.floor(pctZ * gridHeight)),
);
return { x: gridX, y: gridY };
},
[worldBounds, gridWidth, gridHeight],
);
/**
* Translates 2D Grid coordinates (col, row) back into 3D World coordinates (X, Z)
*/
const gridToWorld = useCallback(
(gridX: number, gridY: number): { x: number; z: number } => {
const { minX, maxX, minZ, maxZ } = worldBounds;
const pctX = gridX / gridWidth;
const pctZ = gridY / gridHeight;
const worldX = minX + pctX * (maxX - minX);
const worldZ = minZ + pctZ * (maxZ - minZ);
return { x: worldX, z: worldZ };
},
[worldBounds, gridWidth, gridHeight],
);
/**
* Runs the A* calculation using 3D world coordinates.
* Returns path in 3D world space.
*/
const calculateWorldPath = useCallback(
(
startWorld: { x: number; z: number },
endWorld: { x: number; z: number },
): { x: number; z: number }[] => {
if (!grid) return [];
const startGrid = worldToGrid(startWorld.x, startWorld.z);
const endGrid = worldToGrid(endWorld.x, endWorld.z);
const gridPath = findPath(grid, startGrid, endGrid, allowDiagonals);
// Convert path coordinates back to 3D space
return gridPath.map((node) => gridToWorld(node.x, node.y));
},
[grid, worldToGrid, gridToWorld, allowDiagonals],
);
/**
* Updates an HTML5 `<canvas>` element with the background color map,
* a path line, and the player/destination indicators.
*/
const renderGPSToCanvas = useCallback(
(
canvas: HTMLCanvasElement,
path: { x: number; z: number }[],
playerWorldPos?: { x: number; z: number },
destWorldPos?: { x: number; z: number },
options: {
pathColor?: string;
pathWidth?: number;
playerColor?: string;
playerSize?: number;
destColor?: string;
destSize?: number;
} = {},
) => {
const ctx = canvas.getContext("2d");
if (!ctx || !colorMapImgRef.current) return;
const {
pathColor = "#3b82f6", // Premium blue
pathWidth = 6,
playerColor = "#ef4444", // Red dot for player
playerSize = 8,
destColor = "#10b981", // Green dot for flag
destSize = 8,
} = options;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
// 1. Draw background color map
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight);
// Helper: translate world coordinates to Canvas pixels
const worldToCanvas = (wx: number, wz: number): Position => {
const { minX, maxX, minZ, maxZ } = worldBounds;
const px = ((wx - minX) / (maxX - minX)) * canvasWidth;
const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight;
return { x: px, y: py };
};
// 2. Draw A* Path Line
if (path.length > 1) {
ctx.beginPath();
const startNode = path[0]!;
const startPt = worldToCanvas(startNode.x, startNode.z);
ctx.moveTo(startPt.x, startPt.y);
for (let i = 1; i < path.length; i++) {
const node = path[i]!;
const pt = worldToCanvas(node.x, node.z);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = pathColor;
ctx.lineWidth = pathWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// Add a soft glow effect for premium feel
ctx.shadowBlur = 8;
ctx.shadowColor = pathColor;
ctx.stroke();
// Reset shadow for subsequent drawings
ctx.shadowBlur = 0;
}
// 3. Draw Destination Indicator
if (destWorldPos) {
const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z);
ctx.beginPath();
ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI);
ctx.fillStyle = destColor;
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
// 4. Draw Player Indicator
if (playerWorldPos) {
const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z);
ctx.beginPath();
ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI);
ctx.fillStyle = playerColor;
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
},
[worldBounds],
);
return {
grid,
loading,
error,
calculateWorldPath,
renderGPSToCanvas,
worldToGrid,
gridToWorld,
};
}
+215
View File
@@ -0,0 +1,215 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { findWaypointPath } from "./WaypointAStar";
import type { Waypoint } from "./types";
import type { WorldBounds } from "./useGPS";
export interface UseWaypointGPSOptions {
roadNetworkUrl: string; // URL/Path to roadNetwork.json
colorMapUrl: string; // URL/Path to color_map.png
worldBounds: WorldBounds;
}
export function useWaypointGPS({
roadNetworkUrl,
colorMapUrl,
worldBounds,
}: UseWaypointGPSOptions) {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const colorMapImgRef = useRef<HTMLImageElement | null>(null);
// Load waypoint list and background color map image
useEffect(() => {
let active = true;
setLoading(true);
setError(null);
async function initGPS() {
try {
// 1. Fetch the road network JSON
const response = await fetch(roadNetworkUrl);
if (!response.ok) {
throw new Error(`Failed to load road network from ${roadNetworkUrl}`);
}
const data: Waypoint[] = await response.json();
// 2. Pre-load the color map image
const colorMapImg = new Image();
colorMapImg.crossOrigin = "anonymous";
await new Promise((resolve, reject) => {
colorMapImg.onload = resolve;
colorMapImg.onerror = reject;
colorMapImg.src = colorMapUrl;
});
if (active) {
setWaypoints(data);
colorMapImgRef.current = colorMapImg;
setLoading(false);
}
} catch (err: any) {
if (active) {
setError(err.message || "Failed to initialize Waypoint GPS");
setLoading(false);
}
}
}
initGPS();
return () => {
active = false;
};
}, [roadNetworkUrl, colorMapUrl]);
/**
* Calculates the shortest path between start and end world points.
*/
const calculateRoute = useCallback(
(
startWorld: { x: number; y: number; z: number },
endWorld: { x: number; y: number; z: number },
): Waypoint[] => {
if (waypoints.length === 0) return [];
return findWaypointPath(waypoints, startWorld, endWorld);
},
[waypoints],
);
/**
* Renders the road network path, player position, and waypoint target onto a canvas.
*/
const renderGPSToCanvas = useCallback(
(
canvas: HTMLCanvasElement,
path: Waypoint[],
playerWorldPos?: { x: number; y: number; z: number },
destWorldPos?: { x: number; y: number; z: number },
options: {
pathColor?: string;
pathWidth?: number;
playerColor?: string;
playerSize?: number;
destColor?: string;
destSize?: number;
showAllWaypoints?: boolean; // Debug mode
} = {},
) => {
const ctx = canvas.getContext("2d");
if (!ctx || !colorMapImgRef.current) return;
const {
pathColor = "#10b981", // Premium emerald green
pathWidth = 6,
playerColor = "#ff0055", // Neon pink-red for bike
playerSize = 8,
destColor = "#00ffcc", // Neon cyan for target
destSize = 8,
showAllWaypoints = false,
} = options;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
// 1. Draw color map background
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight);
// Helper: translate world coordinates (X, Z) to Canvas pixels (x, y)
const worldToCanvas = (wx: number, wz: number) => {
const { minX, maxX, minZ, maxZ } = worldBounds;
const px = ((wx - minX) / (maxX - minX)) * canvasWidth;
const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight;
return { x: px, y: py };
};
// 2. [Debug] Draw all network connections
if (showAllWaypoints && waypoints.length > 0) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1.5;
const drawn = new Set<string>();
waypoints.forEach((wp) => {
const startPt = worldToCanvas(wp.x, wp.z);
wp.connections.forEach((connId) => {
const other = waypoints.find((w) => w.id === connId);
if (other) {
const key =
wp.id < other.id
? `${wp.id}-${other.id}`
: `${other.id}-${wp.id}`;
if (!drawn.has(key)) {
drawn.add(key);
const endPt = worldToCanvas(other.x, other.z);
ctx.beginPath();
ctx.moveTo(startPt.x, startPt.y);
ctx.lineTo(endPt.x, endPt.y);
ctx.stroke();
}
}
});
});
}
// 3. Draw calculated A* path line
if (path.length > 1) {
ctx.beginPath();
const startNode = path[0]!;
const startPt = worldToCanvas(startNode.x, startNode.z);
ctx.moveTo(startPt.x, startPt.y);
for (let i = 1; i < path.length; i++) {
const node = path[i]!;
const pt = worldToCanvas(node.x, node.z);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = pathColor;
ctx.lineWidth = pathWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// Add soft premium path glow
ctx.shadowBlur = 8;
ctx.shadowColor = pathColor;
ctx.stroke();
ctx.shadowBlur = 0; // Reset
}
// 4. Draw Destination target
if (destWorldPos) {
const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z);
ctx.beginPath();
ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI);
ctx.fillStyle = destColor;
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
// 5. Draw Player / Bike
if (playerWorldPos) {
const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z);
ctx.beginPath();
ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI);
ctx.fillStyle = playerColor;
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
}
},
[worldBounds, waypoints],
);
return {
waypoints,
loading,
error,
calculateRoute,
renderGPSToCanvas,
};
}
+16
View File
@@ -6,6 +6,8 @@ import {
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { HomePage } from "@/pages/page"; import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page"; import { EditorPage } from "@/pages/editor/page";
import { WaypointEditorPage } from "@/pages/waypoint/page";
import { BackgroundMapPage } from "@/pages/backgroundmap/page";
import { import {
DocsAnimationRoute, DocsAnimationRoute,
DocsAudioRoute, DocsAudioRoute,
@@ -44,6 +46,18 @@ const editorRoute = createRoute({
component: EditorPage, component: EditorPage,
}); });
const waypointRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/waypoint",
component: WaypointEditorPage,
});
const backgroundMapRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/backgroundmap",
component: BackgroundMapPage,
});
const docsRoute = createRoute({ const docsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/docs", path: "/docs",
@@ -80,6 +94,8 @@ const docsChildRoutes = [
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
editorRoute, editorRoute,
waypointRoute,
backgroundMapRoute,
docsRoute.addChildren(docsChildRoutes), docsRoute.addChildren(docsChildRoutes),
]); ]);
+66
View File
@@ -0,0 +1,66 @@
import { Mesh, PlaneGeometry, ShaderMaterial } from "three";
export const createNetShader = (): ShaderMaterial => {
return new ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uGridScale: { value: 15.0 },
uPincushionStrength: { value: 0.4 },
uBloomIntensity: { value: 0.3 },
uGridThickness: { value: 0.02 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform float uGridScale;
uniform float uPincushionStrength;
uniform float uBloomIntensity;
uniform float uGridThickness;
varying vec2 vUv;
vec2 applyPincushion(vec2 uv, float strength) {
vec2 center = uv - 0.5;
float dist = length(center);
float distortion = 1.0 + dist * dist * strength;
return center * distortion + 0.5;
}
float grid(vec2 uv, float scale, float thickness) {
vec2 gridUV = fract(uv * scale);
float lineX = smoothstep(thickness, thickness + 0.01, gridUV.x)
* smoothstep(1.0 - thickness, 1.0 - thickness - 0.01, gridUV.x);
float lineY = smoothstep(thickness, thickness + 0.01, gridUV.y)
* smoothstep(1.0 - thickness, 1.0 - thickness - 0.01, gridUV.y);
return lineX + lineY;
}
void main() {
vec2 uv = applyPincushion(vUv, uPincushionStrength);
float gridPattern = grid(uv, uGridScale, uGridThickness);
vec3 gridColor = vec3(1.0, 0.4, 0.7);
vec3 bgColor = vec3(0.05, 0.02, 0.05);
float bloom = gridPattern * uBloomIntensity;
vec3 col = mix(bgColor, gridColor + bloom, gridPattern);
gl_FragColor = vec4(col, 1.0);
}
`,
});
};
export const createNetMesh = (): Mesh => {
const geometry = new PlaneGeometry(2, 2);
const material = createNetShader();
return new Mesh(geometry, material);
};
+34
View File
@@ -0,0 +1,34 @@
import { ShaderMaterial, Color } from "three";
export const createUnicolorShader = (
color: Color | string | number,
): ShaderMaterial => {
return new ShaderMaterial({
uniforms: {
uColor: { value: color instanceof Color ? color : new Color(color) },
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
vNormal = normalize(normalMatrix * normal);
vPosition = (modelViewMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
float diffuse = max(dot(vNormal, lightDir), 0.0);
float ambient = 0.3;
vec3 finalColor = uColor * (ambient + diffuse * 0.7);
gl_FragColor = vec4(finalColor, 1.0);
}
`,
});
};
+123
View File
@@ -9,6 +9,7 @@ import type {
CinematicManifest, CinematicManifest,
} from "@/types/cinematics/cinematics"; } from "@/types/cinematics/cinematics";
import type { DialogueManifest } from "@/types/dialogues/dialogues"; import type { DialogueManifest } from "@/types/dialogues/dialogues";
import type { Vector3Tuple } from "@/types/three/three";
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest"; import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
@@ -16,6 +17,11 @@ import { queueDialogueById } from "@/utils/dialogues/playDialogue";
export function GameCinematics(): null { export function GameCinematics(): null {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
useEffect(() => {
setGlobalCamera(camera);
}, [camera]);
const [manifest, setManifest] = useState<CinematicManifest | null>(null); const [manifest, setManifest] = useState<CinematicManifest | null>(null);
const [dialogueManifest, setDialogueManifest] = const [dialogueManifest, setDialogueManifest] =
useState<DialogueManifest | null>(null); useState<DialogueManifest | null>(null);
@@ -171,3 +177,120 @@ function playCinematic(
timelineRef.current = timeline; timelineRef.current = timeline;
} }
let cameraTransitionTimeline: gsap.core.Timeline | null = null;
let globalCamera: THREE.Camera | null = null;
export function setGlobalCamera(camera: THREE.Camera | null): void {
globalCamera = camera;
}
export function animateCameraTransition(
targetPosition: Vector3Tuple,
targetLookAt: Vector3Tuple,
duration: number = 1,
onComplete?: () => void,
): void {
if (!globalCamera) {
logger.warn("GameCinematics", "Camera not found for transition");
onComplete?.();
return;
}
const camera = globalCamera;
cameraTransitionTimeline?.kill();
useGameStore.getState().setCinematicPlaying(true);
const target = new THREE.Vector3(...targetLookAt);
cameraTransitionTimeline = gsap.timeline({
onUpdate: () => camera.lookAt(target),
onComplete: () => {
cameraTransitionTimeline = null;
useGameStore.getState().setCinematicPlaying(false);
onComplete?.();
},
});
cameraTransitionTimeline.to(camera.position, {
x: targetPosition[0],
y: targetPosition[1],
z: targetPosition[2],
duration,
ease: "power2.inOut",
});
cameraTransitionTimeline.to(
target,
{
x: targetLookAt[0],
y: targetLookAt[1],
z: targetLookAt[2],
duration,
ease: "power2.inOut",
},
0,
);
}
export function animateCameraTransformTransition(
targetPosition: Vector3Tuple,
targetRotation: Vector3Tuple,
duration: number = 1,
onComplete?: () => void,
): void {
if (!globalCamera) {
logger.warn("GameCinematics", "Camera not found for transition");
onComplete?.();
return;
}
const camera = globalCamera;
cameraTransitionTimeline?.kill();
useGameStore.getState().setCinematicPlaying(true);
// Convert target rotation in degrees to quaternion
const targetEuler = new THREE.Euler(
THREE.MathUtils.degToRad(targetRotation[0]),
THREE.MathUtils.degToRad(targetRotation[1]),
THREE.MathUtils.degToRad(targetRotation[2]),
"YXZ",
);
const startQuaternion = camera.quaternion.clone();
const endQuaternion = new THREE.Quaternion().setFromEuler(targetEuler);
const transitionObj = { progress: 0 };
cameraTransitionTimeline = gsap.timeline({
onUpdate: () => {
camera.quaternion
.copy(startQuaternion)
.slerp(endQuaternion, transitionObj.progress);
},
onComplete: () => {
cameraTransitionTimeline = null;
useGameStore.getState().setCinematicPlaying(false);
onComplete?.();
},
});
cameraTransitionTimeline.to(camera.position, {
x: targetPosition[0],
y: targetPosition[1],
z: targetPosition[2],
duration,
ease: "power2.inOut",
});
cameraTransitionTimeline.to(
transitionObj,
{
progress: 1,
duration,
ease: "power2.inOut",
},
0,
);
}
+2
View File
@@ -1,3 +1,4 @@
import { Ebike } from "@/components/ebike/Ebike";
import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { import {
@@ -80,6 +81,7 @@ export function GameStageContent(): React.JSX.Element {
return ( return (
<> <>
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null} {mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
<Ebike position={[0, 10, 0]} />
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => { {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors); const position = getRepairMissionPosition(mission, anchors);
if (!position) return null; if (!position) return null;
+130 -3
View File
@@ -1,11 +1,13 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Component, useRef } from "react"; import { Component, useRef, useState, useEffect } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { Line } from "@react-three/drei";
import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel"; import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
import { import {
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS, TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
TEST_SCENE_FLOOR_POSITION, TEST_SCENE_FLOOR_POSITION,
@@ -15,9 +17,9 @@ import {
TEST_SCENE_GRABBABLE_METALNESS, TEST_SCENE_GRABBABLE_METALNESS,
TEST_SCENE_GRABBABLE_POSITION, TEST_SCENE_GRABBABLE_POSITION,
TEST_SCENE_GRABBABLE_ROUGHNESS, TEST_SCENE_GRABBABLE_ROUGHNESS,
GAME_REPAIR_ZONES,
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS, TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS, TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
TEST_SCENE_REPAIR_ZONES,
TEST_SCENE_TRIGGER_COLOR, TEST_SCENE_TRIGGER_COLOR,
TEST_SCENE_TRIGGER_METALNESS, TEST_SCENE_TRIGGER_METALNESS,
TEST_SCENE_TRIGGER_POSITION, TEST_SCENE_TRIGGER_POSITION,
@@ -84,11 +86,61 @@ class ModelPreviewErrorBoundary extends Component<
} }
} }
interface Waypoint {
id: number;
x: number;
y: number;
z: number;
connections: number[];
}
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
const floorRef = useRef<THREE.Group>(null); const floorRef = useRef<THREE.Group>(null);
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
useOctreeGraphNode(floorRef, onOctreeReady); useOctreeGraphNode(floorRef, onOctreeReady);
// Load waypoints with double-safe fallback
useEffect(() => {
// 1. Try localStorage
const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
console.log(
`[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`,
);
setWaypoints(parsed);
return;
}
} catch (e) {
console.error("Failed to parse local storage waypoints", e);
}
}
// 2. Try public/roadNetwork.json
console.log(
"[TestMap] Tentative de chargement depuis /roadNetwork.json...",
);
fetch("/roadNetwork.json")
.then((res) => {
if (res.ok) return res.json();
throw new Error("Impossible de charger /roadNetwork.json");
})
.then((data) => {
if (Array.isArray(data)) {
console.log(
`[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`,
);
setWaypoints(data);
}
})
.catch((err) => {
console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err);
});
}, []);
return ( return (
<> <>
<group ref={floorRef}> <group ref={floorRef}>
@@ -98,6 +150,45 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
</mesh> </mesh>
</group> </group>
{/* Render Pathfinder Maps Waypoints & Routes visually */}
<group name="pathfinder-maps-visuals">
{/* Render Connection Lines */}
{waypoints.flatMap((wp) =>
wp.connections.map((connId) => {
const other = waypoints.find((w) => w.id === connId);
// Draw each line only once by enforcing wp.id < other.id
if (other && wp.id < other.id) {
return (
<Line
key={`route-${wp.id}-${other.id}`}
points={[
[wp.x, wp.y + 0.3, wp.z],
[other.x, other.y + 0.3, other.z],
]}
color="#10b981" // Beautiful emerald green
lineWidth={2.5}
transparent
opacity={0.8}
/>
);
}
return null;
}),
)}
{/* Render Waypoint Spheres */}
{waypoints.map((wp) => (
<mesh key={`wp-sphere-${wp.id}`} position={[wp.x, wp.y + 0.3, wp.z]}>
<sphereGeometry args={[0.35, 16, 16]} />
<meshBasicMaterial
color="#059669" // Deep emerald green
transparent
opacity={0.8}
/>
</mesh>
))}
</group>
<Physics> <Physics>
<RigidBody type="fixed"> <RigidBody type="fixed">
<CuboidCollider <CuboidCollider
@@ -141,7 +232,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
</mesh> </mesh>
</TriggerObject> </TriggerObject>
{TEST_SCENE_REPAIR_ZONES.map((zone) => ( {GAME_REPAIR_ZONES.map((zone) => (
<group key={zone.mission}> <group key={zone.mission}>
<group position={zone.position}> <group position={zone.position}>
<RepairPlaygroundZoneMarker color={zone.color} /> <RepairPlaygroundZoneMarker color={zone.color} />
@@ -151,6 +242,42 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
))} ))}
</Physics> </Physics>
{/* Dynamic Futuristic 3D GPS Dashboard Preview */}
<group position={[0, 2.8, -4.8]} rotation={[0, 0, 0]}>
{/* Futuristic glowing screen frame (commented out to show true 3D transparency!) */}
{/*
<mesh>
<boxGeometry args={[4.2, 4.2, 0.1]} />
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} transparent opacity={0.4} />
</mesh>
*/}
{/* Glow accent border (commented out to remove any orange transparency tint!) */}
{/*
<mesh position={[0, 0, 0.01]}>
<boxGeometry args={[4.05, 4.05, 0.02]} />
<meshBasicMaterial color="#f97316" transparent opacity={0.1} />
</mesh>
*/}
{/* GPS Map screen plane */}
<group position={[0, 0, 0.06]}>
<EbikeGPSMap
width={4}
height={4}
startPos={{ x: 10, y: 0, z: -10 }}
destPos={{ x: -40, y: 0, z: 30 }}
mapImageUrl="/assets/gps/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={1}
canvasSize={900}
/>
</group>
</group>
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}> <ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
<AnimatedModel <AnimatedModel
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH} modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
+7 -1
View File
@@ -1,12 +1,18 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
import { PointerLockControls } from "@react-three/drei"; import { PointerLockControls } from "@react-three/drei";
import { setGlobalCamera } from "@/world/GameCinematics";
export function PlayerCamera(): React.JSX.Element { export function PlayerCamera(): React.JSX.Element {
const camera = useThree((state) => state.camera);
useEffect(() => { useEffect(() => {
setGlobalCamera(camera);
return () => { return () => {
setGlobalCamera(null);
document.exitPointerLock(); document.exitPointerLock();
}; };
}, []); }, [camera]);
return <PointerLockControls />; return <PointerLockControls />;
} }
+155 -7
View File
@@ -21,7 +21,6 @@ import {
PLAYER_GRAVITY, PLAYER_GRAVITY,
PLAYER_JUMP_SPEED, PLAYER_JUMP_SPEED,
PLAYER_MAX_DELTA, PLAYER_MAX_DELTA,
PLAYER_WALK_SPEED,
PLAYER_XZ_DAMPING_FACTOR, PLAYER_XZ_DAMPING_FACTOR,
} from "@/data/player/playerConfig"; } from "@/data/player/playerConfig";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked"; import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
@@ -30,6 +29,7 @@ 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";
type Keys = { type Keys = {
forward: boolean; forward: boolean;
@@ -136,9 +136,74 @@ export function PlayerController({
const wantsJump = useRef(false); const wantsJump = useRef(false);
const initializedRef = useRef(false); const initializedRef = useRef(false);
const canMove = useGameStore((state) => state.missionFlow.canMove); const canMove = useGameStore((state) => state.missionFlow.canMove);
const currentSpeed = useGameStore((state) => state.player.currentSpeed);
const movementMode = useGameStore((state) => state.player.movementMode);
const movementModeRef = useRef(movementMode);
const prevMovementModeRef = useRef(movementMode);
const ebikeAngle = useRef(0);
const capsule = useRef(createSpawnCapsule(spawnPosition)); const capsule = useRef(createSpawnCapsule(spawnPosition));
useEffect(() => {
movementModeRef.current = movementMode;
}, [movementMode]);
useEffect(() => {
if (movementMode === "ebike") {
const targetPos: Vector3Tuple = (window as any).ebikeParkedPosition || [
0, 8.2, 0,
];
const targetRot: number = (window as any).ebikeParkedRotation || 0;
const headY = targetPos[1] + PLAYER_EYE_HEIGHT;
const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS;
capsule.current.start.set(targetPos[0], bottomY, targetPos[2]);
capsule.current.end.set(targetPos[0], headY, targetPos[2]);
velocity.current.set(0, 0, 0);
onFloor.current = false;
wantsJump.current = false;
ebikeAngle.current = targetRot;
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
cameraOffset.applyAxisAngle(_up, targetRot);
const camPos = new THREE.Vector3()
.copy(capsule.current.end)
.add(cameraOffset);
camera.position.copy(camPos);
const pitchRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[0],
);
const yawRad =
THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) +
targetRot;
const rollRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[2],
);
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
} else if (
movementMode === "walk" &&
prevMovementModeRef.current === "ebike"
) {
const perspectiveCam = camera as THREE.PerspectiveCamera;
perspectiveCam.fov = 60;
perspectiveCam.updateProjectionMatrix();
const rightDir = new THREE.Vector3();
camera.getWorldDirection(_forward);
_forward.setY(0).normalize();
rightDir.crossVectors(_forward, _up).normalize();
const shift = rightDir.multiplyScalar(3);
capsule.current.translate(shift);
camera.position.copy(capsule.current.end);
}
prevMovementModeRef.current = movementMode;
}, [movementMode, camera]);
useLayoutEffect(() => { useLayoutEffect(() => {
resetPlayerCapsule( resetPlayerCapsule(
capsule.current, capsule.current,
@@ -266,6 +331,16 @@ export function PlayerController({
return; return;
} }
if (movementModeRef.current === "ebike") {
const turnSpeed = 1.8;
if (keys.current.left) {
ebikeAngle.current += turnSpeed * dt;
}
if (keys.current.right) {
ebikeAngle.current -= turnSpeed * dt;
}
}
camera.getWorldDirection(_forward); camera.getWorldDirection(_forward);
_forward.setY(0); _forward.setY(0);
if (_forward.lengthSq() > 0) { if (_forward.lengthSq() > 0) {
@@ -277,14 +352,16 @@ export function PlayerController({
if (!movementLocked) { if (!movementLocked) {
if (keys.current.forward) _wishDir.add(_forward); if (keys.current.forward) _wishDir.add(_forward);
if (keys.current.backward) _wishDir.sub(_forward); if (keys.current.backward) _wishDir.sub(_forward);
if (keys.current.left) _wishDir.sub(_right); if (movementModeRef.current !== "ebike") {
if (keys.current.right) _wishDir.add(_right); if (keys.current.left) _wishDir.sub(_right);
if (keys.current.right) _wishDir.add(_right);
}
} }
if (_wishDir.lengthSq() > 0) _wishDir.normalize(); if (_wishDir.lengthSq() > 0) _wishDir.normalize();
const accel = onFloor.current const accel = onFloor.current
? PLAYER_WALK_SPEED ? currentSpeed
: PLAYER_WALK_SPEED * PLAYER_AIR_CONTROL_FACTOR; : currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
velocity.current.x += velocity.current.x +=
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER; _wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
velocity.current.z += velocity.current.z +=
@@ -353,7 +430,78 @@ export function PlayerController({
} }
} }
camera.position.copy(capsule.current.end); if (movementModeRef.current === "ebike") {
let targetSteer = 0;
if (keys.current.left) targetSteer = 1;
else if (keys.current.right) targetSteer = -1;
const currentSteer = (window as any).ebikeSteerFactor || 0;
const steerFactor = THREE.MathUtils.lerp(
currentSteer,
targetSteer,
8 * dt,
);
(window as any).ebikeSteerFactor = steerFactor;
const speed = velocity.current.length();
const targetFov = 60 + Math.min(speed * 0.35, 9);
const perspectiveCam = camera as THREE.PerspectiveCamera;
perspectiveCam.fov = THREE.MathUtils.lerp(
perspectiveCam.fov,
targetFov,
6 * dt,
);
perspectiveCam.updateProjectionMatrix();
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
const swingX = -Math.abs(steerFactor) * 1.5;
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
cameraOffset.add(cameraSwing);
const targetCamPos = new THREE.Vector3()
.copy(capsule.current.end)
.add(cameraOffset);
camera.position.lerp(targetCamPos, 12 * dt);
const pitchRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[0],
);
const yawRad =
THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) +
ebikeAngle.current;
const rollRad = THREE.MathUtils.degToRad(
EBIKE_CAMERA_TRANSFORM.rotation[2],
);
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
const ebikeVisual = (window as any).ebikeVisualGroup?.current;
if (ebikeVisual) {
ebikeVisual.position.set(
capsule.current.end.x,
capsule.current.end.y - PLAYER_EYE_HEIGHT,
capsule.current.end.z,
);
const leanAngle = steerFactor * 0.26;
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
}
} else {
camera.position.copy(capsule.current.end);
}
(window as any).playerPos = [
capsule.current.end.x,
capsule.current.end.y,
capsule.current.end.z,
];
(window as any).ebikeAngle = ebikeAngle.current;
}); });
return null; return null;