4 Commits

Author SHA1 Message Date
math-pixel a397febd52 fix zoom
🔍 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
2026-05-27 17:23:06 +02:00
math-pixel c15cad2ab0 fix zoom 2026-05-27 17:22:14 +02:00
math-pixel 011e7815a2 update gps 2026-05-27 17:15:08 +02:00
math-pixel 970253801a add map on bike 2026-05-22 18:28:05 +02:00
6 changed files with 154 additions and 26 deletions
+51 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
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";
@@ -39,8 +40,31 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
});
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 "bike":
return { x: 8, y: 0, z: -6 };
case "pylone":
return { x: 64, y: 0, z: -66 };
case "ferme":
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,
@@ -90,6 +114,13 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
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);
@@ -204,7 +235,26 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
<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="/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
</group>
</group>
{debugRef.current.showCameraPoints && (
<>
<mesh position={camPointPos}>
+96 -22
View File
@@ -2,18 +2,36 @@ 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 };
/**
* 3D world position of the destination/target (GPS end point)
*/
destPos?: { x: number; y: number; z: number };
startPos?: { x: number; y: number; z: number } | undefined;
destPos?: { x: number; y: number; z: number } | undefined;
/**
* Optional custom URL to the map background texture.
@@ -41,6 +59,26 @@ export interface EbikeGPSMapProps {
* 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;
}
/**
@@ -56,6 +94,9 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
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);
@@ -63,11 +104,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Offscreen high-res canvas for crystal clear rendering
const [offscreenCanvas] = useState(() => {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 1024;
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);
@@ -121,8 +171,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
img.src = mapImageUrl;
}, [mapImageUrl]);
// Determine grid boundaries
const bounds = useMemo(() => {
// Determine grid boundaries (before zoom)
const baseBounds = useMemo(() => {
if (worldBounds) return worldBounds;
if (waypoints.length === 0) {
@@ -148,6 +198,24 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
};
}, [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;
@@ -187,7 +255,13 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// 1. Draw Map Background (Image or premium blueprint vectors)
if (mapImage) {
ctx.drawImage(mapImage, 0, 0, size, size);
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!)
@@ -235,10 +309,10 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
if (activePath.length > 1) {
// Pass 1: Wide transparent orange bloom
ctx.beginPath();
let pt = worldToCanvas(activePath[0].x, activePath[0].z, size);
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);
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
@@ -251,10 +325,10 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Pass 2: Saturated glow core
ctx.beginPath();
pt = worldToCanvas(activePath[0].x, activePath[0].z, size);
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);
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = '#f97316'; // Vibrant orange
@@ -265,10 +339,10 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// Pass 3: High-intensity white core
ctx.beginPath();
pt = worldToCanvas(activePath[0].x, activePath[0].z, size);
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);
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = '#fff7ed'; // Cream white
@@ -280,8 +354,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
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 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;
@@ -290,7 +364,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
if (totalLen > 0) {
const targetLen = totalLen * animTimeRef.current;
let currentLen = 0;
let dotPt = segments[0].start;
let dotPt = segments[0]!.start;
for (const seg of segments) {
if (currentLen + seg.len >= targetLen) {
@@ -378,9 +452,9 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
}, [waypoints, startPos, destPos, bounds, mapImage]);
return (
<mesh castShadow receiveShadow>
<mesh castShadow receiveShadow position={position as any}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial toneMapped={false} transparent={true} opacity={1} depthWrite={false}>
<meshBasicMaterial toneMapped={false} transparent={true} opacity={1} depthWrite={false} side={THREE.DoubleSide}>
<canvasTexture
ref={textureRef}
attach="map"
+1 -1
View File
@@ -42,7 +42,7 @@ function CameraManager({
boundsTextRef
}: {
autoBounds: any,
boundsTextRef: React.RefObject<HTMLPreElement>
boundsTextRef: React.RefObject<HTMLPreElement | null>
}) {
const { camera, gl, scene } = useThree();
const controlsRef = useRef<any>(null);
+3 -2
View File
@@ -63,6 +63,7 @@ const EditorScene: React.FC<EditorSceneProps> = ({
const { raycaster, pointer, camera } = useThree();
const groupRef = useRef<THREE.Group>(null);
const rubberLineRef = useRef<THREE.Line>(null);
const rubberLineInstance = React.useMemo(() => new THREE.Line(), []);
// Mirror reactive props inside Refs to guarantee useFrame loop never closes over stale state
const hoveredNodeIdRef = useRef<number | null>(null);
@@ -151,7 +152,7 @@ const EditorScene: React.FC<EditorSceneProps> = ({
/>
{/* 2. Drag Rubber Band Preview Line (WebGL optimized) */}
<line ref={rubberLineRef} visible={false}>
<primitive object={rubberLineInstance} ref={rubberLineRef} visible={false}>
<bufferGeometry attach="geometry" />
<lineBasicMaterial
attach="material"
@@ -161,7 +162,7 @@ const EditorScene: React.FC<EditorSceneProps> = ({
transparent
opacity={0.9}
/>
</line>
</primitive>
{/* 3. Render Established Connections */}
<ConnectionLines
+1
View File
@@ -29,6 +29,7 @@ import { GameStageContent } from "@/world/GameStageContent";
import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
interface WorldProps {
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
+2
View File
@@ -266,6 +266,8 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
"minZ": -142,
"maxZ": 138
}}
zoom={1}
canvasSize={900}
/>
</group>
</group>