diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 7fc0f547f..049870fab 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -71,6 +71,7 @@ export function createRenderer( } buildMenu.game = game; buildMenu.eventBus = eventBus; + buildMenu.transformHandler = transformHandler; const leaderboard = document.querySelector("leader-board") as Leaderboard; if (!emojiTable || !(leaderboard instanceof Leaderboard)) { diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index f1cfe17b8..b10901cd7 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -13,11 +13,27 @@ import missileSiloIcon from "../../../../resources/non-commercial/svg/MissileSil import samlauncherIcon from "../../../../resources/non-commercial/svg/SamLauncherIconWhite.svg"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; -import { Cell, Gold, PlayerActions, UnitType } from "../../../core/game/Game"; +import { + BuildableUnit, + Cell, + Gold, + PlayerActions, + UnitType, +} from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; -import { BuildUnitIntentEvent } from "../../Transport"; +import { + CloseViewEvent, + MouseDownEvent, + ShowBuildMenuEvent, + ShowEmojiMenuEvent, +} from "../../InputHandler"; +import { + BuildUnitIntentEvent, + SendUpgradeStructureIntentEvent, +} from "../../Transport"; import { renderNumber } from "../../Utils"; +import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; export interface BuildItemDisplay { @@ -113,6 +129,30 @@ export class BuildMenu extends LitElement implements Layer { private clickedTile: TileRef; public playerActions: PlayerActions | null; private filteredBuildTable: BuildItemDisplay[][] = buildTable; + public transformHandler: TransformHandler; + + init() { + this.eventBus.on(ShowBuildMenuEvent, (e) => { + const clickedCell = this.transformHandler.screenToWorldCoordinates( + e.x, + e.y, + ); + if (clickedCell === null) { + return; + } + if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) { + return; + } + const tile = this.game.ref(clickedCell.x, clickedCell.y); + if (!this.game.myPlayer()?.isAlive()) { + return; + } + this.showMenu(tile); + }); + this.eventBus.on(CloseViewEvent, () => this.hideMenu()); + this.eventBus.on(ShowEmojiMenuEvent, () => this.hideMenu()); + this.eventBus.on(MouseDownEvent, () => this.hideMenu()); + } tick() { if (!this._hidden) { @@ -312,7 +352,7 @@ export class BuildMenu extends LitElement implements Layer { @state() private _hidden = true; - public canBuild(item: BuildItemDisplay): boolean { + public canBuildOrUpgrade(item: BuildItemDisplay): boolean { if (this.game?.myPlayer() === null || this.playerActions === null) { return false; } @@ -321,7 +361,7 @@ export class BuildMenu extends LitElement implements Layer { if (unit.length === 0) { return false; } - return unit[0].canBuild !== false; + return unit[0].canBuild !== false || unit[0].canUpgrade !== false; } public cost(item: BuildItemDisplay): Gold { @@ -342,15 +382,27 @@ export class BuildMenu extends LitElement implements Layer { return player.units(item.unitType).length.toString(); } - public onBuildSelected = (item: BuildItemDisplay) => { - this.eventBus.emit( - new BuildUnitIntentEvent( - item.unitType, - new Cell(this.game.x(this.clickedTile), this.game.y(this.clickedTile)), - ), - ); + public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void { + if (buildableUnit === null) { + return; + } + if (buildableUnit.canUpgrade !== false) { + this.eventBus.emit( + new SendUpgradeStructureIntentEvent( + buildableUnit.canUpgrade, + buildableUnit.type, + ), + ); + } else if (buildableUnit.canBuild) { + this.eventBus.emit( + new BuildUnitIntentEvent( + buildableUnit.type, + new Cell(this.game.x(tile), this.game.y(tile)), + ), + ); + } this.hideMenu(); - }; + } render() { return html` @@ -361,13 +413,23 @@ export class BuildMenu extends LitElement implements Layer { ${this.filteredBuildTable.map( (row) => html`
- ${row.map( - (item) => html` + ${row.map((item) => { + const buildableUnit = this.playerActions?.buildableUnits.find( + (bu) => bu.type === item.unitType, + ); + if (buildableUnit === undefined) { + return html``; + } + const enabled = + buildableUnit.canBuild !== false || + buildableUnit.canUpgrade !== false; + return html`
` : ""} - `, - )} + `; + })} `, )} diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 889052357..ccec0c13c 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -127,6 +127,7 @@ export class MainRadialMenu extends LitElement implements Layer { playerPanel: this.playerPanel, chatIntegration: this.chatIntegration, closeMenu: () => this.closeMenu(), + eventBus: this.eventBus, }; this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement); diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 3c4bc5937..42eff7ba7 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -1,14 +1,8 @@ import { EventBus } from "../../../core/EventBus"; -import { - Cell, - PlayerActions, - PlayerID, - UnitType, -} from "../../../core/game/Game"; +import { Cell, PlayerActions, PlayerID } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { PlayerView } from "../../../core/game/GameView"; import { - BuildUnitIntentEvent, SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, @@ -67,13 +61,6 @@ export class PlayerActionHandler { ): Promise { return await player.bestTransportShipSpawn(tile); } - - handleBuildUnit(unitType: UnitType, cellX: number, cellY: number) { - this.eventBus.emit( - new BuildUnitIntentEvent(unitType, new Cell(cellX, cellY)), - ); - } - handleSpawn(spawnCell: Cell) { this.eventBus.emit(new SendSpawnIntentEvent(spawnCell)); } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 28791dcf8..aafd42fad 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -26,6 +26,7 @@ import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; import infoIcon from "../../../../resources/images/InfoIcon.svg"; import targetIcon from "../../../../resources/images/TargetIconWhite.svg"; import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; +import { EventBus } from "../../../core/EventBus"; export interface MenuElementParams { myPlayer: PlayerView; @@ -38,6 +39,7 @@ export interface MenuElementParams { playerActionHandler: PlayerActionHandler; playerPanel: PlayerPanel; chatIntegration: ChatIntegration; + eventBus: EventBus; closeMenu: () => void; } @@ -371,8 +373,10 @@ export const buildMenuElement: MenuElement = { ? item.key.replace("unit_type.", "") : item.unitType.toString(), disabled: (params: MenuElementParams) => - !params.buildMenu.canBuild(item), - color: params.buildMenu.canBuild(item) ? COLORS.building : undefined, + !params.buildMenu.canBuildOrUpgrade(item), + color: params.buildMenu.canBuildOrUpgrade(item) + ? COLORS.building + : undefined, icon: item.icon, tooltipItems: [ { text: translateText(item.key || ""), className: "title" }, @@ -389,11 +393,15 @@ export const buildMenuElement: MenuElement = { : null, ].filter((item): item is TooltipItem => item !== null), action: (params: MenuElementParams) => { - params.playerActionHandler.handleBuildUnit( - item.unitType, - params.game.x(params.tile), - params.game.y(params.tile), + const buildableUnit = params.playerActions.buildableUnits.find( + (bu) => bu.type === item.unitType, ); + if (buildableUnit === undefined) { + return; + } + if (params.buildMenu.canBuildOrUpgrade(item)) { + params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile); + } params.closeMenu(); }, })); diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 510b87399..69fb22df0 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -430,7 +430,6 @@ export class DefaultConfig implements Config { ), territoryBound: true, constructionDuration: this.instantBuild() ? 0 : 5 * 10, - upgradable: true, }; case UnitType.SAMLauncher: return { @@ -478,6 +477,7 @@ export class DefaultConfig implements Config { constructionDuration: this.instantBuild() ? 0 : 2 * 10, canBuildTrainStation: true, experimental: true, + upgradable: true, }; case UnitType.Construction: return { diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 829705cba..f0485a89f 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -107,6 +107,7 @@ export class Executor { case "embargo": return new EmbargoExecution(player, intent.targetID, intent.action); case "build_unit": + // TODO: fix this return new ConstructionExecution( player, this.mg.ref(intent.x, intent.y), diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 1241484e8..41eeed4b0 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -522,6 +522,7 @@ export interface Player { spawnTile: TileRef, params: UnitParams, ): Unit; + upgradeUnit(unit: Unit): void; captureUnit(unit: Unit): void; @@ -682,6 +683,8 @@ export interface PlayerActions { export interface BuildableUnit { canBuild: TileRef | false; + // unit id of the existing unit that can be upgraded, or false if it cannot be upgraded. + canUpgrade: number | false; type: UnitType; cost: Gold; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 828fbca77..e415a7aeb 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -794,6 +794,27 @@ export class PlayerImpl implements Player { return b; } + // Returns the existing unit that can be upgraded, + // or false if it cannot be upgraded. + // New units of the same type can upgrade existing units. + // e.g. if a place a new city here, will it upgrade an existing city? + private canUpgradeExistingUnit( + type: UnitType, + targetTile: TileRef, + ): Unit | false { + if (!this.mg.config().unitInfo(type).upgradable) { + return false; + } + const range = this.mg.config().structureMinDist(); + const existing = this.mg + .nearbyUnits(targetTile, range, type) + .sort((a, b) => a.distSquared - b.distSquared); + if (existing.length > 0) { + return existing[0].unit; + } + return false; + } + upgradeUnit(unit: Unit) { const cost = this.mg.unitInfo(unit.type()).cost(this); this.removeGold(cost); @@ -803,11 +824,19 @@ export class PlayerImpl implements Player { public buildableUnits(tile: TileRef): BuildableUnit[] { const validTiles = this.validStructureSpawnTiles(tile); return Object.values(UnitType).map((u) => { + let canUpgrade: number | false = false; + if (!this.mg.inSpawnPhase()) { + const existingUnit = this.canUpgradeExistingUnit(u, tile); + if (existingUnit !== false) { + canUpgrade = existingUnit.id(); + } + } return { type: u, canBuild: this.mg.inSpawnPhase() ? false : this.canBuild(u, tile, validTiles), + canUpgrade: canUpgrade, cost: this.mg.config().unitInfo(u).cost(this), } as BuildableUnit; }); diff --git a/tests/PlayerImpl.test.ts b/tests/PlayerImpl.test.ts new file mode 100644 index 000000000..64aecd954 --- /dev/null +++ b/tests/PlayerImpl.test.ts @@ -0,0 +1,48 @@ +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { setup } from "./util/Setup"; + +let game: Game; +let player: Player; + +describe("PlayerImpl", () => { + beforeEach(async () => { + game = await setup( + "plains", + { + infiniteGold: true, + instantBuild: true, + }, + [new PlayerInfo("player", PlayerType.Human, null, "player_id")], + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + player = game.player("player_id"); + }); + + test("City can be upgraded", () => { + const city = player.buildUnit(UnitType.City, game.ref(0, 0), {}); + const buCity = player + .buildableUnits(game.ref(0, 0)) + .find((bu) => bu.type === UnitType.City); + expect(buCity).toBeDefined(); + expect(buCity!.canUpgrade).toBe(city.id()); + }); + + test("DefensePost cannot be upgraded", () => { + player.buildUnit(UnitType.DefensePost, game.ref(0, 0), {}); + const buDefensePost = player + .buildableUnits(game.ref(0, 0)) + .find((bu) => bu.type === UnitType.DefensePost); + expect(buDefensePost).toBeDefined(); + expect(buDefensePost!.canUpgrade).toBeFalsy(); + }); +});