refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
235
packages/framework/pathfinding/src/core/AStarPathfinder.ts
Normal file
235
packages/framework/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/framework/pathfinding/src/core/BinaryHeap.ts
Normal file
155
packages/framework/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/framework/pathfinding/src/core/IPathfinding.ts
Normal file
236
packages/framework/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/framework/pathfinding/src/core/index.ts
Normal file
30
packages/framework/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/framework/pathfinding/src/grid/GridMap.ts
Normal file
364
packages/framework/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/framework/pathfinding/src/grid/index.ts
Normal file
14
packages/framework/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/framework/pathfinding/src/index.ts
Normal file
103
packages/framework/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/framework/pathfinding/src/navmesh/NavMesh.ts
Normal file
615
packages/framework/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/framework/pathfinding/src/navmesh/index.ts
Normal file
11
packages/framework/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/framework/pathfinding/src/nodes/PathfindingNodes.ts
Normal file
409
packages/framework/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/framework/pathfinding/src/nodes/index.ts
Normal file
27
packages/framework/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/framework/pathfinding/src/smoothing/PathSmoother.ts
Normal file
307
packages/framework/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/framework/pathfinding/src/smoothing/index.ts
Normal file
15
packages/framework/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';
|
||||
Reference in New Issue
Block a user