From bfcd630103a9492dbb4851ff2b6baf329fe10200 Mon Sep 17 00:00:00 2001 From: Johannes Baum Date: Sun, 21 Dec 2025 11:14:38 +0100 Subject: [PATCH] #527 allow configuration --- src/GridEngine.test.ts | 1 + src/GridEngineHeadless.test.ts | 1 + src/GridEngineHeadless.ts | 1 + .../TargetMovement/TargetMovement.test.ts | 167 +++++++++++++++++- src/Movement/TargetMovement/TargetMovement.ts | 85 +++++++-- 5 files changed, 243 insertions(+), 12 deletions(-) diff --git a/src/GridEngine.test.ts b/src/GridEngine.test.ts index 6e622bd8..d5cd21f8 100644 --- a/src/GridEngine.test.ts +++ b/src/GridEngine.test.ts @@ -653,6 +653,7 @@ describe("GridEngine", () => { result: "NO_PATH_FOUND", description: "NoPathFoundStrategy STOP: No path found.", layer: undefined, + finishedEvent: "START_MOVEMENT", }); done(); }); diff --git a/src/GridEngineHeadless.test.ts b/src/GridEngineHeadless.test.ts index f398ef25..8922773e 100644 --- a/src/GridEngineHeadless.test.ts +++ b/src/GridEngineHeadless.test.ts @@ -509,6 +509,7 @@ describe("GridEngineHeadless", () => { result: "NO_PATH_FOUND", description: "NoPathFoundStrategy STOP: No path found.", layer: undefined, + finishedEvent: "START_MOVEMENT", }); done(); }); diff --git a/src/GridEngineHeadless.ts b/src/GridEngineHeadless.ts index 820bf420..05d48d7a 100644 --- a/src/GridEngineHeadless.ts +++ b/src/GridEngineHeadless.ts @@ -479,6 +479,7 @@ export class GridEngineHeadless implements IGridEngine { result: finished.result, description: finished.description, layer: finished.layer, + finishedEvent: finished.finishedEvent, })), ); } diff --git a/src/Movement/TargetMovement/TargetMovement.test.ts b/src/Movement/TargetMovement/TargetMovement.test.ts index 63bb788b..e68b940a 100644 --- a/src/Movement/TargetMovement/TargetMovement.test.ts +++ b/src/Movement/TargetMovement/TargetMovement.test.ts @@ -376,6 +376,96 @@ describe("TargetMovement", () => { expect(mockChar.getTilePos()).toEqual(layerPos(new Vector2(1, 1))); }); + it("should turn towards target if distance is reached", () => { + const charPos = layerPos(new Vector2(3, 0)); + const mockChar = createMockChar("char", charPos); + tilemapMock = mockLayeredBlockMap([ + { + layer: "lowerCharLayer", + blockMap: [ + // prettier-ignore + "..#p", + ".t#.", + ".##.", + "....", + ], + }, + ]); + gridTilemap = new GridTilemap( + tilemapMock, + "ge_collide", + CollisionStrategy.BLOCK_TWO_TILES, + ); + + targetMovement = new TargetMovement( + mockChar, + gridTilemap, + layerPos(new Vector2(1, 1)), + { + distance: 4, + config: { + algorithm: shortestPathAlgo, + }, + }, + ); + targetMovement.init(); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + + expect(mockChar.getTilePos()).toEqual(layerPos(new Vector2(1, 3))); + expect(mockChar.getFacingDirection()).toEqual(Direction.UP); + + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + + expect(mockChar.getTilePos()).toEqual(layerPos(new Vector2(1, 3))); + }); + + it("should turn towards target if closest to target is reached", () => { + const charPos = layerPos(new Vector2(1, 0)); + const mockChar = createMockChar("char", charPos); + tilemapMock = mockLayeredBlockMap([ + { + layer: "lowerCharLayer", + blockMap: [ + // prettier-ignore + ".p#.", + "..#.", + "..#.", + "..#t", + ], + }, + ]); + gridTilemap = new GridTilemap( + tilemapMock, + "ge_collide", + CollisionStrategy.BLOCK_TWO_TILES, + ); + + targetMovement = new TargetMovement( + mockChar, + gridTilemap, + layerPos(new Vector2(3, 3)), + { + config: { + algorithm: shortestPathAlgo, + noPathFoundStrategy: NoPathFoundStrategy.CLOSEST_REACHABLE, + }, + }, + ); + targetMovement.init(); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + chunkUpdate(targetMovement, mockChar, CHUNKS_PER_SECOND); + + expect(mockChar.getTilePos()).toEqual(layerPos(new Vector2(1, 3))); + expect(mockChar.getFacingDirection()).toEqual(Direction.RIGHT); + }); + it("should move if closestToTarget is further than distance", () => { const charPos = layerPos(new Vector2(1, 0)); const mockChar = createMockChar("char", charPos); @@ -786,6 +876,7 @@ describe("TargetMovement", () => { description: "NoPathFoundStrategy RETRY: Maximum retries of 2 exceeded.", layer: "lowerCharLayer", + finishedEvent: "START_MOVEMENT", }); expect(finishedObsCompleteMock).toHaveBeenCalled(); }); @@ -962,6 +1053,7 @@ describe("TargetMovement", () => { result: MoveToResult.NO_PATH_FOUND, description: "NoPathFoundStrategy STOP: No path found.", layer: "lowerCharLayer", + finishedEvent: "START_MOVEMENT", }); expect(finishedObsCompleteMock).toHaveBeenCalled(); }); @@ -1050,6 +1142,7 @@ describe("TargetMovement", () => { result: MoveToResult.PATH_BLOCKED_WAIT_TIMEOUT, description: "PathBlockedStrategy WAIT: Wait timeout of 2000ms exceeded.", layer: "lowerCharLayer", + finishedEvent: "START_MOVEMENT", }); expect(finishedObsCompleteMock).toHaveBeenCalled(); }); @@ -1332,6 +1425,7 @@ describe("TargetMovement", () => { description: "PathBlockedStrategy RETRY: Maximum retries of 2 exceeded.", layer: "lowerCharLayer", + finishedEvent: "START_MOVEMENT", }); expect(finishedObsCompleteMock).toHaveBeenCalled(); }); @@ -1385,6 +1479,7 @@ describe("TargetMovement", () => { result: MoveToResult.PATH_BLOCKED, description: `PathBlockedStrategy STOP: Path blocked.`, layer: "lowerCharLayer", + finishedEvent: "START_MOVEMENT", }); expect(finishedObsCompleteMock).toHaveBeenCalled(); }); @@ -1474,6 +1569,7 @@ describe("TargetMovement", () => { description: "Movement of character has been replaced before destination was reached.", layer: "lowerCharLayer", + finishedEvent: "START_MOVEMENT", }); }); @@ -1506,7 +1602,7 @@ describe("TargetMovement", () => { expect(mockCall).toHaveBeenCalled(); }); - it("should fire when char arrives", () => { + it("should fire when char does last step", () => { const mockCall = jest.fn(); const targetPos = { position: new Vector2(0, 0), layer: "testCharLayer" }; gridTilemap.setTransition( @@ -1529,6 +1625,7 @@ describe("TargetMovement", () => { result: MoveToResult.SUCCESS, description: "Successfully arrived.", layer: "testCharLayer", + finishedEvent: "START_MOVEMENT", }); }); @@ -1544,6 +1641,73 @@ describe("TargetMovement", () => { mockChar.update(1000); expect(mockCall).toHaveBeenCalledTimes(1); }); + + it("should fire after char does last step", () => { + const mockCall = jest.fn(); + const targetPos = { + position: new Vector2(2, 0), + layer: "lowerCharLayer", + }; + targetMovement = new TargetMovement(mockChar, gridTilemap, targetPos, { + config: { + algorithm: shortestPathAlgo, + emitFinishedEvent: "END_MOVEMENT", + }, + }); + mockChar.setMovement(targetMovement); + targetMovement.init(); + targetMovement.finishedObs().subscribe(mockCall); + + mockChar.update(500); + mockChar.update(500); + mockChar.update(500); + + expect(mockCall).toHaveBeenCalledWith({ + position: targetPos.position, + result: MoveToResult.SUCCESS, + description: "Successfully arrived.", + layer: "lowerCharLayer", + finishedEvent: "END_MOVEMENT", + }); + }); + + it("should fire before and after char does last step", () => { + const mockCall = jest.fn(); + const targetPos = { + position: new Vector2(2, 0), + layer: "lowerCharLayer", + }; + targetMovement = new TargetMovement(mockChar, gridTilemap, targetPos, { + config: { + algorithm: shortestPathAlgo, + emitFinishedEvent: "BOTH", + }, + }); + mockChar.setMovement(targetMovement); + targetMovement.init(); + targetMovement.finishedObs().subscribe(mockCall); + + mockChar.update(500); + mockChar.update(200); + expect(mockCall).toHaveBeenCalledWith({ + position: targetPos.position, + result: MoveToResult.SUCCESS, + description: "Successfully arrived.", + layer: "lowerCharLayer", + finishedEvent: "START_MOVEMENT", + }); + expect(mockCall).toHaveBeenCalledTimes(1); + + mockChar.update(500); + + expect(mockCall).toHaveBeenCalledWith({ + position: targetPos.position, + result: MoveToResult.SUCCESS, + description: "Successfully arrived.", + layer: "lowerCharLayer", + finishedEvent: "END_MOVEMENT", + }); + }); }); describe("8 directions", () => { @@ -2434,6 +2598,7 @@ describe("TargetMovement", () => { result: MoveToResult.NO_PATH_FOUND, description: "NoPathFoundStrategy STOP: No path found.", layer: "lowerCharLayer", + finishedEvent: "START_MOVEMENT", }); expect(finishedObsCompleteMock).toHaveBeenCalled(); }); diff --git a/src/Movement/TargetMovement/TargetMovement.ts b/src/Movement/TargetMovement/TargetMovement.ts index da1578cd..ac657fcf 100644 --- a/src/Movement/TargetMovement/TargetMovement.ts +++ b/src/Movement/TargetMovement/TargetMovement.ts @@ -26,6 +26,16 @@ import { LayerVecPos, } from "../../Utils/LayerPositionUtils/LayerPositionUtils.js"; +/** + * Defines when the observable returned by {@link TargetMovement.finishedObs} + * should emit the finished event. + * + * 'START_MOVEMENT' - Emit event when player starts the last position change (one field away from target). + * 'END_MOVEMENT' - Emit event when player reached the target position (stopped moving). + * 'BOTH' - Emit event at both times. + **/ +export type FinishedEvent = "START_MOVEMENT" | "END_MOVEMENT" | "BOTH"; + /** * @category Pathfinding */ @@ -152,6 +162,19 @@ export interface MoveToConfig { * Set of characters to ignore at collision checking. */ ignoredChars?: CharId[]; + + /** + * Defines when the observable returned by {@link TargetMovement.finishedObs} + * should emit the finished event. + * + * 'START_MOVEMENT' - Emit event when player starts the last position change (one field away from target). + * 'END_MOVEMENT' - Emit event when player reached the target position (stopped moving). + * 'BOTH' - Emit event at both times. + * + * + * @default 'START_MOVEMENT' + */ + emitFinishedEvent?: FinishedEvent; } /** @@ -176,6 +199,7 @@ export interface Finished { result?: MoveToResult; description?: string; layer: CharLayer; + finishedEvent: Omit; } export interface Options { @@ -224,6 +248,7 @@ export class TargetMovement implements Movement { private maxPathLength = Infinity; private considerCosts = false; private ignoredChars: CharId[] = []; + private emitFinishedEvent: FinishedEvent; constructor( private character: GridCharacter, @@ -293,6 +318,7 @@ export class TargetMovement implements Movement { ); this.pathBlockedWaitTimeoutMs = config?.pathBlockedWaitTimeoutMs || -1; this.ignoredChars = config?.ignoredChars ?? []; + this.emitFinishedEvent = config?.emitFinishedEvent || "START_MOVEMENT"; this.finished$ = new Subject(); } @@ -366,7 +392,10 @@ export class TargetMovement implements Movement { if (this.hasArrived()) { this.stop(MoveToResult.SUCCESS); if (this.existsDistToTarget()) { - this.turnTowardsTarget(); + const nextTile = this.shortestPath[this.shortestPath.length - 1]; + this.turnTowardsTarget(nextTile.position); + } else if (this.hasClosestToTarget()) { + this.turnTowardsTarget(this.targetPos.position); } } else if ( !this.isBlocking( @@ -378,6 +407,10 @@ export class TargetMovement implements Movement { } } + private hasClosestToTarget(): boolean { + return this.distOffset > 0; + } + finishedObs(): Subject { return this.finished$; } @@ -457,23 +490,53 @@ export class TargetMovement implements Movement { } private stop(result?: MoveToResult): void { - this.finished$.next({ + const finished: Finished = { position: this.character.getNextTilePos().position, result, description: this.resultToReason(result), layer: this.character.getNextTilePos().layer, - }); - this.finished$.complete(); + finishedEvent: "START_MOVEMENT", + }; + if (this.emitFinishedEvent === "START_MOVEMENT") { + this.finished$.next(finished); + this.finished$.complete(); + } else if (this.emitFinishedEvent === "END_MOVEMENT") { + this.character + .movementStopped() + .pipe(take(1)) + .subscribe(() => { + this.finished$.next({ + ...finished, + finishedEvent: "END_MOVEMENT", + }); + this.finished$.complete(); + }); + } else if (this.emitFinishedEvent === "BOTH") { + this.finished$.next(finished); + this.character + .movementStopped() + .pipe(take(1)) + .subscribe(() => { + this.finished$.next({ + ...finished, + finishedEvent: "END_MOVEMENT", + }); + this.finished$.complete(); + }); + } this.stopped = true; } - private turnTowardsTarget(): void { - const nextTile = this.shortestPath[this.posOnPath + 1]; - const dir = this.getDir( - this.character.getNextTilePos().position, - nextTile.position, - ); - this.character.turnTowards(dir); + private turnTowardsTarget(pos: Vector2): void { + const dir = this.getDir(this.character.getNextTilePos().position, pos); + // The character is still moving, so we need to wait until it has stopped, + // because it can't turn while in movement. + this.character + .movementStopped() + .pipe(take(1)) + .subscribe(() => { + this.character.turnTowards(dir); + }); } private existsDistToTarget(): boolean {