mirror of
https://github.com/motajs/template.git
synced 2026-09-13 02:08:50 +08:00
feat(02-02): add minimal-loss pathfinding system with getPath, controller contract and fallback policy
This commit is contained in:
parent
0e6536f426
commit
53d019f62e
@ -1,4 +1,5 @@
|
||||
export * from './combat';
|
||||
export * from './event';
|
||||
export * from './path';
|
||||
|
||||
export * from './types';
|
||||
|
||||
3
packages-user/data-system/src/path/index.ts
Normal file
3
packages-user/data-system/src/path/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './graph';
|
||||
export * from './system';
|
||||
export * from './types';
|
||||
575
packages-user/data-system/src/path/system.test.ts
Normal file
575
packages-user/data-system/src/path/system.test.ts
Normal file
@ -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 <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 {
|
||||
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<TestTile>;
|
||||
/** setPos 调用记录 */
|
||||
readonly setPosCalls: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 复刻 DefaultHeroMoveTopImpl 掩码语义的测试谓词:
|
||||
* 事件层恒参与判定,四角朝向直接放行
|
||||
*/
|
||||
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);
|
||||
}
|
||||
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<TestTile> {
|
||||
readonly tile: TestTile;
|
||||
|
||||
constructor(tile: TestTile) {
|
||||
super(new modules.Dir8FaceHandler(), FaceDirection.Down);
|
||||
this.tile = tile;
|
||||
}
|
||||
|
||||
protected override async onMoveStart(): Promise<void> {}
|
||||
|
||||
protected override async onMoveEnd(): Promise<void> {}
|
||||
|
||||
protected override async onStepStart(): Promise<number> {
|
||||
// 移动代码对基类不透明,固定传 0 即可
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected override async onStepEnd(
|
||||
_code: number,
|
||||
step: Readonly<ObjectMoveStep>,
|
||||
tile: TestTile
|
||||
): Promise<ITileLocator> {
|
||||
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<void> {}
|
||||
}
|
||||
|
||||
class TestTileImpl implements TestTile {
|
||||
x: number = 0;
|
||||
y: number = 0;
|
||||
face: FaceDirection = FaceDirection.Down;
|
||||
mover: IObjectMover<TestTile>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
352
packages-user/data-system/src/path/system.ts
Normal file
352
packages-user/data-system/src/path/system.ts
Normal file
@ -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<IObjectMovable>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断移动对象是否携带移动器
|
||||
* @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<number, number> = new Map();
|
||||
const prev: Map<number, IPathfindingStep> = new Map();
|
||||
const visited: Set<number> = 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<void> {
|
||||
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
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user