diff --git a/packages-user/data-system/src/path/graph.test.ts b/packages-user/data-system/src/path/graph.test.ts new file mode 100644 index 0000000..037a768 --- /dev/null +++ b/packages-user/data-system/src/path/graph.test.ts @@ -0,0 +1,387 @@ +// 测试寻路有向图构建:邻域方向组、单向门、终端节点分类与边界守卫 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { FaceDirection } from '@user/data-common'; +import { + type IDataCommon, + type IFaceHandler, + type IGameMap, + type IPassCheckHandler, + type IPassPredicate, + type ITileStore +} from '@user/data-common'; +import { InternalDirectionGroup } from '@motajs/common'; + +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 { + PathfindingGraphBuilder: typeof import('./graph').PathfindingGraphBuilder; + MapState: typeof import('@user/data-base').MapState; + TileStore: typeof import('@user/data-common').TileStore; + FaceManager: typeof import('@user/data-common').FaceManager; + Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler; + RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder; + DirectionMapper: typeof import('@motajs/common').DirectionMapper; + logger: typeof import('@motajs/common').logger; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const graphModule = await import('./graph'); + const baseModule = await import('@user/data-base'); + const commonModule = await import('@user/data-common'); + const motaModule = await import('@motajs/common'); + modules = { + PathfindingGraphBuilder: graphModule.PathfindingGraphBuilder, + MapState: baseModule.MapState, + TileStore: commonModule.TileStore, + FaceManager: commonModule.FaceManager, + Dir8FaceHandler: commonModule.Dir8FaceHandler, + RoleFaceBinder: commonModule.RoleFaceBinder, + DirectionMapper: motaModule.DirectionMapper, + 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 ONEWAY_TILE: TestTileDefinition = { + num: 3, + id: 'oneway', + outPass: 0b0010, + inPass: 0 +}; + +/** 汇入图块:仅可从左方向进入,不可离开 */ +const SINK_TILE: TestTileDefinition = { + num: 4, + id: 'sink', + outPass: 0, + inPass: 0b1000, + eventPass: true +}; + +/** 撞击图块:四向可进可出但事件不通行,构成终端节点 */ +const HIT_TILE: TestTileDefinition = { + num: 5, + id: 'hit', + outPass: 15, + inPass: 15, + eventPass: false +}; + +const ALL_TILES: TestTileDefinition[] = [ + OPEN_TILE, + ONEWAY_TILE, + SINK_TILE, + HIT_TILE +]; + +/** + * 复刻 DefaultHeroMoveTopImpl 掩码语义的测试谓词: + * 事件层恒参与判定,其余层仅当 onlyEvents 为真时参与 + */ +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); + } + if (!canLeave || !canEnter) return false; + + // 判断其他层,仅 onlyEvents 图块参与判定 + for (const layer of this.map.layerList) { + 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(); + if (otherRaw?.pass.onlyEvents) { + canLeave = !!(leaveMask & otherRaw.pass.outPass); + } + if (otherNextRaw?.pass.onlyEvents) { + canEnter = !!(enterMask & otherNextRaw.pass.inPass); + } + if (!canLeave || !canEnter) return false; + } + return true; + } + + 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; + } +} + +/** + * 创建测试地图与有向图构建器 + * @param rows 每行图块数字,行长为宽度乘高度 + * @param width 地图宽度 + * @param predicate 注入的通行性谓词,传入 `null` 表示不注入 + */ +function createFixture( + rows: number[], + width: number, + predicate: IPassPredicate | null +) { + 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 builder = new modules.PathfindingGraphBuilder(); + builder.useMapState(maps); + builder.useMapLayer(layer); + if (predicate) { + builder.usePassPredicate(predicate); + } + return { maps, map: map!, layer, builder }; +} + +describe('pathfinding graph building', () => { + // 验证未注入谓词时图节点齐备但所有边均不可行 + it('builds nodes without edges 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(); + + 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); + } + }); + + // 验证注入谓词后中心节点邻域方向数与 DirectionMapper 四正交组一致为 4 + it('resolves four orthogonal neighbor edges for the center node', () => { + const { map, builder } = createFixture( + [1, 1, 1, 1, 1, 1, 1, 1, 1], + 3, + null + ); + const mapper = new modules.DirectionMapper(); + const expected = [...mapper.map(InternalDirectionGroup.Dir4)]; + const predicate = new FixturePredicate( + map, + new modules.Dir8FaceHandler() + ); + builder.usePassPredicate(predicate); + const graph = builder.build(); + + const center = graph.nodes.get(1 * 3 + 1)!; + expect(center.edges).toHaveLength(expected.length); + expect(center.edges).toHaveLength(4); + const dirs = new Set(center.edges.map(edge => edge.dir)); + expect(dirs).toEqual( + new Set([ + FaceDirection.Up, + FaceDirection.Down, + FaceDirection.Left, + FaceDirection.Right + ]) + ); + }); + + // 验证 useDirGroup 注入八方向组后中心节点拥有 8 条邻域边 + it('expands neighbor edges to eight when Dir8 group is injected', () => { + const { map, builder } = createFixture( + [1, 1, 1, 1, 1, 1, 1, 1, 1], + 3, + null + ); + const predicate = new FixturePredicate( + map, + new modules.Dir8FaceHandler() + ); + builder.usePassPredicate(predicate); + builder.useDirGroup(InternalDirectionGroup.Dir8); + const graph = builder.build(); + + const center = graph.nodes.get(1 * 3 + 1)!; + expect(center.edges).toHaveLength(8); + }); + + // 验证单向门图边方向性:A→B 可行而 B→A 不可行 + it('keeps one-way gates directional from A to B only', () => { + const { map, builder } = createFixture([3, 4], 2, null); + const predicate = new FixturePredicate( + map, + new modules.Dir8FaceHandler() + ); + builder.usePassPredicate(predicate); + const graph = builder.build(); + + const source = graph.nodes.get(0)!; + const sink = graph.nodes.get(1)!; + expect(source.edges).toEqual([{ dir: FaceDirection.Right, to: 1 }]); + expect(sink.edges).toHaveLength(0); + expect(sink.terminal).toBe(false); + }); + + // 验证 canPass 为真但 shouldHit 为真的图块被分类为仅可作路径终点的终端节点 + it('marks can-pass but should-hit blocks as terminal nodes', () => { + const { map, builder } = createFixture([1, 5, 1], 3, null); + const predicate = new FixturePredicate( + map, + new modules.Dir8FaceHandler() + ); + builder.usePassPredicate(predicate); + const graph = builder.build(); + + const hit = graph.nodes.get(1)!; + expect(hit.terminal).toBe(true); + // 终端节点仍保留入边,可作为路径终点 + expect(graph.nodes.get(0)!.edges).toEqual([ + { dir: FaceDirection.Right, to: 1 } + ]); + expect(graph.nodes.get(0)!.terminal).toBe(false); + expect(graph.nodes.get(2)!.terminal).toBe(false); + }); + + // 验证图层未绑定时构建入口告警新码 173 并返回空图而非异常 + it('warns the registered code and returns an empty graph without layer', () => { + const builder = new modules.PathfindingGraphBuilder(); + builder.useMapLayer(null); + + const result = modules.logger.catch(() => builder.build()); + + expect(result.ret.nodes.size).toBe(0); + expect(result.info.map(info => info.code)).toContain(173); + }); +}); diff --git a/packages-user/data-system/src/path/graph.ts b/packages-user/data-system/src/path/graph.ts new file mode 100644 index 0000000..955f486 --- /dev/null +++ b/packages-user/data-system/src/path/graph.ts @@ -0,0 +1,223 @@ +import { + DirectionMapper, + IDirectionDescriptor, + IDirectionMapper, + InternalDirectionGroup, + logger +} from '@motajs/common'; +import { FaceDirection, IDataCommon } from '@user/data-common'; +import { + ILayerLocation, + IMapLayer, + IMapState, + IPassCheckHandler, + IPassPredicate +} from '@user/data-base'; +import { isNil } from 'lodash-es'; + +//#region 图结构 + +export interface IPathGraphEdge { + /** 本条边对应的移动方向 */ + readonly dir: FaceDirection; + /** 边指向的节点索引,值为 y * width + x */ + readonly to: number; +} + +export interface IPathGraphNode { + /** 节点索引,值为 y * width + x */ + readonly index: number; + /** 节点横坐标 */ + readonly x: number; + /** 节点纵坐标 */ + readonly y: number; + /** 节点对应的位置信息,用于损失计算 */ + readonly block: ILayerLocation; + /** 该节点是否仅可作为路径终点,不可作为中间节点 */ + readonly terminal: boolean; + /** 该节点的全部出边 */ + readonly edges: readonly IPathGraphEdge[]; +} + +export interface IPathGraph { + /** 图宽度 */ + readonly width: number; + /** 图高度 */ + readonly height: number; + /** 图内全部节点,键为节点索引,值为 y * width + x */ + readonly nodes: ReadonlyMap; +} + +//#endregion + +//#region 方向解析 + +/** + * 将方向描述器的坐标增量解析为对应的朝向 + * @param x 横坐标增量 + * @param y 纵坐标增量 + */ +function directionOf(x: number, y: number): FaceDirection { + if (x === 0 && y === -1) return FaceDirection.Up; + if (x === 0 && y === 1) return FaceDirection.Down; + if (x === -1 && y === 0) return FaceDirection.Left; + if (x === 1 && y === 0) return FaceDirection.Right; + if (x === -1 && y === -1) return FaceDirection.LeftUp; + if (x === 1 && y === -1) return FaceDirection.RightUp; + if (x === -1 && y === 1) return FaceDirection.LeftDown; + if (x === 1 && y === 1) return FaceDirection.RightDown; + return FaceDirection.Unknown; +} + +//#endregion + +//#region 有向图构建 + +/** + * 寻路有向图构建器,将地图图层转换为以通行性谓词判定边的有向图。 + * 邻域方向由注入的方向组决定,默认仅包含四正交方向; + * 谓词未注入时所有边均不可通行 + */ +export class PathfindingGraphBuilder { + /** 绑定的地图状态对象,用于解析图层所属楼层 id */ + private maps: IMapState | null = null; + /** 绑定的地图图层,图节点来源 */ + private layer: IMapLayer | null = null; + /** 注入的通行性谓词,用于判定边的可行性与终端节点 */ + private predicate: IPassPredicate | null = null; + /** 邻域方向组别,默认四正交方向 */ + private group: number = InternalDirectionGroup.Dir4; + + /** 方向组解析对象 */ + private readonly mapper: IDirectionMapper = new DirectionMapper(); + + /** + * 绑定地图状态对象,用于解析楼层 id + * @param maps 地图状态对象,传入 `null` 解绑 + */ + useMapState(maps: IMapState | null): void { + this.maps = maps; + } + + /** + * 绑定构建有向图所用的地图图层 + * @param layer 地图图层对象,传入 `null` 解绑 + */ + useMapLayer(layer: IMapLayer | null): void { + this.layer = layer; + } + + /** + * 注入判定边可行性的通行性谓词 + * @param predicate 通行性谓词,传入 `null` 解绑 + */ + usePassPredicate(predicate: IPassPredicate | null): void { + this.predicate = predicate; + } + + /** + * 设置邻域使用的方向组 + * @param group 朝向组 + */ + useDirGroup(group: number): void { + this.group = group; + } + + /** + * 构建有向图。图层未绑定时告警并返回空图, + * 不包含任何节点与边 + * @returns 构建的有向图 + */ + build(): IPathGraph { + const layer = this.layer; + if (isNil(layer)) { + logger.warn(173); + return { width: 0, height: 0, nodes: new Map() }; + } + + const width = layer.width; + const height = layer.height; + const floorId = this.resolveFloorId(); + const state: IDataCommon = 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 terminals: Set = new Set(); + const adjacency: Map = new Map(); + const dirs: IDirectionDescriptor[] = [...this.mapper.map(this.group)]; + + // 逐节点判定邻域边可行性 + for (let index = 0; index < blocks.length; index++) { + const block = blocks[index]; + if (!block) continue; + const x = index % width; + const y = Math.floor(index / width); + const edges: IPathGraphEdge[] = []; + for (const desc of dirs) { + const dir = directionOf(desc.x, desc.y); + if (dir === FaceDirection.Unknown) continue; + 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 handler: IPassCheckHandler = { + currLoc: block.locator, + nextLoc: next.locator, + direction: dir, + floorId, + state + }; + const predicate = this.predicate; + if (isNil(predicate) || !predicate.canPass(handler)) continue; + if (predicate.shouldHit(handler)) { + terminals.add(ny * width + nx); + } + 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; + nodes.set(index, { + index, + x: index % width, + y: Math.floor(index / width), + block, + terminal: terminals.has(index), + edges: adjacency.get(index) ?? [] + }); + } + return { width, height, nodes }; + } + + /** + * 解析绑定图层所属的楼层 id + * @returns 楼层 id,无法解析时为 `undefined` + */ + private resolveFloorId(): string | undefined { + const maps = this.maps; + const layer = this.layer; + if (!maps || !layer) return undefined; + for (const [floorId, map] of maps.iterateAllMaps()) { + if (map === layer.map) return floorId; + } + return undefined; + } +} + +//#endregion diff --git a/packages-user/data-system/src/path/types.ts b/packages-user/data-system/src/path/types.ts index dbcdbe6..c663e3d 100644 --- a/packages-user/data-system/src/path/types.ts +++ b/packages-user/data-system/src/path/types.ts @@ -106,14 +106,14 @@ export interface IPathfindingSystem extends IDataBaseExtended { * @param target 目标坐标 * @returns 移动控制器。无法寻路、无路径或已有移动进行中时返回 `null` */ - moveTo(target: ITileLocator): IPathfindingController; + moveTo(target: ITileLocator): IPathfindingController | null; /** * 瞬移至目标位置。瞬移前经回退策略判定,判定需要回退则自动退为逐步寻路 * @param target 目标坐标 * @returns 移动控制器;无法寻路、无路径或已有移动进行中时返回 `null` */ - teleportTo(target: ITileLocator): IPathfindingController; + teleportTo(target: ITileLocator): IPathfindingController | null; /** * 打断当前自动寻路。新的方向输入或新的寻路调用可随时打断并接管 diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index bc9a694..3d16491 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -237,6 +237,7 @@ "169": "The current floor is the last or the first floor in the map list, so related floor change method will not work.", "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." + "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." } }