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();
+ });
+});