From 7a3b6e35c1dfce17e605ba870a023524acba8eec Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Wed, 9 Sep 2026 21:22:49 +0800 Subject: [PATCH] fix(02-03): support pathfinding interruption handoff - Queue new movement until the stopped controller settles - Register pathfinding controller diagnostics and regression coverage --- .../src/path/heroPathfinding.test.ts | 73 +++++++++ .../data-state/src/path/heroPathfinding.ts | 142 +++++++++++++++--- packages/common/src/logger.json | 3 +- 3 files changed, 195 insertions(+), 23 deletions(-) diff --git a/packages-user/data-state/src/path/heroPathfinding.test.ts b/packages-user/data-state/src/path/heroPathfinding.test.ts index 91a1c66..8976b99 100644 --- a/packages-user/data-state/src/path/heroPathfinding.test.ts +++ b/packages-user/data-state/src/path/heroPathfinding.test.ts @@ -190,6 +190,29 @@ function addEvent( }); } +function addBlockingEvent( + events: Map, + id: string, + trigger: number, + calls: EventCall[], + blocked: Promise +): 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 }); + await blocked; + return true; + } + }); +} + describe('hero pathfinding integration', () => { // 验证勇士按最小路径逐步移动并按事件链顺序执行途经事件 it('moves the hero to the target and triggers the traversed event', async () => { @@ -282,4 +305,54 @@ describe('hero pathfinding integration', () => { y: 0 }); }); + + // 验证新寻路会停止旧移动并从兑现后的最新坐标接管 + it('hands over a moving path to a new target', async () => { + const calls: EventCall[] = []; + let release: () => void = () => {}; + const blocked = new Promise(resolve => { + release = resolve; + }); + const fixture = createFixture(); + addBlockingEvent(fixture.events, 'middle-enter', 2, calls, blocked); + + fixture.pathfinding.moveTo({ x: 2, y: 0 }); + await Promise.resolve(); + await Promise.resolve(); + const next = fixture.pathfinding.moveTo({ x: 0, y: 0 }); + expect(next).not.toBeNull(); + release(); + await next!.controller.onEnd; + + expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({ + x: 0, + y: 0 + }); + }); + + // 验证显式打断后移动器可再次启动新的寻路 + it('leaves the mover restartable after explicit interruption', async () => { + const calls: EventCall[] = []; + let release: () => void = () => {}; + const blocked = new Promise(resolve => { + release = resolve; + }); + const fixture = createFixture(); + addBlockingEvent(fixture.events, 'middle-enter', 2, calls, blocked); + + fixture.pathfinding.moveTo({ x: 2, y: 0 }); + await Promise.resolve(); + await Promise.resolve(); + const interrupted = fixture.pathfinding.interrupt(); + release(); + await interrupted; + + const resumed = fixture.pathfinding.moveTo({ x: 0, y: 0 }); + expect(resumed).not.toBeNull(); + await resumed!.controller.onEnd; + 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 f64141c..8bb413f 100644 --- a/packages-user/data-state/src/path/heroPathfinding.ts +++ b/packages-user/data-state/src/path/heroPathfinding.ts @@ -1,4 +1,4 @@ -import { ITileLocator } from '@motajs/common'; +import { ITileLocator, logger } from '@motajs/common'; import { BlockEventType, IBlockEventEnv, @@ -87,11 +87,74 @@ class HeroPathfindingController implements IHeroPathfindingController { } } +class QueuedHeroPathfindingController implements IPathfindingController { + private current: IPathfindingController | null = null; + private currentPath: IPathfindingStep[] = []; + private cancelled: boolean = false; + private completed: boolean = false; + private readonly completion: Promise; + + constructor(start: () => Promise) { + this.completion = Promise.resolve() + .then(start) + .then(async result => { + if (!result) { + this.completed = true; + return; + } + this.current = result; + this.currentPath = [...result.path]; + if (this.cancelled) { + await result.controller.stop(); + this.completed = true; + return; + } + await result.controller.onEnd; + this.completed = true; + }); + } + + get controller(): Readonly { + return this; + } + + get path(): readonly IPathfindingStep[] { + return this.currentPath; + } + + get done(): boolean { + return this.completed; + } + + get onEnd(): Promise { + return this.completion; + } + + isCancelled(): boolean { + return this.cancelled; + } + + push(...steps: Readonly[]): void { + this.current?.controller.push(...steps); + } + + insert(...steps: Readonly[]): void { + this.current?.controller.insert(...steps); + } + + stop(): Promise { + this.cancelled = true; + if (this.current) return this.current.controller.stop(); + return this.completion; + } +} + export class HeroPathfinding implements IHeroPathfinding { readonly state: IHeroPathfindingState; readonly finder: IPathfinder; private readonly system: PathfindingSystem; + private active: IPathfindingController | null = null; constructor(state: IHeroPathfindingState, topImpl: IHeroMoveTopImpl) { this.state = state; @@ -149,35 +212,67 @@ export class HeroPathfinding implements IHeroPathfinding { } moveTo(target: ITileLocator): IPathfindingController | null { + if (this.isMoving()) return this.queueAfterInterrupt(target, false); this.bindCurrentLayer(); 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 - }; + return this.startResolved(target, resolved, false); } teleportTo(target: ITileLocator): IPathfindingController | null { + if (this.isMoving()) return this.queueAfterInterrupt(target, true); this.bindCurrentLayer(); const resolved = this.resolvePath(target); + return this.startResolved(target, resolved, true); + } + + private isMoving(): boolean { + return this.active !== null && !this.active.controller.done; + } + + private queueAfterInterrupt( + target: ITileLocator, + teleport: boolean + ): IPathfindingController { + const queued = new QueuedHeroPathfindingController(async () => { + await this.system.interrupt(); + if (queued.isCancelled()) return null; + this.bindCurrentLayer(); + const resolved = this.resolvePath(target); + return this.startResolved(target, resolved, teleport); + }); + this.active = queued; + return queued; + } + + private startResolved( + target: ITileLocator, + resolved: IResolvedPath, + teleport: boolean + ): IPathfindingController | null { 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 - }; + const result = teleport + ? this.system.teleportTo(destination) + : this.system.moveTo(destination); + if (!result) { + logger.error(65); + return null; + } + const wrapped = + resolved.adjacent && resolved.target + ? { + controller: new HeroPathfindingController( + result.controller, + () => this.finishAdjacentTouch(resolved) + ), + path: result.path + } + : result; + this.active = wrapped; + void wrapped.controller.onEnd.then(() => { + if (this.active === wrapped) this.active = null; + }); + return wrapped; } private resolvePath(target: ITileLocator): IResolvedPath { @@ -298,7 +393,10 @@ export class HeroPathfinding implements IHeroPathfinding { } interrupt(): Promise { - return this.system.interrupt(); + const active = this.active; + this.active = null; + if (!active) return this.system.interrupt(); + return active.controller.stop().then(() => this.system.interrupt()); } useMapState(maps: IMapState | null): void { diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index e6a6536..313b719 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -63,7 +63,8 @@ "61": "Cannot create game map '$3' from raw data since map area cannot be divided by width evenly: area = $1 while divider = $2.", "62": "Cannot add $1 from raw data for game map '$2' since Expected a number for the key of $3, but got $4.", "63": "Cannot create game map '$2' from raw data since the '$1' container is missing, null, or not an object.", - "64": "Cannot create game map '$2' from raw data since the '$1' value has an invalid type or range." + "64": "Cannot create game map '$2' from raw data since the '$1' value has an invalid type or range.", + "65": "Cannot start pathfinding movement because the mover is already active." }, "warn": { "1": "Resource with type of 'none' is loaded.",