feat(02-02): build pathfinding directed graph with injected pass predicate and authorized types fix

This commit is contained in:
unanmed 2026-09-09 17:17:48 +08:00
parent e1f61013ac
commit 0cd6ab6a2d
4 changed files with 614 additions and 3 deletions

View File

@ -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 <K, V>(
this: Map<K, V>,
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<FaceDirection>;
constructor(map: IGameMap, face: IFaceHandler<FaceDirection>) {
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);
});
});

View File

@ -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<number, IPathGraphNode>;
}
//#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<number> = new Set();
const adjacency: Map<number, IPathGraphEdge[]> = 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<number, IPathGraphNode> = 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

View File

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

View File

@ -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."
}
}