first implementation of pathfinding

This commit is contained in:
math-pixel
2026-05-20 14:34:26 +02:00
parent 4faa226326
commit 54a353de03
12 changed files with 3840 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
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;
let 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 [];
}
+207
View File
@@ -0,0 +1,207 @@
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,
};
+100
View File
@@ -0,0 +1,100 @@
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;
}
}
+75
View File
@@ -0,0 +1,75 @@
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);
}
+145
View File
@@ -0,0 +1,145 @@
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);
let 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 [];
}
+10
View File
@@ -0,0 +1,10 @@
export * from './types';
export * from './Grid';
export * from './AStar';
export * from './ImageToGrid';
export * from './useGPS';
export * from './GPSMinimap';
export * from './WaypointAStar';
export * from './useWaypointGPS';
+40
View File
@@ -0,0 +1,40 @@
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;
}
+243
View File
@@ -0,0 +1,243 @@
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,
};
}
+212
View File
@@ -0,0 +1,212 @@
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,
};
}