Skip to content

Commit

Permalink
Implement path smoothing for boat paths
Browse files Browse the repository at this point in the history
Most boats will take natural looking paths now. Between island groups they still tend to hug coasts (see #17)
Closes #14
  • Loading branch information
platz1de committed Sep 28, 2024
1 parent 8e7a436 commit 59e87e8
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 51 deletions.
46 changes: 15 additions & 31 deletions src/game/boat/Boat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ export class Boat {

private readonly owner: Player;
private readonly troops: number;
private readonly paths: number[][] = [];
private currentPathIndex: number = 0;
private currentPath: number[] = [];
private readonly path: number[] = [];
private currentNode: number = 0;

private x: number = 0;
Expand All @@ -31,13 +29,13 @@ export class Boat {
* @param path The path to follow.
* @param troops The amount of troops the boat carries.
*/
constructor(owner: Player, path: number[][], troops: number) {
constructor(owner: Player, path: number[], troops: number) {
this.owner = owner;
this.paths = path;
this.path = path;
this.troops = troops;

this.x = path[0][0] % gameMap.width + 0.5;
this.y = Math.floor(path[0][0] / gameMap.width) + 0.5;
this.x = path[0] % gameMap.width + 0.5;
this.y = Math.floor(path[0] / gameMap.width) + 0.5;
this.updateWaypoint();
}

Expand Down Expand Up @@ -95,26 +93,21 @@ export class Boat {
private updateWaypoint(): boolean {
const beforeX = this.nextX, beforeY = this.nextY;

if (++this.currentNode < this.currentPath.length) {
this.nextX = this.currentPath[this.currentNode] % gameMap.width + 0.5;
this.nextY = Math.floor(this.currentPath[this.currentNode] / gameMap.width) + 0.5;
} else if (this.currentPathIndex < this.paths.length) {
this.currentPath = this.paths[this.currentPathIndex++];
this.currentNode = 0;
this.nextX = this.currentPath[this.currentNode] % gameMap.width + 0.5;
this.nextY = Math.floor(this.currentPath[this.currentNode] / gameMap.width) + 0.5;
if (++this.currentNode < this.path.length) {
this.nextX = this.path[this.currentNode] % gameMap.width + 0.5;
this.nextY = Math.floor(this.path[this.currentNode] / gameMap.width) + 0.5;
} else {
//TODO: find a way to nicely integrate this with the normal attack system (the first tile currently has no cost)
const target = territoryManager.getOwner(this.currentPath[--this.currentNode]);
const target = territoryManager.getOwner(this.path[--this.currentNode]);
if (this.owner.isAlive() && gameMode.canAttack(this.owner.id, target)) {
const transaction = playerManager.getPlayer(target) ? new PlayerTerritoryTransaction(this.owner, playerManager.getPlayer(target)) : new TerritoryTransaction(this.owner);
territoryManager.conquer(this.currentPath[this.currentNode], this.owner.id, transaction);
territoryManager.conquer(this.path[this.currentNode], this.owner.id, transaction);
transaction.apply();

if (target === territoryManager.OWNER_NONE) {
attackActionHandler.attackUnclaimed(this.owner, this.troops, new Set([this.currentPath[this.currentNode]]));
attackActionHandler.attackUnclaimed(this.owner, this.troops, new Set([this.path[this.currentNode]]));
} else {
attackActionHandler.attackPlayer(this.owner, playerManager.getPlayer(target), this.troops, new Set([this.currentPath[this.currentNode]]));
attackActionHandler.attackPlayer(this.owner, playerManager.getPlayer(target), this.troops, new Set([this.path[this.currentNode]]));
}
} else {
playerManager.getPlayer(this.owner.id).addTroops(this.troops);
Expand Down Expand Up @@ -163,19 +156,10 @@ export class Boat {
* @private
*/
private getWaypoint(offset: number): number {
let currentNode = this.currentNode;
let currentPathIndex = this.currentPathIndex - 1;
for (let i = 0; i < offset; i++) {
if (++currentNode < this.paths[currentPathIndex].length) {
continue;
}
if (++currentPathIndex < this.paths.length) {
currentNode = 0;
} else {
return -1;
}
if (this.currentNode + offset >= this.path.length) {
return -1;
}
return this.paths[currentPathIndex][currentNode];
return this.path[this.currentNode + offset];
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/game/boat/BoatManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class BoatManager {
* @param power The percentage of the owner's troops to send.
*/
addBoat(owner: Player, start: number, end: number, power: number): void {
const path = calculateBoatWaypoints(start, end).filter(piece => piece.length > 0);
const path = calculateBoatWaypoints(start, end);

if (path.length > 0) {
this.addBoatInternal(owner, path, power);
Expand All @@ -87,7 +87,7 @@ class BoatManager {
* @param path The path to follow.
* @param power The percentage of the owner's troops to send.
*/
addBoatInternal(owner: Player, path: number[][], power: number): void {
addBoatInternal(owner: Player, path: number[], power: number): void {
const troops = Math.floor(owner.getTroops() * Math.min(1000, power) / 1000);
owner.removeTroops(troops);

Expand Down
2 changes: 1 addition & 1 deletion src/game/bot/BotPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class BotPlayer extends Player {
const start = possibleStarts[random.nextInt(possibleStarts.length)];
const end = rayTraceWater(start % gameMap.width, Math.floor(start / gameMap.width), random.next() - 0.5, random.next() - 0.5);
if (end !== null && gameMode.canAttack(this.id, territoryManager.getOwner(end))) {
boatManager.addBoatInternal(this, [[start, end]], 100);
boatManager.addBoatInternal(this, [start, end], 100);
}
}
}
Expand Down
25 changes: 15 additions & 10 deletions src/map/area/AreaCalculator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {territoryManager} from "../../game/TerritoryManager";
import {gameMap} from "../../game/GameData";
import {checkLineOfSight} from "../../util/VoxelRayTrace";

class AreaCalculator {
readonly AREA_SIZE = 50;
Expand Down Expand Up @@ -227,19 +228,23 @@ class AreaCalculator {
if (other === node) {
continue;
}
const path = [];
let current = other.x - minX + (other.y - minY) * width;
const path = [other.x + other.y * gameMap.width];
let last = other.x - minX + (other.y - minY) * width;
let current = parentMap[last] - 2;
if (current < 0) {
continue;
}
let distance = 0;
while (current >= 0) {
path.push(current % width + minX + (Math.floor(current / width) + minY) * gameMap.width);
const lastX = current % width, lastY = Math.floor(current / width);
current = parentMap[current] - 2;
distance += lastX === current % width || lastY === Math.floor(current / width) ? 1 : 1.5;
}
path.pop();
if (path.length <= 0) {
continue;
const next = parentMap[current] - 2;
if (next >= 0 && !checkLineOfSight(last % width + minX, Math.floor(last / width) + minY, next % width + minX, Math.floor(next / width) + minY)) {
path.push(current % width + minX + (Math.floor(current / width) + minY) * gameMap.width);
distance += Math.sqrt((current % width - last % width) ** 2 + (Math.floor(current / width) - Math.floor(last / width)) ** 2);
last = current;
}
current = next;
}
distance += Math.sqrt((node.x - minX - last % width) ** 2 + (node.y - minY - Math.floor(last / width)) ** 2);
other.canonicalAreaId = id;
node.edges.push({node: other, cost: distance + this.AREA_SIZE / 2, cache: path}); //increase cost to prefer open water paths
this.nodeIndex[id].push(other);
Expand Down
37 changes: 30 additions & 7 deletions src/map/area/BoatPathfinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {territoryManager} from "../../game/TerritoryManager";
import {clientPlayer} from "../../game/player/PlayerManager";
import {gameMap} from "../../game/GameData";
import {UnsupportedDataException} from "../../util/Exceptions";
import {checkLineOfSight} from "../../util/VoxelRayTrace";

/**
* Pathfinding for boats.
Expand All @@ -17,7 +18,7 @@ import {UnsupportedDataException} from "../../util/Exceptions";
* @param end The ending position.
* @returns The path as an array of cached path indices (these combine to form the path).
*/
export function calculateBoatWaypoints(start: number, end: number): number[][] {
export function calculateBoatWaypoints(start: number, end: number): number[] {
const startAreaId = areaCalculator.areaIndex[start];
const startX = start % gameMap.width, startY = Math.floor(start / gameMap.width);

Expand All @@ -28,7 +29,9 @@ export function calculateBoatWaypoints(start: number, end: number): number[][] {
}
});
if (inSameArea) {
return [findPathInArea(start, end), [end]];
const path = findPathInArea(start, end);
path.push(end);
return path;
}

const queue = new PriorityQueue<[Node, number, number]>((a, b) => a[1] < b[1]);
Expand All @@ -44,16 +47,15 @@ export function calculateBoatWaypoints(start: number, end: number): number[][] {
while (!queue.isEmpty()) {
const [node, _, cost] = queue.pop();
if (node.canonicalAreaId === startAreaId) {
const path: number[][] = [];
const path: number[] = [];
let current = {node, cache: findPathInArea(start, node.x + node.y * gameMap.width)}, last = current;
while (current !== undefined) {
path.push(current.cache);
appendSmoothed(path, current.cache);
last = current;
current = parents[current.node.id];
}
path.pop();
path.push(findPathInArea(last.node.x + last.node.y * gameMap.width, end));
path.push([end]);
appendSmoothed(path, findPathInArea(last.node.x + last.node.y * gameMap.width, end));
path.push(end);
return path;
}
for (const edge of node.edges) {
Expand Down Expand Up @@ -263,4 +265,25 @@ function onNeighborWater(tile: number, closure: (tile: number) => void) {
}
closure(checkX + checkY * gameMap.width);
}
}

/**
* Appends smoothed points to the path.
* @param path The path to append to.
* @param points The points to append.
*/
function appendSmoothed(path: number[], points: number[]) {
if (path.length > 1 && checkLineOfSight(path[path.length - 2] % gameMap.width, Math.floor(path[path.length - 2] / gameMap.width), points[0] % gameMap.width, Math.floor(points[0] / gameMap.width))) {
path.pop();
} else if (path.length === 0) {
path.push(points[0]);
}
let last = path[path.length - 1];
for (let i = 0; i < points.length - 1; i++) {
if (!checkLineOfSight(last % gameMap.width, Math.floor(last / gameMap.width), points[i + 1] % gameMap.width, Math.floor(points[i + 1] / gameMap.width))) {
path.push(points[i]);
last = points[i];
}
}
path.push(points[points.length - 1]);
}
31 changes: 31 additions & 0 deletions src/util/VoxelRayTrace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,35 @@ export function rayTraceWater(x: number, y: number, dx: number, dy: number): num
}
}
return x + y * gameMap.width;
}

/**
* Checks if there is a line of sight between two points.
* This function does only allow passing through tiles which aren't directly adjacent to territory tiles.
* @param x1 The x coordinate of the starting point
* @param y1 The y coordinate of the starting point
* @param x2 The x coordinate of the ending point
* @param y2 The y coordinate of the ending point
* @returns True if there is a line of sight, false otherwise
*/
export function checkLineOfSight(x1: number, y1: number, x2: number, y2: number): boolean {
if (x1 === x2 && y1 === y2) {
return true;
}
const dx = x2 - x1;
const dy = y2 - y1;
const steps = Math.max(Math.abs(dx), Math.abs(dy));
const stepX = dx / steps;
const stepY = dy / steps;
const minDistance = stepX === 0 || stepY === 0 ? 0 : -1; // Prevent clipping through corners
let x = x1 + 0.5; // We start at the center of the first tile
let y = y1 + 0.5;
for (let i = 1; i < steps; i++) {
x += stepX;
y += stepY;
if (gameMap.getDistance(Math.floor(x) + Math.floor(y) * gameMap.width) >= minDistance) {
return false;
}
}
return true;
}

0 comments on commit 59e87e8

Please sign in to comment.