Skip to content

Commit

Permalink
Merge pull request #464 from Annoraaq/feature/#271-alternative-targets
Browse files Browse the repository at this point in the history
#271 refactor
  • Loading branch information
Annoraaq committed Jan 7, 2024
2 parents 969e289 + ebdbe90 commit 3c89686
Show file tree
Hide file tree
Showing 3 changed files with 266 additions and 32 deletions.
182 changes: 182 additions & 0 deletions src/Movement/TargetMovement/TargetMovement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
mockCharMap,
updateLayer,
} from "../../Utils/MockFactory/MockFactory.js";
import { LayerPositionUtils } from "../../Utils/LayerPositionUtils/LayerPositionUtils.js";

const TEST_CHAR_CONFIG = {
speed: 1,
Expand Down Expand Up @@ -2260,6 +2261,187 @@ describe("TargetMovement", () => {
});
});

describe("noPathFound: ALTERNATIVE_TARGETS", () => {
it("should fall back to STOP if no targets or alternative strategy is provided", () => {
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,
);

gridTilemap.addCharacter(mockChar);

targetMovement = new TargetMovement(
mockChar,
gridTilemap,
layerPos(new Vector2(1, 3)),
{
config: {
algorithm: shortestPathAlgo,
noPathFoundStrategy: NoPathFoundStrategy.ALTERNATIVE_TARGETS,
},
},
);

const finishedObsCallbackMock = jest.fn();
const finishedObsCompleteMock = jest.fn();
targetMovement.finishedObs().subscribe({
next: finishedObsCallbackMock,
complete: finishedObsCompleteMock,
});
targetMovement.update(100);
mockChar.update(100);
expect(mockChar.isMoving()).toBe(false);

updateLayer(
tilemapMock,
[
// prettier-ignore
".p..",
"....",
"#.##",
".t..",
],
"lowerCharLayer",
);

targetMovement.update(200);
mockChar.update(200);

expect(mockChar.isMoving()).toBe(false);
expect(finishedObsCallbackMock).toHaveBeenCalledWith({
position: charPos.position,
result: MoveToResult.NO_PATH_FOUND,
description: "NoPathFoundStrategy STOP: No path found.",
layer: "lowerCharLayer",
});
expect(finishedObsCompleteMock).toHaveBeenCalled();
});

it("should move towards alternative target if path is blocked", () => {
const charPos = layerPos(new Vector2(2, 5));
const targetPos = layerPos(new Vector2(2, 0));
const alternativeTargets = [
LayerPositionUtils.fromInternal(layerPos(new Vector2(0, 0))),
LayerPositionUtils.fromInternal(layerPos(new Vector2(0, 6))),
LayerPositionUtils.fromInternal(layerPos(new Vector2(3, 6))),
];
tilemapMock = mockLayeredBlockMap([
{
layer: "lowerCharLayer",
blockMap: [
// prettier-ignore
"a.t.",
"####",
"....",
"....",
"...#",
"..s.",
"a..a",
],
},
]);
gridTilemap = new GridTilemap(
tilemapMock,
"ge_collide",
CollisionStrategy.BLOCK_TWO_TILES,
);
const mockChar = createMockChar("char", charPos, {
...TEST_CHAR_CONFIG,
tilemap: gridTilemap,
});

gridTilemap.addCharacter(mockChar);

targetMovement = new TargetMovement(mockChar, gridTilemap, targetPos, {
config: {
algorithm: shortestPathAlgo,
noPathFoundStrategy: NoPathFoundStrategy.ALTERNATIVE_TARGETS,
alternativeTargets,
},
});

expectWalkedPath(
targetMovement,
mockChar,
createPath([
[2, 6],
[1, 6],
[0, 6],
]),
);
});

it("uses fallback strategy", () => {
const charPos = layerPos(new Vector2(2, 5));
const targetPos = layerPos(new Vector2(2, 0));
const alternativeTargets = [
LayerPositionUtils.fromInternal(layerPos(new Vector2(0, 0))),
];
tilemapMock = mockLayeredBlockMap([
{
layer: "lowerCharLayer",
blockMap: [
// prettier-ignore
"a.t.",
"####",
"....",
"....",
"...#",
"..s.",
"....",
],
},
]);
gridTilemap = new GridTilemap(
tilemapMock,
"ge_collide",
CollisionStrategy.BLOCK_TWO_TILES,
);
const mockChar = createMockChar("char", charPos, {
...TEST_CHAR_CONFIG,
tilemap: gridTilemap,
});

gridTilemap.addCharacter(mockChar);

targetMovement = new TargetMovement(mockChar, gridTilemap, targetPos, {
config: {
algorithm: shortestPathAlgo,
noPathFoundStrategy: NoPathFoundStrategy.ALTERNATIVE_TARGETS,
alternativeTargets,
noPathFoundAlternativeTargetsFallbackStrategy:
NoPathFoundStrategy.CLOSEST_REACHABLE,
},
});

expectWalkedPath(
targetMovement,
mockChar,
createPath([
[2, 4],
[2, 3],
[2, 2],
]),
);
});
});

