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>
@@ -0,0 +1 @@
|
||||
module.exports = "test-file-stub";
|
||||
@@ -5,6 +5,9 @@ export default {
|
||||
extensionsToTreatAsEsm: [".ts"],
|
||||
moduleNameMapper: {
|
||||
"^(\\.{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: {
|
||||
"^.+\\.tsx?$": [
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 95 B |
|
After Width: | Height: | Size: 176 B |
@@ -416,6 +416,7 @@
|
||||
"cooldown": "Cooldown",
|
||||
"type": "Type",
|
||||
"upgrade": "Upgrade",
|
||||
"create_station": "Create Station",
|
||||
"level": "Level"
|
||||
},
|
||||
"relation": {
|
||||
|
||||
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 107 B |
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 98 B |
@@ -54,6 +54,10 @@ export class SendUpgradeStructureIntentEvent implements GameEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SendCreateTrainStationIntentEvent implements GameEvent {
|
||||
constructor(public readonly unitId: number) {}
|
||||
}
|
||||
|
||||
export class SendAllianceReplyIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
// The original alliance requestor
|
||||
@@ -200,6 +204,9 @@ export class Transport {
|
||||
this.eventBus.on(SendUpgradeStructureIntentEvent, (e) =>
|
||||
this.onSendUpgradeStructureIntent(e),
|
||||
);
|
||||
this.eventBus.on(SendCreateTrainStationIntentEvent, (e) =>
|
||||
this.onSendCreateTrainStationIntent(e),
|
||||
);
|
||||
this.eventBus.on(SendBoatAttackIntentEvent, (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) {
|
||||
this.sendIntent({
|
||||
type: "targetPlayer",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
|
||||
import dust from "../../../resources/sprites/dust.png";
|
||||
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
|
||||
import miniFire from "../../../resources/sprites/minifire.png";
|
||||
import nuke from "../../../resources/sprites/nukeExplosion.png";
|
||||
@@ -69,6 +70,15 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originX: 6,
|
||||
originY: 6,
|
||||
},
|
||||
[FxType.Dust]: {
|
||||
url: dust,
|
||||
frameWidth: 9,
|
||||
frameCount: 3,
|
||||
frameDuration: 100,
|
||||
looping: false,
|
||||
originX: 4,
|
||||
originY: 5,
|
||||
},
|
||||
[FxType.UnitExplosion]: {
|
||||
url: unitExplosion,
|
||||
frameWidth: 19,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { NameLayer } from "./layers/NameLayer";
|
||||
import { OptionsMenu } from "./layers/OptionsMenu";
|
||||
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
|
||||
import { PlayerPanel } from "./layers/PlayerPanel";
|
||||
import { RailroadLayer } from "./layers/RailroadLayer";
|
||||
import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SpawnAd } from "./layers/SpawnAd";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
@@ -215,6 +216,7 @@ export function createRenderer(
|
||||
const layers: Layer[] = [
|
||||
new TerrainLayer(game, transformHandler),
|
||||
new TerritoryLayer(game, eventBus, transformHandler),
|
||||
new RailroadLayer(game),
|
||||
structureLayer,
|
||||
new UnitLayer(game, eventBus, transformHandler),
|
||||
new FxLayer(game),
|
||||
|
||||
@@ -4,13 +4,25 @@ import hydrogenBombSprite from "../../../resources/sprites/hydrogenbomb.png";
|
||||
import mirvSprite from "../../../resources/sprites/mirv2.png";
|
||||
import samMissileSprite from "../../../resources/sprites/samMissile.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 warshipSprite from "../../../resources/sprites/warship.png";
|
||||
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";
|
||||
|
||||
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.Warship]: warshipSprite,
|
||||
[UnitType.SAMMissile]: samMissileSprite,
|
||||
@@ -18,9 +30,12 @@ const SPRITE_CONFIG: Partial<Record<UnitType, string>> = {
|
||||
[UnitType.HydrogenBomb]: hydrogenBombSprite,
|
||||
[UnitType.TradeShip]: tradeShipSprite,
|
||||
[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
|
||||
export const loadAllSprites = async (): Promise<void> => {
|
||||
@@ -30,7 +45,7 @@ export const loadAllSprites = async (): Promise<void> => {
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async ([unitType, url]) => {
|
||||
const typedUnitType = unitType as UnitType;
|
||||
const typedUnitType = unitType as UnitType | TrainTypeSprite;
|
||||
|
||||
if (!url || url === "") {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -118,6 +154,17 @@ export const colorizeCanvas = (
|
||||
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 = (
|
||||
unit: UnitView,
|
||||
theme: Theme,
|
||||
@@ -129,13 +176,12 @@ export const getColoredSprite = (
|
||||
customTerritoryColor ?? theme.territoryColor(owner);
|
||||
const borderColor: Colord = customBorderColor ?? theme.borderColor(owner);
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const key = `${unit.type()}-${owner.id()}-${territoryColor.toRgbString()}-${borderColor.toRgbString()}`;
|
||||
|
||||
const key = computeSpriteKey(unit, territoryColor, borderColor);
|
||||
if (coloredSpriteCache.has(key)) {
|
||||
return coloredSpriteCache.get(key)!;
|
||||
}
|
||||
|
||||
const sprite = getSpriteForUnit(unit.type());
|
||||
const sprite = getSpriteForUnit(unit);
|
||||
if (sprite === null) {
|
||||
throw new Error(`Failed to load sprite for ${unit.type()}`);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ export enum FxType {
|
||||
SinkingShip = "SinkingShip",
|
||||
Nuke = "Nuke",
|
||||
SAMExplosion = "SAMExplosion",
|
||||
UnderConstruction = "UnderConstruction",
|
||||
Dust = "Dust",
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg";
|
||||
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
|
||||
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
|
||||
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
|
||||
import mirvIcon from "../../../../resources/images/MIRVIcon.svg";
|
||||
import missileSiloIcon from "../../../../resources/images/MissileSiloIconWhite.svg";
|
||||
@@ -93,6 +94,13 @@ export const buildTable: BuildItemDisplay[][] = [
|
||||
key: "unit_type.city",
|
||||
countable: true,
|
||||
},
|
||||
{
|
||||
unitType: UnitType.Factory,
|
||||
icon: factoryIcon,
|
||||
description: "build_menu.desc.factory",
|
||||
key: "unit_type.factory",
|
||||
countable: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
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 { AttackRatioEvent } from "../../InputHandler";
|
||||
import { SendSetTargetTroopRatioEvent } from "../../Transport";
|
||||
@@ -52,6 +53,9 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
@state()
|
||||
private _goldPerSecond: Gold;
|
||||
|
||||
@state()
|
||||
private _factories: number;
|
||||
|
||||
private _lastPopulationIncreaseRate: number;
|
||||
|
||||
private _popRateIsIncreasing: boolean = true;
|
||||
@@ -129,6 +133,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
|
||||
this.currentTroopRatio = player.troops() / player.population();
|
||||
this.requestUpdate();
|
||||
this._factories = player.units(UnitType.Factory).length;
|
||||
}
|
||||
|
||||
onAttackRatioChange(newRatio: number) {
|
||||
@@ -231,7 +236,13 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
>
|
||||
<span translate="no"
|
||||
>${renderNumber(this._gold)}
|
||||
(+${renderNumber(this._goldPerSecond)})</span
|
||||
(+${renderNumber(this._goldPerSecond)}
|
||||
${renderNumber(this._factories)}
|
||||
<img
|
||||
src="${factoryIcon}"
|
||||
style="display: inline"
|
||||
width="15"
|
||||
/>)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
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 { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "../fx/Fx";
|
||||
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
|
||||
import { SpriteFx } from "../fx/SpriteFx";
|
||||
import { shortenNumber, TextFx } from "../fx/TextFx";
|
||||
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class FxLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
@@ -37,6 +41,54 @@ export class FxLayer implements Layer {
|
||||
if (unitView === undefined) return;
|
||||
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) {
|
||||
@@ -54,6 +106,9 @@ export class FxLayer implements Layer {
|
||||
case UnitType.Shell:
|
||||
this.onShellEvent(unit);
|
||||
break;
|
||||
case UnitType.Train:
|
||||
this.onTrainEvent(unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,13 +117,48 @@ export class FxLayer implements Layer {
|
||||
if (unit.reachedTarget()) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const shipExplosion = new SpriteFx(
|
||||
const explosion = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
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.MissileSilo);
|
||||
unitTypes.add(UnitType.SAMLauncher);
|
||||
unitTypes.add(UnitType.Factory);
|
||||
} else {
|
||||
unitTypes.add(UnitType.Warship);
|
||||
unitTypes.add(UnitType.HydrogenBomb);
|
||||
|
||||
@@ -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 cityIcon from "../../../../resources/images/buildings/cityAlt1.png";
|
||||
import factoryIcon from "../../../../resources/images/buildings/factoryAlt1.png";
|
||||
import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png";
|
||||
import anchorIcon from "../../../../resources/images/buildings/port1.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,
|
||||
borderType: UnitBorderType.Round,
|
||||
},
|
||||
[UnitType.Factory]: {
|
||||
icon: factoryIcon,
|
||||
borderRadius: 8.525,
|
||||
territoryRadius: 6.525,
|
||||
borderType: UnitBorderType.Round,
|
||||
},
|
||||
[UnitType.MissileSilo]: {
|
||||
icon: missileSiloIcon,
|
||||
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ProgressBar } from "../ProgressBar";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
import trainStationBadge from "../../../../resources/images/buildings/badges/trainStationBadge.png";
|
||||
|
||||
const COLOR_PROGRESSION = [
|
||||
"rgb(232, 25, 25)",
|
||||
"rgb(240, 122, 25)",
|
||||
@@ -46,6 +48,7 @@ export class UILayer implements Layer {
|
||||
|
||||
// Visual settings for selection
|
||||
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(
|
||||
private game: GameView,
|
||||
@@ -53,6 +56,23 @@ export class UILayer implements Layer {
|
||||
private transformHandler: TransformHandler,
|
||||
) {
|
||||
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 {
|
||||
@@ -145,12 +165,56 @@ export class UILayer implements Layer {
|
||||
const endTick = this.game.config().SAMCooldown();
|
||||
this.drawLoadingBar(unit, endTick);
|
||||
}
|
||||
this.drawBadges(unit);
|
||||
break;
|
||||
case UnitType.City:
|
||||
case UnitType.Port:
|
||||
case UnitType.Factory:
|
||||
this.drawBadges(unit);
|
||||
break;
|
||||
default:
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,10 @@ import { translateText } from "../../../client/Utils";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { SendUpgradeStructureIntentEvent } from "../../Transport";
|
||||
import {
|
||||
SendCreateTrainStationIntentEvent,
|
||||
SendUpgradeStructureIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
import { StructureLayer } from "./StructureLayer";
|
||||
|
||||
@@ -228,6 +231,25 @@ export class UnitInfoModal extends LitElement implements Layer {
|
||||
>
|
||||
${translateText("unit_info_modal.upgrade")}
|
||||
</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
|
||||
@click=${() => {
|
||||
this.onCloseStructureModal();
|
||||
|
||||
@@ -224,7 +224,7 @@ export class UnitLayer implements Layer {
|
||||
|
||||
private clearUnitsCells(unitViews: UnitView[]) {
|
||||
unitViews
|
||||
.filter((unitView) => isSpriteReady(unitView.type()))
|
||||
.filter((unitView) => isSpriteReady(unitView))
|
||||
.forEach((unitView) => {
|
||||
const sprite = getColoredSprite(unitView, this.theme);
|
||||
const clearsize = sprite.width + 1;
|
||||
@@ -279,6 +279,9 @@ export class UnitLayer implements Layer {
|
||||
case UnitType.TradeShip:
|
||||
this.handleTradeShipEvent(unit);
|
||||
break;
|
||||
case UnitType.Train:
|
||||
this.handleTrainEvent(unit);
|
||||
break;
|
||||
case UnitType.MIRVWarhead:
|
||||
this.handleMIRVWarhead(unit);
|
||||
break;
|
||||
@@ -437,6 +440,10 @@ export class UnitLayer implements Layer {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleTrainEvent(unit: UnitView) {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private handleBoatEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
|
||||
|
||||
@@ -115,6 +115,28 @@ export class PseudoRandom {
|
||||
return arr[Math.floor(this._nextFloat() * arr.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a random element from a set.
|
||||
*/
|
||||
randFromSet<T>(set: Set<T>): T {
|
||||
const size = set.size;
|
||||
if (size === 0) {
|
||||
throw new Error("set must not be empty");
|
||||
}
|
||||
|
||||
const index = this.nextInt(0, size);
|
||||
let i = 0;
|
||||
for (const item of set) {
|
||||
if (i === index) {
|
||||
return item;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// This should never happen
|
||||
throw new Error("Unexpected error selecting element from set");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true with probability 1/odds.
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,8 @@ export type Intent =
|
||||
| QuickChatIntent
|
||||
| MoveWarshipIntent
|
||||
| MarkDisconnectedIntent
|
||||
| UpgradeStructureIntent;
|
||||
| UpgradeStructureIntent
|
||||
| CreateStationIntent;
|
||||
|
||||
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
||||
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
||||
@@ -59,6 +60,7 @@ export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
|
||||
export type UpgradeStructureIntent = z.infer<
|
||||
typeof UpgradeStructureIntentSchema
|
||||
>;
|
||||
export type CreateStationIntent = z.infer<typeof CreateStationIntentSchema>;
|
||||
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
|
||||
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
|
||||
export type MarkDisconnectedIntent = z.infer<
|
||||
@@ -277,6 +279,11 @@ export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
|
||||
unitId: z.number(),
|
||||
});
|
||||
|
||||
export const CreateStationIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("create_station"),
|
||||
unitId: z.number(),
|
||||
});
|
||||
|
||||
export const CancelAttackIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("cancel_attack"),
|
||||
attackID: z.string(),
|
||||
@@ -322,6 +329,7 @@ const IntentSchema = z.discriminatedUnion("type", [
|
||||
TargetTroopRatioIntentSchema,
|
||||
BuildUnitIntentSchema,
|
||||
UpgradeStructureIntentSchema,
|
||||
CreateStationIntentSchema,
|
||||
EmbargoIntentSchema,
|
||||
MoveWarshipIntentSchema,
|
||||
QuickChatIntentSchema,
|
||||
|
||||
@@ -131,6 +131,11 @@ export interface Config {
|
||||
unitInfo(type: UnitType): UnitInfo;
|
||||
tradeShipGold(dist: number): Gold;
|
||||
tradeShipSpawnRate(numberOfPorts: number): number;
|
||||
trainGold(): Gold;
|
||||
trainSpawnRate(numberOfStations: number): number;
|
||||
trainStationMinRange(): number;
|
||||
trainStationMaxRange(): number;
|
||||
railroadMaxSize(): number;
|
||||
safeFromPiratesCooldownMax(): number;
|
||||
defensePostRange(): number;
|
||||
SAMCooldown(): number;
|
||||
@@ -157,6 +162,7 @@ export interface Theme {
|
||||
teamColor(team: Team): Colord;
|
||||
territoryColor(playerInfo: PlayerView): Colord;
|
||||
specialBuildingColor(playerInfo: PlayerView): Colord;
|
||||
railroadColor(playerInfo: PlayerView): Colord;
|
||||
borderColor(playerInfo: PlayerView): Colord;
|
||||
defendedBorderColors(playerInfo: PlayerView): { light: Colord; dark: Colord };
|
||||
focusedBorderColor(): Colord;
|
||||
|
||||
@@ -301,6 +301,21 @@ export class DefaultConfig implements Config {
|
||||
tradeShipSpawnRate(numberOfPorts: number): number {
|
||||
return Math.min(50, Math.round(10 * Math.pow(numberOfPorts, 0.6)));
|
||||
}
|
||||
trainSpawnRate(numberOfStations: number): number {
|
||||
return Math.round(50 * Math.pow(numberOfStations, 0.8));
|
||||
}
|
||||
trainGold(): Gold {
|
||||
return BigInt(10_000);
|
||||
}
|
||||
trainStationMinRange(): number {
|
||||
return 15;
|
||||
}
|
||||
trainStationMaxRange(): number {
|
||||
return 80;
|
||||
}
|
||||
railroadMaxSize(): number {
|
||||
return 100;
|
||||
}
|
||||
|
||||
unitInfo(type: UnitType): UnitInfo {
|
||||
switch (type) {
|
||||
@@ -450,11 +465,33 @@ export class DefaultConfig implements Config {
|
||||
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
|
||||
upgradable: true,
|
||||
};
|
||||
case UnitType.Factory:
|
||||
return {
|
||||
cost: (p: Player) =>
|
||||
p.type() === PlayerType.Human && this.infiniteGold()
|
||||
? 0n
|
||||
: BigInt(
|
||||
Math.min(
|
||||
1_000_000,
|
||||
Math.pow(
|
||||
2,
|
||||
p.unitsIncludingConstruction(UnitType.Factory).length,
|
||||
) * 125_000,
|
||||
),
|
||||
),
|
||||
territoryBound: true,
|
||||
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
|
||||
};
|
||||
case UnitType.Construction:
|
||||
return {
|
||||
cost: () => 0n,
|
||||
territoryBound: true,
|
||||
};
|
||||
case UnitType.Train:
|
||||
return {
|
||||
cost: () => 0n,
|
||||
territoryBound: false,
|
||||
};
|
||||
default:
|
||||
assertNever(type);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,16 @@ export class PastelTheme implements Theme {
|
||||
});
|
||||
}
|
||||
|
||||
railroadColor(player: PlayerView): Colord {
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
const color = colord({
|
||||
r: Math.max(tc.r - 10, 0),
|
||||
g: Math.max(tc.g - 10, 0),
|
||||
b: Math.max(tc.b - 10, 0),
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
borderColor(player: PlayerView): Colord {
|
||||
if (this.borderColorCache.has(player.id())) {
|
||||
return this.borderColorCache.get(player.id())!;
|
||||
|
||||
@@ -71,6 +71,16 @@ export class PastelThemeDark implements Theme {
|
||||
});
|
||||
}
|
||||
|
||||
railroadColor(player: PlayerView): Colord {
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
const color = colord({
|
||||
r: Math.max(tc.r - 10, 0),
|
||||
g: Math.max(tc.g - 10, 0),
|
||||
b: Math.max(tc.b - 10, 0),
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
borderColor(player: PlayerView): Colord {
|
||||
if (this.borderColorCache.has(player.id())) {
|
||||
return this.borderColorCache.get(player.id())!;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { CityExecution } from "./CityExecution";
|
||||
import { DefensePostExecution } from "./DefensePostExecution";
|
||||
import { FactoryExecution } from "./FactoryExecution";
|
||||
import { MirvExecution } from "./MIRVExecution";
|
||||
import { MissileSiloExecution } from "./MissileSiloExecution";
|
||||
import { NukeExecution } from "./NukeExecution";
|
||||
@@ -115,6 +116,9 @@ export class ConstructionExecution implements Execution {
|
||||
case UnitType.City:
|
||||
this.mg.addExecution(new CityExecution(player, this.tile));
|
||||
break;
|
||||
case UnitType.Factory:
|
||||
this.mg.addExecution(new FactoryExecution(player, this.tile));
|
||||
break;
|
||||
default:
|
||||
throw Error(`unit type ${this.constructionType} not supported`);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { RetreatExecution } from "./RetreatExecution";
|
||||
import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TargetPlayerExecution } from "./TargetPlayerExecution";
|
||||
import { TrainStationExecution } from "./TrainStationExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
|
||||
|
||||
@@ -112,6 +113,8 @@ export class Executor {
|
||||
);
|
||||
case "upgrade_structure":
|
||||
return new UpgradeStructureExecution(player, intent.unitId);
|
||||
case "create_station":
|
||||
return new TrainStationExecution(player, intent.unitId);
|
||||
case "quick_chat":
|
||||
return new QuickChatExecution(
|
||||
player,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
|
||||
export class FactoryExecution implements Execution {
|
||||
private factory: Unit | null = null;
|
||||
private active: boolean = true;
|
||||
|
||||
constructor(
|
||||
private player: Player,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
const spawnTile = this.player.canBuild(UnitType.Factory, this.tile);
|
||||
if (spawnTile === false) {
|
||||
console.warn("cannot build factory");
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.factory = this.player.buildUnit(UnitType.Factory, spawnTile, {});
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.factory === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (!this.factory.isActive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player !== this.factory.owner()) {
|
||||
this.player = this.factory.owner();
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { EmojiExecution } from "./EmojiExecution";
|
||||
import { NukeExecution } from "./NukeExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TrainStationExecution } from "./TrainStationExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { closestTwoTiles } from "./Util";
|
||||
import { BotBehavior } from "./utils/BotBehavior";
|
||||
@@ -437,10 +438,32 @@ export class FakeHumanExecution implements Execution {
|
||||
this.maybeSpawnStructure(UnitType.Port, 1) ||
|
||||
this.maybeSpawnStructure(UnitType.City, 2) ||
|
||||
this.maybeSpawnWarship() ||
|
||||
this.maybeSpawnTrainStation() ||
|
||||
this.maybeSpawnStructure(UnitType.MissileSilo, 1)
|
||||
);
|
||||
}
|
||||
|
||||
private maybeSpawnTrainStation(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const citiesWithoutStations = this.player.units().filter((unit) => {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
case UnitType.Port:
|
||||
case UnitType.Factory:
|
||||
return !unit.hasTrainStation();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (citiesWithoutStations.length === 0) {
|
||||
return false;
|
||||
}
|
||||
this.mg.addExecution(
|
||||
new TrainStationExecution(this.player, citiesWithoutStations[0].id()),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(type: UnitType, maxNum: number): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const units = this.player.unitsIncludingConstruction(type);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "../game/GameUpdates";
|
||||
import { Railroad } from "../game/Railroad";
|
||||
|
||||
export class RailroadExecution implements Execution {
|
||||
private mg: Game;
|
||||
private active: boolean = true;
|
||||
private headIndex: number = 0;
|
||||
private tailIndex: number = 0;
|
||||
private increment: number = 3;
|
||||
private railTiles: RailTile[] = [];
|
||||
constructor(private railRoad: Railroad) {
|
||||
this.tailIndex = railRoad.tiles.length;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
const tiles = this.railRoad.tiles;
|
||||
// Inverse direction computation for the first tile
|
||||
this.railTiles.push({
|
||||
tile: tiles[0],
|
||||
railType:
|
||||
tiles.length > 0
|
||||
? this.computeExtremityDirection(tiles[0], tiles[1])
|
||||
: RailType.VERTICAL,
|
||||
});
|
||||
for (let i = 1; i < tiles.length - 1; i++) {
|
||||
const direction = this.computeDirection(
|
||||
tiles[i - 1],
|
||||
tiles[i],
|
||||
tiles[i + 1],
|
||||
);
|
||||
this.railTiles.push({ tile: tiles[i], railType: direction });
|
||||
}
|
||||
this.railTiles.push({
|
||||
tile: tiles[tiles.length - 1],
|
||||
railType:
|
||||
tiles.length > 0
|
||||
? this.computeExtremityDirection(
|
||||
tiles[tiles.length - 1],
|
||||
tiles[tiles.length - 2],
|
||||
)
|
||||
: RailType.VERTICAL,
|
||||
});
|
||||
}
|
||||
|
||||
private computeExtremityDirection(tile: TileRef, next: TileRef): RailType {
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
const nextX = this.mg.x(next);
|
||||
const nextY = this.mg.y(next);
|
||||
|
||||
const dx = nextX - x;
|
||||
const dy = nextY - y;
|
||||
|
||||
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
|
||||
|
||||
if (dx === 0) {
|
||||
return RailType.VERTICAL;
|
||||
} else if (dy === 0) {
|
||||
return RailType.HORIZONTAL;
|
||||
}
|
||||
return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
private computeDirection(
|
||||
prev: TileRef,
|
||||
current: TileRef,
|
||||
next: TileRef,
|
||||
): RailType {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
const x1 = this.mg.x(prev);
|
||||
const y1 = this.mg.y(prev);
|
||||
const x2 = this.mg.x(current);
|
||||
const y2 = this.mg.y(current);
|
||||
const x3 = this.mg.x(next);
|
||||
const y3 = this.mg.y(next);
|
||||
|
||||
const dx1 = x2 - x1;
|
||||
const dy1 = y2 - y1;
|
||||
const dx2 = x3 - x2;
|
||||
const dy2 = y3 - y2;
|
||||
|
||||
// Straight line
|
||||
if (dx1 === dx2 && dy1 === dy2) {
|
||||
if (dx1 !== 0) return RailType.HORIZONTAL;
|
||||
if (dy1 !== 0) return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
// Turn (corner) cases
|
||||
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
|
||||
// Now figure out which type of corner
|
||||
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
|
||||
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
|
||||
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
|
||||
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
|
||||
|
||||
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
|
||||
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
|
||||
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
|
||||
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
|
||||
}
|
||||
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
|
||||
return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (!this.activeSourceOrDestination()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (this.headIndex > this.tailIndex) {
|
||||
// Construction complete
|
||||
this.constructionComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedRailTiles: RailTile[];
|
||||
// Check if remaining tiles can be done all at once
|
||||
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
|
||||
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
|
||||
this.constructionComplete();
|
||||
} else {
|
||||
updatedRailTiles = this.railTiles.slice(
|
||||
this.headIndex,
|
||||
this.headIndex + this.increment,
|
||||
);
|
||||
updatedRailTiles = updatedRailTiles.concat(
|
||||
this.railTiles.slice(this.tailIndex - this.increment, this.tailIndex),
|
||||
);
|
||||
this.headIndex += this.increment;
|
||||
this.tailIndex -= this.increment;
|
||||
}
|
||||
if (updatedRailTiles) {
|
||||
this.mg.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: true,
|
||||
railTiles: updatedRailTiles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private activeSourceOrDestination(): boolean {
|
||||
return this.railRoad.from.isActive() && this.railRoad.to.isActive();
|
||||
}
|
||||
|
||||
private constructionComplete() {
|
||||
this.redrawBuildings();
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
private redrawBuildings() {
|
||||
this.railRoad.from.unit.isActive() && this.railRoad.from.unit.touch();
|
||||
this.railRoad.to.unit.isActive() && this.railRoad.to.unit.touch();
|
||||
}
|
||||
}
|
||||
@@ -106,10 +106,10 @@ export class TradeShipExecution implements Execution {
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
// Update safeFromPirates status
|
||||
if (this.mg.isWater(result.tile) && this.mg.isShoreline(result.tile)) {
|
||||
if (this.mg.isWater(result.node) && this.mg.isShoreline(result.node)) {
|
||||
this.tradeShip.setSafeFromPirates();
|
||||
}
|
||||
this.tradeShip.move(result.tile);
|
||||
this.tradeShip.move(result.node);
|
||||
this.tilesTraveled++;
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
Player,
|
||||
TrainType,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { RailNetwork } from "../game/RailNetwork";
|
||||
import { getOrientedRailroad, OrientedRailroad } from "../game/Railroad";
|
||||
import { TrainStation } from "../game/TrainStation";
|
||||
|
||||
export class TrainExecution implements Execution {
|
||||
private active = true;
|
||||
private mg: Game | null = null;
|
||||
private train: Unit | null = null;
|
||||
private cars: Unit[] = [];
|
||||
private hasCargo: boolean = false;
|
||||
private currentTile: number = 0;
|
||||
private spacing = 2;
|
||||
private usedTiles: TileRef[] = []; // used for cars behind
|
||||
private stations: TrainStation[] = [];
|
||||
private currentRailroad: OrientedRailroad | null = null;
|
||||
private speed: number = 3;
|
||||
|
||||
constructor(
|
||||
private railNetwork: RailNetwork,
|
||||
private player: Player,
|
||||
private source: TrainStation,
|
||||
private destination: TrainStation,
|
||||
private numCars: number,
|
||||
) {
|
||||
this.hasCargo = source.unit.type() === UnitType.Factory;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
const stations = this.railNetwork.findStationsPath(
|
||||
this.source,
|
||||
this.destination,
|
||||
);
|
||||
if (!stations || stations.length <= 1) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.stations = stations;
|
||||
const railroad = getOrientedRailroad(this.stations[0], this.stations[1]);
|
||||
if (railroad) {
|
||||
this.currentRailroad = railroad;
|
||||
} else {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const spawn = this.player.canBuild(UnitType.Train, this.stations[0].tile());
|
||||
if (spawn === false) {
|
||||
console.warn(`cannot build train`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.train = this.createTrainUnits(spawn);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.train === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (!this.train.isActive() || !this.activeSourceOrDestination()) {
|
||||
this.deleteTrain();
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = this.getNextTile();
|
||||
if (tile) {
|
||||
this.updateCarsPositions(tile);
|
||||
} else {
|
||||
this.targetReached();
|
||||
this.deleteTrain();
|
||||
}
|
||||
}
|
||||
|
||||
loadCargo() {
|
||||
if (this.hasCargo || this.train === null) {
|
||||
return;
|
||||
}
|
||||
this.hasCargo = true;
|
||||
// Starts at 1: don't load tail engine
|
||||
for (let i = 1; i < this.cars.length; i++) {
|
||||
this.cars[i].setLoaded(true);
|
||||
}
|
||||
}
|
||||
|
||||
private targetReached() {
|
||||
if (this.train === null) {
|
||||
return;
|
||||
}
|
||||
this.train.setReachedTarget();
|
||||
this.cars.forEach((car: Unit) => {
|
||||
car.setReachedTarget();
|
||||
});
|
||||
}
|
||||
|
||||
private createTrainUnits(tile: TileRef): Unit {
|
||||
const train = this.player.buildUnit(UnitType.Train, tile, {
|
||||
targetUnit: this.destination.unit,
|
||||
trainType: TrainType.Engine,
|
||||
});
|
||||
// Tail is also an engine, just for cosmetics
|
||||
this.cars.push(
|
||||
this.player.buildUnit(UnitType.Train, tile, {
|
||||
targetUnit: this.destination.unit,
|
||||
trainType: TrainType.Engine,
|
||||
}),
|
||||
);
|
||||
for (let i = 0; i < this.numCars; i++) {
|
||||
this.cars.push(
|
||||
this.player.buildUnit(UnitType.Train, tile, {
|
||||
trainType: TrainType.Carriage,
|
||||
loaded: this.hasCargo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return train;
|
||||
}
|
||||
|
||||
private deleteTrain() {
|
||||
this.active = false;
|
||||
if (this.train?.isActive()) {
|
||||
this.train.delete(false);
|
||||
}
|
||||
for (const car of this.cars) {
|
||||
if (car.isActive()) {
|
||||
car.delete(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private activeSourceOrDestination(): boolean {
|
||||
return (
|
||||
this.stations.length > 1 &&
|
||||
this.stations[1].isActive() &&
|
||||
this.stations[0].isActive()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the tiles the train go through so the cars can reuse them
|
||||
* Don't simply save the tiles the engine uses, otherwise the spacing will be dictated by the train speed
|
||||
*/
|
||||
private saveTraversedTiles(from: number, speed: number) {
|
||||
if (!this.currentRailroad) {
|
||||
return;
|
||||
}
|
||||
let tileToSave: number = from;
|
||||
for (
|
||||
let i = 0;
|
||||
i < speed && tileToSave < this.currentRailroad.getTiles().length;
|
||||
i++
|
||||
) {
|
||||
this.saveTile(this.currentRailroad.getTiles()[tileToSave]);
|
||||
tileToSave = tileToSave + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private saveTile(tile: TileRef) {
|
||||
this.usedTiles.push(tile);
|
||||
if (this.usedTiles.length > this.cars.length * this.spacing + 3) {
|
||||
this.usedTiles.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private updateCarsPositions(newTile: TileRef) {
|
||||
if (this.cars.length > 0) {
|
||||
for (let i = this.cars.length - 1; i >= 0; --i) {
|
||||
const carTileIndex = (i + 1) * this.spacing + 2;
|
||||
if (this.usedTiles.length > carTileIndex) {
|
||||
this.cars[i].move(this.usedTiles[carTileIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.train !== null) {
|
||||
this.train.move(newTile);
|
||||
}
|
||||
}
|
||||
|
||||
private nextStation() {
|
||||
if (this.stations.length > 2) {
|
||||
this.stations.shift();
|
||||
const railRoad = getOrientedRailroad(this.stations[0], this.stations[1]);
|
||||
if (railRoad) {
|
||||
this.currentRailroad = railRoad;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private canTradeWithDestination() {
|
||||
return (
|
||||
this.stations.length > 1 && this.stations[1].tradeAvailable(this.player)
|
||||
);
|
||||
}
|
||||
|
||||
private getNextTile(): TileRef | null {
|
||||
if (this.currentRailroad === null || !this.canTradeWithDestination()) {
|
||||
return null;
|
||||
}
|
||||
this.saveTraversedTiles(this.currentTile, this.speed);
|
||||
this.currentTile = this.currentTile + this.speed;
|
||||
const leftOver = this.currentTile - this.currentRailroad.getTiles().length;
|
||||
if (leftOver >= 0) {
|
||||
// Station reached, pick the next station
|
||||
this.stationReached();
|
||||
if (!this.nextStation()) {
|
||||
return null; // Destination reached (or no valid connection)
|
||||
}
|
||||
this.currentTile = leftOver;
|
||||
this.saveTraversedTiles(0, leftOver);
|
||||
}
|
||||
return this.currentRailroad.getTiles()[this.currentTile];
|
||||
}
|
||||
|
||||
private stationReached() {
|
||||
if (this.mg === null || this.player === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
this.stations[1].onTrainStop(this);
|
||||
return;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Execution, Game, Player, Unit } from "../game/Game";
|
||||
import { TrainStation } from "../game/TrainStation";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { TrainExecution } from "./TrainExecution";
|
||||
|
||||
export class TrainStationExecution implements Execution {
|
||||
private mg: Game;
|
||||
private active: boolean = true;
|
||||
private random: PseudoRandom | null = null;
|
||||
private station: TrainStation | null = null;
|
||||
private unit: Unit | undefined = undefined;
|
||||
private numCars: number = 5;
|
||||
constructor(
|
||||
private player: Player,
|
||||
private unitId: number,
|
||||
) {}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
this.random = new PseudoRandom(mg.ticks());
|
||||
|
||||
this.unit = this.player.units().find((unit) => unit.id() === this.unitId);
|
||||
if (this.unit === undefined) {
|
||||
console.warn(`station unit is undefined`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.unit.setTrainStation(true);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.mg === undefined) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (!this.isActive() || this.unit === undefined) {
|
||||
return;
|
||||
}
|
||||
if (this.station === null) {
|
||||
// Can't create new executions on init, so it has to be done in the tick
|
||||
this.station = new TrainStation(this.mg, this.unit);
|
||||
this.mg.railNetwork().connectStation(this.station);
|
||||
}
|
||||
if (!this.station.isActive() || !this.random) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
const cluster = this.station.getCluster();
|
||||
if (cluster === null) {
|
||||
return;
|
||||
}
|
||||
const availableForTrade = cluster.availableForTrade(this.unit.owner());
|
||||
if (
|
||||
availableForTrade.size === 0 ||
|
||||
!this.random.chance(
|
||||
this.mg.config().trainSpawnRate(availableForTrade.size),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Pick a destination randomly.
|
||||
// Could be improved to pick a lucrative trip
|
||||
const destination = this.random.randFromSet(availableForTrade);
|
||||
if (destination !== this.station) {
|
||||
this.mg.addExecution(
|
||||
new TrainExecution(
|
||||
this.mg.railNetwork(),
|
||||
this.unit.owner(),
|
||||
this.station,
|
||||
destination,
|
||||
this.numCars,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
this.lastMove = ticks;
|
||||
this.mg = mg;
|
||||
this.pathFinder = PathFinder.Mini(mg, 10_000, 10);
|
||||
this.pathFinder = PathFinder.Mini(mg, 10_000, true, 10);
|
||||
|
||||
if (
|
||||
this.attacker.units(UnitType.TransportShip).length >=
|
||||
@@ -209,7 +209,7 @@ export class TransportShipExecution implements Execution {
|
||||
.boatArriveTroops(this.attacker, this.target, this.troops);
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.boat.move(result.tile);
|
||||
this.boat.move(result.node);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
break;
|
||||
|
||||
@@ -188,7 +188,7 @@ export class WarshipExecution implements Execution {
|
||||
this.warship.move(this.warship.tile());
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.warship.move(result.tile);
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
this.warship.touch();
|
||||
@@ -215,10 +215,10 @@ export class WarshipExecution implements Execution {
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.warship.setTargetTile(undefined);
|
||||
this.warship.move(result.tile);
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
this.warship.move(result.tile);
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
this.warship.touch();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UnitUpdate,
|
||||
} from "./GameUpdates";
|
||||
import { PlayerView } from "./GameView";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Stats } from "./Stats";
|
||||
|
||||
export type PlayerID = string;
|
||||
@@ -149,6 +150,13 @@ export enum UnitType {
|
||||
MIRV = "MIRV",
|
||||
MIRVWarhead = "MIRV Warhead",
|
||||
Construction = "Construction",
|
||||
Train = "Train",
|
||||
Factory = "Factory",
|
||||
}
|
||||
|
||||
export enum TrainType {
|
||||
Engine = "Engine",
|
||||
Carriage = "Carriage",
|
||||
}
|
||||
|
||||
const _structureTypes: ReadonlySet<UnitType> = new Set([
|
||||
@@ -197,6 +205,14 @@ export interface UnitParamsMap {
|
||||
lastSetSafeFromPirates?: number;
|
||||
};
|
||||
|
||||
[UnitType.Train]: {
|
||||
trainType: TrainType;
|
||||
targetUnit?: Unit;
|
||||
loaded?: boolean;
|
||||
};
|
||||
|
||||
[UnitType.Factory]: {};
|
||||
|
||||
[UnitType.MissileSilo]: {
|
||||
cooldownDuration?: number;
|
||||
};
|
||||
@@ -373,6 +389,13 @@ export interface Unit {
|
||||
touch(): void;
|
||||
hash(): number;
|
||||
toUpdate(): UnitUpdate;
|
||||
hasTrainStation(): boolean;
|
||||
setTrainStation(trainStation: boolean): void;
|
||||
|
||||
// Train
|
||||
trainType(): TrainType | undefined;
|
||||
isLoaded(): boolean | undefined;
|
||||
setLoaded(loaded: boolean): void;
|
||||
|
||||
// Targeting
|
||||
setTargetTile(cell: TileRef | undefined): void;
|
||||
@@ -634,6 +657,9 @@ export interface Game extends GameMap {
|
||||
numTilesWithFallout(): number;
|
||||
// Optional as it's not initialized before the end of spawn phase
|
||||
stats(): Stats;
|
||||
|
||||
addUpdate(update: GameUpdate): void;
|
||||
railNetwork(): RailNetwork;
|
||||
}
|
||||
|
||||
export interface PlayerActions {
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
import { GameMap, TileRef, TileUpdate } from "./GameMap";
|
||||
import { GameUpdate, GameUpdateType } from "./GameUpdates";
|
||||
import { PlayerImpl } from "./PlayerImpl";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { createRailNetwork } from "./RailNetworkImpl";
|
||||
import { Stats } from "./Stats";
|
||||
import { StatsImpl } from "./StatsImpl";
|
||||
import { assignTeams } from "./TeamAssignment";
|
||||
@@ -73,6 +75,7 @@ export class GameImpl implements Game {
|
||||
|
||||
private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue];
|
||||
private botTeam: Team = ColoredTeams.Bot;
|
||||
private _railNetwork: RailNetwork = createRailNetwork(this);
|
||||
|
||||
constructor(
|
||||
private _humans: PlayerInfo[],
|
||||
@@ -672,6 +675,9 @@ export class GameImpl implements Game {
|
||||
}
|
||||
removeUnit(u: Unit) {
|
||||
this.unitGrid.removeUnit(u);
|
||||
if (u.hasTrainStation()) {
|
||||
this._railNetwork.removeStation(u);
|
||||
}
|
||||
}
|
||||
|
||||
nearbyUnits(
|
||||
@@ -787,6 +793,9 @@ export class GameImpl implements Game {
|
||||
stats(): Stats {
|
||||
return this._stats;
|
||||
}
|
||||
railNetwork(): RailNetwork {
|
||||
return this._railNetwork;
|
||||
}
|
||||
}
|
||||
|
||||
// Or a more dynamic approach that will catch new enum values:
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PlayerType,
|
||||
Team,
|
||||
Tick,
|
||||
TrainType,
|
||||
UnitType,
|
||||
} from "./Game";
|
||||
import { TileRef, TileUpdate } from "./GameMap";
|
||||
@@ -40,6 +41,8 @@ export enum GameUpdateType {
|
||||
Win,
|
||||
Hash,
|
||||
UnitIncoming,
|
||||
BonusEvent,
|
||||
RailroadEvent,
|
||||
}
|
||||
|
||||
export type GameUpdate =
|
||||
@@ -56,7 +59,36 @@ export type GameUpdate =
|
||||
| EmojiUpdate
|
||||
| WinUpdate
|
||||
| HashUpdate
|
||||
| UnitIncomingUpdate;
|
||||
| UnitIncomingUpdate
|
||||
| BonusEventUpdate
|
||||
| RailroadUpdate;
|
||||
|
||||
export interface BonusEventUpdate {
|
||||
type: GameUpdateType.BonusEvent;
|
||||
tile: TileRef;
|
||||
gold: number;
|
||||
workers: number;
|
||||
troops: number;
|
||||
}
|
||||
|
||||
export enum RailType {
|
||||
VERTICAL,
|
||||
HORIZONTAL,
|
||||
TOP_LEFT,
|
||||
TOP_RIGHT,
|
||||
BOTTOM_LEFT,
|
||||
BOTTOM_RIGHT,
|
||||
}
|
||||
|
||||
export interface RailTile {
|
||||
tile: TileRef;
|
||||
railType: RailType;
|
||||
}
|
||||
export interface RailroadUpdate {
|
||||
type: GameUpdateType.RailroadEvent;
|
||||
isActive: boolean;
|
||||
railTiles: RailTile[];
|
||||
}
|
||||
|
||||
export interface TileUpdateWrapper {
|
||||
type: GameUpdateType.Tile;
|
||||
@@ -84,6 +116,9 @@ export interface UnitUpdate {
|
||||
missileTimerQueue: number[];
|
||||
readyMissileCount: number;
|
||||
level: number;
|
||||
hasTrainStation: boolean;
|
||||
trainType?: TrainType; // Only for trains
|
||||
loaded?: boolean; // Only for trains
|
||||
}
|
||||
|
||||
export interface AttackUpdate {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TerrainType,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
TrainType,
|
||||
UnitInfo,
|
||||
UnitType,
|
||||
} from "./Game";
|
||||
@@ -124,6 +125,15 @@ export class UnitView {
|
||||
level(): number {
|
||||
return this.data.level;
|
||||
}
|
||||
hasTrainStation(): boolean {
|
||||
return this.data.hasTrainStation;
|
||||
}
|
||||
trainType(): TrainType | undefined {
|
||||
return this.data.trainType;
|
||||
}
|
||||
isLoaded(): boolean | undefined {
|
||||
return this.data.loaded;
|
||||
}
|
||||
}
|
||||
|
||||
export class PlayerView {
|
||||
|
||||
@@ -808,10 +808,13 @@ export class PlayerImpl implements Player {
|
||||
return canBuildTransportShip(this.mg, this, targetTile);
|
||||
case UnitType.TradeShip:
|
||||
return this.tradeShipSpawn(targetTile);
|
||||
case UnitType.Train:
|
||||
return this.landBasedUnitSpawn(targetTile);
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.DefensePost:
|
||||
case UnitType.SAMLauncher:
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.Construction:
|
||||
return this.landBasedStructureSpawn(targetTile, validTiles);
|
||||
default:
|
||||
@@ -876,6 +879,10 @@ export class PlayerImpl implements Player {
|
||||
return spawns[0].tile();
|
||||
}
|
||||
|
||||
landBasedUnitSpawn(tile: TileRef): TileRef | false {
|
||||
return this.mg.isLand(tile) ? tile : false;
|
||||
}
|
||||
|
||||
landBasedStructureSpawn(
|
||||
tile: TileRef,
|
||||
validTiles: TileRef[] | null = null,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Unit } from "./Game";
|
||||
import { TrainStation } from "./TrainStation";
|
||||
|
||||
export interface RailNetwork {
|
||||
connectStation(station: TrainStation): void;
|
||||
removeStation(unit: Unit): void;
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { RailroadExecution } from "../execution/RailroadExecution";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { MiniAStar } from "../pathfinding/MiniAStar";
|
||||
import { SerialAStar } from "../pathfinding/SerialAStar";
|
||||
import { Game, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Railroad } from "./Railroad";
|
||||
import { Cluster, TrainStation, TrainStationMapAdapter } from "./TrainStation";
|
||||
|
||||
/**
|
||||
* The Stations handle their own neighbors so the graph is naturally traversable,
|
||||
* but it would be expensive to look through the graph to find a station.
|
||||
* This class stores the existing stations for quick access
|
||||
*/
|
||||
export interface StationManager {
|
||||
addStation(station: TrainStation): void;
|
||||
removeStation(station: TrainStation): void;
|
||||
findStation(unit: Unit): TrainStation | null;
|
||||
getAll(): Set<TrainStation>;
|
||||
}
|
||||
|
||||
export class StationManagerImpl implements StationManager {
|
||||
private stations: Set<TrainStation> = new Set();
|
||||
|
||||
addStation(station: TrainStation) {
|
||||
this.stations.add(station);
|
||||
}
|
||||
|
||||
removeStation(station: TrainStation) {
|
||||
this.stations.delete(station);
|
||||
}
|
||||
|
||||
findStation(unit: Unit): TrainStation | null {
|
||||
for (const station of this.stations) {
|
||||
if (station.unit === unit) return station;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getAll(): Set<TrainStation> {
|
||||
return this.stations;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RailPathFinderService {
|
||||
findTilePath(from: TileRef, to: TileRef): TileRef[];
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
|
||||
}
|
||||
|
||||
class RailPathFinderServiceImpl implements RailPathFinderService {
|
||||
constructor(private game: Game) {}
|
||||
|
||||
findTilePath(from: TileRef, to: TileRef): TileRef[] {
|
||||
const astar = new MiniAStar(
|
||||
this.game.map(),
|
||||
this.game.miniMap(),
|
||||
from,
|
||||
to,
|
||||
5000,
|
||||
20,
|
||||
false,
|
||||
3,
|
||||
);
|
||||
return astar.compute() === PathFindResultType.Completed
|
||||
? astar.reconstructPath()
|
||||
: [];
|
||||
}
|
||||
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
|
||||
const stationAStar = new SerialAStar(
|
||||
from,
|
||||
to,
|
||||
5000,
|
||||
20,
|
||||
new TrainStationMapAdapter(this.game),
|
||||
);
|
||||
return stationAStar.compute() === PathFindResultType.Completed
|
||||
? stationAStar.reconstructPath()
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
export function createRailNetwork(game: Game): RailNetwork {
|
||||
const stationManager = new StationManagerImpl();
|
||||
const pathService = new RailPathFinderServiceImpl(game);
|
||||
return new RailNetworkImpl(game, stationManager, pathService);
|
||||
}
|
||||
|
||||
export class RailNetworkImpl implements RailNetwork {
|
||||
constructor(
|
||||
private game: Game,
|
||||
private stationManager: StationManager,
|
||||
private pathService: RailPathFinderService,
|
||||
) {}
|
||||
|
||||
connectStation(station: TrainStation) {
|
||||
this.stationManager.addStation(station);
|
||||
this.connectToNearbyStations(station);
|
||||
}
|
||||
|
||||
removeStation(unit: Unit): void {
|
||||
const station = this.stationManager.findStation(unit);
|
||||
if (!station) return;
|
||||
|
||||
const neighbors = station.neighbors();
|
||||
this.disconnectFromNetwork(station);
|
||||
this.stationManager.removeStation(station);
|
||||
|
||||
const cluster = station.getCluster();
|
||||
if (!cluster) return;
|
||||
if (neighbors.length === 1) {
|
||||
cluster.removeStation(station);
|
||||
} else if (neighbors.length > 1) {
|
||||
for (const neighbor of neighbors) {
|
||||
const stations = this.computeCluster(neighbor);
|
||||
const newCluster = new Cluster();
|
||||
newCluster.addStations(stations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the intermediary stations connecting two stations
|
||||
*/
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] {
|
||||
return this.pathService.findStationsPath(from, to);
|
||||
}
|
||||
|
||||
private connectToNearbyStations(station: TrainStation) {
|
||||
const neighbors = this.game.nearbyUnits(
|
||||
station.tile(),
|
||||
this.game.config().trainStationMaxRange(),
|
||||
[UnitType.City, UnitType.Factory, UnitType.Port],
|
||||
);
|
||||
|
||||
const editedClusters = new Set<Cluster>();
|
||||
neighbors.sort((a, b) => a.distSquared - b.distSquared);
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
if (neighbor.unit === station.unit) continue;
|
||||
const neighborStation = this.stationManager.findStation(neighbor.unit);
|
||||
if (!neighborStation) continue;
|
||||
|
||||
const neighborCluster = neighborStation.getCluster();
|
||||
if (!neighborCluster || neighborCluster.has(station)) continue;
|
||||
|
||||
if (
|
||||
neighbor.distSquared >
|
||||
this.game.config().trainStationMinRange() ** 2
|
||||
) {
|
||||
if (this.connect(station, neighborStation)) {
|
||||
neighborCluster.addStation(station);
|
||||
editedClusters.add(neighborCluster);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If multiple clusters own the new station, merge them into a single cluster
|
||||
if (editedClusters.size > 1) {
|
||||
this.mergeClusters(editedClusters);
|
||||
} else if (editedClusters.size === 0) {
|
||||
// If no cluster owns the station, creates a new one for it
|
||||
const newCluster = new Cluster();
|
||||
newCluster.addStation(station);
|
||||
}
|
||||
}
|
||||
|
||||
private disconnectFromNetwork(station: TrainStation) {
|
||||
for (const rail of station.getRailroads()) {
|
||||
rail.delete(this.game);
|
||||
}
|
||||
station.clearRailroads();
|
||||
const cluster = station.getCluster();
|
||||
if (cluster !== null && cluster.size() === 1) {
|
||||
this.deleteCluster(cluster);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteCluster(cluster: Cluster) {
|
||||
for (const station of cluster.stations) {
|
||||
station.setCluster(null);
|
||||
}
|
||||
cluster.clear();
|
||||
}
|
||||
|
||||
private connect(from: TrainStation, to: TrainStation) {
|
||||
const path = this.pathService.findTilePath(from.tile(), to.tile());
|
||||
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
|
||||
const railRoad = new Railroad(from, to, path);
|
||||
this.game.addExecution(new RailroadExecution(railRoad));
|
||||
from.addRailroad(railRoad);
|
||||
to.addRailroad(railRoad);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private computeCluster(start: TrainStation): Set<TrainStation> {
|
||||
const visited = new Set<TrainStation>();
|
||||
const queue = [start];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
|
||||
for (const neighbor of current.neighbors()) {
|
||||
if (!visited.has(neighbor)) queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
private mergeClusters(clustersToMerge: Set<Cluster>) {
|
||||
const merged = new Cluster();
|
||||
for (const cluster of clustersToMerge) {
|
||||
merged.merge(cluster);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Game } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
|
||||
import { TrainStation } from "./TrainStation";
|
||||
|
||||
export class Railroad {
|
||||
constructor(
|
||||
public from: TrainStation,
|
||||
public to: TrainStation,
|
||||
public tiles: TileRef[],
|
||||
) {}
|
||||
|
||||
delete(game: Game) {
|
||||
const railTiles: RailTile[] = this.tiles.map((tile) => ({
|
||||
tile,
|
||||
railType: RailType.VERTICAL,
|
||||
}));
|
||||
game.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: false,
|
||||
railTiles,
|
||||
});
|
||||
this.from.getRailroads().delete(this);
|
||||
this.to.getRailroads().delete(this);
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrientedRailroad(
|
||||
from: TrainStation,
|
||||
to: TrainStation,
|
||||
): OrientedRailroad | null {
|
||||
for (const railroad of from.getRailroads()) {
|
||||
if (railroad.from === to) {
|
||||
return new OrientedRailroad(railroad, false);
|
||||
} else if (railroad.to === to) {
|
||||
return new OrientedRailroad(railroad, true);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a railroad with a direction so it always starts at tiles[0]
|
||||
*/
|
||||
export class OrientedRailroad {
|
||||
private tiles: TileRef[] = [];
|
||||
constructor(
|
||||
private railroad: Railroad,
|
||||
private forward: boolean,
|
||||
) {
|
||||
this.tiles = this.forward
|
||||
? this.railroad.tiles
|
||||
: [...this.railroad.tiles].reverse();
|
||||
}
|
||||
|
||||
getTiles(): TileRef[] {
|
||||
return this.tiles;
|
||||
}
|
||||
|
||||
getStart(): TrainStation {
|
||||
return this.forward ? this.railroad.from : this.railroad.to;
|
||||
}
|
||||
|
||||
getEnd(): TrainStation {
|
||||
return this.forward ? this.railroad.to : this.railroad.from;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { TradeShipExecution } from "../execution/TradeShipExecution";
|
||||
import { TrainExecution } from "../execution/TrainExecution";
|
||||
import { GraphAdapter } from "../pathfinding/SerialAStar";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { Game, Player, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
|
||||
import { Railroad } from "./Railroad";
|
||||
|
||||
/**
|
||||
* Handle train stops at various station types
|
||||
*/
|
||||
interface TrainStopHandler {
|
||||
onStop(mg: Game, station: TrainStation, trainExecution: TrainExecution): void;
|
||||
}
|
||||
|
||||
class CityStopHandler implements TrainStopHandler {
|
||||
onStop(
|
||||
mg: Game,
|
||||
station: TrainStation,
|
||||
trainExecution: TrainExecution,
|
||||
): void {
|
||||
const goldBonus = mg.config().trainGold();
|
||||
station.unit.owner().addGold(goldBonus);
|
||||
mg.addUpdate({
|
||||
type: GameUpdateType.BonusEvent,
|
||||
tile: station.tile(),
|
||||
gold: Number(goldBonus),
|
||||
workers: 0,
|
||||
troops: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PortStopHandler implements TrainStopHandler {
|
||||
constructor(private random: PseudoRandom) {}
|
||||
onStop(
|
||||
mg: Game,
|
||||
station: TrainStation,
|
||||
trainExecution: TrainExecution,
|
||||
): void {
|
||||
const unit = station.unit;
|
||||
const ports = unit.owner().tradingPorts(unit);
|
||||
if (ports.length === 0) return;
|
||||
|
||||
const port = this.random.randElement(ports);
|
||||
mg.addExecution(new TradeShipExecution(unit.owner(), unit, port));
|
||||
}
|
||||
}
|
||||
|
||||
class FactoryStopHandler implements TrainStopHandler {
|
||||
onStop(
|
||||
mg: Game,
|
||||
station: TrainStation,
|
||||
trainExecution: TrainExecution,
|
||||
): void {
|
||||
trainExecution.loadCargo();
|
||||
}
|
||||
}
|
||||
|
||||
export function createTrainStopHandlers(
|
||||
random: PseudoRandom,
|
||||
): Partial<Record<UnitType, TrainStopHandler>> {
|
||||
return {
|
||||
[UnitType.City]: new CityStopHandler(),
|
||||
[UnitType.Port]: new PortStopHandler(random),
|
||||
[UnitType.Factory]: new FactoryStopHandler(),
|
||||
};
|
||||
}
|
||||
|
||||
export class TrainStation {
|
||||
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
|
||||
{};
|
||||
private cluster: Cluster | null;
|
||||
private railroads: Set<Railroad> = new Set();
|
||||
|
||||
constructor(
|
||||
private mg: Game,
|
||||
public unit: Unit,
|
||||
) {
|
||||
this.stopHandlers = createTrainStopHandlers(new PseudoRandom(mg.ticks()));
|
||||
}
|
||||
|
||||
tradeAvailable(otherPlayer: Player): boolean {
|
||||
const player = this.unit.owner();
|
||||
return otherPlayer === player || player.canTrade(otherPlayer);
|
||||
}
|
||||
|
||||
clearRailroads() {
|
||||
this.railroads.clear();
|
||||
}
|
||||
|
||||
addRailroad(railRoad: Railroad) {
|
||||
this.railroads.add(railRoad);
|
||||
}
|
||||
|
||||
removeNeighboringRails(station: TrainStation) {
|
||||
const toRemove = [...this.railroads].find(
|
||||
(r) => r.from === station || r.to === station,
|
||||
);
|
||||
if (toRemove) {
|
||||
const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({
|
||||
tile,
|
||||
railType: RailType.VERTICAL,
|
||||
}));
|
||||
this.mg.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: false,
|
||||
railTiles,
|
||||
});
|
||||
this.railroads.delete(toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
neighbors(): TrainStation[] {
|
||||
const neighbors: TrainStation[] = [];
|
||||
for (const r of this.railroads) {
|
||||
if (r.from !== this) {
|
||||
neighbors.push(r.from);
|
||||
} else {
|
||||
neighbors.push(r.to);
|
||||
}
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
tile(): TileRef {
|
||||
return this.unit.tile();
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.unit.isActive();
|
||||
}
|
||||
|
||||
getRailroads(): Set<Railroad> {
|
||||
return this.railroads;
|
||||
}
|
||||
|
||||
setCluster(cluster: Cluster | null) {
|
||||
this.cluster = cluster;
|
||||
}
|
||||
|
||||
getCluster(): Cluster | null {
|
||||
return this.cluster;
|
||||
}
|
||||
|
||||
onTrainStop(trainExecution: TrainExecution) {
|
||||
const type = this.unit.type();
|
||||
const handler = this.stopHandlers[type];
|
||||
if (handler) {
|
||||
handler.onStop(this.mg, this, trainExecution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the trainstation usable with A*
|
||||
*/
|
||||
export class TrainStationMapAdapter implements GraphAdapter<TrainStation> {
|
||||
constructor(private game: Game) {}
|
||||
|
||||
neighbors(node: TrainStation): TrainStation[] {
|
||||
return node.neighbors();
|
||||
}
|
||||
|
||||
cost(node: TrainStation): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
position(node: TrainStation): { x: number; y: number } {
|
||||
return { x: this.game.x(node.tile()), y: this.game.y(node.tile()) };
|
||||
}
|
||||
|
||||
isTraversable(from: TrainStation, to: TrainStation): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster of connected stations
|
||||
*/
|
||||
export class Cluster {
|
||||
public stations: Set<TrainStation> = new Set();
|
||||
|
||||
has(station: TrainStation) {
|
||||
return this.stations.has(station);
|
||||
}
|
||||
|
||||
addStation(station: TrainStation) {
|
||||
this.stations.add(station);
|
||||
station.setCluster(this);
|
||||
}
|
||||
|
||||
removeStation(station: TrainStation) {
|
||||
this.stations.delete(station);
|
||||
}
|
||||
|
||||
addStations(stations: Set<TrainStation>) {
|
||||
for (const station of stations) {
|
||||
this.addStation(station);
|
||||
}
|
||||
}
|
||||
|
||||
merge(other: Cluster) {
|
||||
for (const s of other.stations) {
|
||||
this.addStation(s);
|
||||
}
|
||||
}
|
||||
|
||||
availableForTrade(player: Player): Set<TrainStation> {
|
||||
const tradingStations = new Set<TrainStation>();
|
||||
for (const station of this.stations) {
|
||||
if (station.tradeAvailable(player)) {
|
||||
tradingStations.add(station);
|
||||
}
|
||||
}
|
||||
return tradingStations;
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.stations.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.stations.clear();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MessageType,
|
||||
Player,
|
||||
Tick,
|
||||
TrainType,
|
||||
Unit,
|
||||
UnitInfo,
|
||||
UnitType,
|
||||
@@ -28,9 +29,12 @@ export class UnitImpl implements Unit {
|
||||
private _troops: number;
|
||||
private _missileTimerQueue: number[] = [];
|
||||
private _readyMissileCount: number = 1;
|
||||
private _hasTrainStation: boolean = false;
|
||||
private _patrolTile: TileRef | undefined;
|
||||
private _level: number = 1;
|
||||
private _targetable: boolean = true;
|
||||
private _loaded: boolean | undefined;
|
||||
private _trainType: TrainType | undefined;
|
||||
|
||||
constructor(
|
||||
private _type: UnitType,
|
||||
@@ -53,6 +57,9 @@ export class UnitImpl implements Unit {
|
||||
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
|
||||
this._targetUnit =
|
||||
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
|
||||
this._loaded =
|
||||
"loaded" in params ? (params.loaded ?? undefined) : undefined;
|
||||
this._trainType = "trainType" in params ? params.trainType : undefined;
|
||||
|
||||
switch (this._type) {
|
||||
case UnitType.Warship:
|
||||
@@ -123,6 +130,9 @@ export class UnitImpl implements Unit {
|
||||
missileTimerQueue: this._missileTimerQueue,
|
||||
readyMissileCount: this._readyMissileCount,
|
||||
level: this.level(),
|
||||
hasTrainStation: this._hasTrainStation,
|
||||
trainType: this._trainType,
|
||||
loaded: this._loaded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,6 +361,15 @@ export class UnitImpl implements Unit {
|
||||
return this._level;
|
||||
}
|
||||
|
||||
setTrainStation(trainStation: boolean): void {
|
||||
this._hasTrainStation = trainStation;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
hasTrainStation(): boolean {
|
||||
return this._hasTrainStation;
|
||||
}
|
||||
|
||||
increaseLevel(): void {
|
||||
this._level++;
|
||||
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
|
||||
@@ -358,4 +377,19 @@ export class UnitImpl implements Unit {
|
||||
}
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
trainType(): TrainType | undefined {
|
||||
return this._trainType;
|
||||
}
|
||||
|
||||
isLoaded(): boolean | undefined {
|
||||
return this._loaded;
|
||||
}
|
||||
|
||||
setLoaded(loaded: boolean): void {
|
||||
if (this._loaded !== loaded) {
|
||||
this._loaded = loaded;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { TileRef } from "../game/GameMap";
|
||||
|
||||
export interface AStar {
|
||||
export interface AStar<NodeType> {
|
||||
compute(): PathFindResultType;
|
||||
reconstructPath(): TileRef[];
|
||||
reconstructPath(): NodeType[];
|
||||
}
|
||||
|
||||
export enum PathFindResultType {
|
||||
@@ -11,17 +9,17 @@ export enum PathFindResultType {
|
||||
Completed,
|
||||
PathNotFound,
|
||||
}
|
||||
export type TileResult =
|
||||
export type AStarResult<NodeType> =
|
||||
| {
|
||||
type: PathFindResultType.NextTile;
|
||||
tile: TileRef;
|
||||
node: NodeType;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.Pending;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.Completed;
|
||||
tile: TileRef;
|
||||
node: NodeType;
|
||||
}
|
||||
| {
|
||||
type: PathFindResultType.PathNotFound;
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { Cell } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { AStar, PathFindResultType } from "./AStar";
|
||||
import { SerialAStar } from "./SerialAStar";
|
||||
import { GraphAdapter, SerialAStar } from "./SerialAStar";
|
||||
|
||||
export class MiniAStar implements AStar {
|
||||
private aStar: AStar;
|
||||
export class GameMapAdapter implements GraphAdapter<TileRef> {
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
private waterPath: boolean,
|
||||
) {}
|
||||
|
||||
neighbors(node: TileRef): TileRef[] {
|
||||
return this.gameMap.neighbors(node);
|
||||
}
|
||||
|
||||
cost(node: TileRef): number {
|
||||
return this.gameMap.cost(node);
|
||||
}
|
||||
|
||||
position(node: TileRef): { x: number; y: number } {
|
||||
return { x: this.gameMap.x(node), y: this.gameMap.y(node) };
|
||||
}
|
||||
|
||||
isTraversable(from: TileRef, to: TileRef): boolean {
|
||||
const isWater = this.gameMap.isWater(to);
|
||||
return this.waterPath ? isWater : !isWater;
|
||||
}
|
||||
}
|
||||
export class MiniAStar implements AStar<TileRef> {
|
||||
private aStar: AStar<TileRef>;
|
||||
|
||||
constructor(
|
||||
private gameMap: GameMap,
|
||||
@@ -13,6 +36,8 @@ export class MiniAStar implements AStar {
|
||||
private dst: TileRef,
|
||||
iterations: number,
|
||||
maxTries: number,
|
||||
waterPath: boolean = true,
|
||||
directionChangePenalty: number = 0,
|
||||
) {
|
||||
const srcArray: TileRef[] = Array.isArray(src) ? src : [src];
|
||||
const miniSrc = srcArray.map((srcPoint) =>
|
||||
@@ -32,7 +57,8 @@ export class MiniAStar implements AStar {
|
||||
miniDst,
|
||||
iterations,
|
||||
maxTries,
|
||||
this.miniMap,
|
||||
new GameMapAdapter(miniMap, waterPath),
|
||||
directionChangePenalty,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Game } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { DistanceBasedBezierCurve } from "../utilities/Line";
|
||||
import { AStar, PathFindResultType, TileResult } from "./AStar";
|
||||
import { AStar, AStarResult, PathFindResultType } from "./AStar";
|
||||
import { MiniAStar } from "./MiniAStar";
|
||||
|
||||
const parabolaMinHeight = 50;
|
||||
@@ -89,15 +89,20 @@ export class PathFinder {
|
||||
private curr: TileRef | null = null;
|
||||
private dst: TileRef | null = null;
|
||||
private path: TileRef[] | null = null;
|
||||
private aStar: AStar;
|
||||
private aStar: AStar<TileRef>;
|
||||
private computeFinished = true;
|
||||
|
||||
private constructor(
|
||||
private game: Game,
|
||||
private newAStar: (curr: TileRef, dst: TileRef) => AStar,
|
||||
private newAStar: (curr: TileRef, dst: TileRef) => AStar<TileRef>,
|
||||
) {}
|
||||
|
||||
public static Mini(game: Game, iterations: number, maxTries: number = 20) {
|
||||
public static Mini(
|
||||
game: Game,
|
||||
iterations: number,
|
||||
waterPath: boolean = true,
|
||||
maxTries: number = 20,
|
||||
) {
|
||||
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
|
||||
return new MiniAStar(
|
||||
game.map(),
|
||||
@@ -106,6 +111,7 @@ export class PathFinder {
|
||||
dst,
|
||||
iterations,
|
||||
maxTries,
|
||||
waterPath,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -114,7 +120,7 @@ export class PathFinder {
|
||||
curr: TileRef | null,
|
||||
dst: TileRef | null,
|
||||
dist: number = 1,
|
||||
): TileResult {
|
||||
): AStarResult<TileRef> {
|
||||
if (curr === null) {
|
||||
console.error("curr is null");
|
||||
return { type: PathFindResultType.PathNotFound };
|
||||
@@ -125,7 +131,7 @@ export class PathFinder {
|
||||
}
|
||||
|
||||
if (this.game.manhattanDist(curr, dst) < dist) {
|
||||
return { type: PathFindResultType.Completed, tile: curr };
|
||||
return { type: PathFindResultType.Completed, node: curr };
|
||||
}
|
||||
|
||||
if (this.computeFinished) {
|
||||
@@ -141,7 +147,7 @@ export class PathFinder {
|
||||
if (tile === undefined) {
|
||||
throw new Error("missing tile");
|
||||
}
|
||||
return { type: PathFindResultType.NextTile, tile };
|
||||
return { type: PathFindResultType.NextTile, node: tile };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,46 @@
|
||||
import { PriorityQueue } from "@datastructures-js/priority-queue";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { AStar, PathFindResultType } from "./AStar";
|
||||
|
||||
export class SerialAStar implements AStar {
|
||||
/**
|
||||
* Implement this interface with your graph to find paths with A*
|
||||
*/
|
||||
export interface GraphAdapter<NodeType> {
|
||||
neighbors(node: NodeType): NodeType[];
|
||||
cost(node: NodeType): number;
|
||||
position(node: NodeType): { x: number; y: number };
|
||||
isTraversable(from: NodeType, to: NodeType): boolean;
|
||||
}
|
||||
|
||||
export class SerialAStar<NodeType> implements AStar<NodeType> {
|
||||
private fwdOpenSet: PriorityQueue<{
|
||||
tile: TileRef;
|
||||
tile: NodeType;
|
||||
fScore: number;
|
||||
}>;
|
||||
|
||||
private bwdOpenSet: PriorityQueue<{
|
||||
tile: TileRef;
|
||||
tile: NodeType;
|
||||
fScore: number;
|
||||
}>;
|
||||
|
||||
private fwdCameFrom: Map<TileRef, TileRef>;
|
||||
private bwdCameFrom: Map<TileRef, TileRef>;
|
||||
private fwdGScore: Map<TileRef, number>;
|
||||
private bwdGScore: Map<TileRef, number>;
|
||||
private meetingPoint: TileRef | null;
|
||||
public completed: boolean;
|
||||
private sources: TileRef[];
|
||||
private closestSource: TileRef;
|
||||
private fwdCameFrom = new Map<NodeType, NodeType>();
|
||||
private bwdCameFrom = new Map<NodeType, NodeType>();
|
||||
private fwdGScore = new Map<NodeType, number>();
|
||||
private bwdGScore = new Map<NodeType, number>();
|
||||
|
||||
private meetingPoint: NodeType | null = null;
|
||||
public completed = false;
|
||||
private sources: NodeType[];
|
||||
private closestSource: NodeType;
|
||||
|
||||
constructor(
|
||||
src: TileRef | TileRef[],
|
||||
private dst: TileRef,
|
||||
src: NodeType | NodeType[],
|
||||
private dst: NodeType,
|
||||
private iterations: number,
|
||||
private maxTries: number,
|
||||
private gameMap: GameMap,
|
||||
private graph: GraphAdapter<NodeType>,
|
||||
private directionChangePenalty: number = 0,
|
||||
) {
|
||||
this.fwdOpenSet = new PriorityQueue<{
|
||||
tile: TileRef;
|
||||
fScore: number;
|
||||
}>((a, b) => a.fScore - b.fScore);
|
||||
|
||||
this.bwdOpenSet = new PriorityQueue<{
|
||||
tile: TileRef;
|
||||
fScore: number;
|
||||
}>((a, b) => a.fScore - b.fScore);
|
||||
|
||||
this.fwdCameFrom = new Map<TileRef, TileRef>();
|
||||
this.bwdCameFrom = new Map<TileRef, TileRef>();
|
||||
this.fwdGScore = new Map<TileRef, number>();
|
||||
this.bwdGScore = new Map<TileRef, number>();
|
||||
this.meetingPoint = null;
|
||||
this.completed = false;
|
||||
|
||||
this.fwdOpenSet = new PriorityQueue((a, b) => a.fScore - b.fScore);
|
||||
this.bwdOpenSet = new PriorityQueue((a, b) => a.fScore - b.fScore);
|
||||
this.sources = Array.isArray(src) ? src : [src];
|
||||
this.closestSource = this.findClosestSource(dst);
|
||||
|
||||
@@ -66,7 +61,7 @@ export class SerialAStar implements AStar {
|
||||
});
|
||||
}
|
||||
|
||||
private findClosestSource(tile: TileRef): TileRef {
|
||||
private findClosestSource(tile: NodeType): NodeType {
|
||||
return this.sources.reduce((closest, source) =>
|
||||
this.heuristic(tile, source) < this.heuristic(tile, closest)
|
||||
? source
|
||||
@@ -98,8 +93,7 @@ export class SerialAStar implements AStar {
|
||||
this.completed = true;
|
||||
return PathFindResultType.Completed;
|
||||
}
|
||||
|
||||
this.expandTileRef(fwdCurrent, true);
|
||||
this.expandNode(fwdCurrent, true);
|
||||
|
||||
// Process backward search
|
||||
const bwdCurrent = this.bwdOpenSet.dequeue()!.tile;
|
||||
@@ -110,8 +104,7 @@ export class SerialAStar implements AStar {
|
||||
this.completed = true;
|
||||
return PathFindResultType.Completed;
|
||||
}
|
||||
|
||||
this.expandTileRef(bwdCurrent, false);
|
||||
this.expandNode(bwdCurrent, false);
|
||||
}
|
||||
|
||||
return this.completed
|
||||
@@ -119,11 +112,11 @@ export class SerialAStar implements AStar {
|
||||
: PathFindResultType.PathNotFound;
|
||||
}
|
||||
|
||||
private expandTileRef(current: TileRef, isForward: boolean) {
|
||||
for (const neighbor of this.gameMap.neighbors(current)) {
|
||||
private expandNode(current: NodeType, isForward: boolean) {
|
||||
for (const neighbor of this.graph.neighbors(current)) {
|
||||
if (
|
||||
neighbor !== (isForward ? this.dst : this.closestSource) &&
|
||||
!this.gameMap.isWater(neighbor)
|
||||
!this.graph.isTraversable(current, neighbor)
|
||||
)
|
||||
continue;
|
||||
|
||||
@@ -131,38 +124,51 @@ export class SerialAStar implements AStar {
|
||||
const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet;
|
||||
const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom;
|
||||
|
||||
const tentativeGScore =
|
||||
gScore.get(current)! + this.gameMap.cost(neighbor);
|
||||
const tentativeGScore = gScore.get(current)! + this.graph.cost(neighbor);
|
||||
let penalty = 0;
|
||||
// With a direction change penalty, the path will get as straight as possible
|
||||
if (this.directionChangePenalty > 0) {
|
||||
const prev = cameFrom.get(current);
|
||||
if (prev) {
|
||||
const prevDir = this.getDirection(prev, current);
|
||||
const newDir = this.getDirection(current, neighbor);
|
||||
if (prevDir !== newDir) {
|
||||
penalty = this.directionChangePenalty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!gScore.has(neighbor) || tentativeGScore < gScore.get(neighbor)!) {
|
||||
const totalG = tentativeGScore + penalty;
|
||||
if (!gScore.has(neighbor) || totalG < gScore.get(neighbor)!) {
|
||||
cameFrom.set(neighbor, current);
|
||||
gScore.set(neighbor, tentativeGScore);
|
||||
gScore.set(neighbor, totalG);
|
||||
const fScore =
|
||||
tentativeGScore +
|
||||
totalG +
|
||||
this.heuristic(neighbor, isForward ? this.dst : this.closestSource);
|
||||
openSet.enqueue({ tile: neighbor, fScore: fScore });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private heuristic(a: TileRef, b: TileRef): number {
|
||||
try {
|
||||
return (
|
||||
1.1 *
|
||||
(Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) +
|
||||
Math.abs(this.gameMap.y(a) - this.gameMap.y(b)))
|
||||
);
|
||||
} catch {
|
||||
console.log("uh oh");
|
||||
return 0;
|
||||
}
|
||||
private heuristic(a: NodeType, b: NodeType): number {
|
||||
const posA = this.graph.position(a);
|
||||
const posB = this.graph.position(b);
|
||||
return 1.1 * (Math.abs(posA.x - posB.x) + Math.abs(posA.y - posB.y));
|
||||
}
|
||||
|
||||
public reconstructPath(): TileRef[] {
|
||||
private getDirection(from: NodeType, to: NodeType): string {
|
||||
const fromPos = this.graph.position(from);
|
||||
const toPos = this.graph.position(to);
|
||||
const dx = toPos.x - fromPos.x;
|
||||
const dy = toPos.y - fromPos.y;
|
||||
return `${Math.sign(dx)},${Math.sign(dy)}`;
|
||||
}
|
||||
|
||||
public reconstructPath(): NodeType[] {
|
||||
if (!this.meetingPoint) return [];
|
||||
|
||||
// Reconstruct path from start to meeting point
|
||||
const fwdPath: TileRef[] = [this.meetingPoint];
|
||||
const fwdPath: NodeType[] = [this.meetingPoint];
|
||||
let current = this.meetingPoint;
|
||||
|
||||
while (this.fwdCameFrom.has(current)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||