From 43397779fa8738181a06d9c6b266b867d12bfd32 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Sun, 22 Jun 2025 17:14:08 +0200 Subject: [PATCH] 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 --- __mocks__/fileMock.js | 1 + jest.config.ts | 3 + resources/images/FactoryIconWhite.svg | 5 + .../buildings/badges/trainStationBadge.png | Bin 0 -> 95 bytes resources/images/buildings/factoryAlt1.png | Bin 0 -> 176 bytes resources/lang/en.json | 1 + resources/sprites/dust.png | Bin 0 -> 160 bytes resources/sprites/trainCarriage.png | Bin 0 -> 107 bytes resources/sprites/trainCarriageLoaded.png | Bin 0 -> 114 bytes resources/sprites/trainEngine.png | Bin 0 -> 98 bytes src/client/Transport.ts | 17 ++ src/client/graphics/AnimatedSpriteLoader.ts | 10 + src/client/graphics/GameRenderer.ts | 2 + src/client/graphics/SpriteLoader.ts | 66 ++++- src/client/graphics/fx/Fx.ts | 2 + src/client/graphics/fx/TextFx.ts | 48 ++++ src/client/graphics/layers/BuildMenu.ts | 8 + src/client/graphics/layers/ControlPanel.ts | 15 +- src/client/graphics/layers/FxLayer.ts | 98 ++++++- .../graphics/layers/RadialMenuElements.ts | 1 + src/client/graphics/layers/RailroadLayer.ts | 164 ++++++++++++ src/client/graphics/layers/RailroadSprites.ts | 79 ++++++ src/client/graphics/layers/StructureLayer.ts | 7 + src/client/graphics/layers/UILayer.ts | 64 +++++ src/client/graphics/layers/UnitInfoModal.ts | 24 +- src/client/graphics/layers/UnitLayer.ts | 9 +- src/core/PseudoRandom.ts | 22 ++ src/core/Schemas.ts | 10 +- src/core/configuration/Config.ts | 6 + src/core/configuration/DefaultConfig.ts | 37 +++ src/core/configuration/PastelTheme.ts | 10 + src/core/configuration/PastelThemeDark.ts | 10 + src/core/execution/ConstructionExecution.ts | 4 + src/core/execution/ExecutionManager.ts | 3 + src/core/execution/FactoryExecution.ts | 44 ++++ src/core/execution/FakeHumanExecution.ts | 23 ++ src/core/execution/RailroadExecution.ts | 170 +++++++++++++ src/core/execution/TradeShipExecution.ts | 4 +- src/core/execution/TrainExecution.ts | 240 ++++++++++++++++++ src/core/execution/TrainStationExecution.ts | 83 ++++++ src/core/execution/TransportShipExecution.ts | 4 +- src/core/execution/WarshipExecution.ts | 6 +- src/core/game/Game.ts | 26 ++ src/core/game/GameImpl.ts | 9 + src/core/game/GameUpdates.ts | 37 ++- src/core/game/GameView.ts | 10 + src/core/game/PlayerImpl.ts | 7 + src/core/game/RailNetwork.ts | 8 + src/core/game/RailNetworkImpl.ts | 222 ++++++++++++++++ src/core/game/Railroad.ts | 67 +++++ src/core/game/TrainStation.ts | 227 +++++++++++++++++ src/core/game/UnitImpl.ts | 34 +++ src/core/pathfinding/AStar.ts | 12 +- src/core/pathfinding/MiniAStar.ts | 34 ++- src/core/pathfinding/PathFinding.ts | 20 +- src/core/pathfinding/SerialAStar.ts | 124 ++++----- tests/core/game/Cluster.test.ts | 65 +++++ tests/core/game/RailNetwork.test.ts | 163 ++++++++++++ tests/core/game/TrainStation.test.ts | 132 ++++++++++ tests/global.d.ts | 34 +++ 60 files changed, 2427 insertions(+), 104 deletions(-) create mode 100644 __mocks__/fileMock.js create mode 100644 resources/images/FactoryIconWhite.svg create mode 100644 resources/images/buildings/badges/trainStationBadge.png create mode 100644 resources/images/buildings/factoryAlt1.png create mode 100644 resources/sprites/dust.png create mode 100644 resources/sprites/trainCarriage.png create mode 100644 resources/sprites/trainCarriageLoaded.png create mode 100644 resources/sprites/trainEngine.png create mode 100644 src/client/graphics/fx/TextFx.ts create mode 100644 src/client/graphics/layers/RailroadLayer.ts create mode 100644 src/client/graphics/layers/RailroadSprites.ts create mode 100644 src/core/execution/FactoryExecution.ts create mode 100644 src/core/execution/RailroadExecution.ts create mode 100644 src/core/execution/TrainExecution.ts create mode 100644 src/core/execution/TrainStationExecution.ts create mode 100644 src/core/game/RailNetwork.ts create mode 100644 src/core/game/RailNetworkImpl.ts create mode 100644 src/core/game/Railroad.ts create mode 100644 src/core/game/TrainStation.ts create mode 100644 tests/core/game/Cluster.test.ts create mode 100644 tests/core/game/RailNetwork.test.ts create mode 100644 tests/core/game/TrainStation.test.ts create mode 100644 tests/global.d.ts diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js new file mode 100644 index 000000000..0a445d060 --- /dev/null +++ b/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = "test-file-stub"; diff --git a/jest.config.ts b/jest.config.ts index bb15e1770..86374d775 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -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)$": + "/__mocks__/fileMock.js", + "\\.(css|less)$": "/__mocks__/fileMock.js", }, transform: { "^.+\\.tsx?$": [ diff --git a/resources/images/FactoryIconWhite.svg b/resources/images/FactoryIconWhite.svg new file mode 100644 index 000000000..f37d5f562 --- /dev/null +++ b/resources/images/FactoryIconWhite.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/resources/images/buildings/badges/trainStationBadge.png b/resources/images/buildings/badges/trainStationBadge.png new file mode 100644 index 0000000000000000000000000000000000000000..5452ef2d45ab2a74464cbd45689ae24a74a00ecc GIT binary patch literal 95 zcmeAS@N?(olHy`uVBq!ia0vp^Y(UJ;0V1_0*t`W&jKx9jP7LeL$-D$|Bt2amLpWw8 rCmdk^_y7NY`MGDCd?%f|)L$&EH!dsK=$->ACG_SVMy!~bfNBS5qHQX@Rme0>?TfNTyR27yb# PlR=cHtDnm{r-UW|u01&{ literal 0 HcmV?d00001 diff --git a/resources/lang/en.json b/resources/lang/en.json index 2946e5e2f..e23dd0534 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -416,6 +416,7 @@ "cooldown": "Cooldown", "type": "Type", "upgrade": "Upgrade", + "create_station": "Create Station", "level": "Level" }, "relation": { diff --git a/resources/sprites/dust.png b/resources/sprites/dust.png new file mode 100644 index 0000000000000000000000000000000000000000..9f1b13e9b326dd462230570bbe405269ec3b1c0b GIT binary patch literal 160 zcmeAS@N?(olHy`uVBq!ia0vp^(m>44!3HFM9x&Sqq!^2X+?^QKos)S9U)g^QKn#@0`R{d~)+q$IDJ^LFwqXxs)s^fYsDa=Yqum8 literal 0 HcmV?d00001 diff --git a/resources/sprites/trainCarriage.png b/resources/sprites/trainCarriage.png new file mode 100644 index 0000000000000000000000000000000000000000..0040e6f89c2b262bcdf63319ed3e6b74c3e87b73 GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^tRT$61|)m))t&+=#^NA%Cx&(BWL^R}s-7;6AsjQ4 z6Amy*NJ>aZ{QLj^zr5ao6DLlvJl?T;_v7%+vmR^=A9XoyW#>j;18QLKboFyt=akR{ E0F59a0RR91 literal 0 HcmV?d00001 diff --git a/resources/sprites/trainCarriageLoaded.png b/resources/sprites/trainCarriageLoaded.png new file mode 100644 index 0000000000000000000000000000000000000000..c4ddc111a65af2cff185f9b64a5b6c7a7857501d GIT binary patch literal 114 zcmeAS@N?(olHy`uVBq!ia0vp^tRT$61|)m))t&+=#^NA%Cx&(BWL^R}I-V|$AsjQ4 z6Amy*NJ>aZ+_`h--(i-rx3`X-V0rxJ&Q9TVLV7RWz3V%x!ot9?>LWYbsu|~A0rfF> My85}Sb4q9e0I8ZKApigX literal 0 HcmV?d00001 diff --git a/resources/sprites/trainEngine.png b/resources/sprites/trainEngine.png new file mode 100644 index 0000000000000000000000000000000000000000..6d79749384e7441340308a6d875823f65c437723 GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^tRT$61|)m))t&+=#^NA%Cx&(BWL^R}GM+AuAsjQ4 u6ArNd`~UyH{IRqn0tqQ8M^*`GGcdG#;{Mh6mrDSsj=|H_&t;ucLK6USlN=}j literal 0 HcmV?d00001 diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 515fab7b7..5519c4e3d 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -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", diff --git a/src/client/graphics/AnimatedSpriteLoader.ts b/src/client/graphics/AnimatedSpriteLoader.ts index 709d4b358..91f7254d3 100644 --- a/src/client/graphics/AnimatedSpriteLoader.ts +++ b/src/client/graphics/AnimatedSpriteLoader.ts @@ -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> = { 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, diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 19fc4616b..b672fe7bc 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -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), diff --git a/src/client/graphics/SpriteLoader.ts b/src/client/graphics/SpriteLoader.ts index 4be363134..32bffc156 100644 --- a/src/client/graphics/SpriteLoader.ts +++ b/src/client/graphics/SpriteLoader.ts @@ -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> = { +// 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> = { [UnitType.TransportShip]: transportShipSprite, [UnitType.Warship]: warshipSprite, [UnitType.SAMMissile]: samMissileSprite, @@ -18,9 +30,12 @@ const SPRITE_CONFIG: Partial> = { [UnitType.HydrogenBomb]: hydrogenBombSprite, [UnitType.TradeShip]: tradeShipSprite, [UnitType.MIRV]: mirvSprite, + [TrainTypeSprite.Engine]: trainEngineSprite, + [TrainTypeSprite.Carriage]: trainCarriageSprite, + [TrainTypeSprite.LoadedCarriage]: trainLoadedCarriageSprite, }; -const spriteMap: Map = new Map(); +const spriteMap: Map = new Map(); // preload all images export const loadAllSprites = async (): Promise => { @@ -30,7 +45,7 @@ export const loadAllSprites = async (): Promise => { 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 => { ); }; -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()}`); } diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index d98064c11..3640c743f 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -12,4 +12,6 @@ export enum FxType { SinkingShip = "SinkingShip", Nuke = "Nuke", SAMExplosion = "SAMExplosion", + UnderConstruction = "UnderConstruction", + Dust = "Dust", } diff --git a/src/client/graphics/fx/TextFx.ts b/src/client/graphics/fx/TextFx.ts new file mode 100644 index 000000000..e853dd0c9 --- /dev/null +++ b/src/client/graphics/fx/TextFx.ts @@ -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; + } +} diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index f1ab5973b..580fdcae6 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -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, + }, ], ]; diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index cff9ac873..cce6c9c54 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -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 { > ${renderNumber(this._gold)} - (+${renderNumber(this._goldPerSecond)})) diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index 0d9367e4a..f936dc3ce 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -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); } } } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 9db5543c4..76e7a6978 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -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); diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts new file mode 100644 index 000000000..85bb16afb --- /dev/null +++ b/src/client/graphics/layers/RailroadLayer.ts @@ -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(); + 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); + } + } +} diff --git a/src/client/graphics/layers/RailroadSprites.ts b/src/client/graphics/layers/RailroadSprites.ts new file mode 100644 index 000000000..d76a41804 --- /dev/null +++ b/src/client/graphics/layers/RailroadSprites.ts @@ -0,0 +1,79 @@ +import { RailType } from "../../../core/game/GameUpdates"; + +const railTypeToFunctionMap: Record 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; +} diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 0ae34d5a2..c80b44f19 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -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, diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 0818b1964..9e963506f 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -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 = 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 { + 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 */ diff --git a/src/client/graphics/layers/UnitInfoModal.ts b/src/client/graphics/layers/UnitInfoModal.ts index 675218b56..6a31abdf9 100644 --- a/src/client/graphics/layers/UnitInfoModal.ts +++ b/src/client/graphics/layers/UnitInfoModal.ts @@ -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")} +