feat(02-03): wire hero pathfinding movement

- Bind the hero predicate and event layer into the L2 finder
- Execute translated paths through the hero mover and export the L3 entry
This commit is contained in:
unanmed 2026-09-09 21:13:30 +08:00
parent 68a150882c
commit 2759df531e
7 changed files with 300 additions and 3 deletions

View File

@ -3,10 +3,16 @@
## Deferred Items
- data-base/src/hero/mover.ts 的 2 处既有 TypeScript 诊断TS2339IHeroMoveTopImpl 上不存在 canPass/shouldHit
status: open
status: resolved
**What:** 用户提交 7a011b2 将 `IHeroMoveTopImpl` 重构为 `predicate(): IPassPredicate` 形态后,
消费方 `data-base/src/hero/mover.ts:183/185` 仍直接调用 `canPass/shouldHit`,产生既有诊断。
02-02 计划的 `check:type` 过滤式类型门(`mover\.ts`)会命中该既有文件;按计划声明
「仓库既有诊断不在本计划范围」与本执行器的范围边界规则不修复,留给 02-03 Task 1
(「掩码语义谓词由 02-03 Task 1 从 moverImpl 重构为 predicate() 后经 usePassPredicate 注入」)一并收口。
02-02 计划内文件(`src/path/`、`data-common/src/common/mover.ts`)类型诊断为 0。
02-02 计划内文件(`src/path/`、`data-common/src/common/mover.ts`)类型诊断为 0。
- data-state/src/core.ts 的 2 处既有 TypeScript 诊断TS2322/TS2345TileStore 泛型实现与 ITileStore 不兼容)
status: open
**What:** `CoreState` 原有 `TileStore<LegacyTileData>` 装配在 `this.tileStore``MapState` 构造处
被仓库当前接口泛型诊断命中;本计划仅在同一构造器追加寻路接线,不改变既有 TileStore 契约,
按范围边界规则留给后续数据层类型收口。

View File

@ -184,7 +184,8 @@ export class HeroMover<T extends IHeroLocation>
currLoc: handler.currLoc,
nextLoc: handler.nextLoc,
direction: handler.direction,
floorId: handler.floorId
floorId: handler.floorId,
state: handler.state
};
const predicate = this.topImpl.predicate();
const canPass = predicate.canPass(passHandler);

View File

@ -77,6 +77,7 @@ import { ILoadProgressTotal, LoadProgressTotal } from '@motajs/loader';
import { isNil } from 'lodash-es';
import { logger } from '@motajs/common';
import { DefaultHeroMoveTopImpl } from './hero';
import { HeroPathfinding } from './path';
export class CoreState implements ICoreState {
// Layer 0 公共层,最底层的接口,不会依赖任何其他内容,一般是工具性接口及不需要存档的数据
@ -101,6 +102,8 @@ export class CoreState implements ICoreState {
// Layer 3 用户层,也就是最顶层的内容,一般仅用于初始化以及仅供渲染端调用的顶层模块
readonly loadProgress: ILoadProgressTotal;
readonly dataLoader: IMotaDataLoader;
/** 勇士寻路入口 */
readonly pathfinding: HeroPathfinding;
/** 可存档对象映射 */
private readonly saveables: Map<string, ISaveableContent<any>> = new Map();
@ -235,6 +238,7 @@ export class CoreState implements ICoreState {
// 勇士顶层初始化
const heroMoveTopImpl = new DefaultHeroMoveTopImpl(this);
this.hero.location.mover.useTopImplementation(heroMoveTopImpl);
this.pathfinding = new HeroPathfinding(this, heroMoveTopImpl);
//#endregion
}

View File

@ -1,5 +1,6 @@
export * from './enemy';
export * from './hero';
export * from './path';
export * from './core';
export * from './ins';

View File

@ -0,0 +1,192 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { ITileStore } from '@user/data-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 {
EventExecutor: typeof import('@user/data-system').EventExecutor;
DefaultHeroMoveTopImpl: typeof import('../hero').DefaultHeroMoveTopImpl;
HeroMover: typeof import('@user/data-base').HeroMover;
HeroPathfinding: typeof import('./heroPathfinding').HeroPathfinding;
MapState: typeof import('@user/data-base').MapState;
TileStore: typeof import('@user/data-common').TileStore;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
FaceManager: typeof import('@user/data-common').FaceManager;
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
}
interface EventCall {
readonly id: string;
readonly trigger: number;
readonly hero: Readonly<{ x: number; y: number }>;
}
interface HeroFixture {
x: number;
y: number;
floorId: string;
state: unknown;
mover?: unknown;
setPos(x: number, y: number): void;
getCurrentFaceDirection(): number;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const base = await import('@user/data-base');
const common = await import('@user/data-common');
const state = await import('../hero');
const path = await import('./heroPathfinding');
const event = await import('@user/data-system');
modules = {
EventExecutor: event.EventExecutor,
DefaultHeroMoveTopImpl: state.DefaultHeroMoveTopImpl,
HeroMover: base.HeroMover,
HeroPathfinding: path.HeroPathfinding,
MapState: base.MapState,
TileStore: common.TileStore,
Dir8FaceHandler: common.Dir8FaceHandler,
FaceManager: common.FaceManager,
RoleFaceBinder: common.RoleFaceBinder
};
});
function createFixture() {
const tileStore: ITileStore = new modules.TileStore() as never;
tileStore.addTile({
num: 1,
id: 'floor',
events: {},
type: 0,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
});
const faceManager = new modules.FaceManager();
const faceHandler = new modules.Dir8FaceHandler();
faceManager.register(1, faceHandler);
const commonState = {
tileStore,
itemStore: {},
mapStore: {},
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
saveSystem: {}
} as never;
const maps = new modules.MapState(tileStore, commonState);
const eventMap = maps.fromRaw({
floorId: 'F1',
width: 3,
map: { 0: [1, 1, 1] },
layerAlias: { 0: 'event' },
events: { 0: { 1: { 30: 'middle-enter' } } }
})!;
const events = new Map<string, object>();
const store = {
addEvent() {},
getEvent<
_P extends Record<string, unknown>,
_E extends Record<string, unknown>,
_R = void
>(id: string) {
return events.get(id) as never;
}
};
const executor = new modules.EventExecutor({} as never, () => store);
const state = {
maps,
eventSystem: { executor }
};
const hero: HeroFixture = {
x: 0,
y: 0,
floorId: 'F1',
state,
setPos(x: number, y: number) {
hero.x = x;
hero.y = y;
},
getCurrentFaceDirection() {
return 4;
}
};
const mover = new modules.HeroMover(hero as never, faceHandler);
hero.mover = mover;
const heroState = { location: hero };
const fixtureState = { ...state, hero: heroState } as never;
const topImpl = new modules.DefaultHeroMoveTopImpl(fixtureState);
mover.useTopImplementation(topImpl);
const pathfinding = new modules.HeroPathfinding(fixtureState, topImpl);
return {
eventMap,
events,
executor,
hero: heroState.location,
pathfinding,
state: fixtureState
};
}
function addEvent(
events: Map<string, object>,
id: string,
trigger: number,
calls: EventCall[]
): void {
events.set(id, {
trigger,
execute: async (
_param: unknown,
env: {
trigger: number;
heroLocator: Readonly<{ x: number; y: number }>;
}
) => {
calls.push({ id, trigger: env.trigger, hero: env.heroLocator });
return true;
}
});
}
describe('hero pathfinding integration', () => {
// 验证勇士按最小路径逐步移动并按事件链顺序执行途经事件
it('moves the hero to the target and triggers the traversed event', async () => {
const calls: EventCall[] = [];
const fixture = createFixture();
addEvent(fixture.events, 'middle-enter', 2, calls);
const path = fixture.pathfinding.getPath({ x: 2, y: 0 });
expect(path.map(step => step.to)).toEqual([
{ x: 1, y: 0 },
{ x: 2, y: 0 }
]);
const result = fixture.pathfinding.moveTo({ x: 2, y: 0 });
expect(result).not.toBeNull();
await result!.controller.onEnd;
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
x: 2,
y: 0
});
expect(calls).toEqual([
{ id: 'middle-enter', trigger: 2, hero: { x: 1, y: 0 } }
]);
});
});

