feat(02-03): add teleport fallback and touch handling

- Detect event-bearing paths and fall back to step movement
- Handle adjacent no-pass targets with OnTouch dispatch and facing
This commit is contained in:
unanmed 2026-09-09 21:18:01 +08:00
parent 2759df531e
commit 6cde84ec31
2 changed files with 328 additions and 8 deletions

View File

@ -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<string, object>();
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
});
});
});

View File

@ -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<ITileLocator> | null;
readonly target: Readonly<ITileLocator> | null;
}
interface IHeroPathfindingController extends IMoverController {}
class HeroPathfindingController implements IHeroPathfindingController {
private completed: boolean = false;
private readonly completion: Promise<void>;
constructor(
private readonly delegate: Readonly<IMoverController>,
afterMove: () => Promise<void>
) {
this.completion = delegate.onEnd.then(async () => {
await afterMove();
this.completed = true;
});
}
get done(): boolean {
return this.completed;
}
get onEnd(): Promise<void> {
return this.completion;
}
push(...steps: Readonly<ObjectMoveStep>[]): void {
this.delegate.push(...steps);
}
insert(...steps: Readonly<ObjectMoveStep>[]): void {
this.delegate.insert(...steps);
}
stop(): Promise<void> {
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<Readonly<ITileLocator>> = [
{ 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<void> {
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<ITileLocator>,
target: Readonly<ITileLocator>
): Promise<void> {
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<void>(invocations, param);
}
interrupt(): Promise<void> {