created path finding web worker

This commit is contained in:
Evan
2024-11-28 12:25:34 -08:00
parent 2216c34c41
commit 3e4f4e42cf
24 changed files with 643 additions and 306 deletions
+50 -9
View File
@@ -48,7 +48,7 @@
"@types/jest": "^29.5.12",
"@types/jquery": "^3.5.31",
"@types/mocha": "^10.0.7",
"@types/node": "^22.7.5",
"@types/node": "^22.10.1",
"@types/sinon": "^17.0.3",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.11",
@@ -77,7 +77,8 @@
"typescript": "^5.6.3",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
"webpack-dev-server": "^5.0.4",
"worker-loader": "^3.0.8"
}
},
"node_modules/@ampproject/remapping": {
@@ -4161,12 +4162,12 @@
}
},
"node_modules/@types/node": {
"version": "22.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.1.tgz",
"integrity": "sha512-k6Gi8Yyo8EtrNtkHXutUu2corfDf9su95VYVP10aGYMMROM6SAItZi0w1XszA6RtWTHSVp5OeFof37w0IEqCQg==",
"version": "22.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz",
"integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.8"
"undici-types": "~6.20.0"
}
},
"node_modules/@types/node-forge": {
@@ -13429,9 +13430,9 @@
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
@@ -14110,6 +14111,46 @@
"dev": true,
"license": "MIT"
},
"node_modules/worker-loader": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-3.0.8.tgz",
"integrity": "sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g==",
"dev": true,
"license": "MIT",
"dependencies": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/worker-loader/node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/workerpool": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz",
+3 -2
View File
@@ -21,7 +21,7 @@
"@types/jest": "^29.5.12",
"@types/jquery": "^3.5.31",
"@types/mocha": "^10.0.7",
"@types/node": "^22.7.5",
"@types/node": "^22.10.1",
"@types/sinon": "^17.0.3",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.11",
@@ -50,7 +50,8 @@
"typescript": "^5.6.3",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
"webpack-dev-server": "^5.0.4",
"worker-loader": "^3.0.8"
},
"dependencies": {
"@datastructures-js/priority-queue": "^6.3.1",
@@ -6,13 +6,14 @@ import { Config, getConfig } from "../core/configuration/Config";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent } from "./InputHandler"
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, ClientMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn } from "../core/Schemas";
import { loadTerrainMap, TerrainMap } from "../core/game/TerrainMapLoader";
import { loadTerrainMap, TerrainMapImpl } from "../core/game/TerrainMapLoader";
import { and, bfs, dist, manhattanDist } from "../core/Util";
import { WinCheckExecution } from "../core/execution/WinCheckExecution";
import { SendAttackIntentEvent, SendSpawnIntentEvent, Transport } from "./Transport";
import { createCanvas } from "./graphics/Utils";
import { DisplayMessageEvent, MessageType } from "./graphics/layers/EventsDisplay";
import { v4 as uuidv4 } from 'uuid';
import { AsyncPathFinderCreator } from "../core/pathfinding/AsyncPathFinding";
export interface LobbyConfig {
@@ -70,6 +71,11 @@ export async function createClientGame(gameConfig: GameConfig, eventBus: EventBu
const terrainMap = await loadTerrainMap(gameConfig.map)
let game = createGame(terrainMap, eventBus, config)
const pathFinder = new AsyncPathFinderCreator(game, gameConfig.map)
console.log('going to init path finder')
await pathFinder.initialize()
console.log('inited path finder')
const canvas = createCanvas()
let gameRenderer = createRenderer(canvas, game, eventBus, gameConfig.clientID)
@@ -82,7 +88,7 @@ export async function createClientGame(gameConfig: GameConfig, eventBus: EventBu
game,
gameRenderer,
new InputHandler(canvas, eventBus),
new Executor(game, gameConfig.difficulty, gameConfig.gameID),
new Executor(game, gameConfig.difficulty, gameConfig.gameID, pathFinder),
transport,
)
}
@@ -166,7 +172,7 @@ export class GameRunner {
return
}
this.isProcessingTurn = true
this.gs.addExecution(...this.executor.createExecs(this.turns[this.currTurn]))
this.gs.addExecution(...this.executor.createExecs(this.turns[this.currTurn]))
this.gs.executeNextTick()
this.renderer.tick()
this.currTurn++
+1 -1
View File
@@ -1,4 +1,4 @@
import { GameRunner, joinLobby } from "./ClientGame";
import { GameRunner, joinLobby } from "./GameRunner";
import backgroundImage from '../../resources/images/TerrainMapFrontPage.png';
import favicon from '../../resources/images/Favicon.png';
-232
View File
@@ -1,232 +0,0 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Tile } from "./game/Game";
import { manhattanDist } from "./Util";
export enum PathFindResultType {
NextTile,
Pending,
Completed,
PathNotFound
}
export type TileResult = {
type: PathFindResultType.NextTile;
tile: Tile
} | {
type: PathFindResultType.Pending;
} | {
type: PathFindResultType.Completed;
tile: Tile
} | {
type: PathFindResultType.PathNotFound;
}
export class AStar {
private fwdOpenSet: PriorityQueue<{ tile: Tile; fScore: number; }>;
private bwdOpenSet: PriorityQueue<{ tile: Tile; fScore: number; }>;
private fwdCameFrom: Map<Tile, Tile>;
private bwdCameFrom: Map<Tile, Tile>;
private fwdGScore: Map<Tile, number>;
private bwdGScore: Map<Tile, number>;
private meetingPoint: Tile | null;
public completed: boolean;
constructor(
private src: Tile,
private dst: Tile,
private canMove: (t: Tile) => boolean,
private iterations: number,
private maxTries: number,
) {
this.fwdOpenSet = new PriorityQueue<{ tile: Tile; fScore: number; }>(
(a, b) => a.fScore - b.fScore
);
this.bwdOpenSet = new PriorityQueue<{ tile: Tile; fScore: number; }>(
(a, b) => a.fScore - b.fScore
);
this.fwdCameFrom = new Map<Tile, Tile>();
this.bwdCameFrom = new Map<Tile, Tile>();
this.fwdGScore = new Map<Tile, number>();
this.bwdGScore = new Map<Tile, number>();
this.meetingPoint = null;
this.completed = false;
// Initialize forward search
this.fwdGScore.set(src, 0);
this.fwdOpenSet.enqueue({ tile: src, fScore: this.heuristic(src, dst) });
// Initialize backward search
this.bwdGScore.set(dst, 0);
this.bwdOpenSet.enqueue({ tile: dst, fScore: this.heuristic(dst, src) });
}
compute(): PathFindResultType {
if (this.completed) return PathFindResultType.Completed;
this.maxTries -= 1
let iterations = this.iterations
while (!this.fwdOpenSet.isEmpty() && !this.bwdOpenSet.isEmpty()) {
iterations--;
if (iterations <= 0) {
if (this.maxTries <= 0) {
return PathFindResultType.PathNotFound
}
return PathFindResultType.Pending;
}
// Process forward search
const fwdCurrent = this.fwdOpenSet.dequeue()!.tile;
if (this.bwdGScore.has(fwdCurrent)) {
// We found a meeting point!
this.meetingPoint = fwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
}
this.expandNode(fwdCurrent, true);
// Process backward search
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
if (this.fwdGScore.has(bwdCurrent)) {
// We found a meeting point!
this.meetingPoint = bwdCurrent;
this.completed = true;
return PathFindResultType.Completed
}
this.expandNode(bwdCurrent, false);
}
return this.completed ? PathFindResultType.Completed : PathFindResultType.PathNotFound
}
private expandNode(current: Tile, isForward: boolean) {
for (const neighbor of current.neighborsWrapped()) {
if (neighbor !== (isForward ? this.dst : this.src) && !this.canMove(neighbor)) continue;
const gScore = isForward ? this.fwdGScore : this.bwdGScore;
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
let tentativeGScore = gScore.get(current)! + 1;
if (neighbor.magnitude() < 10) {
tentativeGScore += 1;
}
if (!gScore.has(neighbor) || tentativeGScore < gScore.get(neighbor)!) {
cameFrom.set(neighbor, current);
gScore.set(neighbor, tentativeGScore);
const fScore = tentativeGScore + this.heuristic(
neighbor,
isForward ? this.dst : this.src
);
openSet.enqueue({ tile: neighbor, fScore: fScore });
}
}
}
private heuristic(a: Tile, b: Tile): number {
// TODO use wrapped
return 1.1 * Math.abs(a.cell().x - b.cell().x) + Math.abs(a.cell().y - b.cell().y);
}
public reconstructPath(): Tile[] {
if (!this.meetingPoint) return [];
// Reconstruct path from start to meeting point
const fwdPath: Tile[] = [this.meetingPoint];
let current = this.meetingPoint;
while (this.fwdCameFrom.has(current)) {
current = this.fwdCameFrom.get(current)!;
fwdPath.unshift(current);
}
// Reconstruct path from meeting point to goal
current = this.meetingPoint;
while (this.bwdCameFrom.has(current)) {
current = this.bwdCameFrom.get(current)!;
fwdPath.push(current);
}
return fwdPath;
}
}
export class PathFinder {
private curr: Tile = null
private dst: Tile = null
private path: Tile[]
private aStar: AStar
private computeFinished = true
constructor(
private iterations: number,
private canMove: (t: Tile) => boolean,
private maxTries: number = 20
) { }
nextTile(curr: Tile, dst: Tile, dist: number = 1): TileResult {
if (curr == null) {
console.error('curr is null')
}
if (dst == null) {
console.error('dst is null')
}
if (manhattanDist(curr.cell(), dst.cell()) < dist) {
return { type: PathFindResultType.Completed, tile: curr }
}
if (this.computeFinished) {
if (this.shouldRecompute(curr, dst)) {
this.curr = curr
this.dst = dst
this.path = null
this.aStar = new AStar(curr, dst, this.canMove, this.iterations, this.maxTries)
this.computeFinished = false
return this.nextTile(curr, dst)
} else {
return { type: PathFindResultType.NextTile, tile: this.path.shift() }
}
}
switch (this.aStar.compute()) {
case PathFindResultType.Completed:
this.computeFinished = true
this.path = this.aStar.reconstructPath()
// Remove the start tile
this.path.shift()
return this.nextTile(curr, dst)
case PathFindResultType.Pending:
return { type: PathFindResultType.Pending }
case PathFindResultType.PathNotFound:
return { type: PathFindResultType.PathNotFound }
}
}
private shouldRecompute(curr: Tile, dst: Tile) {
if (this.path == null || this.curr == null || this.dst == null) {
return true
}
const dist = manhattanDist(curr.cell(), dst.cell())
let tolerance = 10
if (dist > 50) {
tolerance = 10
} else if (dist > 25) {
tolerance = 5
} else if (dist > 10) {
tolerance = 3
} else {
tolerance = 0
}
if (manhattanDist(this.dst.cell(), dst.cell()) > tolerance) {
return true
}
return false
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Cell, Execution, MutableGame, MutablePlayer, MutableUnit, PlayerID, Tile, UnitType } from "../game/Game";
import { AStar, PathFinder, PathFindResultType } from "../PathFinding";
import { PathFinder, PathFindResultType } from "../pathfinding/PathFinding";
import { AStar } from "../pathfinding/AStar";
import { PseudoRandom } from "../PseudoRandom";
import { distSort, distSortUnit, manhattanDist } from "../Util";
import { ShellExecution } from "./ShellExecution";
+2 -1
View File
@@ -1,5 +1,6 @@
import { Cell, Execution, MutableGame, MutablePlayer, MutableUnit, PlayerID, Tile, UnitType } from "../game/Game";
import { AStar, PathFinder, PathFindResultType } from "../PathFinding";
import { PathFinder, PathFindResultType } from "../pathfinding/PathFinding";
import { AStar } from "../pathfinding/AStar";
import { PseudoRandom } from "../PseudoRandom";
import { distSort, distSortUnit, manhattanDist } from "../Util";
+4 -2
View File
@@ -20,6 +20,7 @@ import { DestroyerExecution } from "./DestroyerExecution";
import { PortExecution } from "./PortExecution";
import { MissileSiloExecution } from "./MissileSiloExecution";
import { BattleshipExecution } from "./BattleshipExecution";
import { AsyncPathFinderCreator } from "../pathfinding/AsyncPathFinding";
@@ -30,7 +31,7 @@ export class Executor {
// private random = new PseudoRandom(999)
private random: PseudoRandom = null
constructor(private gs: Game, private difficulty: Difficulty, private gameID: GameID) {
constructor(private gs: Game, private difficulty: Difficulty, private gameID: GameID, private asyncPathFinder: AsyncPathFinderCreator) {
// Add one to avoid id collisions with bots.
this.random = new PseudoRandom(simpleHash(gameID) + 1)
}
@@ -92,7 +93,7 @@ export class Executor {
case UnitType.Battleship:
return new BattleshipExecution(intent.player, new Cell(intent.x, intent.y))
case UnitType.Port:
return new PortExecution(intent.player, new Cell(intent.x, intent.y))
return new PortExecution(intent.player, new Cell(intent.x, intent.y), this.asyncPathFinder)
case UnitType.MissileSilo:
return new MissileSiloExecution(intent.player, new Cell(intent.x, intent.y))
default:
@@ -111,6 +112,7 @@ export class Executor {
const execs = []
for (const nation of this.gs.nations()) {
execs.push(new FakeHumanExecution(
this.asyncPathFinder,
new PlayerInfo(
nation.name,
PlayerType.FakeHuman,
+3 -2
View File
@@ -5,6 +5,7 @@ import { AttackExecution } from "./AttackExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { SpawnExecution } from "./SpawnExecution";
import { PortExecution } from "./PortExecution";
import { AsyncPathFinder, AsyncPathFinderCreator } from "../pathfinding/AsyncPathFinding";
export class FakeHumanExecution implements Execution {
@@ -21,7 +22,7 @@ export class FakeHumanExecution implements Execution {
private relations = new Map<Player, number>()
constructor(private playerInfo: PlayerInfo, private cell: Cell, private strength: number) {
constructor(private asyncPathFinder: AsyncPathFinderCreator, private playerInfo: PlayerInfo, private cell: Cell, private strength: number) {
this.random = new PseudoRandom(simpleHash(playerInfo.id))
}
@@ -151,7 +152,7 @@ export class FakeHumanExecution implements Execution {
const oceanTiles = Array.from(this.player.borderTiles()).filter(t => t.isOceanShore())
if (oceanTiles.length > 0) {
const buildTile = this.random.randElement(oceanTiles)
this.mg.addExecution(new PortExecution(this.player.id(), buildTile.cell()))
this.mg.addExecution(new PortExecution(this.player.id(), buildTile.cell(), this.asyncPathFinder))
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { nextTick } from "process";
import { Cell, Execution, MutableGame, MutablePlayer, PlayerID, Tile, MutableUnit, UnitType } from "../game/Game";
import { PathFinder, PathFindResultType } from "../PathFinding";
import { PathFinder, PathFindResultType } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { bfs, dist, distSortUnit, euclideanDist, manhattanDist } from "../Util";
+10 -5
View File
@@ -1,8 +1,10 @@
import { AllPlayers, Cell, Execution, MutableGame, MutablePlayer, MutableUnit, Player, PlayerID, Tile, Unit, UnitType } from "../game/Game";
import { AStar, PathFinder, PathFindResultType } from "../PathFinding";
import { PathFinder, PathFindResultType } from "../pathfinding/PathFinding";
import { AStar } from "../pathfinding/AStar";
import { PseudoRandom } from "../PseudoRandom";
import { bfs, dist, manhattanDist } from "../Util";
import { TradeShipExecution } from "./TradeShipExecution";
import { AsyncPathFinder, AsyncPathFinderCreator } from "../pathfinding/AsyncPathFinding";
export class PortExecution implements Execution {
@@ -11,11 +13,12 @@ export class PortExecution implements Execution {
private port: MutableUnit
private random: PseudoRandom
private portPaths = new Map<MutableUnit, Tile[]>()
private computingPaths = new Map<MutableUnit, AStar>()
private computingPaths = new Map<MutableUnit, AsyncPathFinder>()
constructor(
private _owner: PlayerID,
private cell: Cell
private cell: Cell,
private asyncPathFinderCreator: AsyncPathFinderCreator
) { }
@@ -25,6 +28,7 @@ export class PortExecution implements Execution {
}
tick(ticks: number): void {
if (this.port == null) {
const tile = this.mg.tile(this.cell)
const player = this.mg.player(this._owner)
@@ -62,7 +66,7 @@ export class PortExecution implements Execution {
const aStar = this.computingPaths.get(port)
switch (aStar.compute()) {
case PathFindResultType.Completed:
this.portPaths.set(port, aStar.reconstructPath())
this.portPaths.set(port, aStar.reconstructPath().map(sn => sn as Tile))
this.computingPaths.delete(port)
break
case PathFindResultType.Pending:
@@ -73,8 +77,9 @@ export class PortExecution implements Execution {
}
continue
}
const asyncPF = this.asyncPathFinderCreator.createPathFinder(this.port.tile(), port.tile(), 100)
// console.log(`adding new port path from ${this.player().name()}:${this.port.tile().cell()} to ${port.owner().name()}:${port.tile().cell()}`)
this.computingPaths.set(port, new AStar(this.port.tile(), port.tile(), t => t.isWater(), 4000, 100))
this.computingPaths.set(port, asyncPF)
}
for (const port of this.portPaths.keys()) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Execution, MutableGame, MutablePlayer, MutableUnit, Tile, Unit, UnitType } from "../game/Game";
import { PathFinder, PathFindResultType } from "../PathFinding";
import { PathFinder, PathFindResultType } from "../pathfinding/PathFinding";
export class ShellExecution implements Execution {
+2 -1
View File
@@ -1,7 +1,8 @@
import { MessageType } from "../../client/graphics/layers/EventsDisplay";
import { renderNumber } from "../../client/graphics/Utils";
import { AllPlayers, Cell, Execution, MutableGame, MutablePlayer, MutableUnit, Player, PlayerID, Tile, Unit, UnitType } from "../game/Game";
import { AStar, PathFinder, PathFindResultType } from "../PathFinding";
import { PathFinder, PathFindResultType } from "../pathfinding/PathFinding";
import { AStar } from "../pathfinding/AStar";
import { PseudoRandom } from "../PseudoRandom";
import { bfs, dist, distSortUnit, manhattanDist } from "../Util";
+2 -1
View File
@@ -2,7 +2,8 @@ import { Unit, Cell, Execution, MutableUnit, MutableGame, MutablePlayer, Player,
import { and, bfs, manhattanDistWrapped, sourceDstOceanShore, targetTransportTile } from "../Util";
import { AttackExecution } from "./AttackExecution";
import { DisplayMessageEvent, MessageType } from "../../client/graphics/layers/EventsDisplay";
import { AStar, PathFinder, PathFindResultType } from "../PathFinding";
import { PathFinder, PathFindResultType } from "../pathfinding/PathFinding";
import { AStar } from "../pathfinding/AStar";
export class TransportShipExecution implements Execution {
+18 -5
View File
@@ -131,7 +131,21 @@ export class PlayerInfo {
) { }
}
export interface Tile {
export interface SearchNode {
cost(): number;
cell(): Cell
}
export interface TerrainMap {
terrain(cell: Cell): TerrainTile
neighbors(terrainTile: TerrainTile): TerrainTile[]
}
export interface TerrainTile extends SearchNode {
terrainType(): TerrainType
}
export interface Tile extends SearchNode {
isLand(): boolean
isShore(): boolean
isOceanShore(): boolean
@@ -150,8 +164,6 @@ export interface Tile {
neighbors(): Tile[]
neighborsWrapped(): Tile[]
onShore(): boolean
x(): number
y(): number
}
export interface Unit {
@@ -279,7 +291,7 @@ export interface Game {
displayMessage(message: string, type: MessageType, playerID: PlayerID | null): void
units(...types: UnitType[]): Unit[]
unitInfo(type: UnitType): UnitInfo
searchMap(): SharedArrayBuffer
terrainMap(): TerrainMap
}
export interface MutableGame extends Game {
@@ -325,4 +337,5 @@ export class TargetPlayerEvent implements GameEvent {
export class EmojiMessageEvent implements GameEvent {
constructor(public readonly message: EmojiMessage) { }
}
}
+10 -12
View File
@@ -2,7 +2,7 @@ import { info } from "console";
import { Config } from "../configuration/Config";
import { EventBus } from "../EventBus";
import { Cell, Execution, MutableGame, Game, MutablePlayer, PlayerEvent, PlayerID, PlayerInfo, Player, TerraNullius, Tile, TileEvent, Unit, UnitEvent as UnitEvent, PlayerType, MutableAllianceRequest, AllianceRequestReplyEvent, AllianceRequestEvent, BrokeAllianceEvent, MutableAlliance, Alliance, AllianceExpiredEvent, Nation, UnitType, UnitInfo } from "./Game";
import { TerrainMap } from "./TerrainMapLoader";
import { TerrainMapImpl } from "./TerrainMapLoader";
import { PlayerImpl } from "./PlayerImpl";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { TileImpl } from "./TileImpl";
@@ -12,7 +12,7 @@ import { ClientID } from "../Schemas";
import { DisplayMessageEvent, MessageType } from "../../client/graphics/layers/EventsDisplay";
import { UnitImpl } from "./UnitImpl";
export function createGame(terrainMap: TerrainMap, eventBus: EventBus, config: Config): Game {
export function createGame(terrainMap: TerrainMapImpl, eventBus: EventBus, config: Config): Game {
return new GameImpl(terrainMap, eventBus, config)
}
@@ -34,26 +34,24 @@ export class GameImpl implements MutableGame {
private _height: number
private _numLandTiles: number
_terraNullius: TerraNulliusImpl
private _searchMap: SharedArrayBuffer
allianceRequests: AllianceRequestImpl[] = []
alliances_: AllianceImpl[] = []
constructor(terrainMap: TerrainMap, public eventBus: EventBus, private _config: Config) {
constructor(private _terrainMap: TerrainMapImpl, public eventBus: EventBus, private _config: Config) {
this._terraNullius = new TerraNulliusImpl(this)
this._width = terrainMap.width();
this._height = terrainMap.height();
this._numLandTiles = terrainMap.numLandTiles
this._searchMap = terrainMap.searchBuffer
this._width = _terrainMap.width();
this._height = _terrainMap.height();
this._numLandTiles = _terrainMap.numLandTiles
this.map = new Array(this._width);
for (let x = 0; x < this._width; x++) {
this.map[x] = new Array(this._height);
for (let y = 0; y < this._height; y++) {
let cell = new Cell(x, y);
this.map[x][y] = new TileImpl(this, this._terraNullius, cell, terrainMap.terrain(cell));
this.map[x][y] = new TileImpl(this, this._terraNullius, cell, _terrainMap.terrain(cell));
}
}
this.nations_ = terrainMap.nationMap.nations
this.nations_ = _terrainMap.nationMap.nations
.map(n => new Nation(
n.name,
new Cell(n.coordinates[0], n.coordinates[1]),
@@ -396,8 +394,8 @@ export class GameImpl implements MutableGame {
this.eventBus.emit(new AllianceExpiredEvent(alliance.requestor(), alliance.recipient()))
}
public searchMap(): SharedArrayBuffer {
return this._searchMap
public terrainMap(): TerrainMapImpl {
return this._terrainMap
}
displayMessage(message: string, type: MessageType, playerID: PlayerID | null): void {
+57 -23
View File
@@ -1,4 +1,4 @@
import { Cell, GameMap, TerrainType } from './Game';
import { Cell, GameMap, SearchNode, TerrainMap, TerrainTile, TerrainType } from './Game';
import europeBin from "!!binary-loader!../../../resources/maps/Europe.bin";
import europeInfo from "../../../resources/maps/Europe.json"
@@ -13,7 +13,7 @@ const maps = new Map()
.set(GameMap.Europe, { bin: europeBin, info: europeInfo })
.set(GameMap.Mena, { bin: menaBin, info: menaInfo });
const loadedMaps = new Map<GameMap, TerrainMap>()
const loadedMaps = new Map<GameMap, TerrainMapImpl>()
export interface NationMap {
name: string;
@@ -29,15 +29,57 @@ export interface Nation {
}
export class TerrainMap {
export class TerrainTileImpl implements TerrainTile {
public shoreline: boolean = false
public magnitude: number = 0
public ocean = false
public land = false
private _neighbors: TerrainTile[] | null = null
constructor(public type: TerrainType, private _cell: Cell) { }
terrainType(): TerrainType {
return this.type
}
cost(): number {
return this.magnitude < 10 ? 2 : 1
}
cell(): Cell {
return this._cell
}
initNeighbors(map: TerrainMapImpl): TerrainTile[] {
if (this._neighbors === null) {
const positions = [
{ x: this._cell.x - 1, y: this._cell.y }, // Left
{ x: this._cell.x + 1, y: this._cell.y }, // Right
{ x: this._cell.x, y: this._cell.y - 1 }, // Up
{ x: this._cell.x, y: this._cell.y + 1 } // Down
];
this._neighbors = positions
.filter(pos => pos.x >= 0 && pos.x < map.width() &&
pos.y >= 0 && pos.y < map.height())
.map(pos => map.terrain(new Cell(pos.x, pos.y)));
}
return this._neighbors;
}
}
export class TerrainMapImpl implements TerrainMap {
constructor(
public readonly tiles: Terrain[][],
public readonly tiles: TerrainTileImpl[][],
public readonly numLandTiles: number,
public readonly nationMap: NationMap,
public searchBuffer: SharedArrayBuffer
) { }
terrain(cell: Cell): Terrain {
neighbors(terrainTile: TerrainTile): TerrainTile[] {
return (terrainTile as TerrainTileImpl).initNeighbors(this);
}
terrain(cell: Cell): TerrainTileImpl {
return this.tiles[cell.x][cell.y]
}
@@ -50,15 +92,7 @@ export class TerrainMap {
}
}
export class Terrain {
public shoreline: boolean = false
public magnitude: number = 0
public ocean = false
public land = false
constructor(public type: TerrainType) { }
}
export async function loadTerrainMap(map: GameMap): Promise<TerrainMap> {
export async function loadTerrainMap(map: GameMap): Promise<TerrainMapImpl> {
if (loadedMaps.has(map)) {
return loadedMaps.get(map)
}
@@ -83,7 +117,7 @@ export async function loadTerrainMap(map: GameMap): Promise<TerrainMap> {
throw new Error(`Invalid data: buffer size ${fileData.length} incorrect for ${width}x${height} terrain plus 4 bytes for dimensions.`);
}
const terrain: Terrain[][] = Array(width).fill(null).map(() => Array(height).fill(null));
const terrain: TerrainTileImpl[][] = Array(width).fill(null).map(() => Array(height).fill(null));
let numLand = 0
// Start from the 5th byte (index 4) when processing terrain data
@@ -115,19 +149,19 @@ export async function loadTerrainMap(map: GameMap): Promise<TerrainMap> {
}
}
terrain[x][y] = new Terrain(type);
terrain[x][y] = new TerrainTileImpl(type, new Cell(x, y));
terrain[x][y].shoreline = shoreline;
terrain[x][y].magnitude = magnitude;
terrain[x][y].ocean = ocean
terrain[x][y].land = land
}
}
const encoder = new TextEncoder();
const encoded = encoder.encode(fileData);
const buffer = new SharedArrayBuffer(encoded.length);
const view = new Uint8Array(buffer);
view.set(encoded)
const m = new TerrainMap(terrain, numLand, mapData.info, buffer);
// const encoder = new TextEncoder();
// const encoded = encoder.encode(fileData);
// const buffer = new SharedArrayBuffer(encoded.length);
// const view = new Uint8Array(buffer);
// view.set(encoded)
const m = new TerrainMapImpl(terrain, numLand, mapData.info);
loadedMaps.set(map, m)
return m
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { TerrainMap } from "./TerrainMapLoader";
import { TerrainMapImpl } from "./TerrainMapLoader";
export enum SearchMapTileType {
Land,
Shore,
+7 -3
View File
@@ -1,5 +1,5 @@
import { Tile, Cell, TerrainType, Player, TerraNullius, MutablePlayer } from "./Game";
import { Terrain } from "./TerrainMapLoader";
import { Tile, Cell, TerrainType, Player, TerraNullius, MutablePlayer, SearchNode, TerrainTile } from "./Game";
import { TerrainTileImpl } from "./TerrainMapLoader";
import { GameImpl } from "./GameImpl";
import { PlayerImpl } from "./PlayerImpl";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
@@ -14,7 +14,7 @@ export class TileImpl implements Tile {
private readonly gs: GameImpl,
public _owner: PlayerImpl | TerraNulliusImpl,
private readonly _cell: Cell,
private readonly _terrain: Terrain
private readonly _terrain: TerrainTileImpl
) { }
neighborsWrapped(): Tile[] {
@@ -108,4 +108,8 @@ export class TileImpl implements Tile {
}
return this._neighbors;
}
cost(): number {
return this.magnitude() < 10 ? 2 : 1
};
}
+138
View File
@@ -0,0 +1,138 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { SearchNode } from "../game/Game";
import { PathFindResultType } from "./PathFinding";
export class AStar {
private fwdOpenSet: PriorityQueue<{ tile: SearchNode; fScore: number; }>;
private bwdOpenSet: PriorityQueue<{ tile: SearchNode; fScore: number; }>;
private fwdCameFrom: Map<SearchNode, SearchNode>;
private bwdCameFrom: Map<SearchNode, SearchNode>;
private fwdGScore: Map<SearchNode, number>;
private bwdGScore: Map<SearchNode, number>;
private meetingPoint: SearchNode | null;
public completed: boolean;
constructor(
private src: SearchNode,
private dst: SearchNode,
private canMove: (t: SearchNode) => boolean,
private neighbors: (sn: SearchNode) => SearchNode[],
private iterations: number,
private maxTries: number
) {
this.fwdOpenSet = new PriorityQueue<{ tile: SearchNode; fScore: number; }>(
(a, b) => a.fScore - b.fScore
);
this.bwdOpenSet = new PriorityQueue<{ tile: SearchNode; fScore: number; }>(
(a, b) => a.fScore - b.fScore
);
this.fwdCameFrom = new Map<SearchNode, SearchNode>();
this.bwdCameFrom = new Map<SearchNode, SearchNode>();
this.fwdGScore = new Map<SearchNode, number>();
this.bwdGScore = new Map<SearchNode, number>();
this.meetingPoint = null;
this.completed = false;
// Initialize forward search
this.fwdGScore.set(src, 0);
this.fwdOpenSet.enqueue({ tile: src, fScore: this.heuristic(src, dst) });
// Initialize backward search
this.bwdGScore.set(dst, 0);
this.bwdOpenSet.enqueue({ tile: dst, fScore: this.heuristic(dst, src) });
}
compute(): PathFindResultType {
if (this.completed) return PathFindResultType.Completed;
this.maxTries -= 1;
let iterations = this.iterations;
while (!this.fwdOpenSet.isEmpty() && !this.bwdOpenSet.isEmpty()) {
iterations--;
if (iterations <= 0) {
if (this.maxTries <= 0) {
return PathFindResultType.PathNotFound;
}
return PathFindResultType.Pending;
}
// Process forward search
const fwdCurrent = this.fwdOpenSet.dequeue()!.tile;
if (this.bwdGScore.has(fwdCurrent)) {
// We found a meeting point!
this.meetingPoint = fwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
}
this.expandSearchNode(fwdCurrent, true);
// Process backward search
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
if (this.fwdGScore.has(bwdCurrent)) {
// We found a meeting point!
this.meetingPoint = bwdCurrent;
this.completed = true;
return PathFindResultType.Completed;
}
this.expandSearchNode(bwdCurrent, false);
}
return this.completed ? PathFindResultType.Completed : PathFindResultType.PathNotFound;
}
private expandSearchNode(current: SearchNode, isForward: boolean) {
for (const neighbor of this.neighbors(current)) {
if (neighbor !== (isForward ? this.dst : this.src) && !this.canMove(neighbor)) continue;
const gScore = isForward ? this.fwdGScore : this.bwdGScore;
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
let tentativeGScore = gScore.get(current)! + neighbor.cost();
if (!gScore.has(neighbor) || tentativeGScore < gScore.get(neighbor)!) {
cameFrom.set(neighbor, current);
gScore.set(neighbor, tentativeGScore);
const fScore = tentativeGScore + this.heuristic(
neighbor,
isForward ? this.dst : this.src
);
openSet.enqueue({ tile: neighbor, fScore: fScore });
}
}
}
private heuristic(a: SearchNode, b: SearchNode): number {
// TODO use wrapped
try {
return 1.1 * Math.abs(a.cell().x - b.cell().x) + Math.abs(a.cell().y - b.cell().y);
} catch {
console.log('uh oh')
}
}
public reconstructPath(): SearchNode[] {
if (!this.meetingPoint) return [];
// Reconstruct path from start to meeting point
const fwdPath: SearchNode[] = [this.meetingPoint];
let current = this.meetingPoint;
while (this.fwdCameFrom.has(current)) {
current = this.fwdCameFrom.get(current)!;
fwdPath.unshift(current);
}
// Reconstruct path from meeting point to goal
current = this.meetingPoint;
while (this.bwdCameFrom.has(current)) {
current = this.bwdCameFrom.get(current)!;
fwdPath.push(current);
}
return fwdPath;
}
}
+121
View File
@@ -0,0 +1,121 @@
import { TerrainTile, Tile, Game, GameMap, Cell } from "../game/Game";
import { PathFindResultType } from "./PathFinding";
export class AsyncPathFinderCreator {
private worker: Worker;
private isInitialized = false;
constructor(private game: Game, private gameMap: GameMap) {
// Create a new worker using webpack worker-loader
// The import.meta.url ensures webpack can properly bundle the worker
this.worker = new Worker(new URL('./PathFinder.worker.ts', import.meta.url));
}
initialize(): Promise<void> {
return new Promise((resolve, reject) => {
this.worker.postMessage({
type: 'init',
gameMap: this.gameMap
});
const handler = (e: MessageEvent) => {
if (e.data.type === 'initialized') {
this.worker.removeEventListener('message', handler);
this.isInitialized = true;
resolve();
} else {
this.worker.removeEventListener('message', handler);
reject('Failed to initialize pathfinder');
}
};
this.worker.addEventListener('message', handler);
});
}
createPathFinder(src: Tile, dst: Tile, numTicks: number): AsyncPathFinder {
if (!this.isInitialized) {
throw new Error('PathFinder not initialized');
}
return new AsyncPathFinder(this.game, this.worker, src, dst, numTicks);
}
cleanup() {
this.worker.terminate();
}
}
// AsyncPathFinder.ts
export class AsyncPathFinder {
private path: Tile[] | 'NOT_FOUND' | null = null;
private promise: Promise<void>;
constructor(
private game: Game,
private worker: Worker,
private src: Tile,
private dst: Tile,
private numTicks: number
) { }
findPath(): Promise<void> {
this.promise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject("Path timeout");
}, 100_000);
const handler = (e: MessageEvent) => {
clearTimeout(timeout);
this.worker.removeEventListener('message', handler);
if (e.data.type === 'pathFound') {
this.path = e.data.path.map(pos => this.game.tile(new Cell(pos.x, pos.y)));
resolve();
} else {
reject(e.data.reason || "Path not found");
}
};
this.worker.addEventListener('message', handler);
this.worker.postMessage({
type: 'findPath',
requestId: crypto.randomUUID(),
currentTick: this.game.ticks(),
duration: this.numTicks,
start: { x: this.src.cell().x, y: this.src.cell().y },
end: { x: this.dst.cell().x, y: this.dst.cell().y }
});
});
return this.promise;
}
// TODO: rename to poll?
compute(): PathFindResultType {
if (this.promise == null) {
this.findPath()
}
this.numTicks--;
if (this.numTicks <= 0) {
for (let i = 0; i < 1_000_000; i++) {
if (this.path == 'NOT_FOUND') {
return PathFindResultType.PathNotFound
}
if (this.path != null) {
console.log('in Asyncclient: found a path!!')
return PathFindResultType.Completed;
}
}
throw new Error(`path not completed in time`)
}
return PathFindResultType.Pending;
}
reconstructPath(): Tile[] {
if (this.path == "NOT_FOUND" || this.path == null) {
throw Error(`cannot reconstruct path: ${this.path}`)
}
return this.path as Tile[]
}
}
+100
View File
@@ -0,0 +1,100 @@
// pathfinding.ts
import { Cell, GameMap, SearchNode, TerrainMap, TerrainTile, TerrainType } from "../game/Game";
import { PathFindResultType } from "./PathFinding";
import { AStar } from "./AStar";
import { loadTerrainMap } from "../game/TerrainMapLoader";
import { PriorityQueue } from "@datastructures-js/priority-queue";
let terrainMapPromise: Promise<TerrainMap>;
let searches = new PriorityQueue<Search>((a: Search, b: Search) => (a.deadline - b.deadline))
let processingInterval: number | null = null;
let isProcessingSearch = false
interface Search {
aStar: AStar,
deadline: number
requestId: string
}
interface SearchRequest {
requestId: string
currentTick: number
// duration in ticks
duration: number
start: { x: number, y: number },
end: { x: number, y: number }
}
self.onmessage = (e) => {
switch (e.data.type) {
case 'init':
initializeMap(e.data);
break;
case 'findPath':
terrainMapPromise.then(tm => findPath(tm, e.data))
break;
}
};
function initializeMap(data: { gameMap: GameMap }) {
terrainMapPromise = loadTerrainMap(data.gameMap)
self.postMessage({ type: 'initialized' });
processingInterval = setInterval(computeSearches, .5) as unknown as number;
}
function findPath(terrainMap: TerrainMap, req: SearchRequest) {
const aStar = new AStar(
terrainMap.terrain(new Cell(req.start.x, req.start.y)),
terrainMap.terrain(new Cell(req.end.x, req.end.y)),
(sn: SearchNode) => (sn as TerrainTile).terrainType() == TerrainType.Ocean,
(sn: SearchNode): SearchNode[] => terrainMap.neighbors((sn as TerrainTile)),
100_000,
1000
);
searches.enqueue({
aStar: aStar,
deadline: req.currentTick + req.duration,
requestId: req.requestId
})
}
function computeSearches() {
if (isProcessingSearch || searches.isEmpty()) {
return
}
isProcessingSearch = true
try {
for (let i = 0; i < 10; i++) {
if (searches.isEmpty()) {
return
}
const search = searches.dequeue()
switch (search.aStar.compute()) {
case PathFindResultType.Completed:
self.postMessage({
type: 'pathFound',
requestId: search.requestId,
path: search.aStar.reconstructPath().map(sn => ({ x: sn.cell().x, y: sn.cell().y }))
});
break;
case PathFindResultType.Pending:
searches.push(search)
break
case PathFindResultType.PathNotFound:
console.warn(`worker: path not found to port`);
self.postMessage({
type: 'error',
requestId: search.requestId,
});
break
}
}
} finally {
isProcessingSearch = false
}
}
+98
View File
@@ -0,0 +1,98 @@
import { Tile } from "../game/Game";
import { manhattanDist } from "../Util";
import { AStar } from "./AStar";
export enum PathFindResultType {
NextTile,
Pending,
Completed,
PathNotFound
}
export type TileResult = {
type: PathFindResultType.NextTile;
tile: Tile
} | {
type: PathFindResultType.Pending;
} | {
type: PathFindResultType.Completed;
tile: Tile
} | {
type: PathFindResultType.PathNotFound;
}
export class PathFinder {
private curr: Tile = null
private dst: Tile = null
private path: Tile[]
private aStar: AStar
private computeFinished = true
constructor(
private iterations: number,
private canMove: (t: Tile) => boolean,
private maxTries: number = 20
) { }
nextTile(curr: Tile, dst: Tile, dist: number = 1): TileResult {
if (curr == null) {
console.error('curr is null')
}
if (dst == null) {
console.error('dst is null')
}
if (manhattanDist(curr.cell(), dst.cell()) < dist) {
return { type: PathFindResultType.Completed, tile: curr }
}
if (this.computeFinished) {
if (this.shouldRecompute(curr, dst)) {
this.curr = curr
this.dst = dst
this.path = null
this.aStar = new AStar(curr, dst, this.canMove, sn => ((sn as Tile).neighbors()), this.iterations, this.maxTries)
this.computeFinished = false
return this.nextTile(curr, dst)
} else {
return { type: PathFindResultType.NextTile, tile: this.path.shift() }
}
}
switch (this.aStar.compute()) {
case PathFindResultType.Completed:
this.computeFinished = true
this.path = this.aStar.reconstructPath().map(sn => sn as Tile)
// Remove the start tile
this.path.shift()
return this.nextTile(curr, dst)
case PathFindResultType.Pending:
return { type: PathFindResultType.Pending }
case PathFindResultType.PathNotFound:
return { type: PathFindResultType.PathNotFound }
}
}
private shouldRecompute(curr: Tile, dst: Tile) {
if (this.path == null || this.curr == null || this.dst == null) {
return true
}
const dist = manhattanDist(curr.cell(), dst.cell())
let tolerance = 10
if (dist > 50) {
tolerance = 10
} else if (dist > 25) {
tolerance = 5
} else if (dist > 10) {
tolerance = 3
} else {
tolerance = 0
}
if (manhattanDist(this.dst.cell(), dst.cell()) > tolerance) {
return true
}
return false
}
}
+3
View File
@@ -2,6 +2,7 @@ import { decodePNGFromStream } from 'pureimage'; import path from 'path';
import fs from 'fs/promises';
import { createReadStream } from 'fs';
import { fileURLToPath } from 'url';
import { TerrainTile } from '../core/game/Game';
const __filename = fileURLToPath(import.meta.url);
@@ -21,6 +22,8 @@ export class TerrainMap {
return this.tiles[coord.x][coord.y]
}
width(): number {
return this.tiles.length
}