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:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View 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);
}

View 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;
}
}
}

View 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;

View 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';

View 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);
}

View 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';

View 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';

View 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();
}

View File

@@ -0,0 +1,11 @@
/**
* @zh 导航网格模块
* @en NavMesh Module
*/
export {
NavMesh,
createNavMesh,
type INavPolygon,
type IPortal
} from './NavMesh';

View 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()]
])
};

View 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';

View 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);
}

View File

@@ -0,0 +1,15 @@
/**
* @zh 路径平滑模块
* @en Path Smoothing Module
*/
export {
bresenhamLineOfSight,
raycastLineOfSight,
LineOfSightSmoother,
CatmullRomSmoother,
CombinedSmoother,
createLineOfSightSmoother,
createCatmullRomSmoother,
createCombinedSmoother
} from './PathSmoother';