View File

@ -0,0 +1,92 @@
import { ITileLocator } from '@motajs/common';
import {
IHeroMoveTopImpl,
IHeroState,
IMapLayer,
IMapState,
IPassPredicate
} from '@user/data-base';
import { IHeroAttr, IObjectMovable, IObjectMover } from '@user/data-common';
import {
IPathfinder,
IPathfindingController,
IPathfindingStep,
IPathfindingSystem,
PathCostFunction,
PathFallbackPolicy,
PathfindingSystem
} from '@user/data-system';
import { IStateSystem } from '@user/data-system';
interface IHeroPathfindingState extends IStateSystem {
readonly hero: IHeroState<IHeroAttr>;
}
interface IHeroPathfinding extends IPathfindingSystem {}
export class HeroPathfinding implements IHeroPathfinding {
readonly state: IHeroPathfindingState;
readonly finder: IPathfinder;
private readonly system: PathfindingSystem;
constructor(state: IHeroPathfindingState, topImpl: IHeroMoveTopImpl) {
this.state = state;
this.system = new PathfindingSystem(state);
this.finder = this.system.finder;
this.system.useMover(state.hero.location.mover);
this.finder.useMapState(state.maps);
this.finder.usePassPredicate(topImpl.predicate());
this.bindCurrentLayer();
}
private bindCurrentLayer(): void {
const floorId = this.state.hero.location.floorId;
const map = floorId ? this.state.maps.getMap(floorId) : null;
const layer: IMapLayer | null = map?.eventLayer ?? null;
this.finder.useMapLayer(layer);
}
useMover(mover: IObjectMover<IObjectMovable> | null): void {
this.system.useMover(mover);
}
useFallbackPolicy(policy: PathFallbackPolicy | null): void {
this.system.useFallbackPolicy(policy);
}
getPath(target: ITileLocator): IPathfindingStep[] {
this.bindCurrentLayer();
return this.system.getPath(target);
}
moveTo(target: ITileLocator): IPathfindingController | null {
this.bindCurrentLayer();
return this.system.moveTo(target);
}
teleportTo(target: ITileLocator): IPathfindingController | null {
this.bindCurrentLayer();
return this.system.teleportTo(target);
}
interrupt(): Promise<void> {
return this.system.interrupt();
}
useMapState(maps: IMapState | null): void {
this.finder.useMapState(maps);
}
useMapLayer(layer: IMapLayer | null): void {
this.finder.useMapLayer(layer);
}
useCostFunction(cost: PathCostFunction | null): void {
this.finder.useCostFunction(cost);
}
usePassPredicate(predicate: IPassPredicate | null): void {
this.finder.usePassPredicate(predicate);
}
}

View File

@ -0,0 +1 @@
export * from './heroPathfinding';