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 17:14:08 +02:00
committed by GitHub
parent 0f2008a68d
commit 43397779fa
60 changed files with 2427 additions and 104 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = "test-file-stub";
+3
View File
@@ -5,6 +5,9 @@ export default {
extensionsToTreatAsEsm: [".ts"], extensionsToTreatAsEsm: [".ts"],
moduleNameMapper: { moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1", "^(\\.{1,2}/.*)\\.js$": "$1",
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/__mocks__/fileMock.js",
"\\.(css|less)$": "<rootDir>/__mocks__/fileMock.js",
}, },
transform: { transform: {
"^.+\\.tsx?$": [ "^.+\\.tsx?$": [
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" fill="#FFF" xml:space="preserve">
<g>
<path d="M 382.702 195.718 L 384 153.6 L 384 102.4 L 320 153.6 L 269.5 194 L 255.568 194.854 L 256 153.6 L 256 102.4 L 192 153.6 L 143.191 191.818 L 128 191.674 L 128 128 L 128 72.215 L 111.057 51.805 L 64 51.2 L 21.179 51.2 L 0 74.4 L 0 256 L 0 460.8 L 256 460.8 L 512 460.8 L 512 281.6 L 512 102.4 L 448 153.6 L 396.932 194.367 L 382.702 195.718 Z M 179.2 384 L 153.6 384 L 128 384 L 128 345.6 L 128 307.2 C 127.777 306.306 138.701 301.558 144.203 301.359 C 148.36 301.209 154.679 301.411 161.651 301.552 C 168.881 301.698 178.003 306.906 179.2 307.2 L 179.2 345.6 L 179.2 384 Z M 307.2 384 L 281.6 384 L 256 384 L 256 345.6 L 256 307.2 L 271.48 300.354 L 289.861 300.122 L 307.2 307.2 L 307.2 345.6 L 307.2 384 Z M 435.2 384 L 409.6 384 L 384 384 L 384 345.6 L 384 307.2 L 399.183 300.354 L 417.852 300.289 L 435.2 307.2 L 435.2 345.6 L 435.2 384 Z" transform="matrix(1, 0, 0, 1, 6.528259374621939, -25.993545919560574)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

+1
View File
@@ -416,6 +416,7 @@
"cooldown": "Cooldown", "cooldown": "Cooldown",
"type": "Type", "type": "Type",
"upgrade": "Upgrade", "upgrade": "Upgrade",
"create_station": "Create Station",
"level": "Level" "level": "Level"
}, },
"relation": { "relation": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

+17
View File
@@ -54,6 +54,10 @@ export class SendUpgradeStructureIntentEvent implements GameEvent {
) {} ) {}
} }
export class SendCreateTrainStationIntentEvent implements GameEvent {
constructor(public readonly unitId: number) {}
}
export class SendAllianceReplyIntentEvent implements GameEvent { export class SendAllianceReplyIntentEvent implements GameEvent {
constructor( constructor(
// The original alliance requestor // The original alliance requestor
@@ -200,6 +204,9 @@ export class Transport {
this.eventBus.on(SendUpgradeStructureIntentEvent, (e) => this.eventBus.on(SendUpgradeStructureIntentEvent, (e) =>
this.onSendUpgradeStructureIntent(e), this.onSendUpgradeStructureIntent(e),
); );
this.eventBus.on(SendCreateTrainStationIntentEvent, (e) =>
this.onSendCreateTrainStationIntent(e),
);
this.eventBus.on(SendBoatAttackIntentEvent, (e) => this.eventBus.on(SendBoatAttackIntentEvent, (e) =>
this.onSendBoatAttackIntent(e), this.onSendBoatAttackIntent(e),
); );
@@ -452,6 +459,16 @@ export class Transport {
}); });
} }
private onSendCreateTrainStationIntent(
event: SendCreateTrainStationIntentEvent,
) {
this.sendIntent({
type: "create_station",
clientID: this.lobbyConfig.clientID,
unitId: event.unitId,
});
}
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) { private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
this.sendIntent({ this.sendIntent({
type: "targetPlayer", type: "targetPlayer",
@@ -1,4 +1,5 @@
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png"; import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
import dust from "../../../resources/sprites/dust.png";
import miniExplosion from "../../../resources/sprites/miniExplosion.png"; import miniExplosion from "../../../resources/sprites/miniExplosion.png";
import miniFire from "../../../resources/sprites/minifire.png"; import miniFire from "../../../resources/sprites/minifire.png";
import nuke from "../../../resources/sprites/nukeExplosion.png"; import nuke from "../../../resources/sprites/nukeExplosion.png";
@@ -69,6 +70,15 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
originX: 6, originX: 6,
originY: 6, originY: 6,
}, },
[FxType.Dust]: {
url: dust,
frameWidth: 9,
frameCount: 3,
frameDuration: 100,
looping: false,
originX: 4,
originY: 5,
},
[FxType.UnitExplosion]: { [FxType.UnitExplosion]: {
url: unitExplosion, url: unitExplosion,
frameWidth: 19, frameWidth: 19,
+2
View File
@@ -22,6 +22,7 @@ import { NameLayer } from "./layers/NameLayer";
import { OptionsMenu } from "./layers/OptionsMenu"; import { OptionsMenu } from "./layers/OptionsMenu";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel"; import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
import { ReplayPanel } from "./layers/ReplayPanel"; import { ReplayPanel } from "./layers/ReplayPanel";
import { SpawnAd } from "./layers/SpawnAd"; import { SpawnAd } from "./layers/SpawnAd";
import { SpawnTimer } from "./layers/SpawnTimer"; import { SpawnTimer } from "./layers/SpawnTimer";
@@ -215,6 +216,7 @@ export function createRenderer(
const layers: Layer[] = [ const layers: Layer[] = [
new TerrainLayer(game, transformHandler), new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler), new TerritoryLayer(game, eventBus, transformHandler),
new RailroadLayer(game),
structureLayer, structureLayer,
new UnitLayer(game, eventBus, transformHandler), new UnitLayer(game, eventBus, transformHandler),
new FxLayer(game), new FxLayer(game),
+56 -10
View File
@@ -4,13 +4,25 @@ import hydrogenBombSprite from "../../../resources/sprites/hydrogenbomb.png";
import mirvSprite from "../../../resources/sprites/mirv2.png"; import mirvSprite from "../../../resources/sprites/mirv2.png";
import samMissileSprite from "../../../resources/sprites/samMissile.png"; import samMissileSprite from "../../../resources/sprites/samMissile.png";
import tradeShipSprite from "../../../resources/sprites/tradeship.png"; import tradeShipSprite from "../../../resources/sprites/tradeship.png";
import trainCarriageSprite from "../../../resources/sprites/trainCarriage.png";
import trainLoadedCarriageSprite from "../../../resources/sprites/trainCarriageLoaded.png";
import trainEngineSprite from "../../../resources/sprites/trainEngine.png";
import transportShipSprite from "../../../resources/sprites/transportship.png"; import transportShipSprite from "../../../resources/sprites/transportship.png";
import warshipSprite from "../../../resources/sprites/warship.png"; import warshipSprite from "../../../resources/sprites/warship.png";
import { Theme } from "../../core/configuration/Config"; import { Theme } from "../../core/configuration/Config";
import { UnitType } from "../../core/game/Game"; import { TrainType, UnitType } from "../../core/game/Game";
import { UnitView } from "../../core/game/GameView"; import { UnitView } from "../../core/game/GameView";
const SPRITE_CONFIG: Partial<Record<UnitType, string>> = { // Can't reuse TrainType because "loaded" is not a type, just an attribute
const TrainTypeSprite = {
Engine: "Engine",
Carriage: "Carriage",
LoadedCarriage: "LoadedCarriage",
} as const;
type TrainTypeSprite = (typeof TrainTypeSprite)[keyof typeof TrainTypeSprite];
const SPRITE_CONFIG: Partial<Record<UnitType | TrainTypeSprite, string>> = {
[UnitType.TransportShip]: transportShipSprite, [UnitType.TransportShip]: transportShipSprite,
[UnitType.Warship]: warshipSprite, [UnitType.Warship]: warshipSprite,
[UnitType.SAMMissile]: samMissileSprite, [UnitType.SAMMissile]: samMissileSprite,
@@ -18,9 +30,12 @@ const SPRITE_CONFIG: Partial<Record<UnitType, string>> = {
[UnitType.HydrogenBomb]: hydrogenBombSprite, [UnitType.HydrogenBomb]: hydrogenBombSprite,
[UnitType.TradeShip]: tradeShipSprite, [UnitType.TradeShip]: tradeShipSprite,
[UnitType.MIRV]: mirvSprite, [UnitType.MIRV]: mirvSprite,
[TrainTypeSprite.Engine]: trainEngineSprite,
[TrainTypeSprite.Carriage]: trainCarriageSprite,
[TrainTypeSprite.LoadedCarriage]: trainLoadedCarriageSprite,
}; };
const spriteMap: Map<UnitType, ImageBitmap> = new Map(); const spriteMap: Map<UnitType | TrainTypeSprite, ImageBitmap> = new Map();
// preload all images // preload all images
export const loadAllSprites = async (): Promise<void> => { export const loadAllSprites = async (): Promise<void> => {
@@ -30,7 +45,7 @@ export const loadAllSprites = async (): Promise<void> => {
await Promise.all( await Promise.all(
entries.map(async ([unitType, url]) => { entries.map(async ([unitType, url]) => {
const typedUnitType = unitType as UnitType; const typedUnitType = unitType as UnitType | TrainTypeSprite;
if (!url || url === "") { if (!url || url === "") {
console.warn(`No sprite URL for ${typedUnitType}, skipping...`); console.warn(`No sprite URL for ${typedUnitType}, skipping...`);
@@ -61,11 +76,32 @@ export const loadAllSprites = async (): Promise<void> => {
); );
}; };
const getSpriteForUnit = (unitType: UnitType): ImageBitmap | null => { /**
return spriteMap.get(unitType) ?? null; * The train sprites rely on the train attributes and not only on its type
*/
function trainTypeToSpriteType(unit: UnitView): TrainTypeSprite {
return unit.trainType() === TrainType.Engine
? TrainTypeSprite.Engine
: unit.isLoaded()
? TrainTypeSprite.LoadedCarriage
: TrainTypeSprite.Carriage;
}
const getSpriteForUnit = (unit: UnitView): ImageBitmap | null => {
const unitType = unit.type();
if (unitType === UnitType.Train) {
const trainType = trainTypeToSpriteType(unit);
return spriteMap.get(trainType) || null;
}
return spriteMap.get(unitType) || null;
}; };
export const isSpriteReady = (unitType: UnitType): boolean => { export const isSpriteReady = (unit: UnitView): boolean => {
const unitType = unit.type();
if (unitType === UnitType.Train) {
const trainType = trainTypeToSpriteType(unit);
return spriteMap.has(trainType);
}
return spriteMap.has(unitType); return spriteMap.has(unitType);
}; };
@@ -118,6 +154,17 @@ export const colorizeCanvas = (
return canvas; return canvas;
}; };
function computeSpriteKey(
unit: UnitView,
territoryColor: Colord,
borderColor: Colord,
): string {
const owner = unit.owner();
const type = `${unit.type()}-${unit.trainType()}-${unit.isLoaded()}`;
const key = `${type}-${owner.id()}-${territoryColor.toRgbString()}-${borderColor.toRgbString()}`;
return key;
}
export const getColoredSprite = ( export const getColoredSprite = (
unit: UnitView, unit: UnitView,
theme: Theme, theme: Theme,
@@ -129,13 +176,12 @@ export const getColoredSprite = (
customTerritoryColor ?? theme.territoryColor(owner); customTerritoryColor ?? theme.territoryColor(owner);
const borderColor: Colord = customBorderColor ?? theme.borderColor(owner); const borderColor: Colord = customBorderColor ?? theme.borderColor(owner);
const spawnHighlightColor = theme.spawnHighlightColor(); const spawnHighlightColor = theme.spawnHighlightColor();
const key = `${unit.type()}-${owner.id()}-${territoryColor.toRgbString()}-${borderColor.toRgbString()}`; const key = computeSpriteKey(unit, territoryColor, borderColor);
if (coloredSpriteCache.has(key)) { if (coloredSpriteCache.has(key)) {
return coloredSpriteCache.get(key)!; return coloredSpriteCache.get(key)!;
} }
const sprite = getSpriteForUnit(unit.type()); const sprite = getSpriteForUnit(unit);
if (sprite === null) { if (sprite === null) {
throw new Error(`Failed to load sprite for ${unit.type()}`); throw new Error(`Failed to load sprite for ${unit.type()}`);
} }
+2
View File
@@ -12,4 +12,6 @@ export enum FxType {
SinkingShip = "SinkingShip", SinkingShip = "SinkingShip",
Nuke = "Nuke", Nuke = "Nuke",
SAMExplosion = "SAMExplosion", SAMExplosion = "SAMExplosion",
UnderConstruction = "UnderConstruction",
Dust = "Dust",
} }
+48
View File
@@ -0,0 +1,48 @@
import { Fx } from "./Fx";
// Shorten a number by replacing thousands with "k"
export function shortenNumber(num: number): string {
if (num >= 1_000) {
return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
} else {
return num.toString();
}
}
export class TextFx implements Fx {
private lifeTime: number = 0;
constructor(
private text: string,
private x: number,
private y: number,
private duration: number,
private riseDistance: number = 30,
private color: { r: number; g: number; b: number } = {
r: 255,
g: 255,
b: 255,
},
private font: string = "11px sans-serif",
) {}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
this.lifeTime += frameTime;
if (this.lifeTime >= this.duration) {
return false;
}
const t = this.lifeTime / this.duration;
const currentY = this.y - t * this.riseDistance;
const alpha = 1 - t;
ctx.save();
ctx.font = this.font;
ctx.fillStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${alpha})`;
ctx.textAlign = "center";
ctx.fillText(this.text, this.x, currentY);
ctx.restore();
return true;
}
}
+8
View File
@@ -2,6 +2,7 @@ import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg"; import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg";
import cityIcon from "../../../../resources/images/CityIconWhite.svg"; import cityIcon from "../../../../resources/images/CityIconWhite.svg";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg"; import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
import mirvIcon from "../../../../resources/images/MIRVIcon.svg"; import mirvIcon from "../../../../resources/images/MIRVIcon.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloIconWhite.svg"; import missileSiloIcon from "../../../../resources/images/MissileSiloIconWhite.svg";
@@ -93,6 +94,13 @@ export const buildTable: BuildItemDisplay[][] = [
key: "unit_type.city", key: "unit_type.city",
countable: true, countable: true,
}, },
{
unitType: UnitType.Factory,
icon: factoryIcon,
description: "build_menu.desc.factory",
key: "unit_type.factory",
countable: true,
},
], ],
]; ];
+13 -2
View File
@@ -1,8 +1,9 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import { translateText } from "../../../client/Utils"; import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus"; import { EventBus } from "../../../core/EventBus";
import { Gold } from "../../../core/game/Game"; import { Gold, UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView"; import { GameView } from "../../../core/game/GameView";
import { AttackRatioEvent } from "../../InputHandler"; import { AttackRatioEvent } from "../../InputHandler";
import { SendSetTargetTroopRatioEvent } from "../../Transport"; import { SendSetTargetTroopRatioEvent } from "../../Transport";
@@ -52,6 +53,9 @@ export class ControlPanel extends LitElement implements Layer {
@state() @state()
private _goldPerSecond: Gold; private _goldPerSecond: Gold;
@state()
private _factories: number;
private _lastPopulationIncreaseRate: number; private _lastPopulationIncreaseRate: number;
private _popRateIsIncreasing: boolean = true; private _popRateIsIncreasing: boolean = true;
@@ -129,6 +133,7 @@ export class ControlPanel extends LitElement implements Layer {
this.currentTroopRatio = player.troops() / player.population(); this.currentTroopRatio = player.troops() / player.population();
this.requestUpdate(); this.requestUpdate();
this._factories = player.units(UnitType.Factory).length;
} }
onAttackRatioChange(newRatio: number) { onAttackRatioChange(newRatio: number) {
@@ -231,7 +236,13 @@ export class ControlPanel extends LitElement implements Layer {
> >
<span translate="no" <span translate="no"
>${renderNumber(this._gold)} >${renderNumber(this._gold)}
(+${renderNumber(this._goldPerSecond)})</span (+${renderNumber(this._goldPerSecond)}
${renderNumber(this._factories)}
<img
src="${factoryIcon}"
style="display: inline"
width="15"
/>)</span
> >
</div> </div>
</div> </div>
+94 -4
View File
@@ -1,14 +1,18 @@
import { Theme } from "../../../core/configuration/Config"; import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game"; import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates"; import {
BonusEventUpdate,
GameUpdateType,
RailroadUpdate,
} from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView"; import { GameView, UnitView } from "../../../core/game/GameView";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "../fx/Fx"; import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx"; import { SpriteFx } from "../fx/SpriteFx";
import { shortenNumber, TextFx } from "../fx/TextFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx"; import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { Layer } from "./Layer"; import { Layer } from "./Layer";
export class FxLayer implements Layer { export class FxLayer implements Layer {
private canvas: HTMLCanvasElement; private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D; private context: CanvasRenderingContext2D;
@@ -37,6 +41,54 @@ export class FxLayer implements Layer {
if (unitView === undefined) return; if (unitView === undefined) return;
this.onUnitEvent(unitView); this.onUnitEvent(unitView);
}); });
this.game
.updatesSinceLastTick()
?.[GameUpdateType.BonusEvent]?.forEach((bonusEvent) => {
if (bonusEvent === undefined) return;
this.onBonusEvent(bonusEvent);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.RailroadEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadEvent(update);
});
}
onBonusEvent(bonus: BonusEventUpdate) {
const tile = bonus.tile;
if (this.game.owner(tile) !== this.game.myPlayer()) {
// Only display text fx for the current player
return;
}
const x = this.game.x(tile);
let y = this.game.y(tile);
const gold = bonus.gold;
const troops = bonus.troops;
const workers = bonus.workers;
if (gold > 0) {
const shortened = shortenNumber(gold);
this.addTextFx(`+ ${shortened} gold`, x, y);
y += 10; // increase y so the next popup starts bellow
}
if (troops > 0) {
const shortened = shortenNumber(troops);
this.addTextFx(`+ ${shortened} troops`, x, y);
y += 10;
}
if (workers > 0) {
const shortened = shortenNumber(workers);
this.addTextFx(`+ ${shortened} workers`, x, y);
}
}
addTextFx(text: string, x: number, y: number) {
const textFx = new TextFx(text, x, y, 500, 20);
this.allFx.push(textFx);
} }
onUnitEvent(unit: UnitView) { onUnitEvent(unit: UnitView) {
@@ -54,6 +106,9 @@ export class FxLayer implements Layer {
case UnitType.Shell: case UnitType.Shell:
this.onShellEvent(unit); this.onShellEvent(unit);
break; break;
case UnitType.Train:
this.onTrainEvent(unit);
break;
} }
} }
@@ -62,13 +117,48 @@ export class FxLayer implements Layer {
if (unit.reachedTarget()) { if (unit.reachedTarget()) {
const x = this.game.x(unit.lastTile()); const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile()); const y = this.game.y(unit.lastTile());
const shipExplosion = new SpriteFx( const explosion = new SpriteFx(
this.animatedSpriteLoader, this.animatedSpriteLoader,
x, x,
y, y,
FxType.MiniExplosion, FxType.MiniExplosion,
); );
this.allFx.push(shipExplosion); this.allFx.push(explosion);
}
}
}
onTrainEvent(unit: UnitView) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.MiniExplosion,
);
this.allFx.push(explosion);
}
}
}
onRailroadEvent(railroad: RailroadUpdate) {
const railTiles = railroad.railTiles;
for (const rail of railTiles) {
// No need for pseudorandom, this is fx
const chanceFx = Math.floor(Math.random() * 3);
if (chanceFx === 0) {
const x = this.game.x(rail.tile);
const y = this.game.y(rail.tile);
const animation = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.Dust,
);
this.allFx.push(animation);
} }
} }
} }
@@ -339,6 +339,7 @@ export const buildMenuElement: MenuElement = {
unitTypes.add(UnitType.Port); unitTypes.add(UnitType.Port);
unitTypes.add(UnitType.MissileSilo); unitTypes.add(UnitType.MissileSilo);
unitTypes.add(UnitType.SAMLauncher); unitTypes.add(UnitType.SAMLauncher);
unitTypes.add(UnitType.Factory);
} else { } else {
unitTypes.add(UnitType.Warship); unitTypes.add(UnitType.Warship);
unitTypes.add(UnitType.HydrogenBomb); unitTypes.add(UnitType.HydrogenBomb);
+164
View File
@@ -0,0 +1,164 @@
import { Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { PlayerID } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import {
GameUpdateType,
RailroadUpdate,
RailTile,
RailType,
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { getRailroadRects } from "./RailroadSprites";
type RailRef = {
tile: RailTile;
numOccurence: number;
lastOwnerId: PlayerID | null;
};
export class RailroadLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private theme: Theme;
// Save the number of railroads per tiles. Delete when it reaches 0
private existingRailroads = new Map<TileRef, RailRef>();
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
constructor(private game: GameView) {
this.theme = game.config().theme();
}
shouldTransform(): boolean {
return true;
}
tick() {
const updates = this.game.updatesSinceLastTick();
const railUpdates =
updates !== null ? updates[GameUpdateType.RailroadEvent] : [];
for (const rail of railUpdates) {
this.handleRailroadRendering(rail);
}
}
updateRailColors() {
const maxTilesPerFrame = this.railTileList.length / 60;
let checked = 0;
while (checked < maxTilesPerFrame && this.railTileList.length > 0) {
const tile = this.railTileList[this.nextRailIndexToCheck];
const railRef = this.existingRailroads.get(tile);
if (railRef) {
const currentOwner = this.game.owner(tile)?.id() ?? null;
if (railRef.lastOwnerId !== currentOwner) {
railRef.lastOwnerId = currentOwner;
this.paintRail(railRef.tile);
}
}
this.nextRailIndexToCheck++;
if (this.nextRailIndexToCheck >= this.railTileList.length) {
this.nextRailIndexToCheck = 0;
}
checked++;
}
}
init() {
this.redraw();
}
redraw() {
this.canvas = document.createElement("canvas");
const context = this.canvas.getContext("2d", { alpha: true });
if (context === null) throw new Error("2d context not supported");
this.context = context;
// Enable smooth scaling
this.context.imageSmoothingEnabled = true;
this.context.imageSmoothingQuality = "high";
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
}
renderLayer(context: CanvasRenderingContext2D) {
this.updateRailColors();
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
private handleRailroadRendering(railUpdate: RailroadUpdate) {
for (const railRoad of railUpdate.railTiles) {
const x = this.game.x(railRoad.tile);
const y = this.game.y(railRoad.tile);
if (railUpdate.isActive) {
this.paintRailroad(railRoad);
} else {
this.clearRailroad(railRoad);
}
}
}
private paintRailroad(railRoad: RailTile) {
const currentOwner = this.game.owner(railRoad.tile)?.id() ?? null;
const railTile = this.existingRailroads.get(railRoad.tile);
if (railTile) {
railTile.numOccurence++;
railTile.tile = railRoad;
railTile.lastOwnerId = currentOwner;
} else {
this.existingRailroads.set(railRoad.tile, {
tile: railRoad,
numOccurence: 1,
lastOwnerId: currentOwner,
});
this.railTileList.push(railRoad.tile);
this.paintRail(railRoad);
}
}
private clearRailroad(railRoad: RailTile) {
const ref = this.existingRailroads.get(railRoad.tile);
if (ref) ref.numOccurence--;
if (!ref || ref.numOccurence <= 0) {
this.existingRailroads.delete(railRoad.tile);
this.railTileList = this.railTileList.filter((t) => t !== railRoad.tile);
this.context.clearRect(
this.game.x(railRoad.tile) * 2 - 1,
this.game.y(railRoad.tile) * 2 - 1,
3,
3,
);
}
}
paintRail(railRoad: RailTile) {
const x = this.game.x(railRoad.tile);
const y = this.game.y(railRoad.tile);
const owner = this.game.owner(railRoad.tile);
const recipient = owner.isPlayer() ? (owner as PlayerView) : null;
const color = recipient
? this.theme.railroadColor(recipient)
: new Colord({ r: 255, g: 255, b: 255, a: 1 });
this.context.fillStyle = color.toRgbString();
this.paintRailRects(x, y, railRoad.railType);
}
private paintRailRects(x: number, y: number, direction: RailType) {
const railRects = getRailroadRects(direction);
for (const [dx, dy, w, h] of railRects) {
this.context.fillRect(x * 2 + dx, y * 2 + dy, w, h);
}
}
}
@@ -0,0 +1,79 @@
import { RailType } from "../../../core/game/GameUpdates";
const railTypeToFunctionMap: Record<RailType, () => number[][]> = {
[RailType.TOP_RIGHT]: topRightRailroadCornerRects,
[RailType.BOTTOM_LEFT]: bottomLeftRailroadCornerRects,
[RailType.TOP_LEFT]: topLeftRailroadCornerRects,
[RailType.BOTTOM_RIGHT]: bottomRightRailroadCornerRects,
[RailType.HORIZONTAL]: horizontalRailroadRects,
[RailType.VERTICAL]: verticalRailroadRects,
};
export function getRailroadRects(type: RailType): number[][] {
const railRects = railTypeToFunctionMap[type];
if (!railRects) {
// Should never happen
throw new Error(`Unsupported RailType: ${type}`);
}
return railRects();
}
function horizontalRailroadRects(): number[][] {
// x/y/w/h
const rects = [
[-1, -1, 2, 1],
[-1, 1, 2, 1],
[-1, 0, 1, 1],
];
return rects;
}
function verticalRailroadRects(): number[][] {
// x/y/w/h
const rects = [
[-1, -2, 1, 2],
[1, -2, 1, 2],
[0, -1, 1, 1],
];
return rects;
}
function topRightRailroadCornerRects(): number[][] {
// x/y/w/h
const rects = [
[-1, -2, 1, 2],
[0, -1, 1, 2],
[1, -2, 1, 4],
];
return rects;
}
function topLeftRailroadCornerRects(): number[][] {
// x/y/w/h
const rects = [
[-1, -2, 1, 4],
[0, -1, 1, 2],
[1, -2, 1, 2],
];
return rects;
}
function bottomRightRailroadCornerRects(): number[][] {
// x/y/w/h
const rects = [
[-1, 1, 1, 2],
[0, 0, 1, 2],
[1, -1, 1, 4],
];
return rects;
}
function bottomLeftRailroadCornerRects(): number[][] {
// x/y/w/h
const rects = [
[-1, -1, 1, 4],
[0, 0, 1, 2],
[1, 1, 1, 2],
];
return rects;
}
@@ -7,6 +7,7 @@ import { Layer } from "./Layer";
import { UnitInfoModal } from "./UnitInfoModal"; import { UnitInfoModal } from "./UnitInfoModal";
import cityIcon from "../../../../resources/images/buildings/cityAlt1.png"; import cityIcon from "../../../../resources/images/buildings/cityAlt1.png";
import factoryIcon from "../../../../resources/images/buildings/factoryAlt1.png";
import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png"; import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png";
import anchorIcon from "../../../../resources/images/buildings/port1.png"; import anchorIcon from "../../../../resources/images/buildings/port1.png";
import MissileSiloReloadingIcon from "../../../../resources/images/buildings/silo1-reloading.png"; import MissileSiloReloadingIcon from "../../../../resources/images/buildings/silo1-reloading.png";
@@ -72,6 +73,12 @@ export class StructureLayer implements Layer {
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
borderType: UnitBorderType.Round, borderType: UnitBorderType.Round,
}, },
[UnitType.Factory]: {
icon: factoryIcon,
borderRadius: 8.525,
territoryRadius: 6.525,
borderType: UnitBorderType.Round,
},
[UnitType.MissileSilo]: { [UnitType.MissileSilo]: {
icon: missileSiloIcon, icon: missileSiloIcon,
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
+64
View File
@@ -9,6 +9,8 @@ import { ProgressBar } from "../ProgressBar";
import { TransformHandler } from "../TransformHandler"; import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer"; import { Layer } from "./Layer";
import trainStationBadge from "../../../../resources/images/buildings/badges/trainStationBadge.png";
const COLOR_PROGRESSION = [ const COLOR_PROGRESSION = [
"rgb(232, 25, 25)", "rgb(232, 25, 25)",
"rgb(240, 122, 25)", "rgb(240, 122, 25)",
@@ -46,6 +48,7 @@ export class UILayer implements Layer {
// Visual settings for selection // Visual settings for selection
private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship) private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship)
private badges: Map<string, HTMLImageElement> = new Map();
constructor( constructor(
private game: GameView, private game: GameView,
@@ -53,6 +56,23 @@ export class UILayer implements Layer {
private transformHandler: TransformHandler, private transformHandler: TransformHandler,
) { ) {
this.theme = game.config().theme(); this.theme = game.config().theme();
this.loadBadges();
}
private loadBadge(badge: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = badge;
img.onload = () => {
this.badges.set(badge, img);
resolve(img);
};
img.onerror = reject;
});
}
private async loadBadges() {
await Promise.all([this.loadBadge(trainStationBadge)]);
} }
shouldTransform(): boolean { shouldTransform(): boolean {
@@ -145,12 +165,56 @@ export class UILayer implements Layer {
const endTick = this.game.config().SAMCooldown(); const endTick = this.game.config().SAMCooldown();
this.drawLoadingBar(unit, endTick); this.drawLoadingBar(unit, endTick);
} }
this.drawBadges(unit);
break;
case UnitType.City:
case UnitType.Port:
case UnitType.Factory:
this.drawBadges(unit);
break; break;
default: default:
return; return;
} }
} }
private drawBadges(unit: UnitView) {
if (unit.hasTrainStation()) {
const icon = this.badges.get(trainStationBadge);
if (icon === undefined) {
return;
}
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2) + 6;
const startY = this.game.y(unit.tile()) - Math.floor(icon.height / 2) - 6;
if (unit.isActive()) {
this.drawIcon(icon, unit, startX, startY);
} else {
this.clearIcon(icon, startX, startY);
}
}
}
private clearIcon(icon: HTMLImageElement, startX: number, startY: number) {
if (this.context !== null) {
this.context.clearRect(startX, startY, icon.width, icon.height);
}
}
private drawIcon(
icon: HTMLImageElement,
unit: UnitView,
startX: number,
startY: number,
) {
if (this.context === null || this.theme === null) {
return;
}
const color = this.theme.borderColor(unit.owner());
this.context.fillStyle = color.toRgbString();
this.context.fillRect(startX, startY, icon.width, icon.height);
this.context.drawImage(icon, startX, startY);
}
/** /**
* Handle the unit selection event * Handle the unit selection event
*/ */
+23 -1
View File
@@ -4,7 +4,10 @@ import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus"; import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game"; import { UnitType } from "../../../core/game/Game";
import { GameView, UnitView } from "../../../core/game/GameView"; import { GameView, UnitView } from "../../../core/game/GameView";
import { SendUpgradeStructureIntentEvent } from "../../Transport"; import {
SendCreateTrainStationIntentEvent,
SendUpgradeStructureIntentEvent,
} from "../../Transport";
import { Layer } from "./Layer"; import { Layer } from "./Layer";
import { StructureLayer } from "./StructureLayer"; import { StructureLayer } from "./StructureLayer";
@@ -228,6 +231,25 @@ export class UnitInfoModal extends LitElement implements Layer {
> >
${translateText("unit_info_modal.upgrade")} ${translateText("unit_info_modal.upgrade")}
</button> </button>
<button
@click=${() => {
if (this.unit) {
this.eventBus.emit(
new SendCreateTrainStationIntentEvent(this.unit.id()),
);
this.onCloseStructureModal();
if (this.structureLayer) {
this.structureLayer.unSelectStructureUnit();
}
}
}}
class="upgrade-button"
title="${translateText("unit_info_modal.create_station")}"
style="width: 100px; height: 32px;
display: ${this.unit.hasTrainStation() ? "none" : "block"};"
>
${translateText("unit_info_modal.create_station")}
</button>
<button <button
@click=${() => { @click=${() => {
this.onCloseStructureModal(); this.onCloseStructureModal();
+8 -1
View File
@@ -224,7 +224,7 @@ export class UnitLayer implements Layer {
private clearUnitsCells(unitViews: UnitView[]) { private clearUnitsCells(unitViews: UnitView[]) {
unitViews unitViews
.filter((unitView) => isSpriteReady(unitView.type())) .filter((unitView) => isSpriteReady(unitView))
.forEach((unitView) => { .forEach((unitView) => {
const sprite = getColoredSprite(unitView, this.theme); const sprite = getColoredSprite(unitView, this.theme);
const clearsize = sprite.width + 1; const clearsize = sprite.width + 1;
@@ -279,6 +279,9 @@ export class UnitLayer implements Layer {
case UnitType.TradeShip: case UnitType.TradeShip:
this.handleTradeShipEvent(unit); this.handleTradeShipEvent(unit);
break; break;
case UnitType.Train:
this.handleTrainEvent(unit);
break;
case UnitType.MIRVWarhead: case UnitType.MIRVWarhead:
this.handleMIRVWarhead(unit); this.handleMIRVWarhead(unit);
break; break;
@@ -437,6 +440,10 @@ export class UnitLayer implements Layer {
this.drawSprite(unit); this.drawSprite(unit);
} }
private handleTrainEvent(unit: UnitView) {
this.drawSprite(unit);
}
private handleBoatEvent(unit: UnitView) { private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit); const rel = this.relationship(unit);
+22
View File
@@ -115,6 +115,28 @@ export class PseudoRandom {
return arr[Math.floor(this._nextFloat() * arr.length)]; 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. * Returns true with probability 1/odds.
*/ */
+9 -1
View File
@@ -35,7 +35,8 @@ export type Intent =
| QuickChatIntent | QuickChatIntent
| MoveWarshipIntent | MoveWarshipIntent
| MarkDisconnectedIntent | MarkDisconnectedIntent
| UpgradeStructureIntent; | UpgradeStructureIntent
| CreateStationIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>; export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>; export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
@@ -59,6 +60,7 @@ export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type UpgradeStructureIntent = z.infer< export type UpgradeStructureIntent = z.infer<
typeof UpgradeStructureIntentSchema typeof UpgradeStructureIntentSchema
>; >;
export type CreateStationIntent = z.infer<typeof CreateStationIntentSchema>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>; export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>; export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type MarkDisconnectedIntent = z.infer< export type MarkDisconnectedIntent = z.infer<
@@ -277,6 +279,11 @@ export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
unitId: z.number(), unitId: z.number(),
}); });
export const CreateStationIntentSchema = BaseIntentSchema.extend({
type: z.literal("create_station"),
unitId: z.number(),
});
export const CancelAttackIntentSchema = BaseIntentSchema.extend({ export const CancelAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("cancel_attack"), type: z.literal("cancel_attack"),
attackID: z.string(), attackID: z.string(),
@@ -322,6 +329,7 @@ const IntentSchema = z.discriminatedUnion("type", [
TargetTroopRatioIntentSchema, TargetTroopRatioIntentSchema,
BuildUnitIntentSchema, BuildUnitIntentSchema,
UpgradeStructureIntentSchema, UpgradeStructureIntentSchema,
CreateStationIntentSchema,
EmbargoIntentSchema, EmbargoIntentSchema,
MoveWarshipIntentSchema, MoveWarshipIntentSchema,
QuickChatIntentSchema, QuickChatIntentSchema,
+6
View File
@@ -131,6 +131,11 @@ export interface Config {
unitInfo(type: UnitType): UnitInfo; unitInfo(type: UnitType): UnitInfo;
tradeShipGold(dist: number): Gold; tradeShipGold(dist: number): Gold;
tradeShipSpawnRate(numberOfPorts: number): number; tradeShipSpawnRate(numberOfPorts: number): number;
trainGold(): Gold;
trainSpawnRate(numberOfStations: number): number;
trainStationMinRange(): number;
trainStationMaxRange(): number;
railroadMaxSize(): number;
safeFromPiratesCooldownMax(): number; safeFromPiratesCooldownMax(): number;
defensePostRange(): number; defensePostRange(): number;
SAMCooldown(): number; SAMCooldown(): number;
@@ -157,6 +162,7 @@ export interface Theme {
teamColor(team: Team): Colord; teamColor(team: Team): Colord;
territoryColor(playerInfo: PlayerView): Colord; territoryColor(playerInfo: PlayerView): Colord;
specialBuildingColor(playerInfo: PlayerView): Colord; specialBuildingColor(playerInfo: PlayerView): Colord;
railroadColor(playerInfo: PlayerView): Colord;
borderColor(playerInfo: PlayerView): Colord; borderColor(playerInfo: PlayerView): Colord;
defendedBorderColors(playerInfo: PlayerView): { light: Colord; dark: Colord }; defendedBorderColors(playerInfo: PlayerView): { light: Colord; dark: Colord };
focusedBorderColor(): Colord; focusedBorderColor(): Colord;
+37
View File
@@ -301,6 +301,21 @@ export class DefaultConfig implements Config {
tradeShipSpawnRate(numberOfPorts: number): number { tradeShipSpawnRate(numberOfPorts: number): number {
return Math.min(50, Math.round(10 * Math.pow(numberOfPorts, 0.6))); 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 { unitInfo(type: UnitType): UnitInfo {
switch (type) { switch (type) {
@@ -450,11 +465,33 @@ export class DefaultConfig implements Config {
constructionDuration: this.instantBuild() ? 0 : 2 * 10, constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true, 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: case UnitType.Construction:
return { return {
cost: () => 0n, cost: () => 0n,
territoryBound: true, territoryBound: true,
}; };
case UnitType.Train:
return {
cost: () => 0n,
territoryBound: false,
};
default: default:
assertNever(type); 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 { borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) { if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(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 { borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) { if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(player.id())!; return this.borderColorCache.get(player.id())!;
@@ -10,6 +10,7 @@ import {
import { TileRef } from "../game/GameMap"; import { TileRef } from "../game/GameMap";
import { CityExecution } from "./CityExecution"; import { CityExecution } from "./CityExecution";
import { DefensePostExecution } from "./DefensePostExecution"; import { DefensePostExecution } from "./DefensePostExecution";
import { FactoryExecution } from "./FactoryExecution";
import { MirvExecution } from "./MIRVExecution"; import { MirvExecution } from "./MIRVExecution";
import { MissileSiloExecution } from "./MissileSiloExecution"; import { MissileSiloExecution } from "./MissileSiloExecution";
import { NukeExecution } from "./NukeExecution"; import { NukeExecution } from "./NukeExecution";
@@ -115,6 +116,9 @@ export class ConstructionExecution implements Execution {
case UnitType.City: case UnitType.City:
this.mg.addExecution(new CityExecution(player, this.tile)); this.mg.addExecution(new CityExecution(player, this.tile));
break; break;
case UnitType.Factory:
this.mg.addExecution(new FactoryExecution(player, this.tile));
break;
default: default:
throw Error(`unit type ${this.constructionType} not supported`); throw Error(`unit type ${this.constructionType} not supported`);
} }
+3
View File
@@ -22,6 +22,7 @@ import { RetreatExecution } from "./RetreatExecution";
import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution"; import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { SpawnExecution } from "./SpawnExecution"; import { SpawnExecution } from "./SpawnExecution";
import { TargetPlayerExecution } from "./TargetPlayerExecution"; import { TargetPlayerExecution } from "./TargetPlayerExecution";
import { TrainStationExecution } from "./TrainStationExecution";
import { TransportShipExecution } from "./TransportShipExecution"; import { TransportShipExecution } from "./TransportShipExecution";
import { UpgradeStructureExecution } from "./UpgradeStructureExecution"; import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
@@ -112,6 +113,8 @@ export class Executor {
); );
case "upgrade_structure": case "upgrade_structure":
return new UpgradeStructureExecution(player, intent.unitId); return new UpgradeStructureExecution(player, intent.unitId);
case "create_station":
return new TrainStationExecution(player, intent.unitId);
case "quick_chat": case "quick_chat":
return new QuickChatExecution( return new QuickChatExecution(
player, 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 { EmojiExecution } from "./EmojiExecution";
import { NukeExecution } from "./NukeExecution"; import { NukeExecution } from "./NukeExecution";
import { SpawnExecution } from "./SpawnExecution"; import { SpawnExecution } from "./SpawnExecution";
import { TrainStationExecution } from "./TrainStationExecution";
import { TransportShipExecution } from "./TransportShipExecution"; import { TransportShipExecution } from "./TransportShipExecution";
import { closestTwoTiles } from "./Util"; import { closestTwoTiles } from "./Util";
import { BotBehavior } from "./utils/BotBehavior"; import { BotBehavior } from "./utils/BotBehavior";
@@ -437,10 +438,32 @@ export class FakeHumanExecution implements Execution {
this.maybeSpawnStructure(UnitType.Port, 1) || this.maybeSpawnStructure(UnitType.Port, 1) ||
this.maybeSpawnStructure(UnitType.City, 2) || this.maybeSpawnStructure(UnitType.City, 2) ||
this.maybeSpawnWarship() || this.maybeSpawnWarship() ||
this.maybeSpawnTrainStation() ||
this.maybeSpawnStructure(UnitType.MissileSilo, 1) 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 { private maybeSpawnStructure(type: UnitType, maxNum: number): boolean {
if (this.player === null) throw new Error("not initialized"); if (this.player === null) throw new Error("not initialized");
const units = this.player.unitsIncludingConstruction(type); 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; break;
case PathFindResultType.NextTile: case PathFindResultType.NextTile:
// Update safeFromPirates status // 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.setSafeFromPirates();
} }
this.tradeShip.move(result.tile); this.tradeShip.move(result.node);
this.tilesTraveled++; this.tilesTraveled++;
break; break;
case PathFindResultType.PathNotFound: 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.lastMove = ticks;
this.mg = mg; this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, 10); this.pathFinder = PathFinder.Mini(mg, 10_000, true, 10);
if ( if (
this.attacker.units(UnitType.TransportShip).length >= this.attacker.units(UnitType.TransportShip).length >=
@@ -209,7 +209,7 @@ export class TransportShipExecution implements Execution {
.boatArriveTroops(this.attacker, this.target, this.troops); .boatArriveTroops(this.attacker, this.target, this.troops);
return; return;
case PathFindResultType.NextTile: case PathFindResultType.NextTile:
this.boat.move(result.tile); this.boat.move(result.node);
break; break;
case PathFindResultType.Pending: case PathFindResultType.Pending:
break; break;
+3 -3
View File
@@ -188,7 +188,7 @@ export class WarshipExecution implements Execution {
this.warship.move(this.warship.tile()); this.warship.move(this.warship.tile());
return; return;
case PathFindResultType.NextTile: case PathFindResultType.NextTile:
this.warship.move(result.tile); this.warship.move(result.node);
break; break;
case PathFindResultType.Pending: case PathFindResultType.Pending:
this.warship.touch(); this.warship.touch();
@@ -215,10 +215,10 @@ export class WarshipExecution implements Execution {
switch (result.type) { switch (result.type) {
case PathFindResultType.Completed: case PathFindResultType.Completed:
this.warship.setTargetTile(undefined); this.warship.setTargetTile(undefined);
this.warship.move(result.tile); this.warship.move(result.node);
break; break;
case PathFindResultType.NextTile: case PathFindResultType.NextTile:
this.warship.move(result.tile); this.warship.move(result.node);
break; break;
case PathFindResultType.Pending: case PathFindResultType.Pending:
this.warship.touch(); this.warship.touch();
+26
View File
@@ -8,6 +8,7 @@ import {
UnitUpdate, UnitUpdate,
} from "./GameUpdates"; } from "./GameUpdates";
import { PlayerView } from "./GameView"; import { PlayerView } from "./GameView";
import { RailNetwork } from "./RailNetwork";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
export type PlayerID = string; export type PlayerID = string;
@@ -149,6 +150,13 @@ export enum UnitType {
MIRV = "MIRV", MIRV = "MIRV",
MIRVWarhead = "MIRV Warhead", MIRVWarhead = "MIRV Warhead",
Construction = "Construction", Construction = "Construction",
Train = "Train",
Factory = "Factory",
}
export enum TrainType {
Engine = "Engine",
Carriage = "Carriage",
} }
const _structureTypes: ReadonlySet<UnitType> = new Set([ const _structureTypes: ReadonlySet<UnitType> = new Set([
@@ -197,6 +205,14 @@ export interface UnitParamsMap {
lastSetSafeFromPirates?: number; lastSetSafeFromPirates?: number;
}; };
[UnitType.Train]: {
trainType: TrainType;
targetUnit?: Unit;
loaded?: boolean;
};
[UnitType.Factory]: {};
[UnitType.MissileSilo]: { [UnitType.MissileSilo]: {
cooldownDuration?: number; cooldownDuration?: number;
}; };
@@ -373,6 +389,13 @@ export interface Unit {
touch(): void; touch(): void;
hash(): number; hash(): number;
toUpdate(): UnitUpdate; toUpdate(): UnitUpdate;
hasTrainStation(): boolean;
setTrainStation(trainStation: boolean): void;
// Train
trainType(): TrainType | undefined;
isLoaded(): boolean | undefined;
setLoaded(loaded: boolean): void;
// Targeting // Targeting
setTargetTile(cell: TileRef | undefined): void; setTargetTile(cell: TileRef | undefined): void;
@@ -634,6 +657,9 @@ export interface Game extends GameMap {
numTilesWithFallout(): number; numTilesWithFallout(): number;
// Optional as it's not initialized before the end of spawn phase // Optional as it's not initialized before the end of spawn phase
stats(): Stats; stats(): Stats;
addUpdate(update: GameUpdate): void;
railNetwork(): RailNetwork;
} }
export interface PlayerActions { export interface PlayerActions {
+9
View File
@@ -30,6 +30,8 @@ import {
import { GameMap, TileRef, TileUpdate } from "./GameMap"; import { GameMap, TileRef, TileUpdate } from "./GameMap";
import { GameUpdate, GameUpdateType } from "./GameUpdates"; import { GameUpdate, GameUpdateType } from "./GameUpdates";
import { PlayerImpl } from "./PlayerImpl"; import { PlayerImpl } from "./PlayerImpl";
import { RailNetwork } from "./RailNetwork";
import { createRailNetwork } from "./RailNetworkImpl";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { StatsImpl } from "./StatsImpl"; import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment"; import { assignTeams } from "./TeamAssignment";
@@ -73,6 +75,7 @@ export class GameImpl implements Game {
private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue]; private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue];
private botTeam: Team = ColoredTeams.Bot; private botTeam: Team = ColoredTeams.Bot;
private _railNetwork: RailNetwork = createRailNetwork(this);
constructor( constructor(
private _humans: PlayerInfo[], private _humans: PlayerInfo[],
@@ -672,6 +675,9 @@ export class GameImpl implements Game {
} }
removeUnit(u: Unit) { removeUnit(u: Unit) {
this.unitGrid.removeUnit(u); this.unitGrid.removeUnit(u);
if (u.hasTrainStation()) {
this._railNetwork.removeStation(u);
}
} }
nearbyUnits( nearbyUnits(
@@ -787,6 +793,9 @@ export class GameImpl implements Game {
stats(): Stats { stats(): Stats {
return this._stats; return this._stats;
} }
railNetwork(): RailNetwork {
return this._railNetwork;
}
} }
// Or a more dynamic approach that will catch new enum values: // Or a more dynamic approach that will catch new enum values:
+36 -1
View File
@@ -9,6 +9,7 @@ import {
PlayerType, PlayerType,
Team, Team,
Tick, Tick,
TrainType,
UnitType, UnitType,
} from "./Game"; } from "./Game";
import { TileRef, TileUpdate } from "./GameMap"; import { TileRef, TileUpdate } from "./GameMap";
@@ -40,6 +41,8 @@ export enum GameUpdateType {
Win, Win,
Hash, Hash,
UnitIncoming, UnitIncoming,
BonusEvent,
RailroadEvent,
} }
export type GameUpdate = export type GameUpdate =
@@ -56,7 +59,36 @@ export type GameUpdate =
| EmojiUpdate | EmojiUpdate
| WinUpdate | WinUpdate
| HashUpdate | 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 { export interface TileUpdateWrapper {
type: GameUpdateType.Tile; type: GameUpdateType.Tile;
@@ -84,6 +116,9 @@ export interface UnitUpdate {
missileTimerQueue: number[]; missileTimerQueue: number[];
readyMissileCount: number; readyMissileCount: number;
level: number; level: number;
hasTrainStation: boolean;
trainType?: TrainType; // Only for trains
loaded?: boolean; // Only for trains
} }
export interface AttackUpdate { export interface AttackUpdate {
+10
View File
@@ -19,6 +19,7 @@ import {
TerrainType, TerrainType,
TerraNullius, TerraNullius,
Tick, Tick,
TrainType,
UnitInfo, UnitInfo,
UnitType, UnitType,
} from "./Game"; } from "./Game";
@@ -124,6 +125,15 @@ export class UnitView {
level(): number { level(): number {
return this.data.level; 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 { export class PlayerView {
+7
View File
@@ -808,10 +808,13 @@ export class PlayerImpl implements Player {
return canBuildTransportShip(this.mg, this, targetTile); return canBuildTransportShip(this.mg, this, targetTile);
case UnitType.TradeShip: case UnitType.TradeShip:
return this.tradeShipSpawn(targetTile); return this.tradeShipSpawn(targetTile);
case UnitType.Train:
return this.landBasedUnitSpawn(targetTile);
case UnitType.MissileSilo: case UnitType.MissileSilo:
case UnitType.DefensePost: case UnitType.DefensePost:
case UnitType.SAMLauncher: case UnitType.SAMLauncher:
case UnitType.City: case UnitType.City:
case UnitType.Factory:
case UnitType.Construction: case UnitType.Construction:
return this.landBasedStructureSpawn(targetTile, validTiles); return this.landBasedStructureSpawn(targetTile, validTiles);
default: default:
@@ -876,6 +879,10 @@ export class PlayerImpl implements Player {
return spawns[0].tile(); return spawns[0].tile();
} }
landBasedUnitSpawn(tile: TileRef): TileRef | false {
return this.mg.isLand(tile) ? tile : false;
}
landBasedStructureSpawn( landBasedStructureSpawn(
tile: TileRef, tile: TileRef,
validTiles: TileRef[] | null = null, 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, MessageType,
Player, Player,
Tick, Tick,
TrainType,
Unit, Unit,
UnitInfo, UnitInfo,
UnitType, UnitType,
@@ -28,9 +29,12 @@ export class UnitImpl implements Unit {
private _troops: number; private _troops: number;
private _missileTimerQueue: number[] = []; private _missileTimerQueue: number[] = [];
private _readyMissileCount: number = 1; private _readyMissileCount: number = 1;
private _hasTrainStation: boolean = false;
private _patrolTile: TileRef | undefined; private _patrolTile: TileRef | undefined;
private _level: number = 1; private _level: number = 1;
private _targetable: boolean = true; private _targetable: boolean = true;
private _loaded: boolean | undefined;
private _trainType: TrainType | undefined;
constructor( constructor(
private _type: UnitType, private _type: UnitType,
@@ -53,6 +57,9 @@ export class UnitImpl implements Unit {
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined; "patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
this._targetUnit = this._targetUnit =
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined; "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) { switch (this._type) {
case UnitType.Warship: case UnitType.Warship:
@@ -123,6 +130,9 @@ export class UnitImpl implements Unit {
missileTimerQueue: this._missileTimerQueue, missileTimerQueue: this._missileTimerQueue,
readyMissileCount: this._readyMissileCount, readyMissileCount: this._readyMissileCount,
level: this.level(), level: this.level(),
hasTrainStation: this._hasTrainStation,
trainType: this._trainType,
loaded: this._loaded,
}; };
} }
@@ -351,6 +361,15 @@ export class UnitImpl implements Unit {
return this._level; return this._level;
} }
setTrainStation(trainStation: boolean): void {
this._hasTrainStation = trainStation;
this.mg.addUpdate(this.toUpdate());
}
hasTrainStation(): boolean {
return this._hasTrainStation;
}
increaseLevel(): void { increaseLevel(): void {
this._level++; this._level++;
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) { if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
@@ -358,4 +377,19 @@ export class UnitImpl implements Unit {
} }
this.mg.addUpdate(this.toUpdate()); 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<NodeType> {
export interface AStar {
compute(): PathFindResultType; compute(): PathFindResultType;
reconstructPath(): TileRef[]; reconstructPath(): NodeType[];
} }
export enum PathFindResultType { export enum PathFindResultType {
@@ -11,17 +9,17 @@ export enum PathFindResultType {
Completed, Completed,
PathNotFound, PathNotFound,
} }
export type TileResult = export type AStarResult<NodeType> =
| { | {
type: PathFindResultType.NextTile; type: PathFindResultType.NextTile;
tile: TileRef; node: NodeType;
} }
| { | {
type: PathFindResultType.Pending; type: PathFindResultType.Pending;
} }
| { | {
type: PathFindResultType.Completed; type: PathFindResultType.Completed;
tile: TileRef; node: NodeType;
} }
| { | {
type: PathFindResultType.PathNotFound; type: PathFindResultType.PathNotFound;
+30 -4
View File
@@ -1,10 +1,33 @@
import { Cell } from "../game/Game"; import { Cell } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap"; import { GameMap, TileRef } from "../game/GameMap";
import { AStar, PathFindResultType } from "./AStar"; import { AStar, PathFindResultType } from "./AStar";
import { SerialAStar } from "./SerialAStar"; import { GraphAdapter, SerialAStar } from "./SerialAStar";
export class MiniAStar implements AStar { export class GameMapAdapter implements GraphAdapter<TileRef> {
private aStar: AStar; 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( constructor(
private gameMap: GameMap, private gameMap: GameMap,
@@ -13,6 +36,8 @@ export class MiniAStar implements AStar {
private dst: TileRef, private dst: TileRef,
iterations: number, iterations: number,
maxTries: number, maxTries: number,
waterPath: boolean = true,
directionChangePenalty: number = 0,
) { ) {
const srcArray: TileRef[] = Array.isArray(src) ? src : [src]; const srcArray: TileRef[] = Array.isArray(src) ? src : [src];
const miniSrc = srcArray.map((srcPoint) => const miniSrc = srcArray.map((srcPoint) =>
@@ -32,7 +57,8 @@ export class MiniAStar implements AStar {
miniDst, miniDst,
iterations, iterations,
maxTries, 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 { GameMap, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom"; import { PseudoRandom } from "../PseudoRandom";
import { DistanceBasedBezierCurve } from "../utilities/Line"; import { DistanceBasedBezierCurve } from "../utilities/Line";
import { AStar, PathFindResultType, TileResult } from "./AStar"; import { AStar, AStarResult, PathFindResultType } from "./AStar";
import { MiniAStar } from "./MiniAStar"; import { MiniAStar } from "./MiniAStar";
const parabolaMinHeight = 50; const parabolaMinHeight = 50;
@@ -89,15 +89,20 @@ export class PathFinder {
private curr: TileRef | null = null; private curr: TileRef | null = null;
private dst: TileRef | null = null; private dst: TileRef | null = null;
private path: TileRef[] | null = null; private path: TileRef[] | null = null;
private aStar: AStar; private aStar: AStar<TileRef>;
private computeFinished = true; private computeFinished = true;
private constructor( private constructor(
private game: Game, 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 PathFinder(game, (curr: TileRef, dst: TileRef) => {
return new MiniAStar( return new MiniAStar(
game.map(), game.map(),
@@ -106,6 +111,7 @@ export class PathFinder {
dst, dst,
iterations, iterations,
maxTries, maxTries,
waterPath,
); );
}); });
} }
@@ -114,7 +120,7 @@ export class PathFinder {
curr: TileRef | null, curr: TileRef | null,
dst: TileRef | null, dst: TileRef | null,
dist: number = 1, dist: number = 1,
): TileResult { ): AStarResult<TileRef> {
if (curr === null) { if (curr === null) {
console.error("curr is null"); console.error("curr is null");
return { type: PathFindResultType.PathNotFound }; return { type: PathFindResultType.PathNotFound };
@@ -125,7 +131,7 @@ export class PathFinder {
} }
if (this.game.manhattanDist(curr, dst) < dist) { if (this.game.manhattanDist(curr, dst) < dist) {
return { type: PathFindResultType.Completed, tile: curr }; return { type: PathFindResultType.Completed, node: curr };
} }
if (this.computeFinished) { if (this.computeFinished) {
@@ -141,7 +147,7 @@ export class PathFinder {
if (tile === undefined) { if (tile === undefined) {
throw new Error("missing tile"); 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 { PriorityQueue } from "@datastructures-js/priority-queue";
import { GameMap, TileRef } from "../game/GameMap";
import { AStar, PathFindResultType } from "./AStar"; 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<{ private fwdOpenSet: PriorityQueue<{
tile: TileRef; tile: NodeType;
fScore: number; fScore: number;
}>; }>;
private bwdOpenSet: PriorityQueue<{ private bwdOpenSet: PriorityQueue<{
tile: TileRef; tile: NodeType;
fScore: number; fScore: number;
}>; }>;
private fwdCameFrom: Map<TileRef, TileRef>; private fwdCameFrom = new Map<NodeType, NodeType>();
private bwdCameFrom: Map<TileRef, TileRef>; private bwdCameFrom = new Map<NodeType, NodeType>();
private fwdGScore: Map<TileRef, number>; private fwdGScore = new Map<NodeType, number>();
private bwdGScore: Map<TileRef, number>; private bwdGScore = new Map<NodeType, number>();
private meetingPoint: TileRef | null;
public completed: boolean; private meetingPoint: NodeType | null = null;
private sources: TileRef[]; public completed = false;
private closestSource: TileRef; private sources: NodeType[];
private closestSource: NodeType;
constructor( constructor(
src: TileRef | TileRef[], src: NodeType | NodeType[],
private dst: TileRef, private dst: NodeType,
private iterations: number, private iterations: number,
private maxTries: number, private maxTries: number,
private gameMap: GameMap, private graph: GraphAdapter<NodeType>,
private directionChangePenalty: number = 0,
) { ) {
this.fwdOpenSet = new PriorityQueue<{ this.fwdOpenSet = new PriorityQueue((a, b) => a.fScore - b.fScore);
tile: TileRef; this.bwdOpenSet = new PriorityQueue((a, b) => a.fScore - b.fScore);
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.sources = Array.isArray(src) ? src : [src]; this.sources = Array.isArray(src) ? src : [src];
this.closestSource = this.findClosestSource(dst); 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) => return this.sources.reduce((closest, source) =>
this.heuristic(tile, source) < this.heuristic(tile, closest) this.heuristic(tile, source) < this.heuristic(tile, closest)
? source ? source
@@ -98,8 +93,7 @@ export class SerialAStar implements AStar {
this.completed = true; this.completed = true;
return PathFindResultType.Completed; return PathFindResultType.Completed;
} }
this.expandNode(fwdCurrent, true);
this.expandTileRef(fwdCurrent, true);
// Process backward search // Process backward search
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile; const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
@@ -110,8 +104,7 @@ export class SerialAStar implements AStar {
this.completed = true; this.completed = true;
return PathFindResultType.Completed; return PathFindResultType.Completed;
} }
this.expandNode(bwdCurrent, false);
this.expandTileRef(bwdCurrent, false);
} }
return this.completed return this.completed
@@ -119,11 +112,11 @@ export class SerialAStar implements AStar {
: PathFindResultType.PathNotFound; : PathFindResultType.PathNotFound;
} }
private expandTileRef(current: TileRef, isForward: boolean) { private expandNode(current: NodeType, isForward: boolean) {
for (const neighbor of this.gameMap.neighbors(current)) { for (const neighbor of this.graph.neighbors(current)) {
if ( if (
neighbor !== (isForward ? this.dst : this.closestSource) && neighbor !== (isForward ? this.dst : this.closestSource) &&
!this.gameMap.isWater(neighbor) !this.graph.isTraversable(current, neighbor)
) )
continue; continue;
@@ -131,38 +124,51 @@ export class SerialAStar implements AStar {
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet; const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom; const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
const tentativeGScore = const tentativeGScore = gScore.get(current)! + this.graph.cost(neighbor);
gScore.get(current)! + this.gameMap.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); cameFrom.set(neighbor, current);
gScore.set(neighbor, tentativeGScore); gScore.set(neighbor, totalG);
const fScore = const fScore =
tentativeGScore + totalG +
this.heuristic(neighbor, isForward ? this.dst : this.closestSource); this.heuristic(neighbor, isForward ? this.dst : this.closestSource);
openSet.enqueue({ tile: neighbor, fScore: fScore }); openSet.enqueue({ tile: neighbor, fScore: fScore });
} }
} }
} }
private heuristic(a: TileRef, b: TileRef): number { private heuristic(a: NodeType, b: NodeType): number {
try { const posA = this.graph.position(a);
return ( const posB = this.graph.position(b);
1.1 * return 1.1 * (Math.abs(posA.x - posB.x) + Math.abs(posA.y - posB.y));
(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;
}
} }
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 []; if (!this.meetingPoint) return [];
// Reconstruct path from start to meeting point // Reconstruct path from start to meeting point
const fwdPath: TileRef[] = [this.meetingPoint]; const fwdPath: NodeType[] = [this.meetingPoint];
let current = this.meetingPoint; let current = this.meetingPoint;
while (this.fwdCameFrom.has(current)) { while (this.fwdCameFrom.has(current)) {
+65
View File
@@ -0,0 +1,65 @@
import { Cluster, TrainStation } from "../../../src/core/game/TrainStation";
const createMockStation = (id: string): jest.Mocked<TrainStation> => {
return {
id,
setCluster: jest.fn(),
getCluster: jest.fn(() => null),
} as any;
};
describe("Cluster tests", () => {
let cluster: Cluster;
let stationA: jest.Mocked<TrainStation>;
let stationB: jest.Mocked<TrainStation>;
let stationC: jest.Mocked<TrainStation>;
beforeEach(() => {
cluster = new Cluster();
stationA = createMockStation("A");
stationB = createMockStation("B");
stationC = createMockStation("C");
});
test("addStation adds a station and sets cluster", () => {
cluster.addStation(stationA);
expect(cluster.has(stationA)).toBe(true);
expect(stationA.setCluster).toHaveBeenCalledWith(cluster);
});
test("removeStation removes station from cluster", () => {
cluster.addStation(stationA);
cluster.removeStation(stationA);
expect(cluster.has(stationA)).toBe(false);
});
test("addStations adds multiple stations and sets cluster", () => {
const set = new Set([stationA, stationB]);
cluster.addStations(set);
expect(cluster.has(stationA)).toBe(true);
expect(cluster.has(stationB)).toBe(true);
expect(stationA.setCluster).toHaveBeenCalledWith(cluster);
expect(stationB.setCluster).toHaveBeenCalledWith(cluster);
});
test("merge combines stations from another cluster", () => {
const otherCluster = new Cluster();
otherCluster.addStation(stationB);
otherCluster.addStation(stationC);
cluster.addStation(stationA);
cluster.merge(otherCluster);
expect(cluster.has(stationA)).toBe(true);
expect(cluster.has(stationB)).toBe(true);
expect(cluster.has(stationC)).toBe(true);
});
test("has returns false for non-member stations", () => {
expect(cluster.has(stationA)).toBe(false);
});
});
+163
View File
@@ -0,0 +1,163 @@
import { Unit } from "../../../src/core/game/Game";
import {
RailNetworkImpl,
StationManagerImpl,
} from "../../../src/core/game/RailNetworkImpl";
import { Railroad } from "../../../src/core/game/Railroad";
import { Cluster } from "../../../src/core/game/TrainStation";
// Mock types
const createMockStation = (unitId: number): any => {
const cluster = new Cluster();
const railroads = new Set<Railroad>();
return {
unit: { id: unitId },
tile: jest.fn(),
neighbors: jest.fn(() => []),
getCluster: jest.fn(() => cluster),
setCluster: jest.fn(),
addRailroad: jest.fn(),
getRailroads: jest.fn(() => railroads),
clearRailroads: jest.fn(),
};
};
describe("StationManagerImpl", () => {
let manager: StationManagerImpl;
beforeEach(() => {
manager = new StationManagerImpl();
});
test("adds and retrieves station", () => {
const station = createMockStation(1);
manager.addStation(station);
expect(manager.findStation(station.unit)).toBe(station);
});
test("removes station", () => {
const station = createMockStation(1);
manager.addStation(station);
manager.removeStation(station);
expect(manager.findStation(station.unit)).toBe(null);
});
});
describe("RailNetworkImpl", () => {
let network: RailNetworkImpl;
let stationManager: any;
let pathService: any;
let game: any;
beforeEach(() => {
stationManager = {
addStation: jest.fn(),
removeStation: jest.fn(),
findStation: jest.fn(),
getAll: jest.fn(() => new Set()),
};
pathService = {
findTilePath: jest.fn(() => [0]),
findStationsPath: jest.fn(() => [0]),
};
game = {
nearbyUnits: jest.fn(() => []),
addExecution: jest.fn(),
config: () => ({
trainStationMaxRange: () => 80,
trainStationMinRange: () => 10,
railroadMaxSize: () => 100,
}),
};
network = new RailNetworkImpl(game, stationManager, pathService);
});
test("does not connect if path is empty or too long", () => {
const stationA = createMockStation(1);
const stationB = createMockStation(2);
game.nearbyUnits.mockReturnValue([stationB]);
pathService.findTilePath.mockReturnValue([]);
network.connectStation(stationA);
const cluster = stationB.getCluster();
cluster.addStation = jest.fn();
expect(cluster.addStation).not.toHaveBeenCalled();
pathService.findTilePath.mockReturnValue(new Array(200));
network.connectStation(stationA);
expect(cluster.addStation).not.toHaveBeenCalled();
});
test("removeStation removes all neighbor links", () => {
const neighbor = { removeNeighboringRails: jest.fn() };
const station = createMockStation(1);
station.neighbors = jest.fn(() => [neighbor]);
stationManager.findStation.mockReturnValue(station);
network.removeStation(station);
expect(station.clearRailroads).toHaveBeenCalled();
});
test("connectStation calls addStation and connects to nearby", () => {
const station = createMockStation(1);
network.connectStation(station);
expect(stationManager.addStation).toHaveBeenCalledWith(station);
});
test("removeStation does nothing if station not found", () => {
stationManager.findStation.mockReturnValue(null);
network.removeStation({ id: 1 } as unknown as Unit);
expect(stationManager.removeStation).not.toHaveBeenCalled();
});
test("removeStation disconnects and removes from cluster if one neighbor", () => {
const cluster = new Cluster();
const neighbor = createMockStation(1);
const station = createMockStation(2);
station.getCluster = jest.fn(() => cluster);
station.neighbors = jest.fn(() => [neighbor]);
cluster.removeStation = jest.fn();
stationManager.findStation.mockReturnValue(station);
network.removeStation(station.unit);
expect(cluster.removeStation).toHaveBeenCalledWith(station);
expect(stationManager.removeStation).toHaveBeenCalledWith(station);
});
test("findStationsPath", () => {
const stationA = createMockStation(1);
const stationB = createMockStation(2);
const result = network.findStationsPath(stationA, stationB);
expect(result).toEqual([0]);
});
test("connectToNearbyStations creates new cluster when no neighbors", () => {
const station = createMockStation(1);
game.nearbyUnits.mockReturnValue([]);
network.connectStation(station);
expect(stationManager.addStation).toHaveBeenCalledWith(station);
expect(station.setCluster).toHaveBeenCalled();
});
test("connectToNearbyStations connects and merges clusters", () => {
const station = createMockStation(1);
const neighborStation = createMockStation(2);
const cluster = new Cluster();
cluster.addStation(neighborStation);
neighborStation.getCluster = jest.fn(() => cluster);
cluster.has = jest.fn(() => false);
const neighborUnit = { unit: neighborStation.unit, distSquared: 20 };
game.nearbyUnits.mockReturnValue([neighborUnit]);
stationManager.findStation.mockReturnValue(neighborStation);
network.connectStation(station);
// Both station should have their cluster reset to the merged one
expect(station.setCluster).toHaveBeenCalled();
expect(neighborStation.setCluster).toHaveBeenCalled();
});
});
+132
View File
@@ -0,0 +1,132 @@
import { TrainExecution } from "../../../src/core/execution/TrainExecution";
import { Game, Unit, UnitType } from "../../../src/core/game/Game";
import { Cluster, TrainStation } from "../../../src/core/game/TrainStation";
jest.mock("../../../src/core/game/Game");
jest.mock("../../../src/core/execution/TrainExecution");
jest.mock("../../../src/core/PseudoRandom");
describe("TrainStation", () => {
let game: jest.Mocked<Game>;
let unit: jest.Mocked<Unit>;
let trainExecution: jest.Mocked<TrainExecution>;
beforeEach(() => {
game = {
ticks: jest.fn().mockReturnValue(123),
config: jest.fn().mockReturnValue({
trainGold: () => 10,
}),
addUpdate: jest.fn(),
addExecution: jest.fn(),
} as any;
unit = {
owner: jest.fn().mockReturnValue({
addGold: jest.fn(),
id: 1,
canTrade: jest.fn().mockReturnValue(true),
tradingPorts: jest.fn().mockReturnValue([{ name: "Port1" }]),
}),
tile: jest.fn().mockReturnValue({ x: 0, y: 0 }),
type: jest.fn(),
isActive: jest.fn().mockReturnValue(true),
} as any;
trainExecution = {
loadCargo: jest.fn(),
} as any;
});
it("handles City stop", () => {
unit.type.mockReturnValue(UnitType.City);
const station = new TrainStation(game, unit);
station.onTrainStop(trainExecution);
expect(unit.owner().addGold).toHaveBeenCalledWith(10);
expect(game.addUpdate).toHaveBeenCalledWith(
expect.objectContaining({
type: expect.any(Number),
gold: 10,
}),
);
});
it("handles Port stop", () => {
unit.type.mockReturnValue(UnitType.Port);
const station = new TrainStation(game, unit);
station.onTrainStop(trainExecution);
expect(game.addExecution).toHaveBeenCalled();
});
it("handles Factory stop", () => {
unit.type.mockReturnValue(UnitType.Factory);
const station = new TrainStation(game, unit);
station.onTrainStop(trainExecution);
expect(trainExecution.loadCargo).toHaveBeenCalled();
});
it("checks trade availability (same owner)", () => {
const otherUnit = {
owner: jest.fn().mockReturnValue(unit.owner()),
} as any;
const station = new TrainStation(game, unit);
const otherStation = new TrainStation(game, otherUnit);
expect(station.tradeAvailable(otherStation.unit.owner())).toBe(true);
});
it("adds and retrieves neighbors", () => {
const stationA = new TrainStation(game, unit);
const stationB = new TrainStation(game, unit);
const railRoad = { from: stationA, to: stationB, tiles: [] } as any;
stationA.addRailroad(railRoad);
const neighbors = stationA.neighbors();
expect(neighbors).toContain(stationB);
});
it("removes neighboring rail", () => {
const stationA = new TrainStation(game, unit);
const stationB = new TrainStation(game, unit);
const railRoad = {
from: stationA,
to: stationB,
tiles: [{ x: 1, y: 1 }],
} as any;
stationA.addRailroad(railRoad);
expect(stationA.getRailroads().size).toBe(1);
stationA.removeNeighboringRails(stationB);
expect(game.addUpdate).toHaveBeenCalledWith(
expect.objectContaining({
isActive: false,
}),
);
expect(stationA.getRailroads().size).toBe(0);
});
it("assigns and retrieves cluster", () => {
const cluster: Cluster = {} as Cluster;
const station = new TrainStation(game, unit);
station.setCluster(cluster);
expect(station.getCluster()).toBe(cluster);
});
it("returns tile and active status", () => {
const station = new TrainStation(game, unit);
expect(station.tile()).toEqual({ x: 0, y: 0 });
expect(station.isActive()).toBe(true);
});
});
+34
View File
@@ -0,0 +1,34 @@
declare module "*.png" {
const content: string;
export default content;
}
declare module "*.jpg" {
const value: string;
export default value;
}
declare module "*.webp" {
const value: string;
export default value;
}
declare module "*.jpeg" {
const value: string;
export default value;
}
declare module "*.svg" {
const value: string;
export default value;
}
declare module "*.bin" {
const value: string;
export default value;
}
declare module "*.txt" {
const value: string;
export default value;
}
declare module "*.html" {
const content: string;
export default content;
}