first implementation of pathfinding
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user