diff --git a/.planning/phases/02-pathfinding/deferred-items.md b/.planning/phases/02-pathfinding/deferred-items.md index ce1b6c6..113bcbc 100644 --- a/.planning/phases/02-pathfinding/deferred-items.md +++ b/.planning/phases/02-pathfinding/deferred-items.md @@ -3,10 +3,16 @@ ## Deferred Items - data-base/src/hero/mover.ts 的 2 处既有 TypeScript 诊断(TS2339:IHeroMoveTopImpl 上不存在 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/TS2345:TileStore 泛型实现与 ITileStore 不兼容) + status: open + **What:** `CoreState` 原有 `TileStore` 装配在 `this.tileStore` 与 `MapState` 构造处 + 被仓库当前接口泛型诊断命中;本计划仅在同一构造器追加寻路接线,不改变既有 TileStore 契约, + 按范围边界规则留给后续数据层类型收口。 diff --git a/packages-user/data-base/src/hero/mover.ts b/packages-user/data-base/src/hero/mover.ts index 0c8b86a..45c3612 100644 --- a/packages-user/data-base/src/hero/mover.ts +++ b/packages-user/data-base/src/hero/mover.ts @@ -184,7 +184,8 @@ export class HeroMover 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); diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index d652a3c..2e7c463 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -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> = 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 } diff --git a/packages-user/data-state/src/index.ts b/packages-user/data-state/src/index.ts index 89c9dbb..fc9de2e 100644 --- a/packages-user/data-state/src/index.ts +++ b/packages-user/data-state/src/index.ts @@ -1,5 +1,6 @@ export * from './enemy'; export * from './hero'; +export * from './path'; export * from './core'; export * from './ins'; diff --git a/packages-user/data-state/src/path/heroPathfinding.test.ts b/packages-user/data-state/src/path/heroPathfinding.test.ts new file mode 100644 index 0000000..8af5209 --- /dev/null +++ b/packages-user/data-state/src/path/heroPathfinding.test.ts @@ -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 ( + 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 { + 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(); + const store = { + addEvent() {}, + getEvent< + _P extends Record, + _E extends Record, + _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, + 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 } } + ]); + }); +}); diff --git a/packages-user/data-state/src/path/heroPathfinding.ts b/packages-user/data-state/src/path/heroPathfinding.ts new file mode 100644 index 0000000..9a9d5dc --- /dev/null +++ b/packages-user/data-state/src/path/heroPathfinding.ts @@ -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; +} + +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 | 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 { + 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); + } +} diff --git a/packages-user/data-state/src/path/index.ts b/packages-user/data-state/src/path/index.ts new file mode 100644 index 0000000..858e89d --- /dev/null +++ b/packages-user/data-state/src/path/index.ts @@ -0,0 +1 @@ +export * from './heroPathfinding';