test.each(["BFS", "BIDIRECTIONAL_SEARCH", "JPS"])(
"should show a warning if considerCost pathfinding option is used with" +
" algorithm different than A*",
Expand Down
95 changes: 69 additions & 26 deletions src/Movement/TargetMovement/TargetMovement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ export interface MoveToConfig {
* @default false
*/
considerCosts?: boolean;

/**
* Only relevant if {@link MoveToConfig.pathBlockedStrategy} is set to {@link
* PathBlockedStrategy.ALTERNATIVE_TARGETS}.
*
* It provides a list of alternative targets that are considered if the main
* target is not reachable. That list is processed in order.
*/
alternativeTargets?: LayerPosition[];

/**
* Only relevant if {@link MoveToConfig.pathBlockedStrategy} is set to {@link
* PathBlockedStrategy.ALTERNATIVE_TARGETS}.
*
* In case all these targets are blocked this is the fallback strategy.
*/
noPathFoundAlternativeTargetsFallbackStrategy?: NoPathFoundStrategy;
}

/**
Expand Down Expand Up @@ -184,6 +201,8 @@ export class TargetMovement implements Movement {
private noPathFoundStrategy: NoPathFoundStrategy;
private stopped = false;
private noPathFoundRetryable: Retryable;
private alternativeTargets?: LayerPosition[];
private noPathFoundAlternativeTargetsFallbackStrategy?: NoPathFoundStrategy;
private pathBlockedRetryable: Retryable;
private pathBlockedWaitTimeoutMs: number;
private pathBlockedWaitElapsed = 0;
Expand Down Expand Up @@ -235,6 +254,11 @@ export class TargetMovement implements Movement {
this.maxPathLength = config.maxPathLength;
}

this.alternativeTargets = config?.alternativeTargets;

this.noPathFoundAlternativeTargetsFallbackStrategy =
config?.noPathFoundAlternativeTargetsFallbackStrategy;

if (config?.considerCosts && this.shortestPathAlgorithm !== "A_STAR") {
console.warn(
`GridEngine: Pathfinding option 'considerCosts' cannot be used with ` +
Expand Down Expand Up @@ -483,37 +507,56 @@ export class TargetMovement implements Movement {

private getShortestPath(): ShortestPath {
const pathfinding = new Pathfinding(this.tilemap);
const { path: shortestPath, closestToTarget } =
pathfinding.findShortestPath(
this.character.getNextTilePos(),
this.targetPos,
this.getPathfindingOptions(),
);
const { path, closestToTarget } = pathfinding.findShortestPath(
this.character.getNextTilePos(),
this.targetPos,
this.getPathfindingOptions(),
);

const noPathFound = shortestPath.length == 0;
const noPathFound = path.length == 0;

if (
noPathFound &&
this.noPathFoundStrategy === NoPathFoundStrategy.CLOSEST_REACHABLE
) {
if (!closestToTarget) {
throw Error(
"ClosestToTarget should never be undefined in TargetMovement.",
);
if (noPathFound) {
if (this.noPathFoundStrategy === NoPathFoundStrategy.CLOSEST_REACHABLE) {
if (!closestToTarget) {
throw Error(
"ClosestToTarget should never be undefined in TargetMovement.",
);
}
return this.pathToAlternativeTarget(closestToTarget, pathfinding);
} else if (
this.noPathFoundStrategy === NoPathFoundStrategy.ALTERNATIVE_TARGETS
) {
for (const altTarget of this.alternativeTargets ?? []) {
const { path, distOffset } = this.pathToAlternativeTarget(
LayerPositionUtils.toInternal(altTarget),
pathfinding,
);
if (path.length > 0) return { path, distOffset };
}
this.noPathFoundStrategy =
this.noPathFoundAlternativeTargetsFallbackStrategy ||
NoPathFoundStrategy.STOP;
return this.getShortestPath();
}
const shortestPathToClosestPoint = pathfinding.findShortestPath(
this.character.getNextTilePos(),
closestToTarget,
this.getPathfindingOptions(),
).path;
const distOffset = this.distanceUtils.distance(
closestToTarget.position,
this.targetPos.position,
);
return { path: shortestPathToClosestPoint, distOffset };
}

return { path: shortestPath, distOffset: 0 };
return { path, distOffset: 0 };
}

private pathToAlternativeTarget(
target: LayerVecPos,
pathfinding: Pathfinding,
): ShortestPath {
const path = pathfinding.findShortestPath(
this.character.getNextTilePos(),
target,
this.getPathfindingOptions(),
).path;
const distOffset = this.distanceUtils.distance(
target.position,
this.targetPos.position,
);
return { path, distOffset };
}

private getDir(from: Vector2, to: Vector2): Direction {
Expand Down
21 changes: 15 additions & 6 deletions src/Pathfinding/NoPathFoundStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,26 @@ export enum NoPathFoundStrategy {
STOP = "STOP",

/**
* Look for the closest point ({@link https://en.wikipedia.org/wiki/Taxicab_geometry | manhattan distance})
* to the target position that is reachable.
* Look for the closest point ({@link
* https://en.wikipedia.org/wiki/Taxicab_geometry | manhattan distance}) to
* the target position that is reachable.
*/
CLOSEST_REACHABLE = "CLOSEST_REACHABLE",

/**
* Tries again after {@link MoveToConfig.noPathFoundRetryBackoffMs}
* milliseconds until the maximum amount of retries ({@link MoveToConfig.noPathFoundMaxRetries})
* has been reached. By default, {@link MoveToConfig.noPathFoundMaxRetries} is
* `-1`, which means that there is no maximum number of retries and it will
* try again possibly "forever".
* milliseconds until the maximum amount of retries ({@link
* MoveToConfig.noPathFoundMaxRetries}) has been reached. By default, {@link
* MoveToConfig.noPathFoundMaxRetries} is `-1`, which means that there is no
* maximum number of retries and it will try again possibly "forever".
*/
RETRY = "RETRY",

/**
* Tries each of {@link MoveToConfig.alternativeTargets}. If there does not
* exist a path to any of these targets, {@link
* MoveToConfig.noPathFoundAlternativeTargetsStrategy} determines the fallback
* strategy.
*/
ALTERNATIVE_TARGETS = "ALTERNATIVE_TARGETS",
}

0 comments on commit 3c89686

Please sign in to comment.