diff --git a/resources/lang/en.json b/resources/lang/en.json index bf2eb1cf6d..6806fd1071 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -98,6 +98,8 @@ "warship": "Captures trade ships, destroys ships and boats" }, "not_enough_money": "Not enough money", + "select_upgrade_amount": "Select Upgrade Amount", + "upgrade_amount": "x{amount}", "warship_shift_hint": "Hold Shift and drag to select multiple warships at once" }, "chat": { @@ -1339,7 +1341,8 @@ }, "radial_menu": { "delete_unit_description": "Click to delete the nearest unit", - "delete_unit_title": "Delete Unit" + "delete_unit_title": "Delete Unit", + "upgrade_x": "Upgrade x{amount}" }, "relation": { "default": "Default", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index bcd99d26ca..2b4c6c0478 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -927,7 +927,18 @@ export class InputHandler { } private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) { - this.uiState.ghostStructure = ghostStructure; + if ( + this.uiState.ghostStructure === ghostStructure && + ghostStructure !== null + ) { + const multipliers = [1, 5, 10, 25, 50]; + const idx = multipliers.indexOf(this.uiState.upgradeMultiplier || 1); + this.uiState.upgradeMultiplier = + multipliers[(idx + 1) % multipliers.length]; + } else { + this.uiState.upgradeMultiplier = 1; + this.uiState.ghostStructure = ghostStructure; + } } /** diff --git a/src/client/Transport.ts b/src/client/Transport.ts index f57e761c9f..6fdf8eecb2 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -57,6 +57,7 @@ export class SendUpgradeStructureIntentEvent implements GameEvent { constructor( public readonly unitId: number, public readonly unitType: UnitType, + public readonly amount: number = 1, ) {} } @@ -518,6 +519,7 @@ export class Transport { type: "upgrade_structure", unit: event.unitType, unitId: event.unitId, + amount: event.amount, }); } diff --git a/src/client/UIState.ts b/src/client/UIState.ts index 90b8194fd5..2bb2307129 100644 --- a/src/client/UIState.ts +++ b/src/client/UIState.ts @@ -4,4 +4,5 @@ export interface UIState { attackRatio: number; ghostStructure: PlayerBuildableUnitType | null; rocketDirectionUp: boolean; + upgradeMultiplier: number; } diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index cba6cb3972..be50a28d01 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -457,7 +457,9 @@ export class BuildPreviewController implements Controller { radiusTileY = this.game.y(upgradeTargetTile); } - const cost = u.cost; + const multiplier = + u.canUpgrade !== false ? this.uiState.upgradeMultiplier || 1 : 1; + const cost = u.cost * BigInt(multiplier); return { ghostType: u.type, tileX: this.game.x(tileRef), @@ -467,6 +469,7 @@ export class BuildPreviewController implements Controller { canBuild: u.canBuild !== false, canUpgrade: u.canUpgrade !== false, cost: Number(cost), + multiplier: multiplier, showCost: this.userSettings.cursorCostLabel(), canAfford: myPlayer.gold() >= cost, ghostRailPaths: u.ghostRailPaths, @@ -508,6 +511,7 @@ export class BuildPreviewController implements Controller { new SendUpgradeStructureIntentEvent( this.ghostUnit.buildableUnit.canUpgrade, this.ghostUnit.buildableUnit.type, + this.uiState.upgradeMultiplier || 1, ), ); this.removeGhostStructure(); diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index dc1f92b64b..1550886a92 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -59,6 +59,7 @@ export function createRenderer( attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, + upgradeMultiplier: 1, }; //hide when the game renders diff --git a/src/client/hud/layers/BuildMenu.ts b/src/client/hud/layers/BuildMenu.ts index 0094f3ec30..edfe4fed1d 100644 --- a/src/client/hud/layers/BuildMenu.ts +++ b/src/client/hud/layers/BuildMenu.ts @@ -356,6 +356,9 @@ export class BuildMenu extends LitElement implements Controller { @state() private _hidden = true; + @state() + private _selectedUpgradeUnitType: UnitType | null = null; + public canBuildOrUpgrade(item: BuildItemDisplay): boolean { if (this.game?.myPlayer() === null || this.playerBuildables === null) { return false; @@ -382,14 +385,10 @@ export class BuildMenu extends LitElement implements Controller { return player.totalUnitLevels(item.unitType).toString(); } - public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void { + public handleBuildClick(buildableUnit: BuildableUnit, tile: TileRef): void { if (buildableUnit.canUpgrade !== false) { - this.eventBus.emit( - new SendUpgradeStructureIntentEvent( - buildableUnit.canUpgrade, - buildableUnit.type, - ), - ); + this._selectedUpgradeUnitType = buildableUnit.type; + this.requestUpdate(); } else if (buildableUnit.canBuild) { const rocketDirectionUp = buildableUnit.type === UnitType.AtomBomb || @@ -399,81 +398,168 @@ export class BuildMenu extends LitElement implements Controller { this.eventBus.emit( new BuildUnitIntentEvent(buildableUnit.type, tile, rocketDirectionUp), ); + this.hideMenu(); + } + } + + public confirmUpgrade(amount: number): void { + if (!this._selectedUpgradeUnitType) { + this.hideMenu(); + return; } + const bu = this.playerBuildables?.find( + (u) => u.type === this._selectedUpgradeUnitType, + ); + if (!bu || bu.canUpgrade === false) { + this.hideMenu(); + return; + } + this.eventBus.emit( + new SendUpgradeStructureIntentEvent(bu.canUpgrade, bu.type, amount), + ); this.hideMenu(); } + renderAmountPanel() { + if (!this._selectedUpgradeUnitType) return html``; + const bu = this.playerBuildables?.find( + (u) => u.type === this._selectedUpgradeUnitType, + ); + if (!bu) return html``; + const baseCost = bu.cost; + const playerGold = this.game?.myPlayer()?.gold() ?? 0n; + + return html` +
+

+ ${translateText("build_menu.select_upgrade_amount")} +

+
+ ${[1, 5, 10, 25, 50].map((amount) => { + const cost = baseCost * BigInt(amount); + const canAfford = playerGold >= cost; + return html` + + `; + })} +
+
+ `; + } + render() { return html`
e.preventDefault()} > - ${this.filteredBuildTable.map( - (row) => html` -
- ${row.map((item) => { - const buildableUnit = this.playerBuildables?.find( - (bu) => bu.type === item.unitType, - ); - if (buildableUnit === undefined) { - return html``; - } - const enabled = - buildableUnit.canBuild !== false || - buildableUnit.canUpgrade !== false; - return html` - - `; - })} -
- `, - )} + ${this._selectedUpgradeUnitType + ? this.renderAmountPanel() + : this.filteredBuildTable.map( + (row) => html` +
+ ${row.map((item) => { + const buildableUnit = this.playerBuildables?.find( + (bu) => bu.type === item.unitType, + ); + if (buildableUnit === undefined) { + return html``; + } + const enabled = + buildableUnit.canBuild !== false || + buildableUnit.canUpgrade !== false; + return html` + + `; + })} +
+ `, + )}
`; } hideMenu() { this._hidden = true; + this._selectedUpgradeUnitType = null; this.requestUpdate(); } diff --git a/src/client/hud/layers/RadialMenuElements.ts b/src/client/hud/layers/RadialMenuElements.ts index f376c46819..f852298ef7 100644 --- a/src/client/hud/layers/RadialMenuElements.ts +++ b/src/client/hud/layers/RadialMenuElements.ts @@ -21,6 +21,7 @@ import { PlayerPanel } from "./PlayerPanel"; import { TooltipItem } from "./RadialMenu"; import { EventBus } from "../../../core/EventBus"; +import { SendUpgradeStructureIntentEvent } from "../../Transport"; const allianceIcon = assetUrl("images/AllianceIconWhite.svg"); const boatIcon = assetUrl("images/BoatIconWhite.svg"); const buildIcon = assetUrl("images/BuildIconWhite.svg"); @@ -86,6 +87,7 @@ export const COLORS = { build: "#e6c74a", building: "#1e3a5f", boat: "#2a82c9", + disabled: "#94a3b8", ally: "#4ade80", breakAlly: "#dc2626", breakAllyNoDebuff: "#d97706", @@ -450,6 +452,60 @@ function createMenuElements( ].filter( (tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null, ), + subMenu: (params: MenuElementParams) => { + const buildableUnit = params.playerActions.buildableUnits.find( + (bu) => bu.type === item.unitType, + ); + if ( + !buildableUnit || + buildableUnit.canUpgrade === false || + !params.buildMenu.canBuildOrUpgrade(item) + ) { + return []; + } + return [1, 5, 10, 25, 50].map((amount) => { + const cost = buildableUnit.cost * BigInt(amount); + return { + id: `upgrade_${item.unitType}_${amount}`, + name: translateText("build_menu.upgrade_amount", { + amount: amount.toString(), + }), + text: translateText("build_menu.upgrade_amount", { + amount: amount.toString(), + }), + fontSize: "20px", + color: (p: MenuElementParams) => + (p.game.myPlayer()?.gold() ?? 0n) >= cost + ? COLORS.building + : COLORS.disabled, + icon: "", + tooltipItems: [ + { + text: translateText("radial_menu.upgrade_x", { + amount: amount.toString(), + }), + className: "title", + }, + { + text: `${renderNumber(cost)} ${translateText("player_panel.gold")}`, + className: "cost", + }, + ], + disabled: (p: MenuElementParams) => + (p.game.myPlayer()?.gold() ?? 0n) < cost, + action: (p: MenuElementParams) => { + p.eventBus.emit( + new SendUpgradeStructureIntentEvent( + buildableUnit.canUpgrade as number, + buildableUnit.type, + amount, + ), + ); + p.closeMenu(); + }, + }; + }); + }, action: (params: MenuElementParams) => { const buildableUnit = params.playerActions.buildableUnits.find( (bu) => bu.type === item.unitType, @@ -458,9 +514,11 @@ function createMenuElements( return; } if (params.buildMenu.canBuildOrUpgrade(item)) { - params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile); + params.buildMenu.handleBuildClick(buildableUnit, params.tile); + params.closeMenu(); + } else { + params.closeMenu(); } - params.closeMenu(); }, }; }); diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 7309139b1e..581fc00fb3 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -10,6 +10,7 @@ */ import type { Config } from "../../../core/configuration/Config"; +import { translateText } from "../../Utils"; import type { SpiralRibbon } from "../frame/SpiralTrails"; import type { AttackRingInput, @@ -986,6 +987,12 @@ export class GPURenderer { cost: data.cost, canAfford: data.canAfford, canPlace: data.canBuild || data.canUpgrade, + topText: + data.multiplier && data.multiplier > 1 + ? translateText("build_menu.upgrade_amount", { + amount: data.multiplier.toString(), + }) + : undefined, } : null, ); diff --git a/src/client/render/gl/passes/WorldTextPass.ts b/src/client/render/gl/passes/WorldTextPass.ts index 6227f69e96..8748dd5e5f 100644 --- a/src/client/render/gl/passes/WorldTextPass.ts +++ b/src/client/render/gl/passes/WorldTextPass.ts @@ -131,6 +131,7 @@ export class WorldTextPass { x: number; y: number; text: string; + topText?: string; colorR: number; colorG: number; colorB: number; @@ -331,6 +332,7 @@ export class WorldTextPass { cost: number; canAfford: boolean; canPlace: boolean; + topText?: string; } | null, ): void { if (label === null) { @@ -349,6 +351,7 @@ export class WorldTextPass { g = 0.6; b = 0.6; } + // The vertex shader adds +0.5 to (x, y) for tile-center alignment, so we // pass raw tile coords here — same convention as the other popup entries. // Y offset is applied in rebuildInstances (zoom-relative). @@ -356,6 +359,7 @@ export class WorldTextPass { x: label.tileX, y: label.tileY, text: renderNumber(label.cost), + topText: label.topText, colorR: r, colorG: g, colorB: b, @@ -486,6 +490,36 @@ export class WorldTextPass { const ghostScale = this.settings.ghostCost.screenScale * dpr * invZoom; const ghostY = label.y + this.settings.ghostCost.screenYOffset * dpr * invZoom; + + if (label.topText) { + const topY = label.y - 30 * dpr * invZoom; + layoutString( + label.topText, + this.glyph, + this.kernTable, + this.charCodes, + this.cursors, + ); + const len = Math.min(label.topText.length, MAX_CHARS); + for (let i = 0; i < len; i++) { + if (this.charCodes[i] === 0) continue; + if (count >= this.maxInstances) this.growBuffer(); + + const off = count * FLOATS_PER_INSTANCE; + this.instanceData[off + 0] = label.x; + this.instanceData[off + 1] = topY; + this.instanceData[off + 2] = this.cursors[i]; + this.instanceData[off + 3] = this.charCodes[i]; + this.instanceData[off + 4] = 1; + this.instanceData[off + 5] = label.colorR; + this.instanceData[off + 6] = label.colorG; + this.instanceData[off + 7] = label.colorB; + this.instanceData[off + 8] = ghostScale; + this.instanceData[off + 9] = GHOST_COST_OUTLINE_WIDTH; + count++; + } + } + layoutString( label.text, this.glyph, diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 12f5c35342..4f65809707 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -215,6 +215,7 @@ export interface GhostPreviewData { canBuild: boolean; // Valid placement? canUpgrade: boolean; // Upgrading existing structure? cost: number; // Gold cost + multiplier?: number; // Upgrade multiplier (e.g., 5 for x5) /** Whether to render the cost label under the ghost (user setting). */ showCost: boolean; /** True if the player has enough gold to afford this build (drives label color). */ diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index 5faafeaa12..302c65b09c 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -412,6 +412,14 @@ export class PlayerView { return owned.filter((u) => types.includes(u.type())); } + unitsOwned(type: UnitType): number { + return this.units(type).length; + } + + unitsConstructed(type: UnitType): number { + return this.units(type).filter((u) => !u.isUnderConstruction()).length; + } + nameLocation(): NameViewData | undefined { return this.nameData; } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index f47dfa1961..e7184ed212 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -520,6 +520,7 @@ export const UpgradeStructureIntentSchema = z.object({ type: z.literal("upgrade_structure"), unit: z.enum(UnitType), unitId: z.number(), + amount: z.number().int().min(1).max(50).optional(), }); export const CancelAttackIntentSchema = z.object({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index ccdb792d69..f9c2260f78 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -107,7 +107,11 @@ export class Executor { } case "upgrade_structure": - return new UpgradeStructureExecution(player, intent.unitId); + return new UpgradeStructureExecution( + player, + intent.unitId, + intent.amount, + ); case "delete_unit": return new DeleteUnitExecution(player, intent.unitId); case "quick_chat": diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts index bc1a58c0da..92c8e95c18 100644 --- a/src/core/execution/UpgradeStructureExecution.ts +++ b/src/core/execution/UpgradeStructureExecution.ts @@ -7,6 +7,7 @@ export class UpgradeStructureExecution implements Execution { constructor( private player: Player, private unitId: number, + private amount: number = 1, ) {} init(mg: Game, ticks: number): void { @@ -21,13 +22,17 @@ export class UpgradeStructureExecution implements Execution { return; } - if (!this.player.canUpgradeUnit(this.structure)) { - console.warn( - `[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`, - ); - return; + for (let i = 0; i < this.amount; i++) { + if (!this.player.canUpgradeUnit(this.structure)) { + if (i === 0) { + console.warn( + `[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`, + ); + } + break; + } + this.player.upgradeUnit(this.structure); } - this.player.upgradeUnit(this.structure); return; } diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 3bf21fee78..440b810b7e 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -65,6 +65,7 @@ describe("InputHandler AutoUpgrade", () => { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true, + upgradeMultiplier: 1, }, mockCanvas, eventBus, diff --git a/tests/client/graphics/RadialMenuElements.test.ts b/tests/client/graphics/RadialMenuElements.test.ts index 649c6fc50d..95bd75e255 100644 --- a/tests/client/graphics/RadialMenuElements.test.ts +++ b/tests/client/graphics/RadialMenuElements.test.ts @@ -115,6 +115,7 @@ describe("RadialMenuElements", () => { cost: vi.fn(() => 100), count: vi.fn(() => 5), sendBuildOrUpgrade: vi.fn(), + handleBuildClick: vi.fn(), }; mockPlayerActions = { @@ -470,7 +471,7 @@ describe("RadialMenuElements", () => { if (cityElement!.action) { cityElement!.action(mockParams); - expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled(); + expect(mockBuildMenu.handleBuildClick).toHaveBeenCalled(); expect(mockParams.closeMenu).toHaveBeenCalled(); } }); @@ -493,7 +494,7 @@ describe("RadialMenuElements", () => { if (atomBombElement!.action) { atomBombElement!.action(mockParams); - expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled(); + expect(mockBuildMenu.handleBuildClick).toHaveBeenCalled(); expect(mockParams.closeMenu).toHaveBeenCalled(); } }); diff --git a/tests/core/executions/UpgradeStructureExecution.test.ts b/tests/core/executions/UpgradeStructureExecution.test.ts new file mode 100644 index 0000000000..8557f46d1e --- /dev/null +++ b/tests/core/executions/UpgradeStructureExecution.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { UpgradeStructureExecution } from "../../../src/core/execution/UpgradeStructureExecution"; +import { + GameType, + PlayerInfo, + PlayerType, + UnitType, +} from "../../../src/core/game/Game"; +import type { TileRef } from "../../../src/core/game/GameMap"; +import { setup } from "../../util/Setup"; + +describe("UpgradeStructureExecution", () => { + it("upgrades a structure the specified amount of times", async () => { + const game = await setup( + "ocean_and_land", + { gameType: GameType.Singleplayer, instantBuild: true }, + [], + undefined, + undefined, + false, + ); + const playerInfo = new PlayerInfo( + "player1", + PlayerType.Human, + null, + "player1_id", + ); + game.addPlayer(playerInfo); + const player = game.player("player1_id")!; + + let landTile: TileRef | undefined = undefined; + for (let y = 0; y < game.map().height(); y++) { + for (let x = 0; x < game.map().width(); x++) { + const t = game.ref(x, y); + if (game.isLand(t)) { + landTile = t; + break; + } + } + if (landTile !== undefined) break; + } + + player.conquer(landTile!); + const city = player.buildUnit(UnitType.City, landTile!, {}); + + game.endSpawnPhase(); + + player.addGold(10_000_000n); + + expect(city.level()).toBe(1); + + const execution = new UpgradeStructureExecution(player, city.id(), 5); + game.addExecution(execution); + game.executeNextTick(); + + expect(city.level()).toBe(6); + }); + + it("stops upgrading early if player cannot afford remaining amounts", async () => { + const game = await setup( + "ocean_and_land", + { gameType: GameType.Singleplayer, instantBuild: true }, + [], + undefined, + undefined, + false, + ); + const playerInfo = new PlayerInfo( + "player1", + PlayerType.Human, + null, + "player1_id", + ); + game.addPlayer(playerInfo); + const player = game.player("player1_id")!; + + let landTile: TileRef | undefined = undefined; + for (let y = 0; y < game.map().height(); y++) { + for (let x = 0; x < game.map().width(); x++) { + const t = game.ref(x, y); + if (game.isLand(t)) { + landTile = t; + break; + } + } + if (landTile !== undefined) break; + } + + player.conquer(landTile!); + const city = player.buildUnit(UnitType.City, landTile!, {}); + + game.endSpawnPhase(); + + player.addGold(750_000n); + + expect(city.level()).toBe(1); + + const execution = new UpgradeStructureExecution(player, city.id(), 5); + game.addExecution(execution); + game.executeNextTick(); + + expect(city.level()).toBe(3); + }); +});