From 9b2c6cc1f6abe220969f2f0895a25b7a4a5a045d Mon Sep 17 00:00:00 2001 From: Ethienne Graveline <28783306+Egraveline@users.noreply.github.com> Date: Tue, 10 Jun 2025 23:04:17 -0400 Subject: [PATCH] Simple Upgradable Structures (Cities, Ports, SAMs and Silos) (#1012) ## Description: https://github.com/openfrontio/OpenFrontIO/issues/776 I've implemented upgradable structures for cities and ports. As of right now this is just meant as a QOL change for structure stacking that currently happens and no gameplay changes are intended. Structure upgrades cost the same as making a new structure of that type and function the same as making a new structure of that type. I'm putting up a draft PR for this now since adding support for SAMs and Silos will take more time to handle the cooldowns and I want to make sure I'm on the right track for getting this merged. I also still need to add bot behavior for this and re-enable min distance for structures. I didn't see translations for the UnitInfoModal so I've left that out for now. I've tested locally in a single player game so far but will document and test more thoroughly before merging. ![image](https://github.com/user-attachments/assets/321a17cf-26a5-4152-aae1-6b6a691638bb) ![image](https://github.com/user-attachments/assets/8cfdabe6-f0a1-435a-a5a3-05b442427c2f) ## 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: # Poutine --------- Co-authored-by: Scott Anderson --- resources/lang/en.json | 7 +- src/client/Transport.ts | 19 +++++ .../graphics/layers/PlayerInfoOverlay.ts | 40 ++++++++++ src/client/graphics/layers/StructureLayer.ts | 9 ++- src/client/graphics/layers/UILayer.ts | 2 +- src/client/graphics/layers/UnitInfoModal.ts | 75 ++++++++++++++++++- src/core/Schemas.ts | 14 +++- src/core/StatsSchemas.ts | 1 + src/core/configuration/DefaultConfig.ts | 14 +++- src/core/execution/ExecutionManager.ts | 3 + src/core/execution/MissileSiloExecution.ts | 12 ++- src/core/execution/SAMLauncherExecution.ts | 20 +++-- .../execution/UpgradeStructureExecution.ts | 44 +++++++++++ src/core/game/Game.ts | 9 ++- src/core/game/GameUpdates.ts | 4 +- src/core/game/GameView.ts | 10 ++- src/core/game/PlayerImpl.ts | 6 ++ src/core/game/Stats.ts | 3 + src/core/game/StatsImpl.ts | 5 ++ src/core/game/UnitImpl.ts | 47 +++++++----- tests/MissileSilo.test.ts | 19 ++++- tests/SAM.test.ts | 17 +++++ 22 files changed, 334 insertions(+), 46 deletions(-) create mode 100644 src/core/execution/UpgradeStructureExecution.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 64c491ee9..701c383fa 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -400,7 +400,8 @@ "sams": "SAMs", "warships": "Warships", "health": "Health", - "attitude": "Attitude" + "attitude": "Attitude", + "levels": "Levels" }, "events_display": { "retreating": "retreating", @@ -411,7 +412,9 @@ "unit_type_unknown": "Unknown", "close": "Close", "cooldown": "Cooldown", - "type": "Type" + "type": "Type", + "upgrade": "Upgrade", + "level": "Level" }, "relation": { "hostile": "Hostile", diff --git a/src/client/Transport.ts b/src/client/Transport.ts index e9124b6f5..b1e73fded 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -44,6 +44,13 @@ export class SendBreakAllianceIntentEvent implements GameEvent { ) {} } +export class SendUpgradeStructureIntentEvent implements GameEvent { + constructor( + public readonly unitId: number, + public readonly unitType: UnitType, + ) {} +} + export class SendAllianceReplyIntentEvent implements GameEvent { constructor( // The original alliance requestor @@ -187,6 +194,9 @@ export class Transport { this.onSendSpawnIntentEvent(e), ); this.eventBus.on(SendAttackIntentEvent, (e) => this.onSendAttackIntent(e)); + this.eventBus.on(SendUpgradeStructureIntentEvent, (e) => + this.onSendUpgradeStructureIntent(e), + ); this.eventBus.on(SendBoatAttackIntentEvent, (e) => this.onSendBoatAttackIntent(e), ); @@ -427,6 +437,15 @@ export class Transport { }); } + private onSendUpgradeStructureIntent(event: SendUpgradeStructureIntentEvent) { + this.sendIntent({ + type: "upgrade_structure", + unit: event.unitType, + clientID: this.lobbyConfig.clientID, + unitId: event.unitId, + }); + } + private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) { this.sendIntent({ type: "targetPlayer", diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 099a865e2..261fd3f0f 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -240,18 +240,58 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
${translateText("player_info_overlay.ports")}: ${player.units(UnitType.Port).length} + ${player + .units(UnitType.Port) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0) > 1 + ? html`(${translateText("player_info_overlay.levels")}: + ${player + .units(UnitType.Port) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0)})` + : ""}
${translateText("player_info_overlay.cities")}: ${player.units(UnitType.City).length} + ${player + .units(UnitType.City) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0) > 1 + ? html`(${translateText("player_info_overlay.levels")}: + ${player + .units(UnitType.City) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0)})` + : ""}
${translateText("player_info_overlay.missile_launchers")}: ${player.units(UnitType.MissileSilo).length} + ${player + .units(UnitType.MissileSilo) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0) > 1 + ? html`(${translateText("player_info_overlay.levels")}: + ${player + .units(UnitType.MissileSilo) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0)})` + : ""}
${translateText("player_info_overlay.sams")}: ${player.units(UnitType.SAMLauncher).length} + ${player + .units(UnitType.SAMLauncher) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0) > 1 + ? html`(${translateText("player_info_overlay.levels")}: + ${player + .units(UnitType.SAMLauncher) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0)})` + : ""}
${translateText("player_info_overlay.warships")}: diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 180005d26..180e838ab 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -242,13 +242,13 @@ export class StructureLayer implements Layer { const config = this.unitConfigs[unitType]; let icon: ImageData | undefined; - if (unitType === UnitType.SAMLauncher && unit.isCooldown()) { + if (unitType === UnitType.SAMLauncher && unit.isInCooldown()) { icon = this.unitIcons.get("reloadingSam"); } else { icon = this.unitIcons.get(iconType); } - if (unitType === UnitType.MissileSilo && unit.isCooldown()) { + if (unitType === UnitType.MissileSilo && unit.isInCooldown()) { icon = this.unitIcons.get("reloadingSilo"); } else { icon = this.unitIcons.get(iconType); @@ -268,13 +268,13 @@ export class StructureLayer implements Layer { if (!unit.isActive()) return; let borderColor = this.theme.borderColor(unit.owner()); - if (unitType === UnitType.SAMLauncher && unit.isCooldown()) { + if (unitType === UnitType.SAMLauncher && unit.isInCooldown()) { borderColor = reloadingColor; } else if (unit.type() === UnitType.Construction) { borderColor = underConstructionColor; } - if (unitType === UnitType.MissileSilo && unit.isCooldown()) { + if (unitType === UnitType.MissileSilo && unit.isInCooldown()) { borderColor = reloadingColor; } else if (unit.type() === UnitType.Construction) { borderColor = underConstructionColor; @@ -391,6 +391,7 @@ export class StructureLayer implements Layer { const screenPos = this.transformHandler.worldToScreenCoordinates(cell); const unitTile = clickedUnit.tile(); this.unitInfoModal?.onOpenStructureModal({ + eventBus: this.eventBus, unit: clickedUnit, x: screenPos.x, y: screenPos.y, diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 9918d8c4f..c9fa49933 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -128,7 +128,7 @@ export class UILayer implements Layer { } case UnitType.SAMLauncher: case UnitType.MissileSilo: - if (unit.isActive() && unit.isCooldown()) { + if (unit.isActive() && unit.isInCooldown()) { const endTick = unit.ticksLeftInCooldown() || 0; this.drawLoadingBar(unit, endTick); } diff --git a/src/client/graphics/layers/UnitInfoModal.ts b/src/client/graphics/layers/UnitInfoModal.ts index 0066aa309..cee548768 100644 --- a/src/client/graphics/layers/UnitInfoModal.ts +++ b/src/client/graphics/layers/UnitInfoModal.ts @@ -1,8 +1,10 @@ import { LitElement, css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; 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 { Layer } from "./Layer"; import { StructureLayer } from "./StructureLayer"; @@ -15,6 +17,7 @@ export class UnitInfoModal extends LitElement implements Layer { public game: GameView; public structureLayer: StructureLayer | null = null; + private eventBus: EventBus; constructor() { super(); @@ -29,12 +32,14 @@ export class UnitInfoModal extends LitElement implements Layer { } public onOpenStructureModal = ({ + eventBus, unit, x, y, tileX, tileY, }: { + eventBus: EventBus; unit: UnitView; x: number; y: number; @@ -44,6 +49,7 @@ export class UnitInfoModal extends LitElement implements Layer { if (!this.game) return; this.x = x; this.y = y; + this.eventBus = eventBus; const targetRef = this.game.ref(tileX, tileY); const allUnitTypes = Object.values(UnitType); @@ -119,12 +125,44 @@ export class UnitInfoModal extends LitElement implements Layer { .close-button:hover { background: #a00; } + + .upgrade-button { + background: #3a0; + color: #fff; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + padding: 6px 12px; + } + + .upgrade-button:hover { + background: #0a0; + } `; render() { if (!this.unit) return null; - const cooldown = this.unit.ticksLeftInCooldown() ?? 0; + const ticksLeftInCooldown = this.unit.ticksLeftInCooldown(); + let configTimer; + switch (this.unit.type()) { + case UnitType.MissileSilo: + configTimer = this.game.config().SiloCooldown(); + break; + case UnitType.SAMLauncher: + configTimer = this.game.config().SAMCooldown(); + break; + } + let cooldown = 0; + if (ticksLeftInCooldown !== undefined && configTimer !== undefined) { + cooldown = configTimer - (this.game.ticks() - ticksLeftInCooldown); + } const secondsLeft = Math.ceil(cooldown / 10); return html` @@ -140,6 +178,16 @@ export class UnitInfoModal extends LitElement implements Layer { ${translateText("unit_info_modal.type")}: ${translateText(+"unit_type." + this.unit.type?.().toLowerCase()) ?? translateText("unit_info_modal.unit_type_unknown")} + ${translateText("unit_info_modal.level")}: + ${this.game.unitInfo(this.unit.type()).upgradable && + this.unit.level?.() + ? this.unit.level?.() + : ""}
${secondsLeft > 0 ? html`
@@ -147,7 +195,30 @@ export class UnitInfoModal extends LitElement implements Layer { ${secondsLeft}s
` : ""} -
+
+