mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-25 00:16:58 +00:00
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:
co-authored by
Scott Anderson
parent
0f2008a68d
commit
43397779fa
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())!;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user