diff --git a/packages-user/data-state/src/path/heroPathfinding.test.ts b/packages-user/data-state/src/path/heroPathfinding.test.ts index 8af5209..91a1c66 100644 --- a/packages-user/data-state/src/path/heroPathfinding.test.ts +++ b/packages-user/data-state/src/path/heroPathfinding.test.ts @@ -40,7 +40,7 @@ interface HeroFixture { y: number; floorId: string; state: unknown; - mover?: unknown; + mover?: { readonly faceDirection: number }; setPos(x: number, y: number): void; getCurrentFaceDirection(): number; } @@ -68,7 +68,13 @@ beforeAll(async () => { }; }); -function createFixture() { +interface FixtureOptions { + readonly middleEvent?: boolean; + readonly targetNoPass?: boolean; + readonly sealedTarget?: boolean; +} + +function createFixture(options: FixtureOptions = {}) { const tileStore: ITileStore = new modules.TileStore() as never; tileStore.addTile({ num: 1, @@ -78,6 +84,14 @@ function createFixture() { pass: { onlyEvents: false, outPass: 15, inPass: 15 }, eventPass: true }); + tileStore.addTile({ + num: 2, + id: 'wall', + events: { 40: 'wall-touch' }, + type: 0, + pass: { onlyEvents: false, outPass: 0, inPass: 0 }, + eventPass: true + }); const faceManager = new modules.FaceManager(); const faceHandler = new modules.Dir8FaceHandler(); faceManager.register(1, faceHandler); @@ -94,9 +108,20 @@ function createFixture() { const eventMap = maps.fromRaw({ floorId: 'F1', width: 3, - map: { 0: [1, 1, 1] }, + map: { + 0: options.sealedTarget + ? [1, 2, 2] + : options.targetNoPass + ? [1, 1, 2] + : [1, 1, 1] + }, layerAlias: { 0: 'event' }, - events: { 0: { 1: { 30: 'middle-enter' } } } + events: { + 0: + options.middleEvent === false + ? {} + : { 1: { 30: 'middle-enter' } } + } })!; const events = new Map(); const store = { @@ -189,4 +214,72 @@ describe('hero pathfinding integration', () => { { id: 'middle-enter', trigger: 2, hero: { x: 1, y: 0 } } ]); }); + + // 验证无事件路径的瞬移一步到达目标 + it('teleports directly when the path has no events', async () => { + const fixture = createFixture({ middleEvent: false }); + const result = fixture.pathfinding.teleportTo({ 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 + }); + }); + + // 验证默认策略检测途经事件并自动回退为逐步移动 + it('falls back to step movement when the path has an event', async () => { + const calls: EventCall[] = []; + const fixture = createFixture(); + addEvent(fixture.events, 'middle-enter', 2, calls); + + const result = fixture.pathfinding.teleportTo({ 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 } } + ]); + }); + + // 验证 no-pass 目标移动至相邻格、面朝目标并派发 OnTouch + it('touches a no-pass target from its reachable adjacent cell', async () => { + const calls: EventCall[] = []; + const fixture = createFixture({ + middleEvent: false, + targetNoPass: true + }); + addEvent(fixture.events, 'wall-touch', 1, calls); + + 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: 1, + y: 0 + }); + expect(fixture.hero.mover!.faceDirection).toBe(3); + expect(calls).toEqual([ + { id: 'wall-touch', trigger: 1, hero: { x: 1, y: 0 } } + ]); + }); + + // 验证四邻无可达格时不可达目标不移动且不触发事件 + it('ignores a no-pass target without a reachable adjacent cell', () => { + const fixture = createFixture({ sealedTarget: true }); + const path = fixture.pathfinding.getPath({ x: 2, y: 0 }); + + expect(path).toEqual([]); + expect(fixture.pathfinding.moveTo({ x: 2, y: 0 })).toBeNull(); + expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({ + x: 0, + y: 0 + }); + }); }); diff --git a/packages-user/data-state/src/path/heroPathfinding.ts b/packages-user/data-state/src/path/heroPathfinding.ts index 9a9d5dc..f64141c 100644 --- a/packages-user/data-state/src/path/heroPathfinding.ts +++ b/packages-user/data-state/src/path/heroPathfinding.ts @@ -1,12 +1,25 @@ import { ITileLocator } from '@motajs/common'; import { + BlockEventType, + IBlockEventEnv, + IBlockEventParam, + IGameEventInvocation, IHeroMoveTopImpl, IHeroState, IMapLayer, IMapState, + IReadonlyTileBase, IPassPredicate } from '@user/data-base'; -import { IHeroAttr, IObjectMovable, IObjectMover } from '@user/data-common'; +import { + EventTrigger, + FaceDirection, + IHeroAttr, + IMoverController, + IObjectMovable, + IObjectMover, + ObjectMoveStep +} from '@user/data-common'; import { IPathfinder, IPathfindingController, @@ -24,6 +37,56 @@ interface IHeroPathfindingState extends IStateSystem { interface IHeroPathfinding extends IPathfindingSystem {} +interface IEventSource { + readonly priority: number; + readonly id: string; + readonly type: BlockEventType; + readonly tile: IReadonlyTileBase | null; +} + +interface IResolvedPath { + readonly path: IPathfindingStep[]; + readonly adjacent: Readonly | null; + readonly target: Readonly | null; +} + +interface IHeroPathfindingController extends IMoverController {} + +class HeroPathfindingController implements IHeroPathfindingController { + private completed: boolean = false; + private readonly completion: Promise; + + constructor( + private readonly delegate: Readonly, + afterMove: () => Promise + ) { + this.completion = delegate.onEnd.then(async () => { + await afterMove(); + this.completed = true; + }); + } + + get done(): boolean { + return this.completed; + } + + get onEnd(): Promise { + return this.completion; + } + + push(...steps: Readonly[]): void { + this.delegate.push(...steps); + } + + insert(...steps: Readonly[]): void { + this.delegate.insert(...steps); + } + + stop(): Promise { + return this.delegate.stop(); + } +} + export class HeroPathfinding implements IHeroPathfinding { readonly state: IHeroPathfindingState; readonly finder: IPathfinder; @@ -37,9 +100,34 @@ export class HeroPathfinding implements IHeroPathfinding { this.system.useMover(state.hero.location.mover); this.finder.useMapState(state.maps); this.finder.usePassPredicate(topImpl.predicate()); + this.system.useFallbackPolicy(path => this.hasEvent(path)); this.bindCurrentLayer(); } + private hasEvent(path: readonly IPathfindingStep[]): boolean { + const layer = this.finderLayer(); + if (!layer) return false; + for (const step of path) { + const loc = layer.getLocationData(step.to.x, step.to.y); + if (!loc) continue; + const point = layer.getPointEvent(step.to.x, step.to.y); + if (point && point.size > 0) return true; + if (loc.static && loc.static.tileEvent().get().size > 0) { + return true; + } + for (const tile of loc.dynamics) { + if (tile.tileEvent().get().size > 0) return true; + } + } + return false; + } + + private finderLayer(): IMapLayer | null { + const floorId = this.state.hero.location.floorId; + const map = floorId ? this.state.maps.getMap(floorId) : null; + return map?.eventLayer ?? null; + } + private bindCurrentLayer(): void { const floorId = this.state.hero.location.floorId; const map = floorId ? this.state.maps.getMap(floorId) : null; @@ -57,17 +145,156 @@ export class HeroPathfinding implements IHeroPathfinding { getPath(target: ITileLocator): IPathfindingStep[] { this.bindCurrentLayer(); - return this.system.getPath(target); + return this.resolvePath(target).path; } moveTo(target: ITileLocator): IPathfindingController | null { this.bindCurrentLayer(); - return this.system.moveTo(target); + const resolved = this.resolvePath(target); + if (resolved.path.length === 0) return null; + const destination = resolved.adjacent ?? target; + const result = this.system.moveTo(destination); + if (!result) return null; + if (!resolved.adjacent || !resolved.target) return result; + return { + controller: new HeroPathfindingController(result.controller, () => + this.finishAdjacentTouch(resolved) + ), + path: result.path + }; } teleportTo(target: ITileLocator): IPathfindingController | null { this.bindCurrentLayer(); - return this.system.teleportTo(target); + const resolved = this.resolvePath(target); + if (resolved.path.length === 0) return null; + const destination = resolved.adjacent ?? target; + const result = this.system.teleportTo(destination); + if (!result) return null; + if (!resolved.adjacent || !resolved.target) return result; + return { + controller: new HeroPathfindingController(result.controller, () => + this.finishAdjacentTouch(resolved) + ), + path: result.path + }; + } + + private resolvePath(target: ITileLocator): IResolvedPath { + const path = this.system.getPath(target); + if (path.length > 0) { + return { path, adjacent: null, target: null }; + } + const layer = this.finderLayer(); + if (!layer || !this.isNoPass(layer, target)) { + return { path: [], adjacent: null, target: null }; + } + + const start = this.state.hero.location; + const candidates: ReadonlyArray> = [ + { x: target.x, y: target.y - 1 }, + { x: target.x + 1, y: target.y }, + { x: target.x, y: target.y + 1 }, + { x: target.x - 1, y: target.y } + ]; + for (const adjacent of candidates) { + if (!layer.inMap(adjacent.x, adjacent.y)) continue; + const adjacentPath = this.finder.find( + { x: start.x, y: start.y }, + adjacent + ); + if (adjacentPath.length > 0) { + return { path: adjacentPath, adjacent, target }; + } + } + return { path: [], adjacent: null, target: null }; + } + + private isNoPass(layer: IMapLayer, target: ITileLocator): boolean { + const raw = layer.getLocationData(target.x, target.y)?.static?.raw(); + if (!raw) return false; + return ( + !raw.eventPass || raw.pass.inPass === 0 || raw.pass.outPass === 0 + ); + } + + private directionTo(target: ITileLocator): FaceDirection { + const hero = this.state.hero.location; + if (target.x > hero.x) return FaceDirection.Right; + if (target.x < hero.x) return FaceDirection.Left; + if (target.y > hero.y) return FaceDirection.Down; + return FaceDirection.Up; + } + + private async finishAdjacentTouch(resolved: IResolvedPath): Promise { + const adjacent = resolved.adjacent!; + const target = resolved.target!; + const mover = this.state.hero.location.mover; + mover.setFaceDir(this.directionTo(target)); + await this.dispatchTouch(adjacent, target); + } + + private async dispatchTouch( + heroLoc: Readonly, + target: Readonly + ): Promise { + const layer = this.finderLayer(); + if (!layer) return; + const pointSources: IEventSource[] = []; + const tileSources: IEventSource[] = []; + const point = layer.getPointEvent(target.x, target.y); + const loc = layer.getLocationData(target.x, target.y); + if (point) { + for (const [priority, id] of point) { + pointSources.push({ + priority, + id, + type: BlockEventType.PointEvent, + tile: null + }); + } + } + if (loc) { + if (loc.static) { + for (const [priority, id] of loc.static.tileEvent().get()) { + tileSources.push({ + priority, + id, + type: BlockEventType.TileEvent, + tile: loc.static + }); + } + } + for (const tile of loc.dynamics) { + for (const [priority, id] of tile.tileEvent().get()) { + tileSources.push({ + priority, + id, + type: BlockEventType.TileEvent, + tile + }); + } + } + } + pointSources.sort((a, b) => b.priority - a.priority); + tileSources.sort((a, b) => b.priority - a.priority); + const invocations: IGameEventInvocation[] = []; + for (const source of [...pointSources, ...tileSources]) { + const env: IBlockEventEnv = { + state: this.state, + type: source.type, + trigger: EventTrigger.OnTouch, + heroLocator: heroLoc, + triggerLocator: target, + tile: source.tile, + layer, + map: layer.map + }; + invocations.push({ id: source.id, env }); + } + if (invocations.length === 0) return; + const param: IBlockEventParam = { custom: {} }; + await this.state.eventSystem.executor.execute(invocations, param); } interrupt(): Promise {