merge develop into feat/map-environment
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user