mirror of
https://github.com/motajs/template.git
synced 2026-09-13 02:08:50 +08:00
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:
parent
2759df531e
commit
6cde84ec31
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user