diff --git a/packages-user/data-system/src/path/finder.ts b/packages-user/data-system/src/path/finder.ts index 919369b..fa7e3b2 100644 --- a/packages-user/data-system/src/path/finder.ts +++ b/packages-user/data-system/src/path/finder.ts @@ -1,6 +1,5 @@ import { InternalDirectionGroup, ITileLocator, logger } from '@motajs/common'; import { - ILayerLocation, IMapLayer, IMapState, IPassPredicate, @@ -135,21 +134,6 @@ export class PathfindingFinder implements IPathfinder { this.group = group; } - /** - * 获取进入指定位置节点的损失,损失值为 NaN 或负数时告警并按损失 1 处理, - * Infinity 为合法损失值 - * @param block 位置信息 - */ - private getNodeCost(block: ILayerLocation): number { - if (!this.cost) return 1; - const value = this.cost(block); - if (Number.isNaN(value) || value < 0) { - logger.warn(174); - return 1; - } - return value; - } - /** * 在有向图上执行最小损失搜索,终端节点仅可作为路径终点,不可作为中间节点 * @param graph 寻路有向图 @@ -192,7 +176,7 @@ export class PathfindingFinder implements IPathfinder { const next = graph.nodes.get(edge.to); if (!next) continue; if (next.terminal && edge.to !== targetIndex) continue; - const total = currDist + this.getNodeCost(next.block); + const total = currDist + next.cost; const known = dist.get(edge.to); if (isNil(known) || total < known) { dist.set(edge.to, total); @@ -241,9 +225,10 @@ export class PathfindingFinder implements IPathfinder { const builder = new PathfindingGraphBuilder(); builder.useMapState(maps); builder.useMapLayer(layer); + builder.useCostFunction(this.cost); builder.usePassPredicate(this.predicate); builder.useDirGroup(this.group); - const graph = builder.build(); + const graph = builder.build(start); return this.search(graph, start, target); } } diff --git a/packages-user/data-system/src/path/graph.test.ts b/packages-user/data-system/src/path/graph.test.ts index 42fb57b..a9df592 100644 --- a/packages-user/data-system/src/path/graph.test.ts +++ b/packages-user/data-system/src/path/graph.test.ts @@ -1,4 +1,4 @@ -// 测试寻路有向图构建:邻域方向组、单向门、终端节点分类与边界守卫 +// 测试寻路有向图构建:BFS 可达过滤、邻域方向组、单向门、终端节点分类、损失预计算与边界守卫 import { beforeAll, describe, expect, it, vi } from 'vitest'; import { FaceDirection } from '@user/data-common'; import { @@ -101,6 +101,15 @@ const SINK_TILE: TestTileDefinition = { eventPass: true }; +/** 墙体图块:不可进入也不可离开 */ +const WALL_TILE: TestTileDefinition = { + num: 6, + id: 'wall', + outPass: 0, + inPass: 0, + eventPass: true +}; + /** 撞击图块:四向可进可出但事件不通行,构成终端节点 */ const HIT_TILE: TestTileDefinition = { num: 5, @@ -112,6 +121,7 @@ const HIT_TILE: TestTileDefinition = { const ALL_TILES: TestTileDefinition[] = [ OPEN_TILE, + WALL_TILE, ONEWAY_TILE, SINK_TILE, HIT_TILE @@ -176,8 +186,8 @@ class FixturePredicate implements IPassPredicate { // 判断事件层 const curr = event.getLocationData(currLoc.x, currLoc.y); const next = event.getLocationData(nextLoc.x, nextLoc.y); - const currRaw = curr?.static.raw(); - const nextRaw = next?.static.raw(); + const currRaw = curr?.static?.raw(); + const nextRaw = next?.static?.raw(); if (currRaw) { canLeave = !!(leaveMask & currRaw.pass.outPass); } @@ -191,8 +201,8 @@ class FixturePredicate implements IPassPredicate { if (layer === event) continue; const other = layer.getLocationData(currLoc.x, currLoc.y); const otherNext = layer.getLocationData(nextLoc.x, nextLoc.y); - const otherRaw = other?.static.raw(); - const otherNextRaw = otherNext?.static.raw(); + const otherRaw = other?.static?.raw(); + const otherNextRaw = otherNext?.static?.raw(); if (otherRaw?.pass.onlyEvents) { canLeave = !!(leaveMask & otherRaw.pass.outPass); } @@ -209,7 +219,7 @@ class FixturePredicate implements IPassPredicate { if (!event) return false; const { nextLoc } = handler; const next = event.getLocationData(nextLoc.x, nextLoc.y); - const nextRaw = next?.static.raw(); + const nextRaw = next?.static?.raw(); if (!nextRaw) return false; return !nextRaw.eventPass; } @@ -271,23 +281,23 @@ function createFixture( } describe('pathfinding graph building', () => { - // 验证未注入谓词时图节点齐备但所有边均不可行 - it('builds nodes without edges when no predicate is injected', () => { + // 验证未注入谓词时无可通行边,BFS 仅包含起始位置自身且无损失告警 + it('includes only the start node when no predicate is injected', () => { const { map, builder } = createFixture( [1, 1, 1, 1, 1, 1, 1, 1, 1], 3, null ); builder.useMapLayer(map.getLayerByAlias('event')); - const graph = builder.build(); + const graph = builder.build({ x: 1, y: 1 }); expect(graph.width).toBe(3); expect(graph.height).toBe(3); - expect(graph.nodes.size).toBe(9); - for (const node of graph.nodes.values()) { - expect(node.edges).toHaveLength(0); - expect(node.terminal).toBe(false); - } + expect(graph.nodes.size).toBe(1); + const start = graph.nodes.get(1 * 3 + 1)!; + expect(start.edges).toHaveLength(0); + expect(start.terminal).toBe(false); + expect(start.cost).toBe(1); }); // 验证注入谓词后中心节点邻域方向数与 DirectionMapper 四正交组一致为 4 @@ -304,7 +314,7 @@ describe('pathfinding graph building', () => { new modules.Dir8FaceHandler() ); builder.usePassPredicate(predicate); - const graph = builder.build(); + const graph = builder.build({ x: 1, y: 1 }); const center = graph.nodes.get(1 * 3 + 1)!; expect(center.edges).toHaveLength(expected.length); @@ -333,7 +343,7 @@ describe('pathfinding graph building', () => { ); builder.usePassPredicate(predicate); builder.useDirGroup(InternalDirectionGroup.Dir8); - const graph = builder.build(); + const graph = builder.build({ x: 1, y: 1 }); const center = graph.nodes.get(1 * 3 + 1)!; expect(center.edges).toHaveLength(8); @@ -347,7 +357,7 @@ describe('pathfinding graph building', () => { new modules.Dir8FaceHandler() ); builder.usePassPredicate(predicate); - const graph = builder.build(); + const graph = builder.build({ x: 0, y: 0 }); const source = graph.nodes.get(0)!; const sink = graph.nodes.get(1)!; @@ -364,7 +374,7 @@ describe('pathfinding graph building', () => { new modules.Dir8FaceHandler() ); builder.usePassPredicate(predicate); - const graph = builder.build(); + const graph = builder.build({ x: 0, y: 0 }); const hit = graph.nodes.get(1)!; expect(hit.terminal).toBe(true); @@ -381,9 +391,135 @@ describe('pathfinding graph building', () => { const builder = new modules.PathfindingGraphBuilder(); builder.useMapLayer(null); - const result = modules.logger.catch(() => builder.build()); + const result = modules.logger.catch(() => + builder.build({ x: 0, y: 0 }) + ); expect(result.ret.nodes.size).toBe(0); expect(result.info.map(info => info.code)).toContain(173); }); + + // 验证起始位置越界时构建入口同样告警新码 173 并返回空图 + it('warns the registered code and returns an empty graph when start is out of map', () => { + const { builder } = createFixture([1, 1, 1, 1, 1, 1, 1, 1, 1], 3, null); + + const result = modules.logger.catch(() => + builder.build({ x: 3, y: 0 }) + ); + + expect(result.ret.nodes.size).toBe(0); + expect(result.ret.width).toBe(0); + expect(result.info.map(info => info.code)).toContain(173); + }); + + // 验证墙体隔断区域不进入有向图:图仅包含从起始位置沿可通行边可达的节点 + it('excludes walled-off regions from the graph', () => { + const { map, builder } = createFixture( + [1, 6, 1, 1, 6, 1, 1, 6, 1], + 3, + null + ); + builder.usePassPredicate( + new FixturePredicate(map, new modules.Dir8FaceHandler()) + ); + const graph = builder.build({ x: 0, y: 0 }); + + expect(graph.nodes.size).toBe(3); + expect(graph.nodes.has(0)).toBe(true); + expect(graph.nodes.has(3)).toBe(true); + expect(graph.nodes.has(6)).toBe(true); + expect(graph.nodes.has(2)).toBe(false); + expect(graph.nodes.has(5)).toBe(false); + expect(graph.nodes.has(8)).toBe(false); + }); + + // 验证节点损失在构建时预计算:自定义损失函数的取值直接出现在 node.cost 上 + it('precomputes node costs from the injected cost function at build time', () => { + const { map, builder } = createFixture( + [1, 1, 1, 1, 1, 1, 1, 1, 1], + 3, + null + ); + builder.usePassPredicate( + new FixturePredicate(map, new modules.Dir8FaceHandler()) + ); + builder.useCostFunction(block => + block.locator.x === 1 && block.locator.y === 1 ? 7 : 3 + ); + const graph = builder.build({ x: 1, y: 1 }); + + expect(graph.nodes.get(1 * 3 + 1)!.cost).toBe(7); + expect(graph.nodes.get(0)!.cost).toBe(3); + expect(graph.nodes.get(2 * 3 + 2)!.cost).toBe(3); + }); + + // 验证 Infinity 是合法损失值:构建时不告警,损失值原样保留在节点上 + it('allows Infinity as a legitimate node cost without warning', () => { + const { map, builder } = createFixture( + [1, 1, 1, 1, 1, 1, 1, 1, 1], + 3, + null + ); + builder.usePassPredicate( + new FixturePredicate(map, new modules.Dir8FaceHandler()) + ); + builder.useCostFunction(block => + block.locator.x === 1 && block.locator.y === 1 + ? Number.POSITIVE_INFINITY + : 1 + ); + + const result = modules.logger.catch(() => + builder.build({ x: 0, y: 0 }) + ); + + expect(result.info.map(info => info.code)).not.toContain(174); + expect(result.ret.nodes.get(1 * 3 + 1)!.cost).toBe( + Number.POSITIVE_INFINITY + ); + }); + + // 验证 NaN 损失在构建时告警新码 174 且每个非法节点仅告警一次,并回退为损失 1 + it('warns once per invalid node per build and falls back to unit cost on NaN', () => { + const { map, builder } = createFixture( + [1, 1, 1, 1, 1, 1, 1, 1, 1], + 3, + null + ); + builder.usePassPredicate( + new FixturePredicate(map, new modules.Dir8FaceHandler()) + ); + builder.useCostFunction(block => + block.locator.x === 1 && block.locator.y === 1 ? Number.NaN : 1 + ); + + const result = modules.logger.catch(() => + builder.build({ x: 0, y: 0 }) + ); + + expect(result.info.filter(info => info.code === 174)).toHaveLength(1); + expect(result.ret.nodes.get(1 * 3 + 1)!.cost).toBe(1); + }); + + // 验证负数损失同样在构建时告警新码 174 并回退为损失 1 + it('warns and falls back to unit cost on negative node cost', () => { + const { map, builder } = createFixture( + [1, 1, 1, 1, 1, 1, 1, 1, 1], + 3, + null + ); + builder.usePassPredicate( + new FixturePredicate(map, new modules.Dir8FaceHandler()) + ); + builder.useCostFunction(block => + block.locator.x === 1 && block.locator.y === 1 ? -2 : 1 + ); + + const result = modules.logger.catch(() => + builder.build({ x: 0, y: 0 }) + ); + + expect(result.info.filter(info => info.code === 174)).toHaveLength(1); + expect(result.ret.nodes.get(1 * 3 + 1)!.cost).toBe(1); + }); }); diff --git a/packages-user/data-system/src/path/graph.ts b/packages-user/data-system/src/path/graph.ts index 5556f73..8d5e89c 100644 --- a/packages-user/data-system/src/path/graph.ts +++ b/packages-user/data-system/src/path/graph.ts @@ -3,6 +3,7 @@ import { IDirectionDescriptor, IDirectionMapper, InternalDirectionGroup, + ITileLocator, logger } from '@motajs/common'; import { FaceDirection } from '@user/data-common'; @@ -18,7 +19,8 @@ import { IPathGraph, IPathGraphEdge, IPathGraphNode, - IPathfindingGraphBuilder + IPathfindingGraphBuilder, + PathCostFunction } from './types'; /** @@ -43,6 +45,8 @@ export class PathfindingGraphBuilder implements IPathfindingGraphBuilder { private maps: IMapState | null = null; /** 绑定的地图图层,图节点来源 */ private layer: IMapLayer | null = null; + /** 注入的损失函数,未注入时每格损失 1 */ + private cost: PathCostFunction | null = null; /** 注入的通行性谓词,用于判定边的可行性与终端节点 */ private predicate: IPassPredicate | null = null; /** 邻域方向组别,默认四正交方向 */ @@ -59,6 +63,10 @@ export class PathfindingGraphBuilder implements IPathfindingGraphBuilder { this.layer = layer; } + useCostFunction(cost: PathCostFunction | null): void { + this.cost = cost; + } + usePassPredicate(predicate: IPassPredicate | null): void { this.predicate = predicate; } @@ -81,9 +89,24 @@ export class PathfindingGraphBuilder implements IPathfindingGraphBuilder { return undefined; } - build(): IPathGraph { + /** + * 获取进入指定位置节点的损失,损失值为 NaN 或负数时告警并按损失 1 处理, + * Infinity 为合法损失值 + * @param block 位置信息 + */ + private resolveCost(block: ILayerLocation): number { + if (!this.cost) return 1; + const value = this.cost(block); + if (Number.isNaN(value) || value < 0) { + logger.warn(174); + return 1; + } + return value; + } + + build(start: ITileLocator): IPathGraph { const layer = this.layer; - if (isNil(layer)) { + if (isNil(layer) || !layer.inMap(start.x, start.y)) { logger.warn(173); return { width: 0, height: 0, nodes: new Map() }; } @@ -92,28 +115,21 @@ export class PathfindingGraphBuilder implements IPathfindingGraphBuilder { const height = layer.height; const floorId = this.resolveFloorId(); const state = layer.state; - const blocks: (ILayerLocation | null)[] = new Array( - width * height - ).fill(null); - - // 收集图内全部图块作为图节点 - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - if (!layer.inMap(x, y)) continue; - const loc = layer.getLocationData(x, y); - if (!loc) continue; - blocks[y * width + x] = loc; - } - } + const dirs: IDirectionDescriptor[] = [...this.mapper.map(this.group)]; const terminals: Set = new Set(); const adjacency: Map = new Map(); - const dirs: IDirectionDescriptor[] = [...this.mapper.map(this.group)]; + const blocks: Map = new Map(); + const startIndex = start.y * width + start.x; + blocks.set(startIndex, layer.getLocationData(start.x, start.y)!); + adjacency.set(startIndex, []); - // 逐节点判定邻域边可行性 - for (let index = 0; index < blocks.length; index++) { - const block = blocks[index]; - if (!block) continue; + // 以起始位置为中心 BFS,仅沿可通行有向边扩展,不可达区域不入图 + const queue: number[] = [startIndex]; + let head = 0; + while (head < queue.length) { + const index = queue[head++]!; + const block = blocks.get(index)!; const x = index % width; const y = Math.floor(index / width); const edges: IPathGraphEdge[] = []; @@ -123,8 +139,7 @@ export class PathfindingGraphBuilder implements IPathfindingGraphBuilder { const nx = x + desc.x; const ny = y + desc.y; if (!layer.inMap(nx, ny)) continue; - const next = blocks[ny * width + nx]; - if (!next) continue; + const next = layer.getLocationData(nx, ny)!; const handler: IPassCheckHandler = { currLoc: block.locator, nextLoc: next.locator, @@ -135,23 +150,28 @@ export class PathfindingGraphBuilder implements IPathfindingGraphBuilder { if (isNil(this.predicate) || !this.predicate.canPass(handler)) { continue; } + const nextIndex = ny * width + nx; if (this.predicate.shouldHit(handler)) { - terminals.add(ny * width + nx); + terminals.add(nextIndex); + } + edges.push({ dir, to: nextIndex }); + if (!blocks.has(nextIndex)) { + blocks.set(nextIndex, next); + adjacency.set(nextIndex, []); + queue.push(nextIndex); } - edges.push({ dir, to: ny * width + nx }); } adjacency.set(index, edges); } const nodes: Map = new Map(); - for (let index = 0; index < blocks.length; index++) { - const block = blocks[index]; - if (!block) continue; + for (const [index, block] of blocks) { nodes.set(index, { index, x: index % width, y: Math.floor(index / width), block, + cost: this.resolveCost(block), terminal: terminals.has(index), edges: adjacency.get(index) ?? [] }); diff --git a/packages-user/data-system/src/path/performance.test.ts b/packages-user/data-system/src/path/performance.test.ts index b4f786e..e896af5 100644 --- a/packages-user/data-system/src/path/performance.test.ts +++ b/packages-user/data-system/src/path/performance.test.ts @@ -145,8 +145,8 @@ class FixturePredicate implements IPassPredicate { let canEnter = true; const curr = event.getLocationData(currLoc.x, currLoc.y); const next = event.getLocationData(nextLoc.x, nextLoc.y); - const currRaw = curr?.static.raw(); - const nextRaw = next?.static.raw(); + const currRaw = curr?.static?.raw(); + const nextRaw = next?.static?.raw(); if (currRaw) { canLeave = !!(leaveMask & currRaw.pass.outPass); } @@ -289,7 +289,7 @@ function createPerformanceSystem(rows: number[], width: number) { builder.useMapState(maps); builder.useMapLayer(layer); builder.usePassPredicate(predicate); - return builder.build(); + return builder.build({ x: 0, y: 0 }); } }; } diff --git a/packages-user/data-system/src/path/system.test.ts b/packages-user/data-system/src/path/system.test.ts index a4cc80e..433298f 100644 --- a/packages-user/data-system/src/path/system.test.ts +++ b/packages-user/data-system/src/path/system.test.ts @@ -178,8 +178,8 @@ class FixturePredicate implements IPassPredicate { let canEnter = true; const curr = event.getLocationData(currLoc.x, currLoc.y); const next = event.getLocationData(nextLoc.x, nextLoc.y); - const currRaw = curr?.static.raw(); - const nextRaw = next?.static.raw(); + const currRaw = curr?.static?.raw(); + const nextRaw = next?.static?.raw(); if (currRaw) { canLeave = !!(leaveMask & currRaw.pass.outPass); } @@ -194,7 +194,7 @@ class FixturePredicate implements IPassPredicate { if (!event) return false; const { nextLoc } = handler; const next = event.getLocationData(nextLoc.x, nextLoc.y); - const nextRaw = next?.static.raw(); + const nextRaw = next?.static?.raw(); if (!nextRaw) return false; return !nextRaw.eventPass; } diff --git a/packages-user/data-system/src/path/types.ts b/packages-user/data-system/src/path/types.ts index 8060930..50e4363 100644 --- a/packages-user/data-system/src/path/types.ts +++ b/packages-user/data-system/src/path/types.ts @@ -140,8 +140,10 @@ export interface IPathGraphNode { readonly x: number; /** 节点纵坐标 */ readonly y: number; - /** 节点对应的位置信息,用于损失计算 */ + /** 节点对应的位置信息 */ readonly block: ILayerLocation; + /** 进入该节点的损失,构建图时由损失函数计算 */ + readonly cost: number; /** 该节点是否仅可作为路径终点,不可作为中间节点 */ readonly terminal: boolean; /** 该节点的全部出边 */ @@ -170,6 +172,12 @@ export interface IPathfindingGraphBuilder { */ useMapLayer(layer: IMapLayer | null): void; + /** + * 设置构建图时使用的损失函数,未注入时每格损失 1 + * @param cost 损失函数 + */ + useCostFunction(cost: PathCostFunction | null): void; + /** * 注入判定边可行性的通行性谓词 * @param predicate 通行性谓词 @@ -183,9 +191,11 @@ export interface IPathfindingGraphBuilder { useDirGroup(group: number): void; /** - * 构建有向图。图层未绑定时告警并返回空图,不包含任何节点与边 + * 以起始位置为中心构建有向图:沿可通行有向边 BFS 扩展, + * 仅包含从起始位置可达的节点。图层未绑定或起始位置越界时告警并返回空图 + * @param start BFS 起始位置 */ - build(): IPathGraph; + build(start: ITileLocator): IPathGraph; } //#endregion