Add trains (#1159)

## Description:

Add a rail network to handle train stations/railroad between structures.

Changes:
- `RailNetwork` is responsible for the train station graph. Use it to
connect new `TrainStations`
- A `RailRoad` connects two `TrainStation`
- No loop possible in the rail network
- Train stations handles its railroads
- Added a layer to draw the railroads under the structures

#### Clusters
- To speed up computations, each `TrainStation` references its own
cluster
- A cluster is a list of `TrainStation` connected with each other,
created by the `RailNetwork` when connecting the station
- Train stations spawn trains randomly depending on its current cluster
size
- A `TrainStation` decides randomly of the train destination by picking
one from the cluster

#### Production building:
- Added a factory which has no gameplay impact currently. _To be
discussed._

#### Train stops:
- When a train reaches a factory, it's filled with a "cargo". The loaded
trains has no impact currently. _To be discussed._
- When a train reaches a city, the player earn 10k gold
- When a train reaches a port, it sends a new tradeship if possible
- If a destination/source is destroyed, the train & railroad are deleted
too


https://github.com/user-attachments/assets/42375c17-9e04-4a42-98d0-708c81ffd609


https://github.com/user-attachments/assets/fbecdb53-a516-4df8-87fb-1f9a62c4efa0



## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

IngloriousTom

---------

Co-authored-by: Scott Anderson <scottanderson@users.noreply.github.com>
This commit is contained in:
DevelopingTom
2025-06-22 08:14:08 -07:00
committed by GitHub
co-authored by Scott Anderson
parent 0f2008a68d
commit 43397779fa
60 changed files with 2427 additions and 104 deletions
+22
View File
@@ -115,6 +115,28 @@ export class PseudoRandom {
return arr[Math.floor(this._nextFloat() * arr.length)];
}
/**
* Selects a random element from a set.
*/
randFromSet<T>(set: Set<T>): T {
const size = set.size;
if (size === 0) {
throw new Error("set must not be empty");
}
const index = this.nextInt(0, size);
let i = 0;
for (const item of set) {
if (i === index) {
return item;
}
i++;
}
// This should never happen
throw new Error("Unexpected error selecting element from set");
}
/**
* Returns true with probability 1/odds.
*/
+9 -1
View File
@@ -35,7 +35,8 @@ export type Intent =
| QuickChatIntent
| MoveWarshipIntent
| MarkDisconnectedIntent
| UpgradeStructureIntent;
| UpgradeStructureIntent
| CreateStationIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
@@ -59,6 +60,7 @@ export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type UpgradeStructureIntent = z.infer<
typeof UpgradeStructureIntentSchema
>;
export type CreateStationIntent = z.infer<typeof CreateStationIntentSchema>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type MarkDisconnectedIntent = z.infer<
@@ -277,6 +279,11 @@ export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
unitId: z.number(),
});
export const CreateStationIntentSchema = BaseIntentSchema.extend({
type: z.literal("create_station"),
unitId: z.number(),
});
export const CancelAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("cancel_attack"),
attackID: z.string(),
@@ -322,6 +329,7 @@ const IntentSchema = z.discriminatedUnion("type", [
TargetTroopRatioIntentSchema,
BuildUnitIntentSchema,
UpgradeStructureIntentSchema,
CreateStationIntentSchema,
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
+6
View File
@@ -131,6 +131,11 @@ export interface Config {
unitInfo(type: UnitType): UnitInfo;
tradeShipGold(dist: number): Gold;
tradeShipSpawnRate(numberOfPorts: number): number;
trainGold(): Gold;
trainSpawnRate(numberOfStations: number): number;
trainStationMinRange(): number;
trainStationMaxRange(): number;
railroadMaxSize(): number;
safeFromPiratesCooldownMax(): number;
defensePostRange(): number;
SAMCooldown(): number;
@@ -157,6 +162,7 @@ export interface Theme {
teamColor(team: Team): Colord;
territoryColor(playerInfo: PlayerView): Colord;
specialBuildingColor(playerInfo: PlayerView): Colord;
railroadColor(playerInfo: PlayerView): Colord;
borderColor(playerInfo: PlayerView): Colord;
defendedBorderColors(playerInfo: PlayerView): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
+37
View File
@@ -301,6 +301,21 @@ export class DefaultConfig implements Config {
tradeShipSpawnRate(numberOfPorts: number): number {
return Math.min(50, Math.round(10 * Math.pow(numberOfPorts, 0.6)));
}
trainSpawnRate(numberOfStations: number): number {
return Math.round(50 * Math.pow(numberOfStations, 0.8));
}
trainGold(): Gold {
return BigInt(10_000);
}
trainStationMinRange(): number {
return 15;
}
trainStationMaxRange(): number {
return 80;
}
railroadMaxSize(): number {
return 100;
}
unitInfo(type: UnitType): UnitInfo {
switch (type) {
@@ -450,11 +465,33 @@ export class DefaultConfig implements Config {
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
};
case UnitType.Factory:
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
? 0n
: BigInt(
Math.min(
1_000_000,
Math.pow(
2,
p.unitsIncludingConstruction(UnitType.Factory).length,
) * 125_000,
),
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
};
case UnitType.Construction:
return {
cost: () => 0n,
territoryBound: true,
};
case UnitType.Train:
return {
cost: () => 0n,
territoryBound: false,
};
default:
assertNever(type);
}
+10
View File
@@ -71,6 +71,16 @@ export class PastelTheme implements Theme {
});
}
railroadColor(player: PlayerView): Colord {
const tc = this.territoryColor(player).rgba;
const color = colord({
r: Math.max(tc.r - 10, 0),
g: Math.max(tc.g - 10, 0),
b: Math.max(tc.b - 10, 0),
});
return color;
}
borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(player.id())!;
+10
View File
@@ -71,6 +71,16 @@ export class PastelThemeDark implements Theme {
});
}
railroadColor(player: PlayerView): Colord {
const tc = this.territoryColor(player).rgba;
const color = colord({
r: Math.max(tc.r - 10, 0),
g: Math.max(tc.g - 10, 0),
b: Math.max(tc.b - 10, 0),
});
return color;
}
borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(player.id())!;
@@ -10,6 +10,7 @@ import {
import { TileRef } from "../game/GameMap";
import { CityExecution } from "./CityExecution";
import { DefensePostExecution } from "./DefensePostExecution";
import { FactoryExecution } from "./FactoryExecution";
import { MirvExecution } from "./MIRVExecution";
import { MissileSiloExecution } from "./MissileSiloExecution";
import { NukeExecution } from "./NukeExecution";
@@ -115,6 +116,9 @@ export class ConstructionExecution implements Execution {
case UnitType.City:
this.mg.addExecution(new CityExecution(player, this.tile));
break;
case UnitType.Factory:
this.mg.addExecution(new FactoryExecution(player, this.tile));
break;
default:
throw Error(`unit type ${this.constructionType} not supported`);
}
+3
View File
@@ -22,6 +22,7 @@ import { RetreatExecution } from "./RetreatExecution";
import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TargetPlayerExecution } from "./TargetPlayerExecution";
import { TrainStationExecution } from "./TrainStationExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
@@ -112,6 +113,8 @@ export class Executor {
);
case "upgrade_structure":
return new UpgradeStructureExecution(player, intent.unitId);
case "create_station":
return new TrainStationExecution(player, intent.unitId);
case "quick_chat":
return new QuickChatExecution(
player,
+44
View File
@@ -0,0 +1,44 @@
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
export class FactoryExecution implements Execution {
private factory: Unit | null = null;
private active: boolean = true;
constructor(
private player: Player,
private tile: TileRef,
) {}
init(mg: Game, ticks: number): void {
const spawnTile = this.player.canBuild(UnitType.Factory, this.tile);
if (spawnTile === false) {
console.warn("cannot build factory");
this.active = false;
return;
}
this.factory = this.player.buildUnit(UnitType.Factory, spawnTile, {});
}
tick(ticks: number): void {
if (this.factory === null) {
throw new Error("Not initialized");
}
if (!this.factory.isActive()) {
this.active = false;
return;
}
if (this.player !== this.factory.owner()) {
this.player = this.factory.owner();
}
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+23
View File
@@ -22,6 +22,7 @@ import { ConstructionExecution } from "./ConstructionExecution";
import { EmojiExecution } from "./EmojiExecution";
import { NukeExecution } from "./NukeExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TrainStationExecution } from "./TrainStationExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { closestTwoTiles } from "./Util";
import { BotBehavior } from "./utils/BotBehavior";
@@ -437,10 +438,32 @@ export class FakeHumanExecution implements Execution {
this.maybeSpawnStructure(UnitType.Port, 1) ||
this.maybeSpawnStructure(UnitType.City, 2) ||
this.maybeSpawnWarship() ||
this.maybeSpawnTrainStation() ||
this.maybeSpawnStructure(UnitType.MissileSilo, 1)
);
}
private maybeSpawnTrainStation(): boolean {
if (this.player === null) throw new Error("not initialized");
const citiesWithoutStations = this.player.units().filter((unit) => {
switch (unit.type()) {
case UnitType.City:
case UnitType.Port:
case UnitType.Factory:
return !unit.hasTrainStation();
default:
return false;
}
});
if (citiesWithoutStations.length === 0) {
return false;
}
this.mg.addExecution(
new TrainStationExecution(this.player, citiesWithoutStations[0].id()),
);
return true;
}
private maybeSpawnStructure(type: UnitType, maxNum: number): boolean {
if (this.player === null) throw new Error("not initialized");
const units = this.player.unitsIncludingConstruction(type);
+170
View File
@@ -0,0 +1,170 @@
import { Execution, Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { GameUpdateType, RailTile, RailType } from "../game/GameUpdates";
import { Railroad } from "../game/Railroad";
export class RailroadExecution implements Execution {
private mg: Game;
private active: boolean = true;
private headIndex: number = 0;
private tailIndex: number = 0;
private increment: number = 3;
private railTiles: RailTile[] = [];
constructor(private railRoad: Railroad) {
this.tailIndex = railRoad.tiles.length;
}
isActive(): boolean {
return this.active;
}
init(mg: Game, ticks: number): void {
this.mg = mg;
const tiles = this.railRoad.tiles;
// Inverse direction computation for the first tile
this.railTiles.push({
tile: tiles[0],
railType:
tiles.length > 0
? this.computeExtremityDirection(tiles[0], tiles[1])
: RailType.VERTICAL,
});
for (let i = 1; i < tiles.length - 1; i++) {
const direction = this.computeDirection(
tiles[i - 1],
tiles[i],
tiles[i + 1],
);
this.railTiles.push({ tile: tiles[i], railType: direction });
}
this.railTiles.push({
tile: tiles[tiles.length - 1],
railType:
tiles.length > 0
? this.computeExtremityDirection(
tiles[tiles.length - 1],
tiles[tiles.length - 2],
)
: RailType.VERTICAL,
});
}
private computeExtremityDirection(tile: TileRef, next: TileRef): RailType {
const x = this.mg.x(tile);
const y = this.mg.y(tile);
const nextX = this.mg.x(next);
const nextY = this.mg.y(next);
const dx = nextX - x;
const dy = nextY - y;
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
if (dx === 0) {
return RailType.VERTICAL;
} else if (dy === 0) {
return RailType.HORIZONTAL;
}
return RailType.VERTICAL;
}
private computeDirection(
prev: TileRef,
current: TileRef,
next: TileRef,
): RailType {
if (this.mg === null) {
throw new Error("Not initialized");
}
const x1 = this.mg.x(prev);
const y1 = this.mg.y(prev);
const x2 = this.mg.x(current);
const y2 = this.mg.y(current);
const x3 = this.mg.x(next);
const y3 = this.mg.y(next);
const dx1 = x2 - x1;
const dy1 = y2 - y1;
const dx2 = x3 - x2;
const dy2 = y3 - y2;
// Straight line
if (dx1 === dx2 && dy1 === dy2) {
if (dx1 !== 0) return RailType.HORIZONTAL;
if (dy1 !== 0) return RailType.VERTICAL;
}
// Turn (corner) cases
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
// Now figure out which type of corner
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
return RailType.VERTICAL;
}
tick(ticks: number): void {
if (this.mg === null) {
throw new Error("Not initialized");
}
if (!this.activeSourceOrDestination()) {
this.active = false;
return;
}
if (this.headIndex > this.tailIndex) {
// Construction complete
this.constructionComplete();
return;
}
let updatedRailTiles: RailTile[];
// Check if remaining tiles can be done all at once
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
this.constructionComplete();
} else {
updatedRailTiles = this.railTiles.slice(
this.headIndex,
this.headIndex + this.increment,
);
updatedRailTiles = updatedRailTiles.concat(
this.railTiles.slice(this.tailIndex - this.increment, this.tailIndex),
);
this.headIndex += this.increment;
this.tailIndex -= this.increment;
}
if (updatedRailTiles) {
this.mg.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: true,
railTiles: updatedRailTiles,
});
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
private activeSourceOrDestination(): boolean {
return this.railRoad.from.isActive() && this.railRoad.to.isActive();
}
private constructionComplete() {
this.redrawBuildings();
this.active = false;
}
private redrawBuildings() {
this.railRoad.from.unit.isActive() && this.railRoad.from.unit.touch();
this.railRoad.to.unit.isActive() && this.railRoad.to.unit.touch();
}
}
+2 -2
View File
@@ -106,10 +106,10 @@ export class TradeShipExecution implements Execution {
break;
case PathFindResultType.NextTile:
// Update safeFromPirates status
if (this.mg.isWater(result.tile) && this.mg.isShoreline(result.tile)) {
if (this.mg.isWater(result.node) && this.mg.isShoreline(result.node)) {
this.tradeShip.setSafeFromPirates();
}
this.tradeShip.move(result.tile);
this.tradeShip.move(result.node);
this.tilesTraveled++;
break;
case PathFindResultType.PathNotFound:
+240
View File
@@ -0,0 +1,240 @@
import {
Execution,
Game,
Player,
TrainType,
Unit,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { RailNetwork } from "../game/RailNetwork";
import { getOrientedRailroad, OrientedRailroad } from "../game/Railroad";
import { TrainStation } from "../game/TrainStation";
export class TrainExecution implements Execution {
private active = true;
private mg: Game | null = null;
private train: Unit | null = null;
private cars: Unit[] = [];
private hasCargo: boolean = false;
private currentTile: number = 0;
private spacing = 2;
private usedTiles: TileRef[] = []; // used for cars behind
private stations: TrainStation[] = [];
private currentRailroad: OrientedRailroad | null = null;
private speed: number = 3;
constructor(
private railNetwork: RailNetwork,
private player: Player,
private source: TrainStation,
private destination: TrainStation,
private numCars: number,
) {
this.hasCargo = source.unit.type() === UnitType.Factory;
}
init(mg: Game, ticks: number): void {
this.mg = mg;
const stations = this.railNetwork.findStationsPath(
this.source,
this.destination,
);
if (!stations || stations.length <= 1) {
this.active = false;
return;
}
this.stations = stations;
const railroad = getOrientedRailroad(this.stations[0], this.stations[1]);
if (railroad) {
this.currentRailroad = railroad;
} else {
this.active = false;
return;
}
const spawn = this.player.canBuild(UnitType.Train, this.stations[0].tile());
if (spawn === false) {
console.warn(`cannot build train`);
this.active = false;
return;
}
this.train = this.createTrainUnits(spawn);
}
tick(ticks: number): void {
if (this.train === null) {
throw new Error("Not initialized");
}
if (!this.train.isActive() || !this.activeSourceOrDestination()) {
this.deleteTrain();
return;
}
const tile = this.getNextTile();
if (tile) {
this.updateCarsPositions(tile);
} else {
this.targetReached();
this.deleteTrain();
}
}
loadCargo() {
if (this.hasCargo || this.train === null) {
return;
}
this.hasCargo = true;
// Starts at 1: don't load tail engine
for (let i = 1; i < this.cars.length; i++) {
this.cars[i].setLoaded(true);
}
}
private targetReached() {
if (this.train === null) {
return;
}
this.train.setReachedTarget();
this.cars.forEach((car: Unit) => {
car.setReachedTarget();
});
}
private createTrainUnits(tile: TileRef): Unit {
const train = this.player.buildUnit(UnitType.Train, tile, {
targetUnit: this.destination.unit,
trainType: TrainType.Engine,
});
// Tail is also an engine, just for cosmetics
this.cars.push(
this.player.buildUnit(UnitType.Train, tile, {
targetUnit: this.destination.unit,
trainType: TrainType.Engine,
}),
);
for (let i = 0; i < this.numCars; i++) {
this.cars.push(
this.player.buildUnit(UnitType.Train, tile, {
trainType: TrainType.Carriage,
loaded: this.hasCargo,
}),
);
}
return train;
}
private deleteTrain() {
this.active = false;
if (this.train?.isActive()) {
this.train.delete(false);
}
for (const car of this.cars) {
if (car.isActive()) {
car.delete(false);
}
}
}
private activeSourceOrDestination(): boolean {
return (
this.stations.length > 1 &&
this.stations[1].isActive() &&
this.stations[0].isActive()
);
}
/**
* Save the tiles the train go through so the cars can reuse them
* Don't simply save the tiles the engine uses, otherwise the spacing will be dictated by the train speed
*/
private saveTraversedTiles(from: number, speed: number) {
if (!this.currentRailroad) {
return;
}
let tileToSave: number = from;
for (
let i = 0;
i < speed && tileToSave < this.currentRailroad.getTiles().length;
i++
) {
this.saveTile(this.currentRailroad.getTiles()[tileToSave]);
tileToSave = tileToSave + 1;
}
}
private saveTile(tile: TileRef) {
this.usedTiles.push(tile);
if (this.usedTiles.length > this.cars.length * this.spacing + 3) {
this.usedTiles.shift();
}
}
private updateCarsPositions(newTile: TileRef) {
if (this.cars.length > 0) {
for (let i = this.cars.length - 1; i >= 0; --i) {
const carTileIndex = (i + 1) * this.spacing + 2;
if (this.usedTiles.length > carTileIndex) {
this.cars[i].move(this.usedTiles[carTileIndex]);
}
}
}
if (this.train !== null) {
this.train.move(newTile);
}
}
private nextStation() {
if (this.stations.length > 2) {
this.stations.shift();
const railRoad = getOrientedRailroad(this.stations[0], this.stations[1]);
if (railRoad) {
this.currentRailroad = railRoad;
return true;
}
}
return false;
}
private canTradeWithDestination() {
return (
this.stations.length > 1 && this.stations[1].tradeAvailable(this.player)
);
}
private getNextTile(): TileRef | null {
if (this.currentRailroad === null || !this.canTradeWithDestination()) {
return null;
}
this.saveTraversedTiles(this.currentTile, this.speed);
this.currentTile = this.currentTile + this.speed;
const leftOver = this.currentTile - this.currentRailroad.getTiles().length;
if (leftOver >= 0) {
// Station reached, pick the next station
this.stationReached();
if (!this.nextStation()) {
return null; // Destination reached (or no valid connection)
}
this.currentTile = leftOver;
this.saveTraversedTiles(0, leftOver);
}
return this.currentRailroad.getTiles()[this.currentTile];
}
private stationReached() {
if (this.mg === null || this.player === null) {
throw new Error("Not initialized");
}
this.stations[1].onTrainStop(this);
return;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
@@ -0,0 +1,83 @@
import { Execution, Game, Player, Unit } from "../game/Game";
import { TrainStation } from "../game/TrainStation";
import { PseudoRandom } from "../PseudoRandom";
import { TrainExecution } from "./TrainExecution";
export class TrainStationExecution implements Execution {
private mg: Game;
private active: boolean = true;
private random: PseudoRandom | null = null;
private station: TrainStation | null = null;
private unit: Unit | undefined = undefined;
private numCars: number = 5;
constructor(
private player: Player,
private unitId: number,
) {}
isActive(): boolean {
return this.active;
}
init(mg: Game, ticks: number): void {
this.mg = mg;
this.random = new PseudoRandom(mg.ticks());
this.unit = this.player.units().find((unit) => unit.id() === this.unitId);
if (this.unit === undefined) {
console.warn(`station unit is undefined`);
this.active = false;
return;
}
this.unit.setTrainStation(true);
}
tick(ticks: number): void {
if (this.mg === undefined) {
throw new Error("Not initialized");
}
if (!this.isActive() || this.unit === undefined) {
return;
}
if (this.station === null) {
// Can't create new executions on init, so it has to be done in the tick
this.station = new TrainStation(this.mg, this.unit);
this.mg.railNetwork().connectStation(this.station);
}
if (!this.station.isActive() || !this.random) {
this.active = false;
return;
}
const cluster = this.station.getCluster();
if (cluster === null) {
return;
}
const availableForTrade = cluster.availableForTrade(this.unit.owner());
if (
availableForTrade.size === 0 ||
!this.random.chance(
this.mg.config().trainSpawnRate(availableForTrade.size),
)
) {
return;
}
// Pick a destination randomly.
// Could be improved to pick a lucrative trip
const destination = this.random.randFromSet(availableForTrade);
if (destination !== this.station) {
this.mg.addExecution(
new TrainExecution(
this.mg.railNetwork(),
this.unit.owner(),
this.station,
destination,
this.numCars,
),
);
}
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+2 -2
View File
@@ -64,7 +64,7 @@ export class TransportShipExecution implements Execution {
this.lastMove = ticks;
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, 10);
this.pathFinder = PathFinder.Mini(mg, 10_000, true, 10);
if (
this.attacker.units(UnitType.TransportShip).length >=
@@ -209,7 +209,7 @@ export class TransportShipExecution implements Execution {
.boatArriveTroops(this.attacker, this.target, this.troops);
return;
case PathFindResultType.NextTile:
this.boat.move(result.tile);
this.boat.move(result.node);
break;
case PathFindResultType.Pending:
break;
+3 -3
View File
@@ -188,7 +188,7 @@ export class WarshipExecution implements Execution {
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
this.warship.move(result.node);
break;
case PathFindResultType.Pending:
this.warship.touch();
@@ -215,10 +215,10 @@ export class WarshipExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
this.warship.setTargetTile(undefined);
this.warship.move(result.tile);
this.warship.move(result.node);
break;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
this.warship.move(result.node);
break;
case PathFindResultType.Pending:
this.warship.touch();
+26
View File
@@ -8,6 +8,7 @@ import {
UnitUpdate,
} from "./GameUpdates";
import { PlayerView } from "./GameView";
import { RailNetwork } from "./RailNetwork";
import { Stats } from "./Stats";
export type PlayerID = string;
@@ -149,6 +150,13 @@ export enum UnitType {
MIRV = "MIRV",
MIRVWarhead = "MIRV Warhead",
Construction = "Construction",
Train = "Train",
Factory = "Factory",
}
export enum TrainType {
Engine = "Engine",
Carriage = "Carriage",
}
const _structureTypes: ReadonlySet<UnitType> = new Set([
@@ -197,6 +205,14 @@ export interface UnitParamsMap {
lastSetSafeFromPirates?: number;
};
[UnitType.Train]: {
trainType: TrainType;
targetUnit?: Unit;
loaded?: boolean;
};
[UnitType.Factory]: {};
[UnitType.MissileSilo]: {
cooldownDuration?: number;
};
@@ -373,6 +389,13 @@ export interface Unit {
touch(): void;
hash(): number;
toUpdate(): UnitUpdate;
hasTrainStation(): boolean;
setTrainStation(trainStation: boolean): void;
// Train
trainType(): TrainType | undefined;
isLoaded(): boolean | undefined;
setLoaded(loaded: boolean): void;
// Targeting
setTargetTile(cell: TileRef | undefined): void;
@@ -634,6 +657,9 @@ export interface Game extends GameMap {
numTilesWithFallout(): number;
// Optional as it's not initialized before the end of spawn phase
stats(): Stats;
addUpdate(update: GameUpdate): void;
railNetwork(): RailNetwork;
}
export interface PlayerActions {
+9
View File
@@ -30,6 +30,8 @@ import {
import { GameMap, TileRef, TileUpdate } from "./GameMap";
import { GameUpdate, GameUpdateType } from "./GameUpdates";
import { PlayerImpl } from "./PlayerImpl";
import { RailNetwork } from "./RailNetwork";
import { createRailNetwork } from "./RailNetworkImpl";
import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
@@ -73,6 +75,7 @@ export class GameImpl implements Game {
private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue];
private botTeam: Team = ColoredTeams.Bot;
private _railNetwork: RailNetwork = createRailNetwork(this);
constructor(
private _humans: PlayerInfo[],
@@ -672,6 +675,9 @@ export class GameImpl implements Game {
}
removeUnit(u: Unit) {
this.unitGrid.removeUnit(u);
if (u.hasTrainStation()) {
this._railNetwork.removeStation(u);
}
}
nearbyUnits(
@@ -787,6 +793,9 @@ export class GameImpl implements Game {
stats(): Stats {
return this._stats;
}
railNetwork(): RailNetwork {
return this._railNetwork;
}
}
// Or a more dynamic approach that will catch new enum values:
+36 -1
View File
@@ -9,6 +9,7 @@ import {
PlayerType,
Team,
Tick,
TrainType,
UnitType,
} from "./Game";
import { TileRef, TileUpdate } from "./GameMap";
@@ -40,6 +41,8 @@ export enum GameUpdateType {
Win,
Hash,
UnitIncoming,
BonusEvent,
RailroadEvent,
}
export type GameUpdate =
@@ -56,7 +59,36 @@ export type GameUpdate =
| EmojiUpdate
| WinUpdate
| HashUpdate
| UnitIncomingUpdate;
| UnitIncomingUpdate
| BonusEventUpdate
| RailroadUpdate;
export interface BonusEventUpdate {
type: GameUpdateType.BonusEvent;
tile: TileRef;
gold: number;
workers: number;
troops: number;
}
export enum RailType {
VERTICAL,
HORIZONTAL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
}
export interface RailTile {
tile: TileRef;
railType: RailType;
}
export interface RailroadUpdate {
type: GameUpdateType.RailroadEvent;
isActive: boolean;
railTiles: RailTile[];
}
export interface TileUpdateWrapper {
type: GameUpdateType.Tile;
@@ -84,6 +116,9 @@ export interface UnitUpdate {
missileTimerQueue: number[];
readyMissileCount: number;
level: number;
hasTrainStation: boolean;
trainType?: TrainType; // Only for trains
loaded?: boolean; // Only for trains
}
export interface AttackUpdate {
+10
View File
@@ -19,6 +19,7 @@ import {
TerrainType,
TerraNullius,
Tick,
TrainType,
UnitInfo,
UnitType,
} from "./Game";
@@ -124,6 +125,15 @@ export class UnitView {
level(): number {
return this.data.level;
}
hasTrainStation(): boolean {
return this.data.hasTrainStation;
}
trainType(): TrainType | undefined {
return this.data.trainType;
}
isLoaded(): boolean | undefined {
return this.data.loaded;
}
}
export class PlayerView {
+7
View File
@@ -808,10 +808,13 @@ export class PlayerImpl implements Player {
return canBuildTransportShip(this.mg, this, targetTile);
case UnitType.TradeShip:
return this.tradeShipSpawn(targetTile);
case UnitType.Train:
return this.landBasedUnitSpawn(targetTile);
case UnitType.MissileSilo:
case UnitType.DefensePost:
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Factory:
case UnitType.Construction:
return this.landBasedStructureSpawn(targetTile, validTiles);
default:
@@ -876,6 +879,10 @@ export class PlayerImpl implements Player {
return spawns[0].tile();
}
landBasedUnitSpawn(tile: TileRef): TileRef | false {
return this.mg.isLand(tile) ? tile : false;
}
landBasedStructureSpawn(
tile: TileRef,
validTiles: TileRef[] | null = null,
+8
View File
@@ -0,0 +1,8 @@
import { Unit } from "./Game";
import { TrainStation } from "./TrainStation";
export interface RailNetwork {
connectStation(station: TrainStation): void;
removeStation(unit: Unit): void;
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
}
+222
View File
@@ -0,0 +1,222 @@
import { RailroadExecution } from "../execution/RailroadExecution";
import { PathFindResultType } from "../pathfinding/AStar";
import { MiniAStar } from "../pathfinding/MiniAStar";
import { SerialAStar } from "../pathfinding/SerialAStar";
import { Game, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { RailNetwork } from "./RailNetwork";
import { Railroad } from "./Railroad";
import { Cluster, TrainStation, TrainStationMapAdapter } from "./TrainStation";
/**
* The Stations handle their own neighbors so the graph is naturally traversable,
* but it would be expensive to look through the graph to find a station.
* This class stores the existing stations for quick access
*/
export interface StationManager {
addStation(station: TrainStation): void;
removeStation(station: TrainStation): void;
findStation(unit: Unit): TrainStation | null;
getAll(): Set<TrainStation>;
}
export class StationManagerImpl implements StationManager {
private stations: Set<TrainStation> = new Set();
addStation(station: TrainStation) {
this.stations.add(station);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
}
findStation(unit: Unit): TrainStation | null {
for (const station of this.stations) {
if (station.unit === unit) return station;
}
return null;
}
getAll(): Set<TrainStation> {
return this.stations;
}
}
export interface RailPathFinderService {
findTilePath(from: TileRef, to: TileRef): TileRef[];
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
}
class RailPathFinderServiceImpl implements RailPathFinderService {
constructor(private game: Game) {}
findTilePath(from: TileRef, to: TileRef): TileRef[] {
const astar = new MiniAStar(
this.game.map(),
this.game.miniMap(),
from,
to,
5000,
20,
false,
3,
);
return astar.compute() === PathFindResultType.Completed
? astar.reconstructPath()
: [];
}
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
const stationAStar = new SerialAStar(
from,
to,
5000,
20,
new TrainStationMapAdapter(this.game),
);
return stationAStar.compute() === PathFindResultType.Completed
? stationAStar.reconstructPath()
: [];
}
}
export function createRailNetwork(game: Game): RailNetwork {
const stationManager = new StationManagerImpl();
const pathService = new RailPathFinderServiceImpl(game);
return new RailNetworkImpl(game, stationManager, pathService);
}
export class RailNetworkImpl implements RailNetwork {
constructor(
private game: Game,
private stationManager: StationManager,
private pathService: RailPathFinderService,
) {}
connectStation(station: TrainStation) {
this.stationManager.addStation(station);
this.connectToNearbyStations(station);
}
removeStation(unit: Unit): void {
const station = this.stationManager.findStation(unit);
if (!station) return;
const neighbors = station.neighbors();
this.disconnectFromNetwork(station);
this.stationManager.removeStation(station);
const cluster = station.getCluster();
if (!cluster) return;
if (neighbors.length === 1) {
cluster.removeStation(station);
} else if (neighbors.length > 1) {
for (const neighbor of neighbors) {
const stations = this.computeCluster(neighbor);
const newCluster = new Cluster();
newCluster.addStations(stations);
}
}
}
/**
* Return the intermediary stations connecting two stations
*/
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
return this.pathService.findStationsPath(from, to);
}
private connectToNearbyStations(station: TrainStation) {
const neighbors = this.game.nearbyUnits(
station.tile(),
this.game.config().trainStationMaxRange(),
[UnitType.City, UnitType.Factory, UnitType.Port],
);
const editedClusters = new Set<Cluster>();
neighbors.sort((a, b) => a.distSquared - b.distSquared);
for (const neighbor of neighbors) {
if (neighbor.unit === station.unit) continue;
const neighborStation = this.stationManager.findStation(neighbor.unit);
if (!neighborStation) continue;
const neighborCluster = neighborStation.getCluster();
if (!neighborCluster || neighborCluster.has(station)) continue;
if (
neighbor.distSquared >
this.game.config().trainStationMinRange() ** 2
) {
if (this.connect(station, neighborStation)) {
neighborCluster.addStation(station);
editedClusters.add(neighborCluster);
}
}
}
// If multiple clusters own the new station, merge them into a single cluster
if (editedClusters.size > 1) {
this.mergeClusters(editedClusters);
} else if (editedClusters.size === 0) {
// If no cluster owns the station, creates a new one for it
const newCluster = new Cluster();
newCluster.addStation(station);
}
}
private disconnectFromNetwork(station: TrainStation) {
for (const rail of station.getRailroads()) {
rail.delete(this.game);
}
station.clearRailroads();
const cluster = station.getCluster();
if (cluster !== null && cluster.size() === 1) {
this.deleteCluster(cluster);
}
}
private deleteCluster(cluster: Cluster) {
for (const station of cluster.stations) {
station.setCluster(null);
}
cluster.clear();
}
private connect(from: TrainStation, to: TrainStation) {
const path = this.pathService.findTilePath(from.tile(), to.tile());
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
const railRoad = new Railroad(from, to, path);
this.game.addExecution(new RailroadExecution(railRoad));
from.addRailroad(railRoad);
to.addRailroad(railRoad);
return true;
}
return false;
}
private computeCluster(start: TrainStation): Set<TrainStation> {
const visited = new Set<TrainStation>();
const queue = [start];
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
for (const neighbor of current.neighbors()) {
if (!visited.has(neighbor)) queue.push(neighbor);
}
}
return visited;
}
private mergeClusters(clustersToMerge: Set<Cluster>) {
const merged = new Cluster();
for (const cluster of clustersToMerge) {
merged.merge(cluster);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Game } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { TrainStation } from "./TrainStation";
export class Railroad {
constructor(
public from: TrainStation,
public to: TrainStation,
public tiles: TileRef[],
) {}
delete(game: Game) {
const railTiles: RailTile[] = this.tiles.map((tile) => ({
tile,
railType: RailType.VERTICAL,
}));
game.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: false,
railTiles,
});
this.from.getRailroads().delete(this);
this.to.getRailroads().delete(this);
}
}
export function getOrientedRailroad(
from: TrainStation,
to: TrainStation,
): OrientedRailroad | null {
for (const railroad of from.getRailroads()) {
if (railroad.from === to) {
return new OrientedRailroad(railroad, false);
} else if (railroad.to === to) {
return new OrientedRailroad(railroad, true);
}
}
return null;
}
/**
* Wrap a railroad with a direction so it always starts at tiles[0]
*/
export class OrientedRailroad {
private tiles: TileRef[] = [];
constructor(
private railroad: Railroad,
private forward: boolean,
) {
this.tiles = this.forward
? this.railroad.tiles
: [...this.railroad.tiles].reverse();
}
getTiles(): TileRef[] {
return this.tiles;
}
getStart(): TrainStation {
return this.forward ? this.railroad.from : this.railroad.to;
}
getEnd(): TrainStation {
return this.forward ? this.railroad.to : this.railroad.from;
}
}
+227
View File
@@ -0,0 +1,227 @@
import { TradeShipExecution } from "../execution/TradeShipExecution";
import { TrainExecution } from "../execution/TrainExecution";
import { GraphAdapter } from "../pathfinding/SerialAStar";
import { PseudoRandom } from "../PseudoRandom";
import { Game, Player, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
import { Railroad } from "./Railroad";
/**
* Handle train stops at various station types
*/
interface TrainStopHandler {
onStop(mg: Game, station: TrainStation, trainExecution: TrainExecution): void;
}
class CityStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
const goldBonus = mg.config().trainGold();
station.unit.owner().addGold(goldBonus);
mg.addUpdate({
type: GameUpdateType.BonusEvent,
tile: station.tile(),
gold: Number(goldBonus),
workers: 0,
troops: 0,
});
}
}
class PortStopHandler implements TrainStopHandler {
constructor(private random: PseudoRandom) {}
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
const unit = station.unit;
const ports = unit.owner().tradingPorts(unit);
if (ports.length === 0) return;
const port = this.random.randElement(ports);
mg.addExecution(new TradeShipExecution(unit.owner(), unit, port));
}
}
class FactoryStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
trainExecution.loadCargo();
}
}
export function createTrainStopHandlers(
random: PseudoRandom,
): Partial<Record<UnitType, TrainStopHandler>> {
return {
[UnitType.City]: new CityStopHandler(),
[UnitType.Port]: new PortStopHandler(random),
[UnitType.Factory]: new FactoryStopHandler(),
};
}
export class TrainStation {
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
{};
private cluster: Cluster | null;
private railroads: Set<Railroad> = new Set();
constructor(
private mg: Game,
public unit: Unit,
) {
this.stopHandlers = createTrainStopHandlers(new PseudoRandom(mg.ticks()));
}
tradeAvailable(otherPlayer: Player): boolean {
const player = this.unit.owner();
return otherPlayer === player || player.canTrade(otherPlayer);
}
clearRailroads() {
this.railroads.clear();
}
addRailroad(railRoad: Railroad) {
this.railroads.add(railRoad);
}
removeNeighboringRails(station: TrainStation) {
const toRemove = [...this.railroads].find(
(r) => r.from === station || r.to === station,
);
if (toRemove) {
const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({
tile,
railType: RailType.VERTICAL,
}));
this.mg.addUpdate({
type: GameUpdateType.RailroadEvent,
isActive: false,
railTiles,
});
this.railroads.delete(toRemove);
}
}
neighbors(): TrainStation[] {
const neighbors: TrainStation[] = [];
for (const r of this.railroads) {
if (r.from !== this) {
neighbors.push(r.from);
} else {
neighbors.push(r.to);
}
}
return neighbors;
}
tile(): TileRef {
return this.unit.tile();
}
isActive(): boolean {
return this.unit.isActive();
}
getRailroads(): Set<Railroad> {
return this.railroads;
}
setCluster(cluster: Cluster | null) {
this.cluster = cluster;
}
getCluster(): Cluster | null {
return this.cluster;
}
onTrainStop(trainExecution: TrainExecution) {
const type = this.unit.type();
const handler = this.stopHandlers[type];
if (handler) {
handler.onStop(this.mg, this, trainExecution);
}
}
}
/**
* Make the trainstation usable with A*
*/
export class TrainStationMapAdapter implements GraphAdapter<TrainStation> {
constructor(private game: Game) {}
neighbors(node: TrainStation): TrainStation[] {
return node.neighbors();
}
cost(node: TrainStation): number {
return 1;
}
position(node: TrainStation): { x: number; y: number } {
return { x: this.game.x(node.tile()), y: this.game.y(node.tile()) };
}
isTraversable(from: TrainStation, to: TrainStation): boolean {
return true;
}
}
/**
* Cluster of connected stations
*/
export class Cluster {
public stations: Set<TrainStation> = new Set();
has(station: TrainStation) {
return this.stations.has(station);
}
addStation(station: TrainStation) {
this.stations.add(station);
station.setCluster(this);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
}
addStations(stations: Set<TrainStation>) {
for (const station of stations) {
this.addStation(station);
}
}
merge(other: Cluster) {
for (const s of other.stations) {
this.addStation(s);
}
}
availableForTrade(player: Player): Set<TrainStation> {
const tradingStations = new Set<TrainStation>();
for (const station of this.stations) {
if (station.tradeAvailable(player)) {
tradingStations.add(station);
}
}
return tradingStations;
}
size() {
return this.stations.size;
}
clear() {
this.stations.clear();
}
}
+34
View File
@@ -4,6 +4,7 @@ import {
MessageType,
Player,
Tick,
TrainType,
Unit,
UnitInfo,
UnitType,
@@ -28,9 +29,12 @@ export class UnitImpl implements Unit {
private _troops: number;
private _missileTimerQueue: number[] = [];
private _readyMissileCount: number = 1;
private _hasTrainStation: boolean = false;
private _patrolTile: TileRef | undefined;
private _level: number = 1;
private _targetable: boolean = true;
private _loaded: boolean | undefined;
private _trainType: TrainType | undefined;
constructor(
private _type: UnitType,
@@ -53,6 +57,9 @@ export class UnitImpl implements Unit {
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
this._targetUnit =
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
this._loaded =
"loaded" in params ? (params.loaded ?? undefined) : undefined;
this._trainType = "trainType" in params ? params.trainType : undefined;
switch (this._type) {
case UnitType.Warship:
@@ -123,6 +130,9 @@ export class UnitImpl implements Unit {
missileTimerQueue: this._missileTimerQueue,
readyMissileCount: this._readyMissileCount,
level: this.level(),
hasTrainStation: this._hasTrainStation,
trainType: this._trainType,
loaded: this._loaded,
};
}
@@ -351,6 +361,15 @@ export class UnitImpl implements Unit {
return this._level;
}
setTrainStation(trainStation: boolean): void {
this._hasTrainStation = trainStation;
this.mg.addUpdate(this.toUpdate());
}
hasTrainStation(): boolean {
return this._hasTrainStation;
}
increaseLevel(): void {
this._level++;
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
@@ -358,4 +377,19 @@ export class UnitImpl implements Unit {
}
this.mg.addUpdate(this.toUpdate());
}
trainType(): TrainType | undefined {
return this._trainType;
}
isLoaded(): boolean | undefined {
return this._loaded;
}
setLoaded(loaded: boolean): void {
if (this._loaded !== loaded) {
this._loaded = loaded;
this.mg.addUpdate(this.toUpdate());
}
}
}
+5 -7
View File
@@ -1,8 +1,6 @@
import { TileRef } from "../game/GameMap";
export interface AStar {
export interface AStar<NodeType> {
compute(): PathFindResultType;
reconstructPath(): TileRef[];
reconstructPath(): NodeType[];
}
export enum PathFindResultType {
@@ -11,17 +9,17 @@ export enum PathFindResultType {
Completed,
PathNotFound,
}
export type TileResult =
export type AStarResult<NodeType> =
| {
type: PathFindResultType.NextTile;
tile: TileRef;
node: NodeType;
}
| {
type: PathFindResultType.Pending;
}
| {
type: PathFindResultType.Completed;
tile: TileRef;
node: NodeType;
}
| {
type: PathFindResultType.PathNotFound;
+30 -4
View File
@@ -1,10 +1,33 @@
import { Cell } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { AStar, PathFindResultType } from "./AStar";
import { SerialAStar } from "./SerialAStar";
import { GraphAdapter, SerialAStar } from "./SerialAStar";
export class MiniAStar implements AStar {
private aStar: AStar;
export class GameMapAdapter implements GraphAdapter<TileRef> {
constructor(
private gameMap: GameMap,
private waterPath: boolean,
) {}
neighbors(node: TileRef): TileRef[] {
return this.gameMap.neighbors(node);
}
cost(node: TileRef): number {
return this.gameMap.cost(node);
}
position(node: TileRef): { x: number; y: number } {
return { x: this.gameMap.x(node), y: this.gameMap.y(node) };
}
isTraversable(from: TileRef, to: TileRef): boolean {
const isWater = this.gameMap.isWater(to);
return this.waterPath ? isWater : !isWater;
}
}
export class MiniAStar implements AStar<TileRef> {
private aStar: AStar<TileRef>;
constructor(
private gameMap: GameMap,
@@ -13,6 +36,8 @@ export class MiniAStar implements AStar {
private dst: TileRef,
iterations: number,
maxTries: number,
waterPath: boolean = true,
directionChangePenalty: number = 0,
) {
const srcArray: TileRef[] = Array.isArray(src) ? src : [src];
const miniSrc = srcArray.map((srcPoint) =>
@@ -32,7 +57,8 @@ export class MiniAStar implements AStar {
miniDst,
iterations,
maxTries,
this.miniMap,
new GameMapAdapter(miniMap, waterPath),
directionChangePenalty,
);
}
+13 -7
View File
@@ -2,7 +2,7 @@ import { Game } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { DistanceBasedBezierCurve } from "../utilities/Line";
import { AStar, PathFindResultType, TileResult } from "./AStar";
import { AStar, AStarResult, PathFindResultType } from "./AStar";
import { MiniAStar } from "./MiniAStar";
const parabolaMinHeight = 50;
@@ -89,15 +89,20 @@ export class PathFinder {
private curr: TileRef | null = null;
private dst: TileRef | null = null;
private path: TileRef[] | null = null;
private aStar: AStar;
private aStar: AStar<TileRef>;
private computeFinished = true;
private constructor(
private game: Game,
private newAStar: (curr: TileRef, dst: TileRef) => AStar,
private newAStar: (curr: TileRef, dst: TileRef) => AStar<TileRef>,
) {}
public static Mini(game: Game, iterations: number, maxTries: number = 20) {
public static Mini(
game: Game,
iterations: number,
waterPath: boolean = true,
maxTries: number = 20,
) {
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
return new MiniAStar(
game.map(),
@@ -106,6 +111,7 @@ export class PathFinder {
dst,
iterations,
maxTries,
waterPath,
);
});
}
@@ -114,7 +120,7 @@ export class PathFinder {
curr: TileRef | null,
dst: TileRef | null,
dist: number = 1,
): TileResult {
): AStarResult<TileRef> {
if (curr === null) {
console.error("curr is null");
return { type: PathFindResultType.PathNotFound };
@@ -125,7 +131,7 @@ export class PathFinder {
}
if (this.game.manhattanDist(curr, dst) < dist) {
return { type: PathFindResultType.Completed, tile: curr };
return { type: PathFindResultType.Completed, node: curr };
}
if (this.computeFinished) {
@@ -141,7 +147,7 @@ export class PathFinder {
if (tile === undefined) {
throw new Error("missing tile");
}
return { type: PathFindResultType.NextTile, tile };
return { type: PathFindResultType.NextTile, node: tile };
}
}
+65 -59
View File
@@ -1,51 +1,46 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { GameMap, TileRef } from "../game/GameMap";
import { AStar, PathFindResultType } from "./AStar";
export class SerialAStar implements AStar {
/**
* Implement this interface with your graph to find paths with A*
*/
export interface GraphAdapter<NodeType> {
neighbors(node: NodeType): NodeType[];
cost(node: NodeType): number;
position(node: NodeType): { x: number; y: number };
isTraversable(from: NodeType, to: NodeType): boolean;
}
export class SerialAStar<NodeType> implements AStar<NodeType> {
private fwdOpenSet: PriorityQueue<{
tile: TileRef;
tile: NodeType;
fScore: number;
}>;
private bwdOpenSet: PriorityQueue<{
tile: TileRef;
tile: NodeType;
fScore: number;
}>;
private fwdCameFrom: Map<TileRef, TileRef>;
private bwdCameFrom: Map<TileRef, TileRef>;
private fwdGScore: Map<TileRef, number>;
private bwdGScore: Map<TileRef, number>;
private meetingPoint: TileRef | null;
public completed: boolean;
private sources: TileRef[];
private closestSource: TileRef;
private fwdCameFrom = new Map<NodeType, NodeType>();
private bwdCameFrom = new Map<NodeType, NodeType>();
private fwdGScore = new Map<NodeType, number>();
private bwdGScore = new Map<NodeType, number>();
private meetingPoint: NodeType | null = null;
public completed = false;
private sources: NodeType[];
private closestSource: NodeType;
constructor(
src: TileRef | TileRef[],
private dst: TileRef,
src: NodeType | NodeType[],
private dst: NodeType,
private iterations: number,
private maxTries: number,
private gameMap: GameMap,
private graph: GraphAdapter<NodeType>,
private directionChangePenalty: number = 0,
) {
this.fwdOpenSet = new PriorityQueue<{
tile: TileRef;
fScore: number;
}>((a, b) => a.fScore - b.fScore);
this.bwdOpenSet = new PriorityQueue<{
tile: TileRef;
fScore: number;
}>((a, b) => a.fScore - b.fScore);
this.fwdCameFrom = new Map<TileRef, TileRef>();
this.bwdCameFrom = new Map<TileRef, TileRef>();
this.fwdGScore = new Map<TileRef, number>();
this.bwdGScore = new Map<TileRef, number>();
this.meetingPoint = null;
this.completed = false;
this.fwdOpenSet = new PriorityQueue((a, b) => a.fScore - b.fScore);
this.bwdOpenSet = new PriorityQueue((a, b) => a.fScore - b.fScore);
this.sources = Array.isArray(src) ? src : [src];
this.closestSource = this.findClosestSource(dst);
@@ -66,7 +61,7 @@ export class SerialAStar implements AStar {
});
}
private findClosestSource(tile: TileRef): TileRef {
private findClosestSource(tile: NodeType): NodeType {
return this.sources.reduce((closest, source) =>
this.heuristic(tile, source) < this.heuristic(tile, closest)
? source
@@ -98,8 +93,7 @@ export class SerialAStar implements AStar {
this.completed = true;
return PathFindResultType.Completed;
}
this.expandTileRef(fwdCurrent, true);
this.expandNode(fwdCurrent, true);
// Process backward search
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
@@ -110,8 +104,7 @@ export class SerialAStar implements AStar {
this.completed = true;
return PathFindResultType.Completed;
}
this.expandTileRef(bwdCurrent, false);
this.expandNode(bwdCurrent, false);
}
return this.completed
@@ -119,11 +112,11 @@ export class SerialAStar implements AStar {
: PathFindResultType.PathNotFound;
}
private expandTileRef(current: TileRef, isForward: boolean) {
for (const neighbor of this.gameMap.neighbors(current)) {
private expandNode(current: NodeType, isForward: boolean) {
for (const neighbor of this.graph.neighbors(current)) {
if (
neighbor !== (isForward ? this.dst : this.closestSource) &&
!this.gameMap.isWater(neighbor)
!this.graph.isTraversable(current, neighbor)
)
continue;
@@ -131,38 +124,51 @@ export class SerialAStar implements AStar {
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
const tentativeGScore =
gScore.get(current)! + this.gameMap.cost(neighbor);
const tentativeGScore = gScore.get(current)! + this.graph.cost(neighbor);
let penalty = 0;
// With a direction change penalty, the path will get as straight as possible
if (this.directionChangePenalty > 0) {
const prev = cameFrom.get(current);
if (prev) {
const prevDir = this.getDirection(prev, current);
const newDir = this.getDirection(current, neighbor);
if (prevDir !== newDir) {
penalty = this.directionChangePenalty;
}
}
}
if (!gScore.has(neighbor) || tentativeGScore < gScore.get(neighbor)!) {
const totalG = tentativeGScore + penalty;
if (!gScore.has(neighbor) || totalG < gScore.get(neighbor)!) {
cameFrom.set(neighbor, current);
gScore.set(neighbor, tentativeGScore);
gScore.set(neighbor, totalG);
const fScore =
tentativeGScore +
totalG +
this.heuristic(neighbor, isForward ? this.dst : this.closestSource);
openSet.enqueue({ tile: neighbor, fScore: fScore });
}
}
}
private heuristic(a: TileRef, b: TileRef): number {
try {
return (
1.1 *
(Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) +
Math.abs(this.gameMap.y(a) - this.gameMap.y(b)))
);
} catch {
console.log("uh oh");
return 0;
}
private heuristic(a: NodeType, b: NodeType): number {
const posA = this.graph.position(a);
const posB = this.graph.position(b);
return 1.1 * (Math.abs(posA.x - posB.x) + Math.abs(posA.y - posB.y));
}
public reconstructPath(): TileRef[] {
private getDirection(from: NodeType, to: NodeType): string {
const fromPos = this.graph.position(from);
const toPos = this.graph.position(to);
const dx = toPos.x - fromPos.x;
const dy = toPos.y - fromPos.y;
return `${Math.sign(dx)},${Math.sign(dy)}`;
}
public reconstructPath(): NodeType[] {
if (!this.meetingPoint) return [];
// Reconstruct path from start to meeting point
const fwdPath: TileRef[] = [this.meetingPoint];
const fwdPath: NodeType[] = [this.meetingPoint];
let current = this.meetingPoint;
while (this.fwdCameFrom.has(current)) {