feat(pathfinding): 添加寻路系统模块 (#333)
* feat(pathfinding): 添加寻路系统模块 实现完整的寻路系统,支持 A* 算法、网格地图、导航网格和路径平滑: A* 寻路算法: - 高效的二叉堆优先队列 - 可配置的启发式权重 - 最大搜索节点限制 - 支持对角移动和穿角避免 网格地图 (GridMap): - 基于二维数组的网格地图 - 支持 4 方向和 8 方向移动 - 可变移动代价 - 从数组或字符串加载地图 导航网格 (NavMesh): - 凸多边形导航网格 - 自动检测相邻多边形 - 漏斗算法路径优化 - 适合复杂地形 路径平滑: - Bresenham 视线检测 - 射线投射视线检测 - 视线简化器 (移除不必要的拐点) - Catmull-Rom 曲线平滑 - 组合平滑器 启发式函数: - 曼哈顿距离 (4方向) - 欧几里得距离 (任意方向) - 切比雪夫距离 (8方向) - 八角距离 (8方向,对角线√2) 蓝图节点 (8个): - FindPath: 基础寻路 - FindPathSmooth: 平滑寻路 - IsWalkable: 检查可通行性 - GetPathLength: 获取路径点数 - GetPathDistance: 获取路径距离 - GetPathPoint: 获取路径点 - MoveAlongPath: 沿路径移动 - HasLineOfSight: 视线检测 * chore: update pnpm-lock.yaml for pathfinding package
This commit is contained in:
24
packages/pathfinding/module.json
Normal file
24
packages/pathfinding/module.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"id": "pathfinding",
|
||||||
|
"name": "@esengine/pathfinding",
|
||||||
|
"globalKey": "pathfinding",
|
||||||
|
"displayName": "Pathfinding",
|
||||||
|
"description": "寻路系统,提供 A*、网格地图、导航网格和路径平滑 | Pathfinding system with A*, grid map, NavMesh and path smoothing",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "AI",
|
||||||
|
"icon": "Route",
|
||||||
|
"tags": ["pathfinding", "navigation", "astar", "navmesh", "ai"],
|
||||||
|
"isCore": false,
|
||||||
|
"defaultEnabled": true,
|
||||||
|
"isEngineModule": true,
|
||||||
|
"canContainContent": false,
|
||||||
|
"platforms": ["web", "desktop", "mobile"],
|
||||||
|
"dependencies": ["core", "math", "blueprint"],
|
||||||
|
"exports": {
|
||||||
|
"components": ["PathfindingAgentComponent"],
|
||||||
|
"systems": ["PathfindingSystem"]
|
||||||
|
},
|
||||||
|
"requiresWasm": false,
|
||||||
|
"outputPath": "dist/index.js",
|
||||||
|
"pluginExport": "PathfindingPlugin"
|
||||||
|
}
|
||||||
37
packages/pathfinding/package.json
Normal file
37
packages/pathfinding/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/pathfinding",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"module.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"build:watch": "tsup --watch",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
|
"@esengine/ecs-framework-math": "workspace:*",
|
||||||
|
"@esengine/blueprint": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
235
packages/pathfinding/src/core/AStarPathfinder.ts
Normal file
235
packages/pathfinding/src/core/AStarPathfinder.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @zh A* 寻路算法实现
|
||||||
|
* @en A* Pathfinding Algorithm Implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BinaryHeap } from './BinaryHeap';
|
||||||
|
import type {
|
||||||
|
IPathfindingMap,
|
||||||
|
IPathfinder,
|
||||||
|
IPathResult,
|
||||||
|
IPathfindingOptions,
|
||||||
|
IPathNode,
|
||||||
|
IPoint
|
||||||
|
} from './IPathfinding';
|
||||||
|
import { EMPTY_PATH_RESULT, DEFAULT_PATHFINDING_OPTIONS } from './IPathfinding';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 内部节点类型 | Internal Node Type
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface AStarNode {
|
||||||
|
node: IPathNode;
|
||||||
|
g: number; // Cost from start
|
||||||
|
h: number; // Heuristic to end
|
||||||
|
f: number; // Total cost (g + h)
|
||||||
|
parent: AStarNode | null;
|
||||||
|
closed: boolean;
|
||||||
|
opened: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// A* 寻路器 | A* Pathfinder
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh A* 寻路器
|
||||||
|
* @en A* Pathfinder
|
||||||
|
*
|
||||||
|
* @zh 使用 A* 算法在地图上查找最短路径
|
||||||
|
* @en Uses A* algorithm to find shortest path on a map
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const map = new GridMap(width, height);
|
||||||
|
* const pathfinder = new AStarPathfinder(map);
|
||||||
|
* const result = pathfinder.findPath(0, 0, 10, 10);
|
||||||
|
* if (result.found) {
|
||||||
|
* console.log('Path:', result.path);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class AStarPathfinder implements IPathfinder {
|
||||||
|
private readonly map: IPathfindingMap;
|
||||||
|
private nodeCache: Map<string | number, AStarNode> = new Map();
|
||||||
|
private openList: BinaryHeap<AStarNode>;
|
||||||
|
|
||||||
|
constructor(map: IPathfindingMap) {
|
||||||
|
this.map = map;
|
||||||
|
this.openList = new BinaryHeap<AStarNode>((a, b) => a.f - b.f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 查找路径
|
||||||
|
* @en Find path
|
||||||
|
*/
|
||||||
|
findPath(
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
endX: number,
|
||||||
|
endY: number,
|
||||||
|
options?: IPathfindingOptions
|
||||||
|
): IPathResult {
|
||||||
|
const opts = { ...DEFAULT_PATHFINDING_OPTIONS, ...options };
|
||||||
|
|
||||||
|
// Clear previous state
|
||||||
|
this.clear();
|
||||||
|
|
||||||
|
// Get start and end nodes
|
||||||
|
const startNode = this.map.getNodeAt(startX, startY);
|
||||||
|
const endNode = this.map.getNodeAt(endX, endY);
|
||||||
|
|
||||||
|
// Validate nodes
|
||||||
|
if (!startNode || !endNode) {
|
||||||
|
return EMPTY_PATH_RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startNode.walkable || !endNode.walkable) {
|
||||||
|
return EMPTY_PATH_RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same position
|
||||||
|
if (startNode.id === endNode.id) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: [startNode.position],
|
||||||
|
cost: 0,
|
||||||
|
nodesSearched: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize start node
|
||||||
|
const start = this.getOrCreateAStarNode(startNode);
|
||||||
|
start.g = 0;
|
||||||
|
start.h = this.map.heuristic(startNode.position, endNode.position) * opts.heuristicWeight;
|
||||||
|
start.f = start.h;
|
||||||
|
start.opened = true;
|
||||||
|
|
||||||
|
this.openList.push(start);
|
||||||
|
|
||||||
|
let nodesSearched = 0;
|
||||||
|
const endPosition = endNode.position;
|
||||||
|
|
||||||
|
// A* main loop
|
||||||
|
while (!this.openList.isEmpty && nodesSearched < opts.maxNodes) {
|
||||||
|
const current = this.openList.pop()!;
|
||||||
|
current.closed = true;
|
||||||
|
nodesSearched++;
|
||||||
|
|
||||||
|
// Found the goal
|
||||||
|
if (current.node.id === endNode.id) {
|
||||||
|
return this.buildPath(current, nodesSearched);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explore neighbors
|
||||||
|
const neighbors = this.map.getNeighbors(current.node);
|
||||||
|
|
||||||
|
for (const neighborNode of neighbors) {
|
||||||
|
// Skip if not walkable or already closed
|
||||||
|
if (!neighborNode.walkable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const neighbor = this.getOrCreateAStarNode(neighborNode);
|
||||||
|
|
||||||
|
if (neighbor.closed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate tentative g score
|
||||||
|
const movementCost = this.map.getMovementCost(current.node, neighborNode);
|
||||||
|
const tentativeG = current.g + movementCost;
|
||||||
|
|
||||||
|
// Check if this path is better
|
||||||
|
if (!neighbor.opened) {
|
||||||
|
// First time visiting this node
|
||||||
|
neighbor.g = tentativeG;
|
||||||
|
neighbor.h = this.map.heuristic(neighborNode.position, endPosition) * opts.heuristicWeight;
|
||||||
|
neighbor.f = neighbor.g + neighbor.h;
|
||||||
|
neighbor.parent = current;
|
||||||
|
neighbor.opened = true;
|
||||||
|
this.openList.push(neighbor);
|
||||||
|
} else if (tentativeG < neighbor.g) {
|
||||||
|
// Found a better path
|
||||||
|
neighbor.g = tentativeG;
|
||||||
|
neighbor.f = neighbor.g + neighbor.h;
|
||||||
|
neighbor.parent = current;
|
||||||
|
this.openList.update(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No path found
|
||||||
|
return {
|
||||||
|
found: false,
|
||||||
|
path: [],
|
||||||
|
cost: 0,
|
||||||
|
nodesSearched
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清理状态
|
||||||
|
* @en Clear state
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.nodeCache.clear();
|
||||||
|
this.openList.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取或创建 A* 节点
|
||||||
|
* @en Get or create A* node
|
||||||
|
*/
|
||||||
|
private getOrCreateAStarNode(node: IPathNode): AStarNode {
|
||||||
|
let astarNode = this.nodeCache.get(node.id);
|
||||||
|
|
||||||
|
if (!astarNode) {
|
||||||
|
astarNode = {
|
||||||
|
node,
|
||||||
|
g: Infinity,
|
||||||
|
h: 0,
|
||||||
|
f: Infinity,
|
||||||
|
parent: null,
|
||||||
|
closed: false,
|
||||||
|
opened: false
|
||||||
|
};
|
||||||
|
this.nodeCache.set(node.id, astarNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return astarNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 构建路径结果
|
||||||
|
* @en Build path result
|
||||||
|
*/
|
||||||
|
private buildPath(endNode: AStarNode, nodesSearched: number): IPathResult {
|
||||||
|
const path: IPoint[] = [];
|
||||||
|
let current: AStarNode | null = endNode;
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
path.unshift(current.node.position);
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path,
|
||||||
|
cost: endNode.g,
|
||||||
|
nodesSearched
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工厂函数 | Factory Function
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 A* 寻路器
|
||||||
|
* @en Create A* pathfinder
|
||||||
|
*/
|
||||||
|
export function createAStarPathfinder(map: IPathfindingMap): AStarPathfinder {
|
||||||
|
return new AStarPathfinder(map);
|
||||||
|
}
|
||||||
155
packages/pathfinding/src/core/BinaryHeap.ts
Normal file
155
packages/pathfinding/src/core/BinaryHeap.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* @zh 二叉堆(优先队列)
|
||||||
|
* @en Binary Heap (Priority Queue)
|
||||||
|
*
|
||||||
|
* @zh 用于 A* 算法的高效开放列表
|
||||||
|
* @en Efficient open list for A* algorithm
|
||||||
|
*/
|
||||||
|
export class BinaryHeap<T> {
|
||||||
|
private heap: T[] = [];
|
||||||
|
private readonly compare: (a: T, b: T) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建二叉堆
|
||||||
|
* @en Create binary heap
|
||||||
|
*
|
||||||
|
* @param compare - @zh 比较函数,返回负数表示 a < b @en Compare function, returns negative if a < b
|
||||||
|
*/
|
||||||
|
constructor(compare: (a: T, b: T) => number) {
|
||||||
|
this.compare = compare;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 堆大小
|
||||||
|
* @en Heap size
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return this.heap.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否为空
|
||||||
|
* @en Is empty
|
||||||
|
*/
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return this.heap.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 插入元素
|
||||||
|
* @en Push element
|
||||||
|
*/
|
||||||
|
push(item: T): void {
|
||||||
|
this.heap.push(item);
|
||||||
|
this.bubbleUp(this.heap.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 弹出最小元素
|
||||||
|
* @en Pop minimum element
|
||||||
|
*/
|
||||||
|
pop(): T | undefined {
|
||||||
|
if (this.heap.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.heap[0];
|
||||||
|
const last = this.heap.pop()!;
|
||||||
|
|
||||||
|
if (this.heap.length > 0) {
|
||||||
|
this.heap[0] = last;
|
||||||
|
this.sinkDown(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 查看最小元素(不移除)
|
||||||
|
* @en Peek minimum element (without removing)
|
||||||
|
*/
|
||||||
|
peek(): T | undefined {
|
||||||
|
return this.heap[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 更新元素(重新排序)
|
||||||
|
* @en Update element (re-sort)
|
||||||
|
*/
|
||||||
|
update(item: T): void {
|
||||||
|
const index = this.heap.indexOf(item);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.bubbleUp(index);
|
||||||
|
this.sinkDown(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否包含元素
|
||||||
|
* @en Check if contains element
|
||||||
|
*/
|
||||||
|
contains(item: T): boolean {
|
||||||
|
return this.heap.indexOf(item) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清空堆
|
||||||
|
* @en Clear heap
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.heap.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 上浮操作
|
||||||
|
* @en Bubble up operation
|
||||||
|
*/
|
||||||
|
private bubbleUp(index: number): void {
|
||||||
|
const item = this.heap[index];
|
||||||
|
|
||||||
|
while (index > 0) {
|
||||||
|
const parentIndex = Math.floor((index - 1) / 2);
|
||||||
|
const parent = this.heap[parentIndex];
|
||||||
|
|
||||||
|
if (this.compare(item, parent) >= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.heap[index] = parent;
|
||||||
|
index = parentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.heap[index] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 下沉操作
|
||||||
|
* @en Sink down operation
|
||||||
|
*/
|
||||||
|
private sinkDown(index: number): void {
|
||||||
|
const length = this.heap.length;
|
||||||
|
const item = this.heap[index];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const leftIndex = 2 * index + 1;
|
||||||
|
const rightIndex = 2 * index + 2;
|
||||||
|
let smallest = index;
|
||||||
|
|
||||||
|
if (leftIndex < length && this.compare(this.heap[leftIndex], this.heap[smallest]) < 0) {
|
||||||
|
smallest = leftIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightIndex < length && this.compare(this.heap[rightIndex], this.heap[smallest]) < 0) {
|
||||||
|
smallest = rightIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smallest === index) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.heap[index] = this.heap[smallest];
|
||||||
|
this.heap[smallest] = item;
|
||||||
|
index = smallest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
packages/pathfinding/src/core/IPathfinding.ts
Normal file
236
packages/pathfinding/src/core/IPathfinding.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* @zh 寻路系统核心接口
|
||||||
|
* @en Pathfinding System Core Interfaces
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 基础类型 | Basic Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 2D 坐标点
|
||||||
|
* @en 2D coordinate point
|
||||||
|
*/
|
||||||
|
export interface IPoint {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建点
|
||||||
|
* @en Create a point
|
||||||
|
*/
|
||||||
|
export function createPoint(x: number, y: number): IPoint {
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 路径节点
|
||||||
|
* @en Path node
|
||||||
|
*/
|
||||||
|
export interface IPathNode {
|
||||||
|
/** @zh 节点唯一标识 @en Unique node identifier */
|
||||||
|
readonly id: string | number;
|
||||||
|
/** @zh 节点位置 @en Node position */
|
||||||
|
readonly position: IPoint;
|
||||||
|
/** @zh 移动代价 @en Movement cost */
|
||||||
|
readonly cost: number;
|
||||||
|
/** @zh 是否可通行 @en Is walkable */
|
||||||
|
readonly walkable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 路径结果
|
||||||
|
* @en Path result
|
||||||
|
*/
|
||||||
|
export interface IPathResult {
|
||||||
|
/** @zh 是否找到路径 @en Whether path was found */
|
||||||
|
readonly found: boolean;
|
||||||
|
/** @zh 路径点列表 @en List of path points */
|
||||||
|
readonly path: readonly IPoint[];
|
||||||
|
/** @zh 路径总代价 @en Total path cost */
|
||||||
|
readonly cost: number;
|
||||||
|
/** @zh 搜索的节点数 @en Number of nodes searched */
|
||||||
|
readonly nodesSearched: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 空路径结果
|
||||||
|
* @en Empty path result
|
||||||
|
*/
|
||||||
|
export const EMPTY_PATH_RESULT: IPathResult = {
|
||||||
|
found: false,
|
||||||
|
path: [],
|
||||||
|
cost: 0,
|
||||||
|
nodesSearched: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 地图接口 | Map Interface
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 寻路地图接口
|
||||||
|
* @en Pathfinding map interface
|
||||||
|
*/
|
||||||
|
export interface IPathfindingMap {
|
||||||
|
/**
|
||||||
|
* @zh 获取节点的邻居
|
||||||
|
* @en Get neighbors of a node
|
||||||
|
*/
|
||||||
|
getNeighbors(node: IPathNode): IPathNode[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取指定位置的节点
|
||||||
|
* @en Get node at position
|
||||||
|
*/
|
||||||
|
getNodeAt(x: number, y: number): IPathNode | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 计算两点间的启发式距离
|
||||||
|
* @en Calculate heuristic distance between two points
|
||||||
|
*/
|
||||||
|
heuristic(a: IPoint, b: IPoint): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 计算两个邻居节点间的移动代价
|
||||||
|
* @en Calculate movement cost between two neighbor nodes
|
||||||
|
*/
|
||||||
|
getMovementCost(from: IPathNode, to: IPathNode): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查位置是否可通行
|
||||||
|
* @en Check if position is walkable
|
||||||
|
*/
|
||||||
|
isWalkable(x: number, y: number): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 启发式函数 | Heuristic Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 启发式函数类型
|
||||||
|
* @en Heuristic function type
|
||||||
|
*/
|
||||||
|
export type HeuristicFunction = (a: IPoint, b: IPoint) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 曼哈顿距离(4方向移动)
|
||||||
|
* @en Manhattan distance (4-directional movement)
|
||||||
|
*/
|
||||||
|
export function manhattanDistance(a: IPoint, b: IPoint): number {
|
||||||
|
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 欧几里得距离(任意方向移动)
|
||||||
|
* @en Euclidean distance (any direction movement)
|
||||||
|
*/
|
||||||
|
export function euclideanDistance(a: IPoint, b: IPoint): number {
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = a.y - b.y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 切比雪夫距离(8方向移动)
|
||||||
|
* @en Chebyshev distance (8-directional movement)
|
||||||
|
*/
|
||||||
|
export function chebyshevDistance(a: IPoint, b: IPoint): number {
|
||||||
|
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 八角距离(8方向移动,对角线代价为 √2)
|
||||||
|
* @en Octile distance (8-directional, diagonal cost √2)
|
||||||
|
*/
|
||||||
|
export function octileDistance(a: IPoint, b: IPoint): number {
|
||||||
|
const dx = Math.abs(a.x - b.x);
|
||||||
|
const dy = Math.abs(a.y - b.y);
|
||||||
|
const D = 1;
|
||||||
|
const D2 = Math.SQRT2;
|
||||||
|
return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 寻路器接口 | Pathfinder Interface
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 寻路配置
|
||||||
|
* @en Pathfinding options
|
||||||
|
*/
|
||||||
|
export interface IPathfindingOptions {
|
||||||
|
/** @zh 最大搜索节点数 @en Maximum nodes to search */
|
||||||
|
maxNodes?: number;
|
||||||
|
/** @zh 启发式权重 (>1 更快但可能非最优) @en Heuristic weight (>1 faster but may be suboptimal) */
|
||||||
|
heuristicWeight?: number;
|
||||||
|
/** @zh 是否允许对角移动 @en Allow diagonal movement */
|
||||||
|
allowDiagonal?: boolean;
|
||||||
|
/** @zh 是否避免穿角 @en Avoid corner cutting */
|
||||||
|
avoidCorners?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认寻路配置
|
||||||
|
* @en Default pathfinding options
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PATHFINDING_OPTIONS: Required<IPathfindingOptions> = {
|
||||||
|
maxNodes: 10000,
|
||||||
|
heuristicWeight: 1.0,
|
||||||
|
allowDiagonal: true,
|
||||||
|
avoidCorners: true
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 寻路器接口
|
||||||
|
* @en Pathfinder interface
|
||||||
|
*/
|
||||||
|
export interface IPathfinder {
|
||||||
|
/**
|
||||||
|
* @zh 查找路径
|
||||||
|
* @en Find path
|
||||||
|
*/
|
||||||
|
findPath(
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
endX: number,
|
||||||
|
endY: number,
|
||||||
|
options?: IPathfindingOptions
|
||||||
|
): IPathResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清理状态(用于重用)
|
||||||
|
* @en Clear state (for reuse)
|
||||||
|
*/
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 路径平滑接口 | Path Smoothing Interface
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 路径平滑器接口
|
||||||
|
* @en Path smoother interface
|
||||||
|
*/
|
||||||
|
export interface IPathSmoother {
|
||||||
|
/**
|
||||||
|
* @zh 平滑路径
|
||||||
|
* @en Smooth path
|
||||||
|
*/
|
||||||
|
smooth(path: readonly IPoint[], map: IPathfindingMap): IPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 视线检测函数类型
|
||||||
|
* @en Line of sight check function type
|
||||||
|
*/
|
||||||
|
export type LineOfSightCheck = (
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
map: IPathfindingMap
|
||||||
|
) => boolean;
|
||||||
30
packages/pathfinding/src/core/index.ts
Normal file
30
packages/pathfinding/src/core/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* @zh 寻路核心模块
|
||||||
|
* @en Pathfinding Core Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type {
|
||||||
|
IPoint,
|
||||||
|
IPathNode,
|
||||||
|
IPathResult,
|
||||||
|
IPathfindingMap,
|
||||||
|
IPathfinder,
|
||||||
|
IPathSmoother,
|
||||||
|
IPathfindingOptions,
|
||||||
|
HeuristicFunction,
|
||||||
|
LineOfSightCheck
|
||||||
|
} from './IPathfinding';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPoint,
|
||||||
|
EMPTY_PATH_RESULT,
|
||||||
|
DEFAULT_PATHFINDING_OPTIONS,
|
||||||
|
manhattanDistance,
|
||||||
|
euclideanDistance,
|
||||||
|
chebyshevDistance,
|
||||||
|
octileDistance
|
||||||
|
} from './IPathfinding';
|
||||||
|
|
||||||
|
export { BinaryHeap } from './BinaryHeap';
|
||||||
|
|
||||||
|
export { AStarPathfinder, createAStarPathfinder } from './AStarPathfinder';
|
||||||
364
packages/pathfinding/src/grid/GridMap.ts
Normal file
364
packages/pathfinding/src/grid/GridMap.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* @zh 网格地图实现
|
||||||
|
* @en Grid Map Implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IPathfindingMap,
|
||||||
|
IPathNode,
|
||||||
|
IPoint,
|
||||||
|
HeuristicFunction
|
||||||
|
} from '../core/IPathfinding';
|
||||||
|
import { createPoint, octileDistance } from '../core/IPathfinding';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 网格节点 | Grid Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网格节点
|
||||||
|
* @en Grid node
|
||||||
|
*/
|
||||||
|
export class GridNode implements IPathNode {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: IPoint;
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
cost: number;
|
||||||
|
walkable: boolean;
|
||||||
|
|
||||||
|
constructor(x: number, y: number, walkable: boolean = true, cost: number = 1) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.id = `${x},${y}`;
|
||||||
|
this.position = createPoint(x, y);
|
||||||
|
this.walkable = walkable;
|
||||||
|
this.cost = cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 移动方向 | Movement Directions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 4方向偏移 (上下左右)
|
||||||
|
* @en 4-directional offsets (up, down, left, right)
|
||||||
|
*/
|
||||||
|
export const DIRECTIONS_4 = [
|
||||||
|
{ dx: 0, dy: -1 }, // Up
|
||||||
|
{ dx: 1, dy: 0 }, // Right
|
||||||
|
{ dx: 0, dy: 1 }, // Down
|
||||||
|
{ dx: -1, dy: 0 } // Left
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 8方向偏移 (含对角线)
|
||||||
|
* @en 8-directional offsets (including diagonals)
|
||||||
|
*/
|
||||||
|
export const DIRECTIONS_8 = [
|
||||||
|
{ dx: 0, dy: -1 }, // Up
|
||||||
|
{ dx: 1, dy: -1 }, // Up-Right
|
||||||
|
{ dx: 1, dy: 0 }, // Right
|
||||||
|
{ dx: 1, dy: 1 }, // Down-Right
|
||||||
|
{ dx: 0, dy: 1 }, // Down
|
||||||
|
{ dx: -1, dy: 1 }, // Down-Left
|
||||||
|
{ dx: -1, dy: 0 }, // Left
|
||||||
|
{ dx: -1, dy: -1 } // Up-Left
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 网格地图配置 | Grid Map Options
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网格地图配置
|
||||||
|
* @en Grid map options
|
||||||
|
*/
|
||||||
|
export interface IGridMapOptions {
|
||||||
|
/** @zh 是否允许对角移动 @en Allow diagonal movement */
|
||||||
|
allowDiagonal?: boolean;
|
||||||
|
/** @zh 对角移动代价 @en Diagonal movement cost */
|
||||||
|
diagonalCost?: number;
|
||||||
|
/** @zh 是否避免穿角 @en Avoid corner cutting */
|
||||||
|
avoidCorners?: boolean;
|
||||||
|
/** @zh 启发式函数 @en Heuristic function */
|
||||||
|
heuristic?: HeuristicFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认网格地图配置
|
||||||
|
* @en Default grid map options
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GRID_OPTIONS: Required<IGridMapOptions> = {
|
||||||
|
allowDiagonal: true,
|
||||||
|
diagonalCost: Math.SQRT2,
|
||||||
|
avoidCorners: true,
|
||||||
|
heuristic: octileDistance
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 网格地图 | Grid Map
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网格地图
|
||||||
|
* @en Grid Map
|
||||||
|
*
|
||||||
|
* @zh 基于二维数组的网格地图实现,支持4方向和8方向移动
|
||||||
|
* @en Grid map implementation based on 2D array, supports 4 and 8 directional movement
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Create a 10x10 grid
|
||||||
|
* const grid = new GridMap(10, 10);
|
||||||
|
*
|
||||||
|
* // Set some cells as obstacles
|
||||||
|
* grid.setWalkable(5, 5, false);
|
||||||
|
* grid.setWalkable(5, 6, false);
|
||||||
|
*
|
||||||
|
* // Use with pathfinder
|
||||||
|
* const pathfinder = new AStarPathfinder(grid);
|
||||||
|
* const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class GridMap implements IPathfindingMap {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
private readonly nodes: GridNode[][];
|
||||||
|
private readonly options: Required<IGridMapOptions>;
|
||||||
|
|
||||||
|
constructor(width: number, height: number, options?: IGridMapOptions) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.options = { ...DEFAULT_GRID_OPTIONS, ...options };
|
||||||
|
this.nodes = this.createNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建网格节点
|
||||||
|
* @en Create grid nodes
|
||||||
|
*/
|
||||||
|
private createNodes(): GridNode[][] {
|
||||||
|
const nodes: GridNode[][] = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
nodes[y] = [];
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
nodes[y][x] = new GridNode(x, y, true, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取指定位置的节点
|
||||||
|
* @en Get node at position
|
||||||
|
*/
|
||||||
|
getNodeAt(x: number, y: number): GridNode | null {
|
||||||
|
if (!this.isInBounds(x, y)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.nodes[y][x];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查坐标是否在边界内
|
||||||
|
* @en Check if coordinates are within bounds
|
||||||
|
*/
|
||||||
|
isInBounds(x: number, y: number): boolean {
|
||||||
|
return x >= 0 && x < this.width && y >= 0 && y < this.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查位置是否可通行
|
||||||
|
* @en Check if position is walkable
|
||||||
|
*/
|
||||||
|
isWalkable(x: number, y: number): boolean {
|
||||||
|
const node = this.getNodeAt(x, y);
|
||||||
|
return node !== null && node.walkable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置位置是否可通行
|
||||||
|
* @en Set position walkability
|
||||||
|
*/
|
||||||
|
setWalkable(x: number, y: number, walkable: boolean): void {
|
||||||
|
const node = this.getNodeAt(x, y);
|
||||||
|
if (node) {
|
||||||
|
node.walkable = walkable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置位置的移动代价
|
||||||
|
* @en Set movement cost at position
|
||||||
|
*/
|
||||||
|
setCost(x: number, y: number, cost: number): void {
|
||||||
|
const node = this.getNodeAt(x, y);
|
||||||
|
if (node) {
|
||||||
|
node.cost = cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取节点的邻居
|
||||||
|
* @en Get neighbors of a node
|
||||||
|
*/
|
||||||
|
getNeighbors(node: IPathNode): GridNode[] {
|
||||||
|
const neighbors: GridNode[] = [];
|
||||||
|
const { x, y } = node.position;
|
||||||
|
const directions = this.options.allowDiagonal ? DIRECTIONS_8 : DIRECTIONS_4;
|
||||||
|
|
||||||
|
for (let i = 0; i < directions.length; i++) {
|
||||||
|
const dir = directions[i];
|
||||||
|
const nx = x + dir.dx;
|
||||||
|
const ny = y + dir.dy;
|
||||||
|
|
||||||
|
if (!this.isInBounds(nx, ny)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const neighbor = this.nodes[ny][nx];
|
||||||
|
|
||||||
|
if (!neighbor.walkable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check corner cutting for diagonal movement
|
||||||
|
if (this.options.avoidCorners && dir.dx !== 0 && dir.dy !== 0) {
|
||||||
|
const horizontal = this.getNodeAt(x + dir.dx, y);
|
||||||
|
const vertical = this.getNodeAt(x, y + dir.dy);
|
||||||
|
|
||||||
|
if (!horizontal?.walkable || !vertical?.walkable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
neighbors.push(neighbor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 计算启发式距离
|
||||||
|
* @en Calculate heuristic distance
|
||||||
|
*/
|
||||||
|
heuristic(a: IPoint, b: IPoint): number {
|
||||||
|
return this.options.heuristic(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 计算移动代价
|
||||||
|
* @en Calculate movement cost
|
||||||
|
*/
|
||||||
|
getMovementCost(from: IPathNode, to: IPathNode): number {
|
||||||
|
const dx = Math.abs(from.position.x - to.position.x);
|
||||||
|
const dy = Math.abs(from.position.y - to.position.y);
|
||||||
|
|
||||||
|
// Diagonal movement
|
||||||
|
if (dx !== 0 && dy !== 0) {
|
||||||
|
return to.cost * this.options.diagonalCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cardinal movement
|
||||||
|
return to.cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从二维数组加载地图
|
||||||
|
* @en Load map from 2D array
|
||||||
|
*
|
||||||
|
* @param data - @zh 0=可通行,非0=不可通行 @en 0=walkable, non-0=blocked
|
||||||
|
*/
|
||||||
|
loadFromArray(data: number[][]): void {
|
||||||
|
for (let y = 0; y < Math.min(data.length, this.height); y++) {
|
||||||
|
for (let x = 0; x < Math.min(data[y].length, this.width); x++) {
|
||||||
|
this.nodes[y][x].walkable = data[y][x] === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从字符串加载地图
|
||||||
|
* @en Load map from string
|
||||||
|
*
|
||||||
|
* @param str - @zh 地图字符串,'.'=可通行,'#'=障碍 @en Map string, '.'=walkable, '#'=blocked
|
||||||
|
*/
|
||||||
|
loadFromString(str: string): void {
|
||||||
|
const lines = str.trim().split('\n');
|
||||||
|
|
||||||
|
for (let y = 0; y < Math.min(lines.length, this.height); y++) {
|
||||||
|
const line = lines[y];
|
||||||
|
for (let x = 0; x < Math.min(line.length, this.width); x++) {
|
||||||
|
this.nodes[y][x].walkable = line[x] !== '#';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 导出为字符串
|
||||||
|
* @en Export to string
|
||||||
|
*/
|
||||||
|
toString(): string {
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
result += this.nodes[y][x].walkable ? '.' : '#';
|
||||||
|
}
|
||||||
|
result += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重置所有节点为可通行
|
||||||
|
* @en Reset all nodes to walkable
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
this.nodes[y][x].walkable = true;
|
||||||
|
this.nodes[y][x].cost = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置矩形区域的通行性
|
||||||
|
* @en Set walkability for a rectangle region
|
||||||
|
*/
|
||||||
|
setRectWalkable(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
walkable: boolean
|
||||||
|
): void {
|
||||||
|
for (let dy = 0; dy < height; dy++) {
|
||||||
|
for (let dx = 0; dx < width; dx++) {
|
||||||
|
this.setWalkable(x + dx, y + dy, walkable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工厂函数 | Factory Function
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建网格地图
|
||||||
|
* @en Create grid map
|
||||||
|
*/
|
||||||
|
export function createGridMap(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
options?: IGridMapOptions
|
||||||
|
): GridMap {
|
||||||
|
return new GridMap(width, height, options);
|
||||||
|
}
|
||||||
14
packages/pathfinding/src/grid/index.ts
Normal file
14
packages/pathfinding/src/grid/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @zh 网格地图模块
|
||||||
|
* @en Grid Map Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
GridNode,
|
||||||
|
GridMap,
|
||||||
|
createGridMap,
|
||||||
|
DIRECTIONS_4,
|
||||||
|
DIRECTIONS_8,
|
||||||
|
type IGridMapOptions,
|
||||||
|
DEFAULT_GRID_OPTIONS
|
||||||
|
} from './GridMap';
|
||||||
103
packages/pathfinding/src/index.ts
Normal file
103
packages/pathfinding/src/index.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @esengine/pathfinding
|
||||||
|
*
|
||||||
|
* @zh 寻路系统
|
||||||
|
* @en Pathfinding System
|
||||||
|
*
|
||||||
|
* @zh 提供 A* 寻路、网格地图、导航网格和路径平滑
|
||||||
|
* @en Provides A* pathfinding, grid map, NavMesh and path smoothing
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Core | 核心
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type {
|
||||||
|
IPoint,
|
||||||
|
IPathNode,
|
||||||
|
IPathResult,
|
||||||
|
IPathfindingMap,
|
||||||
|
IPathfinder,
|
||||||
|
IPathSmoother,
|
||||||
|
IPathfindingOptions,
|
||||||
|
HeuristicFunction,
|
||||||
|
LineOfSightCheck
|
||||||
|
} from './core';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPoint,
|
||||||
|
EMPTY_PATH_RESULT,
|
||||||
|
DEFAULT_PATHFINDING_OPTIONS,
|
||||||
|
manhattanDistance,
|
||||||
|
euclideanDistance,
|
||||||
|
chebyshevDistance,
|
||||||
|
octileDistance,
|
||||||
|
BinaryHeap,
|
||||||
|
AStarPathfinder,
|
||||||
|
createAStarPathfinder
|
||||||
|
} from './core';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Grid | 网格地图
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type { IGridMapOptions } from './grid';
|
||||||
|
|
||||||
|
export {
|
||||||
|
GridNode,
|
||||||
|
GridMap,
|
||||||
|
createGridMap,
|
||||||
|
DIRECTIONS_4,
|
||||||
|
DIRECTIONS_8,
|
||||||
|
DEFAULT_GRID_OPTIONS
|
||||||
|
} from './grid';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NavMesh | 导航网格
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type { INavPolygon, IPortal } from './navmesh';
|
||||||
|
|
||||||
|
export { NavMesh, createNavMesh } from './navmesh';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Smoothing | 路径平滑
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
bresenhamLineOfSight,
|
||||||
|
raycastLineOfSight,
|
||||||
|
LineOfSightSmoother,
|
||||||
|
CatmullRomSmoother,
|
||||||
|
CombinedSmoother,
|
||||||
|
createLineOfSightSmoother,
|
||||||
|
createCatmullRomSmoother,
|
||||||
|
createCombinedSmoother
|
||||||
|
} from './smoothing';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Blueprint Nodes | 蓝图节点
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Templates
|
||||||
|
FindPathTemplate,
|
||||||
|
FindPathSmoothTemplate,
|
||||||
|
IsWalkableTemplate,
|
||||||
|
GetPathLengthTemplate,
|
||||||
|
GetPathDistanceTemplate,
|
||||||
|
GetPathPointTemplate,
|
||||||
|
MoveAlongPathTemplate,
|
||||||
|
HasLineOfSightTemplate,
|
||||||
|
// Executors
|
||||||
|
FindPathExecutor,
|
||||||
|
FindPathSmoothExecutor,
|
||||||
|
IsWalkableExecutor,
|
||||||
|
GetPathLengthExecutor,
|
||||||
|
GetPathDistanceExecutor,
|
||||||
|
GetPathPointExecutor,
|
||||||
|
MoveAlongPathExecutor,
|
||||||
|
HasLineOfSightExecutor,
|
||||||
|
// Collection
|
||||||
|
PathfindingNodeDefinitions
|
||||||
|
} from './nodes';
|
||||||
615
packages/pathfinding/src/navmesh/NavMesh.ts
Normal file
615
packages/pathfinding/src/navmesh/NavMesh.ts
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
/**
|
||||||
|
* @zh 导航网格实现
|
||||||
|
* @en NavMesh Implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IPathfindingMap,
|
||||||
|
IPathNode,
|
||||||
|
IPoint,
|
||||||
|
IPathResult,
|
||||||
|
IPathfindingOptions
|
||||||
|
} from '../core/IPathfinding';
|
||||||
|
import { createPoint, euclideanDistance, EMPTY_PATH_RESULT, DEFAULT_PATHFINDING_OPTIONS } from '../core/IPathfinding';
|
||||||
|
import { BinaryHeap } from '../core/BinaryHeap';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 导航多边形 | Navigation Polygon
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 导航多边形
|
||||||
|
* @en Navigation polygon
|
||||||
|
*/
|
||||||
|
export interface INavPolygon {
|
||||||
|
/** @zh 多边形ID @en Polygon ID */
|
||||||
|
readonly id: number;
|
||||||
|
/** @zh 顶点列表 @en Vertex list */
|
||||||
|
readonly vertices: readonly IPoint[];
|
||||||
|
/** @zh 中心点 @en Center point */
|
||||||
|
readonly center: IPoint;
|
||||||
|
/** @zh 邻居多边形ID @en Neighbor polygon IDs */
|
||||||
|
readonly neighbors: readonly number[];
|
||||||
|
/** @zh 到邻居的共享边 @en Shared edges to neighbors */
|
||||||
|
readonly portals: ReadonlyMap<number, IPortal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 入口(两个多边形之间的共享边)
|
||||||
|
* @en Portal (shared edge between two polygons)
|
||||||
|
*/
|
||||||
|
export interface IPortal {
|
||||||
|
/** @zh 边的左端点 @en Left endpoint of edge */
|
||||||
|
readonly left: IPoint;
|
||||||
|
/** @zh 边的右端点 @en Right endpoint of edge */
|
||||||
|
readonly right: IPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 导航网格节点 | NavMesh Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 导航网格节点(包装多边形)
|
||||||
|
* @en NavMesh node (wraps polygon)
|
||||||
|
*/
|
||||||
|
class NavMeshNode implements IPathNode {
|
||||||
|
readonly id: number;
|
||||||
|
readonly position: IPoint;
|
||||||
|
readonly cost: number;
|
||||||
|
readonly walkable: boolean;
|
||||||
|
readonly polygon: INavPolygon;
|
||||||
|
|
||||||
|
constructor(polygon: INavPolygon) {
|
||||||
|
this.id = polygon.id;
|
||||||
|
this.position = polygon.center;
|
||||||
|
this.cost = 1;
|
||||||
|
this.walkable = true;
|
||||||
|
this.polygon = polygon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 导航网格 | Navigation Mesh
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 导航网格
|
||||||
|
* @en Navigation Mesh
|
||||||
|
*
|
||||||
|
* @zh 使用凸多边形网格进行高效寻路,适合复杂地形
|
||||||
|
* @en Uses convex polygon mesh for efficient pathfinding, suitable for complex terrain
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const navmesh = new NavMesh();
|
||||||
|
*
|
||||||
|
* // Add polygons
|
||||||
|
* navmesh.addPolygon([
|
||||||
|
* { x: 0, y: 0 }, { x: 10, y: 0 },
|
||||||
|
* { x: 10, y: 10 }, { x: 0, y: 10 }
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* // Build connections
|
||||||
|
* navmesh.build();
|
||||||
|
*
|
||||||
|
* // Find path
|
||||||
|
* const result = navmesh.findPath(1, 1, 8, 8);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class NavMesh implements IPathfindingMap {
|
||||||
|
private polygons: Map<number, INavPolygon> = new Map();
|
||||||
|
private nodes: Map<number, NavMeshNode> = new Map();
|
||||||
|
private nextId = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 添加导航多边形
|
||||||
|
* @en Add navigation polygon
|
||||||
|
*
|
||||||
|
* @returns @zh 多边形ID @en Polygon ID
|
||||||
|
*/
|
||||||
|
addPolygon(vertices: IPoint[], neighbors: number[] = []): number {
|
||||||
|
const id = this.nextId++;
|
||||||
|
const center = this.calculateCenter(vertices);
|
||||||
|
|
||||||
|
const polygon: INavPolygon = {
|
||||||
|
id,
|
||||||
|
vertices,
|
||||||
|
center,
|
||||||
|
neighbors,
|
||||||
|
portals: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.polygons.set(id, polygon);
|
||||||
|
this.nodes.set(id, new NavMeshNode(polygon));
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置两个多边形之间的连接
|
||||||
|
* @en Set connection between two polygons
|
||||||
|
*/
|
||||||
|
setConnection(
|
||||||
|
polyA: number,
|
||||||
|
polyB: number,
|
||||||
|
portal: IPortal
|
||||||
|
): void {
|
||||||
|
const polygonA = this.polygons.get(polyA);
|
||||||
|
const polygonB = this.polygons.get(polyB);
|
||||||
|
|
||||||
|
if (!polygonA || !polygonB) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update neighbors and portals
|
||||||
|
const neighborsA = [...polygonA.neighbors];
|
||||||
|
const portalsA = new Map(polygonA.portals);
|
||||||
|
|
||||||
|
if (!neighborsA.includes(polyB)) {
|
||||||
|
neighborsA.push(polyB);
|
||||||
|
}
|
||||||
|
portalsA.set(polyB, portal);
|
||||||
|
|
||||||
|
this.polygons.set(polyA, {
|
||||||
|
...polygonA,
|
||||||
|
neighbors: neighborsA,
|
||||||
|
portals: portalsA
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reverse portal for the other direction
|
||||||
|
const reversePortal: IPortal = {
|
||||||
|
left: portal.right,
|
||||||
|
right: portal.left
|
||||||
|
};
|
||||||
|
|
||||||
|
const neighborsB = [...polygonB.neighbors];
|
||||||
|
const portalsB = new Map(polygonB.portals);
|
||||||
|
|
||||||
|
if (!neighborsB.includes(polyA)) {
|
||||||
|
neighborsB.push(polyA);
|
||||||
|
}
|
||||||
|
portalsB.set(polyA, reversePortal);
|
||||||
|
|
||||||
|
this.polygons.set(polyB, {
|
||||||
|
...polygonB,
|
||||||
|
neighbors: neighborsB,
|
||||||
|
portals: portalsB
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 自动检测并建立相邻多边形的连接
|
||||||
|
* @en Auto-detect and build connections between adjacent polygons
|
||||||
|
*/
|
||||||
|
build(): void {
|
||||||
|
const polygonList = Array.from(this.polygons.values());
|
||||||
|
|
||||||
|
for (let i = 0; i < polygonList.length; i++) {
|
||||||
|
for (let j = i + 1; j < polygonList.length; j++) {
|
||||||
|
const polyA = polygonList[i];
|
||||||
|
const polyB = polygonList[j];
|
||||||
|
|
||||||
|
const sharedEdge = this.findSharedEdge(polyA.vertices, polyB.vertices);
|
||||||
|
|
||||||
|
if (sharedEdge) {
|
||||||
|
this.setConnection(polyA.id, polyB.id, sharedEdge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 查找两个多边形的共享边
|
||||||
|
* @en Find shared edge between two polygons
|
||||||
|
*/
|
||||||
|
private findSharedEdge(
|
||||||
|
verticesA: readonly IPoint[],
|
||||||
|
verticesB: readonly IPoint[]
|
||||||
|
): IPortal | null {
|
||||||
|
const epsilon = 0.0001;
|
||||||
|
|
||||||
|
for (let i = 0; i < verticesA.length; i++) {
|
||||||
|
const a1 = verticesA[i];
|
||||||
|
const a2 = verticesA[(i + 1) % verticesA.length];
|
||||||
|
|
||||||
|
for (let j = 0; j < verticesB.length; j++) {
|
||||||
|
const b1 = verticesB[j];
|
||||||
|
const b2 = verticesB[(j + 1) % verticesB.length];
|
||||||
|
|
||||||
|
// Check if edges match (in either direction)
|
||||||
|
const match1 =
|
||||||
|
Math.abs(a1.x - b2.x) < epsilon &&
|
||||||
|
Math.abs(a1.y - b2.y) < epsilon &&
|
||||||
|
Math.abs(a2.x - b1.x) < epsilon &&
|
||||||
|
Math.abs(a2.y - b1.y) < epsilon;
|
||||||
|
|
||||||
|
const match2 =
|
||||||
|
Math.abs(a1.x - b1.x) < epsilon &&
|
||||||
|
Math.abs(a1.y - b1.y) < epsilon &&
|
||||||
|
Math.abs(a2.x - b2.x) < epsilon &&
|
||||||
|
Math.abs(a2.y - b2.y) < epsilon;
|
||||||
|
|
||||||
|
if (match1 || match2) {
|
||||||
|
return {
|
||||||
|
left: a1,
|
||||||
|
right: a2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 计算多边形中心
|
||||||
|
* @en Calculate polygon center
|
||||||
|
*/
|
||||||
|
private calculateCenter(vertices: readonly IPoint[]): IPoint {
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
|
||||||
|
for (const v of vertices) {
|
||||||
|
x += v.x;
|
||||||
|
y += v.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPoint(x / vertices.length, y / vertices.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 查找包含点的多边形
|
||||||
|
* @en Find polygon containing point
|
||||||
|
*/
|
||||||
|
findPolygonAt(x: number, y: number): INavPolygon | null {
|
||||||
|
for (const polygon of this.polygons.values()) {
|
||||||
|
if (this.isPointInPolygon(x, y, polygon.vertices)) {
|
||||||
|
return polygon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查点是否在多边形内
|
||||||
|
* @en Check if point is inside polygon
|
||||||
|
*/
|
||||||
|
private isPointInPolygon(x: number, y: number, vertices: readonly IPoint[]): boolean {
|
||||||
|
let inside = false;
|
||||||
|
const n = vertices.length;
|
||||||
|
|
||||||
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||||
|
const xi = vertices[i].x;
|
||||||
|
const yi = vertices[i].y;
|
||||||
|
const xj = vertices[j].x;
|
||||||
|
const yj = vertices[j].y;
|
||||||
|
|
||||||
|
if (
|
||||||
|
yi > y !== yj > y &&
|
||||||
|
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
|
||||||
|
) {
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// IPathfindingMap 接口实现 | IPathfindingMap Interface Implementation
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
getNodeAt(x: number, y: number): IPathNode | null {
|
||||||
|
const polygon = this.findPolygonAt(x, y);
|
||||||
|
return polygon ? this.nodes.get(polygon.id) ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNeighbors(node: IPathNode): IPathNode[] {
|
||||||
|
const navNode = node as NavMeshNode;
|
||||||
|
const neighbors: IPathNode[] = [];
|
||||||
|
|
||||||
|
for (const neighborId of navNode.polygon.neighbors) {
|
||||||
|
const neighbor = this.nodes.get(neighborId);
|
||||||
|
if (neighbor) {
|
||||||
|
neighbors.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
heuristic(a: IPoint, b: IPoint): number {
|
||||||
|
return euclideanDistance(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMovementCost(from: IPathNode, to: IPathNode): number {
|
||||||
|
return euclideanDistance(from.position, to.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
isWalkable(x: number, y: number): boolean {
|
||||||
|
return this.findPolygonAt(x, y) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 寻路 | Pathfinding
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 在导航网格上寻路
|
||||||
|
* @en Find path on navigation mesh
|
||||||
|
*/
|
||||||
|
findPath(
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
endX: number,
|
||||||
|
endY: number,
|
||||||
|
options?: IPathfindingOptions
|
||||||
|
): IPathResult {
|
||||||
|
const opts = { ...DEFAULT_PATHFINDING_OPTIONS, ...options };
|
||||||
|
|
||||||
|
const startPolygon = this.findPolygonAt(startX, startY);
|
||||||
|
const endPolygon = this.findPolygonAt(endX, endY);
|
||||||
|
|
||||||
|
if (!startPolygon || !endPolygon) {
|
||||||
|
return EMPTY_PATH_RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same polygon
|
||||||
|
if (startPolygon.id === endPolygon.id) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: [createPoint(startX, startY), createPoint(endX, endY)],
|
||||||
|
cost: euclideanDistance(
|
||||||
|
createPoint(startX, startY),
|
||||||
|
createPoint(endX, endY)
|
||||||
|
),
|
||||||
|
nodesSearched: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A* on polygon graph
|
||||||
|
const polygonPath = this.findPolygonPath(startPolygon, endPolygon, opts);
|
||||||
|
|
||||||
|
if (!polygonPath.found) {
|
||||||
|
return EMPTY_PATH_RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert polygon path to point path using funnel algorithm
|
||||||
|
const start = createPoint(startX, startY);
|
||||||
|
const end = createPoint(endX, endY);
|
||||||
|
const pointPath = this.funnelPath(start, end, polygonPath.polygons);
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: pointPath,
|
||||||
|
cost: this.calculatePathLength(pointPath),
|
||||||
|
nodesSearched: polygonPath.nodesSearched
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 在多边形图上寻路
|
||||||
|
* @en Find path on polygon graph
|
||||||
|
*/
|
||||||
|
private findPolygonPath(
|
||||||
|
start: INavPolygon,
|
||||||
|
end: INavPolygon,
|
||||||
|
opts: Required<IPathfindingOptions>
|
||||||
|
): { found: boolean; polygons: INavPolygon[]; nodesSearched: number } {
|
||||||
|
interface AStarState {
|
||||||
|
polygon: INavPolygon;
|
||||||
|
g: number;
|
||||||
|
f: number;
|
||||||
|
parent: AStarState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openList = new BinaryHeap<AStarState>((a, b) => a.f - b.f);
|
||||||
|
const closed = new Set<number>();
|
||||||
|
const states = new Map<number, AStarState>();
|
||||||
|
|
||||||
|
const startState: AStarState = {
|
||||||
|
polygon: start,
|
||||||
|
g: 0,
|
||||||
|
f: euclideanDistance(start.center, end.center) * opts.heuristicWeight,
|
||||||
|
parent: null
|
||||||
|
};
|
||||||
|
|
||||||
|
states.set(start.id, startState);
|
||||||
|
openList.push(startState);
|
||||||
|
|
||||||
|
let nodesSearched = 0;
|
||||||
|
|
||||||
|
while (!openList.isEmpty && nodesSearched < opts.maxNodes) {
|
||||||
|
const current = openList.pop()!;
|
||||||
|
nodesSearched++;
|
||||||
|
|
||||||
|
if (current.polygon.id === end.id) {
|
||||||
|
// Reconstruct path
|
||||||
|
const path: INavPolygon[] = [];
|
||||||
|
let state: AStarState | null = current;
|
||||||
|
|
||||||
|
while (state) {
|
||||||
|
path.unshift(state.polygon);
|
||||||
|
state = state.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { found: true, polygons: path, nodesSearched };
|
||||||
|
}
|
||||||
|
|
||||||
|
closed.add(current.polygon.id);
|
||||||
|
|
||||||
|
for (const neighborId of current.polygon.neighbors) {
|
||||||
|
if (closed.has(neighborId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const neighborPolygon = this.polygons.get(neighborId);
|
||||||
|
if (!neighborPolygon) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = current.g + euclideanDistance(
|
||||||
|
current.polygon.center,
|
||||||
|
neighborPolygon.center
|
||||||
|
);
|
||||||
|
|
||||||
|
let neighborState = states.get(neighborId);
|
||||||
|
|
||||||
|
if (!neighborState) {
|
||||||
|
neighborState = {
|
||||||
|
polygon: neighborPolygon,
|
||||||
|
g,
|
||||||
|
f: g + euclideanDistance(neighborPolygon.center, end.center) * opts.heuristicWeight,
|
||||||
|
parent: current
|
||||||
|
};
|
||||||
|
states.set(neighborId, neighborState);
|
||||||
|
openList.push(neighborState);
|
||||||
|
} else if (g < neighborState.g) {
|
||||||
|
neighborState.g = g;
|
||||||
|
neighborState.f = g + euclideanDistance(neighborPolygon.center, end.center) * opts.heuristicWeight;
|
||||||
|
neighborState.parent = current;
|
||||||
|
openList.update(neighborState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { found: false, polygons: [], nodesSearched };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 使用漏斗算法优化路径
|
||||||
|
* @en Optimize path using funnel algorithm
|
||||||
|
*/
|
||||||
|
private funnelPath(
|
||||||
|
start: IPoint,
|
||||||
|
end: IPoint,
|
||||||
|
polygons: INavPolygon[]
|
||||||
|
): IPoint[] {
|
||||||
|
if (polygons.length <= 1) {
|
||||||
|
return [start, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect portals
|
||||||
|
const portals: IPortal[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < polygons.length - 1; i++) {
|
||||||
|
const portal = polygons[i].portals.get(polygons[i + 1].id);
|
||||||
|
if (portal) {
|
||||||
|
portals.push(portal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portals.length === 0) {
|
||||||
|
return [start, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple string pulling algorithm
|
||||||
|
const path: IPoint[] = [start];
|
||||||
|
|
||||||
|
let apex = start;
|
||||||
|
let leftIndex = 0;
|
||||||
|
let rightIndex = 0;
|
||||||
|
let left = portals[0].left;
|
||||||
|
let right = portals[0].right;
|
||||||
|
|
||||||
|
for (let i = 1; i <= portals.length; i++) {
|
||||||
|
const nextLeft = i < portals.length ? portals[i].left : end;
|
||||||
|
const nextRight = i < portals.length ? portals[i].right : end;
|
||||||
|
|
||||||
|
// Update right
|
||||||
|
if (this.triArea2(apex, right, nextRight) <= 0) {
|
||||||
|
if (apex === right || this.triArea2(apex, left, nextRight) > 0) {
|
||||||
|
right = nextRight;
|
||||||
|
rightIndex = i;
|
||||||
|
} else {
|
||||||
|
path.push(left);
|
||||||
|
apex = left;
|
||||||
|
leftIndex = rightIndex = leftIndex;
|
||||||
|
left = right = apex;
|
||||||
|
i = leftIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update left
|
||||||
|
if (this.triArea2(apex, left, nextLeft) >= 0) {
|
||||||
|
if (apex === left || this.triArea2(apex, right, nextLeft) < 0) {
|
||||||
|
left = nextLeft;
|
||||||
|
leftIndex = i;
|
||||||
|
} else {
|
||||||
|
path.push(right);
|
||||||
|
apex = right;
|
||||||
|
leftIndex = rightIndex = rightIndex;
|
||||||
|
left = right = apex;
|
||||||
|
i = rightIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.push(end);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 计算三角形面积的两倍(用于判断点的相对位置)
|
||||||
|
* @en Calculate twice the triangle area (for point relative position)
|
||||||
|
*/
|
||||||
|
private triArea2(a: IPoint, b: IPoint, c: IPoint): number {
|
||||||
|
return (c.x - a.x) * (b.y - a.y) - (b.x - a.x) * (c.y - a.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 计算路径总长度
|
||||||
|
* @en Calculate total path length
|
||||||
|
*/
|
||||||
|
private calculatePathLength(path: readonly IPoint[]): number {
|
||||||
|
let length = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
length += euclideanDistance(path[i - 1], path[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清空导航网格
|
||||||
|
* @en Clear navigation mesh
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.polygons.clear();
|
||||||
|
this.nodes.clear();
|
||||||
|
this.nextId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有多边形
|
||||||
|
* @en Get all polygons
|
||||||
|
*/
|
||||||
|
getPolygons(): INavPolygon[] {
|
||||||
|
return Array.from(this.polygons.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取多边形数量
|
||||||
|
* @en Get polygon count
|
||||||
|
*/
|
||||||
|
get polygonCount(): number {
|
||||||
|
return this.polygons.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工厂函数 | Factory Function
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建导航网格
|
||||||
|
* @en Create navigation mesh
|
||||||
|
*/
|
||||||
|
export function createNavMesh(): NavMesh {
|
||||||
|
return new NavMesh();
|
||||||
|
}
|
||||||
11
packages/pathfinding/src/navmesh/index.ts
Normal file
11
packages/pathfinding/src/navmesh/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @zh 导航网格模块
|
||||||
|
* @en NavMesh Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavMesh,
|
||||||
|
createNavMesh,
|
||||||
|
type INavPolygon,
|
||||||
|
type IPortal
|
||||||
|
} from './NavMesh';
|
||||||
409
packages/pathfinding/src/nodes/PathfindingNodes.ts
Normal file
409
packages/pathfinding/src/nodes/PathfindingNodes.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* @zh 寻路系统蓝图节点
|
||||||
|
* @en Pathfinding System Blueprint Nodes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BlueprintNodeTemplate, BlueprintNode, INodeExecutor, ExecutionResult } from '@esengine/blueprint';
|
||||||
|
import type { IPathResult, IPoint } from '../core/IPathfinding';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 执行上下文接口 | Execution Context Interface
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface PathfindingContext {
|
||||||
|
evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown;
|
||||||
|
setOutputs(nodeId: string, outputs: Record<string, unknown>): void;
|
||||||
|
findPath(startX: number, startY: number, endX: number, endY: number): IPathResult;
|
||||||
|
findPathSmooth(startX: number, startY: number, endX: number, endY: number): IPathResult;
|
||||||
|
isWalkable(x: number, y: number): boolean;
|
||||||
|
getPathDistance(path: IPoint[]): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FindPath 节点 | FindPath Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const FindPathTemplate: BlueprintNodeTemplate = {
|
||||||
|
type: 'FindPath',
|
||||||
|
title: 'Find Path',
|
||||||
|
category: 'custom',
|
||||||
|
description: 'Find path from start to end / 从起点到终点寻路',
|
||||||
|
keywords: ['path', 'pathfinding', 'astar', 'navigate', 'route'],
|
||||||
|
menuPath: ['Pathfinding', 'Find Path'],
|
||||||
|
inputs: [
|
||||||
|
{ name: 'exec', displayName: '', type: 'exec' },
|
||||||
|
{ name: 'startX', displayName: 'Start X', type: 'float' },
|
||||||
|
{ name: 'startY', displayName: 'Start Y', type: 'float' },
|
||||||
|
{ name: 'endX', displayName: 'End X', type: 'float' },
|
||||||
|
{ name: 'endY', displayName: 'End Y', type: 'float' }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'exec', displayName: '', type: 'exec' },
|
||||||
|
{ name: 'found', displayName: 'Found', type: 'bool' },
|
||||||
|
{ name: 'path', displayName: 'Path', type: 'array' },
|
||||||
|
{ name: 'cost', displayName: 'Cost', type: 'float' }
|
||||||
|
],
|
||||||
|
color: '#4caf50'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FindPathExecutor implements INodeExecutor {
|
||||||
|
execute(node: BlueprintNode, context: unknown): ExecutionResult {
|
||||||
|
const ctx = context as PathfindingContext;
|
||||||
|
const startX = ctx.evaluateInput(node.id, 'startX', 0) as number;
|
||||||
|
const startY = ctx.evaluateInput(node.id, 'startY', 0) as number;
|
||||||
|
const endX = ctx.evaluateInput(node.id, 'endX', 0) as number;
|
||||||
|
const endY = ctx.evaluateInput(node.id, 'endY', 0) as number;
|
||||||
|
|
||||||
|
const result = ctx.findPath(startX, startY, endX, endY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputs: {
|
||||||
|
found: result.found,
|
||||||
|
path: result.path,
|
||||||
|
cost: result.cost
|
||||||
|
},
|
||||||
|
nextExec: 'exec'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FindPathSmooth 节点 | FindPathSmooth Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const FindPathSmoothTemplate: BlueprintNodeTemplate = {
|
||||||
|
type: 'FindPathSmooth',
|
||||||
|
title: 'Find Path (Smooth)',
|
||||||
|
category: 'custom',
|
||||||
|
description: 'Find path with smoothing / 寻路并平滑路径',
|
||||||
|
keywords: ['path', 'pathfinding', 'smooth', 'navigate'],
|
||||||
|
menuPath: ['Pathfinding', 'Find Path (Smooth)'],
|
||||||
|
inputs: [
|
||||||
|
{ name: 'exec', displayName: '', type: 'exec' },
|
||||||
|
{ name: 'startX', displayName: 'Start X', type: 'float' },
|
||||||
|
{ name: 'startY', displayName: 'Start Y', type: 'float' },
|
||||||
|
{ name: 'endX', displayName: 'End X', type: 'float' },
|
||||||
|
{ name: 'endY', displayName: 'End Y', type: 'float' }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'exec', displayName: '', type: 'exec' },
|
||||||
|
{ name: 'found', displayName: 'Found', type: 'bool' },
|
||||||
|
{ name: 'path', displayName: 'Path', type: 'array' },
|
||||||
|
{ name: 'cost', displayName: 'Cost', type: 'float' }
|
||||||
|
],
|
||||||
|
color: '#4caf50'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FindPathSmoothExecutor implements INodeExecutor {
|
||||||
|
execute(node: BlueprintNode, context: unknown): ExecutionResult {
|
||||||
|
const ctx = context as PathfindingContext;
|
||||||
|
const startX = ctx.evaluateInput(node.id, 'startX', 0) as number;
|
||||||
|
const startY = ctx.evaluateInput(node.id, 'startY', 0) as number;
|
||||||
|
const endX = ctx.evaluateInput(node.id, 'endX', 0) as number;
|
||||||
|
const endY = ctx.evaluateInput(node.id, 'endY', 0) as number;
|
||||||
|
|
||||||
|
const result = ctx.findPathSmooth(startX, startY, endX, endY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputs: {
|
||||||
|
found: result.found,
|
||||||
|
path: result.path,
|
||||||
|
cost: result.cost
|
||||||
|
},
|
||||||
|
nextExec: 'exec'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// IsWalkable 节点 | IsWalkable Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const IsWalkableTemplate: BlueprintNodeTemplate = {
|
||||||
|
type: 'IsWalkable',
|
||||||
|
title: 'Is Walkable',
|
||||||
|
category: 'custom',
|
||||||
|
description: 'Check if position is walkable / 检查位置是否可通行',
|
||||||
|
keywords: ['walkable', 'obstacle', 'blocked', 'terrain'],
|
||||||
|
menuPath: ['Pathfinding', 'Is Walkable'],
|
||||||
|
isPure: true,
|
||||||
|
inputs: [
|
||||||
|
{ name: 'x', displayName: 'X', type: 'float' },
|
||||||
|
{ name: 'y', displayName: 'Y', type: 'float' }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'walkable', displayName: 'Walkable', type: 'bool' }
|
||||||
|
],
|
||||||
|
color: '#4caf50'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class IsWalkableExecutor implements INodeExecutor {
|
||||||
|
execute(node: BlueprintNode, context: unknown): ExecutionResult {
|
||||||
|
const ctx = context as PathfindingContext;
|
||||||
|
const x = ctx.evaluateInput(node.id, 'x', 0) as number;
|
||||||
|
const y = ctx.evaluateInput(node.id, 'y', 0) as number;
|
||||||
|
|
||||||
|
const walkable = ctx.isWalkable(x, y);
|
||||||
|
|
||||||
|
return { outputs: { walkable } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GetPathLength 节点 | GetPathLength Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const GetPathLengthTemplate: BlueprintNodeTemplate = {
|
||||||
|
type: 'GetPathLength',
|
||||||
|
title: 'Get Path Length',
|
||||||
|
category: 'custom',
|
||||||
|
description: 'Get the number of points in path / 获取路径点数量',
|
||||||
|
keywords: ['path', 'length', 'count', 'waypoints'],
|
||||||
|
menuPath: ['Pathfinding', 'Get Path Length'],
|
||||||
|
isPure: true,
|
||||||
|
inputs: [
|
||||||
|
{ name: 'path', displayName: 'Path', type: 'array' }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'length', displayName: 'Length', type: 'int' }
|
||||||
|
],
|
||||||
|
color: '#4caf50'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GetPathLengthExecutor implements INodeExecutor {
|
||||||
|
execute(node: BlueprintNode, context: unknown): ExecutionResult {
|
||||||
|
const ctx = context as PathfindingContext;
|
||||||
|
const path = ctx.evaluateInput(node.id, 'path', []) as IPoint[];
|
||||||
|
|
||||||
|
return { outputs: { length: path.length } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GetPathDistance 节点 | GetPathDistance Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const GetPathDistanceTemplate: BlueprintNodeTemplate = {
|
||||||
|
type: 'GetPathDistance',
|
||||||
|
title: 'Get Path Distance',
|
||||||
|
category: 'custom',
|
||||||
|
description: 'Get total path distance / 获取路径总距离',
|
||||||
|
keywords: ['path', 'distance', 'length', 'travel'],
|
||||||
|
menuPath: ['Pathfinding', 'Get Path Distance'],
|
||||||
|
isPure: true,
|
||||||
|
inputs: [
|
||||||
|
{ name: 'path', displayName: 'Path', type: 'array' }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'distance', displayName: 'Distance', type: 'float' }
|
||||||
|
],
|
||||||
|
color: '#4caf50'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GetPathDistanceExecutor implements INodeExecutor {
|
||||||
|
execute(node: BlueprintNode, context: unknown): ExecutionResult {
|
||||||
|
const ctx = context as PathfindingContext;
|
||||||
|
const path = ctx.evaluateInput(node.id, 'path', []) as IPoint[];
|
||||||
|
|
||||||
|
const distance = ctx.getPathDistance(path);
|
||||||
|
|
||||||
|
return { outputs: { distance } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GetPathPoint 节点 | GetPathPoint Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const GetPathPointTemplate: BlueprintNodeTemplate = {
|
||||||
|
type: 'GetPathPoint',
|
||||||
|
title: 'Get Path Point',
|
||||||
|
category: 'custom',
|
||||||
|
description: 'Get point at index in path / 获取路径中指定索引的点',
|
||||||
|
keywords: ['path', 'point', 'waypoint', 'index'],
|
||||||
|
menuPath: ['Pathfinding', 'Get Path Point'],
|
||||||
|
isPure: true,
|
||||||
|
inputs: [
|
||||||
|
{ name: 'path', displayName: 'Path', type: 'array' },
|
||||||
|
{ name: 'index', displayName: 'Index', type: 'int' }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'x', displayName: 'X', type: 'float' },
|
||||||
|
{ name: 'y', displayName: 'Y', type: 'float' },
|
||||||
|
{ name: 'valid', displayName: 'Valid', type: 'bool' }
|
||||||
|
],
|
||||||
|
color: '#4caf50'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GetPathPointExecutor implements INodeExecutor {
|
||||||
|
execute(node: BlueprintNode, context: unknown): ExecutionResult {
|
||||||
|
const ctx = context as PathfindingContext;
|
||||||
|
const path = ctx.evaluateInput(node.id, 'path', []) as IPoint[];
|
||||||
|
const index = ctx.evaluateInput(node.id, 'index', 0) as number;
|
||||||
|
|
||||||
|
if (index >= 0 && index < path.length) {
|
||||||
|
return {
|
||||||
|
outputs: {
|
||||||
|
x: path[index].x,
|
||||||
|
y: path[index].y,
|
||||||
|
valid: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputs: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
valid: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MoveAlongPath 节点 | MoveAlongPath Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const MoveAlongPathTemplate: BlueprintNodeTemplate = {
|
||||||
|
type: 'MoveAlongPath',
|
||||||
|
title: 'Move Along Path',
|
||||||
|
category: 'custom',
|
||||||
|
description: 'Get position along path at progress / 获取路径上指定进度的位置',
|
||||||
|
keywords: ['path', 'move', 'lerp', 'progress', 'interpolate'],
|
||||||
|
menuPath: ['Pathfinding', 'Move Along Path'],
|
||||||
|
isPure: true,
|
||||||
|
inputs: [
|
||||||
|
{ name: 'path', displayName: 'Path', type: 'array' },
|
||||||
|
{ name: 'progress', displayName: 'Progress (0-1)', type: 'float' }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'x', displayName: 'X', type: 'float' },
|
||||||
|
{ name: 'y', displayName: 'Y', type: 'float' }
|
||||||
|
],
|
||||||
|
color: '#4caf50'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MoveAlongPathExecutor implements INodeExecutor {
|
||||||
|
execute(node: BlueprintNode, context: unknown): ExecutionResult {
|
||||||
|
const ctx = context as PathfindingContext;
|
||||||
|
const path = ctx.evaluateInput(node.id, 'path', []) as IPoint[];
|
||||||
|
let progress = ctx.evaluateInput(node.id, 'progress', 0) as number;
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
return { outputs: { x: 0, y: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.length === 1) {
|
||||||
|
return { outputs: { x: path[0].x, y: path[0].y } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp progress
|
||||||
|
progress = Math.max(0, Math.min(1, progress));
|
||||||
|
|
||||||
|
// Calculate total distance
|
||||||
|
let totalDistance = 0;
|
||||||
|
const segmentDistances: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const dx = path[i].x - path[i - 1].x;
|
||||||
|
const dy = path[i].y - path[i - 1].y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
segmentDistances.push(dist);
|
||||||
|
totalDistance += dist;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalDistance === 0) {
|
||||||
|
return { outputs: { x: path[0].x, y: path[0].y } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the segment and position
|
||||||
|
const targetDistance = progress * totalDistance;
|
||||||
|
let accumulatedDistance = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < segmentDistances.length; i++) {
|
||||||
|
const segmentDist = segmentDistances[i];
|
||||||
|
|
||||||
|
if (accumulatedDistance + segmentDist >= targetDistance) {
|
||||||
|
const segmentProgress = (targetDistance - accumulatedDistance) / segmentDist;
|
||||||
|
const x = path[i].x + (path[i + 1].x - path[i].x) * segmentProgress;
|
||||||
|
const y = path[i].y + (path[i + 1].y - path[i].y) * segmentProgress;
|
||||||
|
return { outputs: { x, y } };
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulatedDistance += segmentDist;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return last point
|
||||||
|
const last = path[path.length - 1];
|
||||||
|
return { outputs: { x: last.x, y: last.y } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HasLineOfSight 节点 | HasLineOfSight Node
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const HasLineOfSightTemplate: BlueprintNodeTemplate = {
|
||||||
|
type: 'HasLineOfSight',
|
||||||
|
title: 'Has Line of Sight',
|
||||||
|
category: 'custom',
|
||||||
|
description: 'Check if there is a clear line between two points / 检查两点之间是否有清晰的视线',
|
||||||
|
keywords: ['line', 'sight', 'los', 'visibility', 'raycast'],
|
||||||
|
menuPath: ['Pathfinding', 'Has Line of Sight'],
|
||||||
|
isPure: true,
|
||||||
|
inputs: [
|
||||||
|
{ name: 'startX', displayName: 'Start X', type: 'float' },
|
||||||
|
{ name: 'startY', displayName: 'Start Y', type: 'float' },
|
||||||
|
{ name: 'endX', displayName: 'End X', type: 'float' },
|
||||||
|
{ name: 'endY', displayName: 'End Y', type: 'float' }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'hasLOS', displayName: 'Has LOS', type: 'bool' }
|
||||||
|
],
|
||||||
|
color: '#4caf50'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HasLineOfSightExecutor implements INodeExecutor {
|
||||||
|
execute(node: BlueprintNode, context: unknown): ExecutionResult {
|
||||||
|
const ctx = context as PathfindingContext & {
|
||||||
|
hasLineOfSight?(x1: number, y1: number, x2: number, y2: number): boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startX = ctx.evaluateInput(node.id, 'startX', 0) as number;
|
||||||
|
const startY = ctx.evaluateInput(node.id, 'startY', 0) as number;
|
||||||
|
const endX = ctx.evaluateInput(node.id, 'endX', 0) as number;
|
||||||
|
const endY = ctx.evaluateInput(node.id, 'endY', 0) as number;
|
||||||
|
|
||||||
|
const hasLOS = ctx.hasLineOfSight?.(startX, startY, endX, endY) ?? true;
|
||||||
|
|
||||||
|
return { outputs: { hasLOS } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 节点定义集合 | Node Definition Collection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const PathfindingNodeDefinitions = {
|
||||||
|
templates: [
|
||||||
|
FindPathTemplate,
|
||||||
|
FindPathSmoothTemplate,
|
||||||
|
IsWalkableTemplate,
|
||||||
|
GetPathLengthTemplate,
|
||||||
|
GetPathDistanceTemplate,
|
||||||
|
GetPathPointTemplate,
|
||||||
|
MoveAlongPathTemplate,
|
||||||
|
HasLineOfSightTemplate
|
||||||
|
],
|
||||||
|
executors: new Map<string, INodeExecutor>([
|
||||||
|
['FindPath', new FindPathExecutor()],
|
||||||
|
['FindPathSmooth', new FindPathSmoothExecutor()],
|
||||||
|
['IsWalkable', new IsWalkableExecutor()],
|
||||||
|
['GetPathLength', new GetPathLengthExecutor()],
|
||||||
|
['GetPathDistance', new GetPathDistanceExecutor()],
|
||||||
|
['GetPathPoint', new GetPathPointExecutor()],
|
||||||
|
['MoveAlongPath', new MoveAlongPathExecutor()],
|
||||||
|
['HasLineOfSight', new HasLineOfSightExecutor()]
|
||||||
|
])
|
||||||
|
};
|
||||||
27
packages/pathfinding/src/nodes/index.ts
Normal file
27
packages/pathfinding/src/nodes/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @zh 寻路蓝图节点模块
|
||||||
|
* @en Pathfinding Blueprint Nodes Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Templates
|
||||||
|
FindPathTemplate,
|
||||||
|
FindPathSmoothTemplate,
|
||||||
|
IsWalkableTemplate,
|
||||||
|
GetPathLengthTemplate,
|
||||||
|
GetPathDistanceTemplate,
|
||||||
|
GetPathPointTemplate,
|
||||||
|
MoveAlongPathTemplate,
|
||||||
|
HasLineOfSightTemplate,
|
||||||
|
// Executors
|
||||||
|
FindPathExecutor,
|
||||||
|
FindPathSmoothExecutor,
|
||||||
|
IsWalkableExecutor,
|
||||||
|
GetPathLengthExecutor,
|
||||||
|
GetPathDistanceExecutor,
|
||||||
|
GetPathPointExecutor,
|
||||||
|
MoveAlongPathExecutor,
|
||||||
|
HasLineOfSightExecutor,
|
||||||
|
// Collection
|
||||||
|
PathfindingNodeDefinitions
|
||||||
|
} from './PathfindingNodes';
|
||||||
307
packages/pathfinding/src/smoothing/PathSmoother.ts
Normal file
307
packages/pathfinding/src/smoothing/PathSmoother.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* @zh 路径平滑算法
|
||||||
|
* @en Path Smoothing Algorithms
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IPathfindingMap,
|
||||||
|
IPathSmoother,
|
||||||
|
IPoint
|
||||||
|
} from '../core/IPathfinding';
|
||||||
|
import { createPoint } from '../core/IPathfinding';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 视线检测 | Line of Sight
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 使用 Bresenham 算法检测视线
|
||||||
|
* @en Line of sight check using Bresenham algorithm
|
||||||
|
*/
|
||||||
|
export function bresenhamLineOfSight(
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
map: IPathfindingMap
|
||||||
|
): boolean {
|
||||||
|
// Round to grid coordinates
|
||||||
|
let ix1 = Math.floor(x1);
|
||||||
|
let iy1 = Math.floor(y1);
|
||||||
|
const ix2 = Math.floor(x2);
|
||||||
|
const iy2 = Math.floor(y2);
|
||||||
|
|
||||||
|
const dx = Math.abs(ix2 - ix1);
|
||||||
|
const dy = Math.abs(iy2 - iy1);
|
||||||
|
|
||||||
|
const sx = ix1 < ix2 ? 1 : -1;
|
||||||
|
const sy = iy1 < iy2 ? 1 : -1;
|
||||||
|
|
||||||
|
let err = dx - dy;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (!map.isWalkable(ix1, iy1)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ix1 === ix2 && iy1 === iy2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const e2 = 2 * err;
|
||||||
|
|
||||||
|
if (e2 > -dy) {
|
||||||
|
err -= dy;
|
||||||
|
ix1 += sx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e2 < dx) {
|
||||||
|
err += dx;
|
||||||
|
iy1 += sy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 使用射线投射检测视线(更精确)
|
||||||
|
* @en Line of sight check using ray casting (more precise)
|
||||||
|
*/
|
||||||
|
export function raycastLineOfSight(
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
map: IPathfindingMap,
|
||||||
|
stepSize: number = 0.5
|
||||||
|
): boolean {
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance === 0) {
|
||||||
|
return map.isWalkable(Math.floor(x1), Math.floor(y1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = Math.ceil(distance / stepSize);
|
||||||
|
const stepX = dx / steps;
|
||||||
|
const stepY = dy / steps;
|
||||||
|
|
||||||
|
let x = x1;
|
||||||
|
let y = y1;
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
if (!map.isWalkable(Math.floor(x), Math.floor(y))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
x += stepX;
|
||||||
|
y += stepY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 路径简化器(拐点移除)| Path Simplifier (Waypoint Removal)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 路径简化器 - 移除不必要的拐点
|
||||||
|
* @en Path Simplifier - Removes unnecessary waypoints
|
||||||
|
*
|
||||||
|
* @zh 使用视线检测移除可以直接到达的中间点
|
||||||
|
* @en Uses line of sight to remove intermediate points that can be reached directly
|
||||||
|
*/
|
||||||
|
export class LineOfSightSmoother implements IPathSmoother {
|
||||||
|
private readonly lineOfSight: typeof bresenhamLineOfSight;
|
||||||
|
|
||||||
|
constructor(lineOfSight: typeof bresenhamLineOfSight = bresenhamLineOfSight) {
|
||||||
|
this.lineOfSight = lineOfSight;
|
||||||
|
}
|
||||||
|
|
||||||
|
smooth(path: readonly IPoint[], map: IPathfindingMap): IPoint[] {
|
||||||
|
if (path.length <= 2) {
|
||||||
|
return [...path];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: IPoint[] = [path[0]];
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
while (current < path.length - 1) {
|
||||||
|
// Find the furthest point we can see from current
|
||||||
|
let furthest = current + 1;
|
||||||
|
|
||||||
|
for (let i = path.length - 1; i > current + 1; i--) {
|
||||||
|
if (this.lineOfSight(
|
||||||
|
path[current].x,
|
||||||
|
path[current].y,
|
||||||
|
path[i].x,
|
||||||
|
path[i].y,
|
||||||
|
map
|
||||||
|
)) {
|
||||||
|
furthest = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(path[furthest]);
|
||||||
|
current = furthest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 曲线平滑器 | Curve Smoother
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Catmull-Rom 样条曲线平滑
|
||||||
|
* @en Catmull-Rom spline smoothing
|
||||||
|
*/
|
||||||
|
export class CatmullRomSmoother implements IPathSmoother {
|
||||||
|
private readonly segments: number;
|
||||||
|
private readonly tension: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param segments - @zh 每段之间的插值点数 @en Number of interpolation points per segment
|
||||||
|
* @param tension - @zh 张力 (0-1) @en Tension (0-1)
|
||||||
|
*/
|
||||||
|
constructor(segments: number = 5, tension: number = 0.5) {
|
||||||
|
this.segments = segments;
|
||||||
|
this.tension = tension;
|
||||||
|
}
|
||||||
|
|
||||||
|
smooth(path: readonly IPoint[], _map: IPathfindingMap): IPoint[] {
|
||||||
|
if (path.length <= 2) {
|
||||||
|
return [...path];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: IPoint[] = [];
|
||||||
|
|
||||||
|
// Add phantom points at the ends
|
||||||
|
const points = [
|
||||||
|
path[0],
|
||||||
|
...path,
|
||||||
|
path[path.length - 1]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length - 2; i++) {
|
||||||
|
const p0 = points[i - 1];
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[i + 1];
|
||||||
|
const p3 = points[i + 2];
|
||||||
|
|
||||||
|
for (let j = 0; j < this.segments; j++) {
|
||||||
|
const t = j / this.segments;
|
||||||
|
const point = this.interpolate(p0, p1, p2, p3, t);
|
||||||
|
result.push(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add final point
|
||||||
|
result.push(path[path.length - 1]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Catmull-Rom 插值
|
||||||
|
* @en Catmull-Rom interpolation
|
||||||
|
*/
|
||||||
|
private interpolate(
|
||||||
|
p0: IPoint,
|
||||||
|
p1: IPoint,
|
||||||
|
p2: IPoint,
|
||||||
|
p3: IPoint,
|
||||||
|
t: number
|
||||||
|
): IPoint {
|
||||||
|
const t2 = t * t;
|
||||||
|
const t3 = t2 * t;
|
||||||
|
|
||||||
|
const tension = this.tension;
|
||||||
|
|
||||||
|
const x =
|
||||||
|
0.5 *
|
||||||
|
((2 * p1.x) +
|
||||||
|
(-p0.x + p2.x) * t * tension +
|
||||||
|
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 * tension +
|
||||||
|
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3 * tension);
|
||||||
|
|
||||||
|
const y =
|
||||||
|
0.5 *
|
||||||
|
((2 * p1.y) +
|
||||||
|
(-p0.y + p2.y) * t * tension +
|
||||||
|
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 * tension +
|
||||||
|
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3 * tension);
|
||||||
|
|
||||||
|
return createPoint(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 组合平滑器 | Combined Smoother
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 组合路径平滑器
|
||||||
|
* @en Combined path smoother
|
||||||
|
*
|
||||||
|
* @zh 先简化路径,再用曲线平滑
|
||||||
|
* @en First simplify path, then smooth with curves
|
||||||
|
*/
|
||||||
|
export class CombinedSmoother implements IPathSmoother {
|
||||||
|
private readonly simplifier: LineOfSightSmoother;
|
||||||
|
private readonly curveSmoother: CatmullRomSmoother;
|
||||||
|
|
||||||
|
constructor(curveSegments: number = 5, tension: number = 0.5) {
|
||||||
|
this.simplifier = new LineOfSightSmoother();
|
||||||
|
this.curveSmoother = new CatmullRomSmoother(curveSegments, tension);
|
||||||
|
}
|
||||||
|
|
||||||
|
smooth(path: readonly IPoint[], map: IPathfindingMap): IPoint[] {
|
||||||
|
// First simplify
|
||||||
|
const simplified = this.simplifier.smooth(path, map);
|
||||||
|
|
||||||
|
// Then curve smooth
|
||||||
|
return this.curveSmoother.smooth(simplified, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工厂函数 | Factory Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建视线平滑器
|
||||||
|
* @en Create line of sight smoother
|
||||||
|
*/
|
||||||
|
export function createLineOfSightSmoother(
|
||||||
|
lineOfSight?: typeof bresenhamLineOfSight
|
||||||
|
): LineOfSightSmoother {
|
||||||
|
return new LineOfSightSmoother(lineOfSight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建曲线平滑器
|
||||||
|
* @en Create curve smoother
|
||||||
|
*/
|
||||||
|
export function createCatmullRomSmoother(
|
||||||
|
segments?: number,
|
||||||
|
tension?: number
|
||||||
|
): CatmullRomSmoother {
|
||||||
|
return new CatmullRomSmoother(segments, tension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建组合平滑器
|
||||||
|
* @en Create combined smoother
|
||||||
|
*/
|
||||||
|
export function createCombinedSmoother(
|
||||||
|
curveSegments?: number,
|
||||||
|
tension?: number
|
||||||
|
): CombinedSmoother {
|
||||||
|
return new CombinedSmoother(curveSegments, tension);
|
||||||
|
}
|
||||||
15
packages/pathfinding/src/smoothing/index.ts
Normal file
15
packages/pathfinding/src/smoothing/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @zh 路径平滑模块
|
||||||
|
* @en Path Smoothing Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
bresenhamLineOfSight,
|
||||||
|
raycastLineOfSight,
|
||||||
|
LineOfSightSmoother,
|
||||||
|
CatmullRomSmoother,
|
||||||
|
CombinedSmoother,
|
||||||
|
createLineOfSightSmoother,
|
||||||
|
createCatmullRomSmoother,
|
||||||
|
createCombinedSmoother
|
||||||
|
} from './PathSmoother';
|
||||||
14
packages/pathfinding/tsconfig.build.json
Normal file
14
packages/pathfinding/tsconfig.build.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": false,
|
||||||
|
"incremental": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
8
packages/pathfinding/tsconfig.json
Normal file
8
packages/pathfinding/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
10
packages/pathfinding/tsup.config.ts
Normal file
10
packages/pathfinding/tsup.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
tsconfig: 'tsconfig.build.json'
|
||||||
|
});
|
||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -1440,6 +1440,25 @@ importers:
|
|||||||
specifier: ^5.0.8
|
specifier: ^5.0.8
|
||||||
version: 5.0.8(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
version: 5.0.8(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||||
|
|
||||||
|
packages/pathfinding:
|
||||||
|
dependencies:
|
||||||
|
'@esengine/blueprint':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../blueprint
|
||||||
|
'@esengine/ecs-framework':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core
|
||||||
|
'@esengine/ecs-framework-math':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../math
|
||||||
|
devDependencies:
|
||||||
|
tsup:
|
||||||
|
specifier: ^8.0.0
|
||||||
|
version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.8.0
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/physics-rapier2d:
|
packages/physics-rapier2d:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@esengine/platform-common':
|
'@esengine/platform-common':
|
||||||
|
|||||||
Reference in New Issue
Block a user