Files
esengine/packages/framework/pathfinding/src/grid/GridMap.ts

365 lines
9.8 KiB
TypeScript
Raw Normal View History

/**
* @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 48
* @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);
}