diff --git a/packages-user/data-system/src/index.ts b/packages-user/data-system/src/index.ts index 0a13a4a..53d628e 100644 --- a/packages-user/data-system/src/index.ts +++ b/packages-user/data-system/src/index.ts @@ -1,4 +1,5 @@ export * from './combat'; export * from './event'; +export * from './path'; export * from './types'; diff --git a/packages-user/data-system/src/path/index.ts b/packages-user/data-system/src/path/index.ts new file mode 100644 index 0000000..5b6e9dd --- /dev/null +++ b/packages-user/data-system/src/path/index.ts @@ -0,0 +1,3 @@ +export * from './graph'; +export * from './system'; +export * from './types'; diff --git a/packages-user/data-system/src/path/system.test.ts b/packages-user/data-system/src/path/system.test.ts new file mode 100644 index 0000000..ae1f643 --- /dev/null +++ b/packages-user/data-system/src/path/system.test.ts @@ -0,0 +1,575 @@ +// 测试寻路系统:最小损失搜索、自定义损失、仅取路径、控制器契约与回退策略 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type ITileLocator } from '@motajs/common'; +import { FaceDirection } from '@user/data-common'; +import { + type IDataCommon, + type IFaceHandler, + type IObjectMovable, + type IObjectMover, + type ITileStore, + ObjectMoveStep, + ObjectMoveType +} from '@user/data-common'; +import { + type IGameMap, + type IMapLayer, + type IPassCheckHandler, + type IPassPredicate +} from '@user/data-base'; +import { type PathfindingSystem } from './system'; +import { type IPathfindingStep, type PathCostFunction } from './types'; + +vi.hoisted(() => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + Map.prototype.getOrInsertComputed ??= function ( + this: Map, + key: K, + callback: (key: K) => V + ): V { + const existing = this.get(key); + if (existing !== undefined) return existing; + const value = callback(key); + this.set(key, value); + return value; + }; +}); + +interface TestModules { + PathfindingSystem: typeof import('./system').PathfindingSystem; + MapState: typeof import('@user/data-base').MapState; + TileStore: typeof import('@user/data-common').TileStore; + ObjectMover: typeof import('@user/data-common').ObjectMover; + FaceManager: typeof import('@user/data-common').FaceManager; + Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler; + RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder; + logger: typeof import('@motajs/common').logger; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const systemModule = await import('./system'); + const baseModule = await import('@user/data-base'); + const commonModule = await import('@user/data-common'); + const motaModule = await import('@motajs/common'); + modules = { + PathfindingSystem: systemModule.PathfindingSystem, + MapState: baseModule.MapState, + TileStore: commonModule.TileStore, + ObjectMover: commonModule.ObjectMover, + FaceManager: commonModule.FaceManager, + Dir8FaceHandler: commonModule.Dir8FaceHandler, + RoleFaceBinder: commonModule.RoleFaceBinder, + logger: motaModule.logger + }; +}); + +/** 测试图块定义,键为图块数字 */ +interface TestTileDefinition { + /** 图块数字 */ + num: number; + /** 图块字符串 id */ + id: string; + /** 可以离开的方向 */ + outPass: number; + /** 可以进入的方向 */ + inPass: number; + /** 事件可通行性,`false` 表示撞击触发 */ + eventPass: boolean; +} + +/** 开阔图块:四向可进可出 */ +const OPEN_TILE: TestTileDefinition = { + num: 1, + id: 'open', + outPass: 15, + inPass: 15, + eventPass: true +}; + +/** 墙体图块:不可进入也不可离开 */ +const WALL_TILE: TestTileDefinition = { + num: 6, + id: 'wall', + outPass: 0, + inPass: 0, + eventPass: true +}; + +/** 撞击图块:四向可进可出但事件不通行,构成终端节点 */ +const HIT_TILE: TestTileDefinition = { + num: 5, + id: 'hit', + outPass: 15, + inPass: 15, + eventPass: false +}; + +const ALL_TILES: TestTileDefinition[] = [OPEN_TILE, WALL_TILE, HIT_TILE]; + +/** 测试用移动对象接口,记录 setPos 调用并携带移动器 */ +interface TestTile extends IObjectMovable { + /** 当前横坐标 */ + x: number; + /** 当前纵坐标 */ + y: number; + /** 绑定的移动器 */ + mover: IObjectMover; + /** setPos 调用记录 */ + readonly setPosCalls: { x: number; y: number }[]; +} + +/** + * 复刻 DefaultHeroMoveTopImpl 掩码语义的测试谓词: + * 事件层恒参与判定,四角朝向直接放行 + */ +class FixturePredicate implements IPassPredicate { + /** 绑定的楼层地图对象 */ + private readonly map: IGameMap; + /** 朝向管理对象,用于求相反方向 */ + private readonly face: IFaceHandler; + + constructor(map: IGameMap, face: IFaceHandler) { + this.map = map; + this.face = face; + } + + /** + * 将朝向转换为对应的通行性位掩码 + * @param dir 朝向 + */ + private passBit(dir: FaceDirection): number { + switch (dir) { + case FaceDirection.Up: + return 0b0001; + case FaceDirection.Right: + return 0b0010; + case FaceDirection.Down: + return 0b0100; + case FaceDirection.Left: + return 0b1000; + default: + return 0; + } + } + + canPass(handler: IPassCheckHandler): boolean { + const event = this.map.eventLayer; + if (!event) return false; + const { currLoc, nextLoc, direction } = handler; + + // 四角朝向直接判定为可通行,与 moverImpl 语义一致 + if ( + direction === FaceDirection.LeftDown || + direction === FaceDirection.LeftUp || + direction === FaceDirection.RightDown || + direction === FaceDirection.RightUp + ) { + return true; + } + + const opposite = this.face.opposite(direction); + const leaveMask = this.passBit(direction); + const enterMask = this.passBit(opposite); + + let canLeave = true; + 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(); + if (currRaw) { + canLeave = !!(leaveMask & currRaw.pass.outPass); + } + if (nextRaw) { + canEnter = !!(enterMask & nextRaw.pass.inPass); + } + return canLeave && canEnter; + } + + shouldHit(handler: IPassCheckHandler): boolean { + const event = this.map.eventLayer; + if (!event) return false; + const { nextLoc } = handler; + const next = event.getLocationData(nextLoc.x, nextLoc.y); + const nextRaw = next?.static.raw(); + if (!nextRaw) return false; + return !nextRaw.eventPass; + } +} + +/** 寻路系统测试夹具 */ +interface SystemFixture { + /** 楼层地图对象 */ + map: IGameMap; + /** 事件层图层对象 */ + layer: IMapLayer; + /** 被测寻路系统 */ + system: PathfindingSystem; + /** 绑定的测试移动对象 */ + tile: TestTile; +} + +/** + * 创建测试用移动对象,移动器由工厂注入 + */ +function createTestTile(): TestTile { + class TestMover extends modules.ObjectMover { + readonly tile: TestTile; + + constructor(tile: TestTile) { + super(new modules.Dir8FaceHandler(), FaceDirection.Down); + this.tile = tile; + } + + protected override async onMoveStart(): Promise {} + + protected override async onMoveEnd(): Promise {} + + protected override async onStepStart(): Promise { + // 移动代码对基类不透明,固定传 0 即可 + return 0; + } + + protected override async onStepEnd( + _code: number, + step: Readonly, + tile: TestTile + ): Promise { + if (step.type === ObjectMoveType.Teleport) { + return { x: step.x, y: step.y }; + } + if (step.type === ObjectMoveType.Dir) { + switch (step.move) { + case FaceDirection.Right: + return { x: tile.x + 1, y: tile.y }; + case FaceDirection.Left: + return { x: tile.x - 1, y: tile.y }; + case FaceDirection.Up: + return { x: tile.x, y: tile.y - 1 }; + case FaceDirection.Down: + return { x: tile.x, y: tile.y + 1 }; + default: + return { x: tile.x, y: tile.y }; + } + } + return { x: tile.x, y: tile.y }; + } + + protected override async onStepSettled(): Promise {} + } + + class TestTileImpl implements TestTile { + x: number = 0; + y: number = 0; + face: FaceDirection = FaceDirection.Down; + mover: IObjectMover; + readonly setPosCalls: { x: number; y: number }[] = []; + + constructor() { + this.mover = new TestMover(this); + } + + setPos(x: number, y: number): void { + this.x = x; + this.y = y; + this.setPosCalls.push({ x, y }); + } + + getCurrentFaceDirection(): FaceDirection { + return this.face; + } + } + + return new TestTileImpl(); +} + +/** + * 创建绑定地图与移动对象的寻路系统夹具, + * 通行性谓词与损失函数由测试按需注入 + * @param rows 每行图块数字,行长为宽度乘高度 + * @param width 地图宽度 + */ +function createSystem(rows: number[], width: number): SystemFixture { + const tileStore: ITileStore = new modules.TileStore() as never; + for (const tile of ALL_TILES) { + tileStore.addTile({ + num: tile.num, + id: tile.id, + events: {}, + type: 0, + pass: { + onlyEvents: false, + outPass: tile.outPass, + inPass: tile.inPass + }, + eventPass: tile.eventPass + }); + } + const faceManager = new modules.FaceManager(); + faceManager.register(1, new modules.Dir8FaceHandler()); + const commonState: IDataCommon = { + tileStore, + itemStore: {}, + mapStore: {}, + eventStore: {}, + roleFace: new modules.RoleFaceBinder(), + faceManager, + saveSystem: {} + } as never; + const maps = new modules.MapState(tileStore, commonState); + const map = maps.fromRaw({ + floorId: 'F1', + width, + map: { 0: rows }, + layerAlias: { 0: 'event' }, + events: { 0: {} } + })!; + const layer = map.getLayerByAlias('event')!; + const system = new modules.PathfindingSystem(commonState as never); + const tile = createTestTile(); + system.useMovable(tile); + system.finder.useMapState(maps); + system.finder.useMapLayer(layer); + return { map, layer, system, tile }; +} + +/** + * 向夹具注入复刻掩码语义的测试谓词 + * @param fixture 寻路系统测试夹具 + */ +function injectPredicate(fixture: SystemFixture): void { + const predicate = new FixturePredicate( + fixture.map, + new modules.Dir8FaceHandler() + ); + fixture.system.finder.usePassPredicate(predicate); +} + +describe('pathfinding system', () => { + // 验证默认每格损失 1 时返回格数最少的步骤序列,每步含方向与起终点 + it('returns the shortest step sequence with default unit cost', () => { + const fixture = createSystem([1, 1, 1, 1, 1, 1, 1, 1, 1], 3); + injectPredicate(fixture); + + const steps = fixture.system.finder.find( + { x: 0, y: 0 }, + { x: 2, y: 0 } + ); + + expect(steps).toEqual([ + { + dir: FaceDirection.Right, + from: { x: 0, y: 0 }, + to: { x: 1, y: 0 } + }, + { + dir: FaceDirection.Right, + from: { x: 1, y: 0 }, + to: { x: 2, y: 0 } + } + ]); + }); + + // 验证注入自定义损失后选择损失更小的岔路而非步数最少的直路 + it('reroutes through the cheaper fork with a custom cost function', () => { + const fixture = createSystem([1, 1, 1, 1, 1, 1, 1, 1, 1], 3); + injectPredicate(fixture); + const cost: PathCostFunction = block => + block.locator.x === 1 && block.locator.y === 1 ? 10 : 1; + fixture.system.finder.useCostFunction(cost); + + const steps = fixture.system.finder.find( + { x: 0, y: 1 }, + { x: 2, y: 1 } + ); + + expect(steps).toHaveLength(4); + for (const step of steps) { + expect(step.to).not.toEqual({ x: 1, y: 1 }); + } + }); + + // 验证损失函数返回 NaN 时告警新码 174 并按默认损失 1 处理 + it('warns the cost guard code and falls back to unit cost on NaN', () => { + const fixture = createSystem([1, 1, 1, 1, 1, 1, 1, 1, 1], 3); + injectPredicate(fixture); + const cost: PathCostFunction = block => + block.locator.x === 1 && block.locator.y === 1 ? Number.NaN : 1; + fixture.system.finder.useCostFunction(cost); + + const result = modules.logger.catch(() => + fixture.system.finder.find({ x: 0, y: 1 }, { x: 2, y: 1 }) + ); + + expect(result.info.map(info => info.code)).toContain(174); + expect(result.ret).toHaveLength(2); + }); + + // 验证不可达目标返回空数组且不移动对象 + it('returns an empty path and never moves for unreachable targets', () => { + const fixture = createSystem([1, 6, 1, 1, 6, 1, 1, 6, 1], 3); + injectPredicate(fixture); + fixture.tile.x = 0; + fixture.tile.y = 0; + + const steps = fixture.system.finder.find( + { x: 0, y: 0 }, + { x: 2, y: 1 } + ); + const path = fixture.system.getPath({ x: 2, y: 1 }); + + expect(steps).toEqual([]); + expect(path).toEqual([]); + expect(fixture.tile.x).toBe(0); + expect(fixture.tile.y).toBe(0); + expect(fixture.tile.setPosCalls).toEqual([]); + }); + + // 验终端节点不可作为中间节点穿越但可作为路径终点 + it('never passes through terminal nodes but may end on them', () => { + const fixture = createSystem([1, 5, 1, 1, 1, 1, 1, 1, 1], 3); + injectPredicate(fixture); + + const detour = fixture.system.finder.find( + { x: 0, y: 0 }, + { x: 2, y: 0 } + ); + expect(detour).toHaveLength(4); + for (const step of detour) { + expect(step.to).not.toEqual({ x: 1, y: 0 }); + } + + const direct = fixture.system.finder.find( + { x: 0, y: 0 }, + { x: 1, y: 0 } + ); + expect(direct).toEqual([ + { + dir: FaceDirection.Right, + from: { x: 0, y: 0 }, + to: { x: 1, y: 0 } + } + ]); + }); + + // 验证 moveTo 有路径时返回含控制器与路径的包装并真实到达目标 + it('moves step by step to the target and wraps the controller', async () => { + const fixture = createSystem([1, 1, 1, 1, 1, 1, 1, 1, 1], 3); + injectPredicate(fixture); + + const result = fixture.system.moveTo({ x: 2, y: 0 }); + + expect(result).not.toBeNull(); + expect(result!.path).toHaveLength(2); + await result!.controller.onEnd; + expect(fixture.tile.x).toBe(2); + expect(fixture.tile.y).toBe(0); + }); + + // 验证 moveTo 未绑定移动对象或无路径时返回 null 而非异常 + it('returns null from moveTo when movable is unbound or unreachable', () => { + const fixture = createSystem([1, 6, 1, 1, 6, 1, 1, 6, 1], 3); + injectPredicate(fixture); + fixture.system.useMovable(null); + + expect(fixture.system.moveTo({ x: 2, y: 1 })).toBeNull(); + + fixture.system.useMovable(fixture.tile); + expect(fixture.system.moveTo({ x: 2, y: 1 })).toBeNull(); + }); + + // 验证已有移动进行中时再次寻路返回 null + it('returns null on a new move while another move is in progress', () => { + const fixture = createSystem([1, 1, 1, 1, 1, 1, 1, 1, 1], 3); + injectPredicate(fixture); + + const first = fixture.system.moveTo({ x: 2, y: 0 }); + const second = fixture.system.moveTo({ x: 2, y: 0 }); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + return first!.controller.onEnd; + }); + + // 验证未注入回退策略时瞬移请求默认回退为逐步寻路 + it('falls back to step-by-step movement when policy is null', async () => { + const fixture = createSystem([1, 1, 1], 3); + injectPredicate(fixture); + + const result = fixture.system.teleportTo({ x: 2, y: 0 }); + + expect(result).not.toBeNull(); + await result!.controller.onEnd; + expect(fixture.tile.setPosCalls).toEqual([ + { x: 1, y: 0 }, + { x: 2, y: 0 } + ]); + expect(fixture.tile.x).toBe(2); + }); + + // 验证回退策略以路径步骤序列为入参被调用并生效于移动方式决策 + it('consults the fallback policy with the path steps and honors it', async () => { + const fixture = createSystem([1, 1, 1], 3); + injectPredicate(fixture); + let received: readonly IPathfindingStep[] | null = null; + fixture.system.useFallbackPolicy(path => { + received = path; + return true; + }); + + const stepped = fixture.system.teleportTo({ x: 2, y: 0 }); + + expect(received).not.toBeNull(); + expect(received).toHaveLength(2); + await stepped!.controller.onEnd; + expect(fixture.tile.setPosCalls).toEqual([ + { x: 1, y: 0 }, + { x: 2, y: 0 } + ]); + + const teleportFixture = createSystem([1, 1, 1], 3); + injectPredicate(teleportFixture); + teleportFixture.system.useFallbackPolicy(() => false); + + const teleported = teleportFixture.system.teleportTo({ x: 2, y: 0 }); + + expect(teleported).not.toBeNull(); + await teleported!.controller.onEnd; + expect(teleportFixture.tile.setPosCalls).toEqual([{ x: 2, y: 0 }]); + expect(teleportFixture.tile.x).toBe(2); + }); + + // 验证未绑定地图状态或移动对象时告警新码 173 并返回空结果 + it('warns the guard code and returns empty results when unbound', () => { + const system = new modules.PathfindingSystem({} as never); + + const findResult = modules.logger.catch(() => + system.finder.find({ x: 0, y: 0 }, { x: 1, y: 0 }) + ); + const pathResult = modules.logger.catch(() => + system.getPath({ x: 1, y: 0 }) + ); + + expect(findResult.ret).toEqual([]); + expect(findResult.info.map(info => info.code)).toContain(173); + expect(pathResult.ret).toEqual([]); + expect(pathResult.info.map(info => info.code)).toContain(173); + }); + + // 验证打断入口可安全调用并停止进行中的移动 + it('interrupts the ongoing pathfinding move safely', async () => { + const fixture = createSystem([1, 1, 1], 3); + injectPredicate(fixture); + + const result = fixture.system.moveTo({ x: 2, y: 0 }); + expect(result).not.toBeNull(); + await fixture.system.interrupt(); + await result!.controller.onEnd; + expect(result!.controller.done).toBe(true); + }); +}); diff --git a/packages-user/data-system/src/path/system.ts b/packages-user/data-system/src/path/system.ts new file mode 100644 index 0000000..00f23dd --- /dev/null +++ b/packages-user/data-system/src/path/system.ts @@ -0,0 +1,352 @@ +import { InternalDirectionGroup, ITileLocator, logger } from '@motajs/common'; +import { IObjectMovable, IObjectMover } from '@user/data-common'; +import { + ILayerLocation, + IMapLayer, + IMapState, + IPassPredicate, + IStateBase +} from '@user/data-base'; +import { isNil } from 'lodash-es'; +import { IPathGraph, PathfindingGraphBuilder } from './graph'; +import { + IPathfinder, + IPathfindingController, + IPathfindingStep, + IPathfindingSystem, + PathCostFunction, + PathFallbackPolicy +} from './types'; + +//#region 移动器识别 + +/** + * 携带移动器的移动对象,动态图块等真实移动对象均满足此结构 + */ +export interface IMovableWithMover extends IObjectMovable { + /** 移动器对象 */ + readonly mover: IObjectMover; +} + +/** + * 判断移动对象是否携带移动器 + * @param movable 移动对象 + */ +function hasMover(movable: IObjectMovable): movable is IMovableWithMover { + return 'mover' in movable; +} + +//#endregion + +//#region 寻路求解器 + +/** + * 寻路求解器,在动态构建的有向图上执行最小损失搜索。 + * 损失函数与通行性谓词均为可注入槽位,未注入损失函数时每格损失 1, + * 未注入谓词时所有边均不可通行 + */ +export class PathfindingFinder implements IPathfinder { + /** 当前对象对应的数据层对象 */ + readonly state: IStateBase; + + /** 绑定的地图状态对象,用于解析楼层 id */ + private maps: IMapState | null = null; + /** 绑定的地图图层,图节点来源 */ + private layer: IMapLayer | null = null; + /** 注入的通行性谓词,用于判定边可行性与终端节点 */ + private predicate: IPassPredicate | null = null; + /** 注入的损失函数,未注入时每格损失 1 */ + private cost: PathCostFunction | null = null; + /** 邻域方向组别,默认四正交方向 */ + private group: number = InternalDirectionGroup.Dir4; + + constructor(state: IStateBase) { + this.state = state; + } + + /** + * 绑定寻路所用的地图状态对象 + * @param maps 地图状态对象,传入 `null` 解绑 + */ + useMapState(maps: IMapState | null): void { + this.maps = maps; + } + + /** + * 绑定构建有向图所用的地图图层 + * @param layer 地图图层对象,传入 `null` 解绑 + */ + useMapLayer(layer: IMapLayer | null): void { + this.layer = layer; + } + + /** + * 设置自定义损失函数 + * @param cost 损失函数,传入 `null` 恢复默认 + */ + useCostFunction(cost: PathCostFunction | null): void { + this.cost = cost; + } + + /** + * 设置通行性谓词 + * @param predicate 通行性谓词,传入 `null` 恢复默认 + */ + usePassPredicate(predicate: IPassPredicate | null): void { + this.predicate = predicate; + } + + /** + * 设置寻路使用的朝向组 + * @param group 朝向组 + */ + useDirGroup(group: number): void { + this.group = group; + } + + /** + * 在当前绑定状态下执行寻路,返回损失最小的步骤序列。 + * 地图状态或图层未绑定、坐标越界等非法输入下告警并返回空数组; + * 目标不可达时同样返回空数组 + * @param start 寻路起始位置 + * @param target 寻路目标位置 + */ + find(start: ITileLocator, target: ITileLocator): IPathfindingStep[] { + const maps = this.maps; + const layer = this.layer; + if (isNil(maps) || isNil(layer)) { + logger.warn(173); + return []; + } + if ( + !layer.inMap(start.x, start.y) || + !layer.inMap(target.x, target.y) + ) { + logger.warn(173); + return []; + } + + // 数据端状态可变,每次寻路动态构建图,不做缓存 + const builder = new PathfindingGraphBuilder(); + builder.useMapState(maps); + builder.useMapLayer(layer); + builder.usePassPredicate(this.predicate); + builder.useDirGroup(this.group); + const graph = builder.build(); + return this.search(graph, start, target); + } + + /** + * 在有向图上执行 Dijkstra 最小损失搜索, + * 终端节点仅可作为路径终点,不可作为中间节点 + * @param graph 寻路有向图 + * @param start 寻路起始位置 + * @param target 寻路目标位置 + */ + private search( + graph: IPathGraph, + start: ITileLocator, + target: ITileLocator + ): IPathfindingStep[] { + const startIndex = start.y * graph.width + start.x; + const targetIndex = target.y * graph.width + target.x; + if (startIndex === targetIndex) return []; + if (!graph.nodes.has(startIndex) || !graph.nodes.has(targetIndex)) { + return []; + } + + const dist: Map = new Map(); + const prev: Map = new Map(); + const visited: Set = new Set(); + dist.set(startIndex, 0); + + while (true) { + let currIndex = -1; + let currDist = Infinity; + for (const [index, value] of dist) { + if (!visited.has(index) && value < currDist) { + currIndex = index; + currDist = value; + } + } + if (currIndex === -1) return []; + if (currIndex === targetIndex) break; + visited.add(currIndex); + const node = graph.nodes.get(currIndex); + if (!node || node.terminal) continue; + for (const edge of node.edges) { + if (visited.has(edge.to)) continue; + 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 known = dist.get(edge.to); + if (isNil(known) || total < known) { + dist.set(edge.to, total); + prev.set(edge.to, { + dir: edge.dir, + from: { x: node.x, y: node.y }, + to: { x: next.x, y: next.y } + }); + } + } + } + + const steps: IPathfindingStep[] = []; + let curr = targetIndex; + while (curr !== startIndex) { + const step = prev.get(curr); + if (!step) return []; + steps.push(step); + curr = step.from.y * graph.width + step.from.x; + } + steps.reverse(); + return steps; + } + + /** + * 获取进入指定位置节点的损失,非有限数或负数时 + * 告警并按默认损失 1 处理,保证搜索的非负权不变式 + * @param block 位置信息 + */ + private getNodeCost(block: ILayerLocation): number { + const cost = this.cost; + if (!cost) return 1; + const value = cost(block); + if (!Number.isFinite(value) || value < 0) { + logger.warn(174); + return 1; + } + return value; + } +} + +//#endregion + +//#region 寻路系统 + +/** + * 寻路系统,绑定移动对象后提供仅取路径、逐步寻路与瞬移寻路入口。 + * 瞬移前经回退策略判定,判定需要回退或未注入策略时 + * 自动退为逐步寻路 + */ +export class PathfindingSystem implements IPathfindingSystem { + /** 寻路求解器 */ + readonly finder: IPathfinder; + + /** 绑定的移动对象 */ + private movable: IObjectMovable | null = null; + /** 注入的瞬移回退策略,未注入时必定回退为逐步寻路 */ + private policy: PathFallbackPolicy | null = null; + /** 最近一次寻路移动的控制器包装 */ + private current: IPathfindingController | null = null; + + constructor(readonly state: IStateBase) { + this.finder = new PathfindingFinder(state); + } + + /** + * 绑定寻路移动对象,可绑定勇士位置或任意动态图块 + * @param movable 移动对象,传入 `null` 解绑 + */ + useMovable(movable: IObjectMovable | null): void { + this.movable = movable; + } + + /** + * 设置瞬移回退策略,传入 `null` 恢复默认必定逐步 + * @param policy 回退策略函数 + */ + useFallbackPolicy(policy: PathFallbackPolicy | null): void { + this.policy = policy; + } + + /** + * 仅获取从当前位置至目标位置的最小损失路径,不产生任何移动。 + * 未绑定移动对象时告警并返回空数组 + * @param target 目标坐标 + */ + getPath(target: ITileLocator): IPathfindingStep[] { + const movable = this.movable; + if (isNil(movable)) { + logger.warn(173); + return []; + } + return this.finder.find({ x: movable.x, y: movable.y }, target); + } + + /** + * 逐步寻路至目标位置,触发途经事件。 + * 无法寻路、无路径或已有移动进行中时返回 `null` + * @param target 目标坐标 + */ + moveTo(target: ITileLocator): IPathfindingController | null { + const path = this.getPath(target); + if (path.length === 0) return null; + return this.startMove(path, false); + } + + /** + * 瞬移至目标位置。瞬移前经回退策略判定, + * 判定需要回退则自动退为逐步寻路; + * 无法寻路、无路径或已有移动进行中时返回 `null` + * @param target 目标坐标 + */ + teleportTo(target: ITileLocator): IPathfindingController | null { + const path = this.getPath(target); + if (path.length === 0) return null; + const policy = this.policy; + if (isNil(policy) || policy(path)) { + return this.startMove(path, false); + } + return this.startMove(path, true); + } + + /** + * 打断当前自动寻路。新的方向输入或新的寻路调用可随时打断并接管, + * 接管时序由 03 计划的接线定义,此处仅停止进行中的移动 + */ + async interrupt(): Promise { + const current = this.current; + this.current = null; + if (current && !current.controller.done) { + await current.controller.stop(); + } + } + + /** + * 按指定移动方式启动寻路移动,返回控制器包装。 + * 对象未携带移动器或已有移动进行中时返回 `null` + * @param path 寻路步骤序列 + * @param teleport 是否瞬移 + */ + private startMove( + path: readonly IPathfindingStep[], + teleport: boolean + ): IPathfindingController | null { + const movable = this.movable; + if (!movable) return null; + const current = this.current; + if (current && !current.controller.done) return null; + if (!hasMover(movable)) return null; + const mover = movable.mover; + + if (teleport) { + const last = path[path.length - 1]; + mover.tp(last.to.x, last.to.y); + } else { + for (const step of path) { + mover.step(step.dir); + } + } + + // 移动器移动中时启动失败,对应已有移动进行中的契约 + const controller = mover.start(); + if (!controller) return null; + const result: IPathfindingController = { controller, path }; + this.current = result; + return result; + } +} + +//#endregion diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 3d16491..2719398 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -238,6 +238,7 @@ "170": "Game event id '$1' has already been used, old event will be overridden.", "171": "Event id '$1' not found in event store, event will be skipped.", "172": "Event returned non-boolean value '$1' during reduction. JavaScript short-circuit semantics will be used.", - "173": "Pathfinding input is invalid or a required binding (map state, map layer) is missing. An empty result will be returned." + "173": "Pathfinding input is invalid or a required binding (map state, map layer) is missing. An empty result will be returned.", + "174": "Pathfinding cost function returned a non-finite or negative value. Default cost 1 will be used instead." } }