fix(02-03): support pathfinding interruption handoff

- Queue new movement until the stopped controller settles
- Register pathfinding controller diagnostics and regression coverage
This commit is contained in:
unanmed 2026-09-09 21:22:49 +08:00
parent 6cde84ec31
commit 7a3b6e35c1
3 changed files with 195 additions and 23 deletions

View File

@ -190,6 +190,29 @@ function addEvent(
});
}
function addBlockingEvent(
events: Map<string, object>,
id: string,
trigger: number,
calls: EventCall[],
blocked: Promise<void>
): 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<void>(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<void>(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
});
});
});

View File

@ -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<void>;
constructor(start: () => Promise<IPathfindingController | null>) {
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<IMoverController> {
return this;
}
get path(): readonly IPathfindingStep[] {
return this.currentPath;
}
get done(): boolean {
return this.completed;
}
get onEnd(): Promise<void> {
return this.completion;
}
isCancelled(): boolean {
return this.cancelled;
}
push(...steps: Readonly<ObjectMoveStep>[]): void {
this.current?.controller.push(...steps);
}
insert(...steps: Readonly<ObjectMoveStep>[]): void {
this.current?.controller.insert(...steps);
}
stop(): Promise<void> {
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<void> {
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 {

View File

@ -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.",