From dddf54be0b75fd055fac1d0507709de69620919d Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Tue, 21 Oct 2025 19:07:14 +0200 Subject: [PATCH] Add deletion duration and indicators (#2216) ## Description: Adds a timer before self deleting units Adds a loading bar under deleting units Adds a timer in radial menu for clarity purposes ![deletecd](https://github.com/user-attachments/assets/613bf742-ef90-42b5-a258-b928daae6aaa) ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: Mr.Box --------- Co-authored-by: Evan --- src/client/graphics/layers/RadialMenu.ts | 25 ++++++++ .../graphics/layers/RadialMenuElements.ts | 8 ++- .../graphics/layers/StructureDrawingUtils.ts | 58 ++++++++++++++----- .../graphics/layers/StructureIconsLayer.ts | 11 ++++ src/client/graphics/layers/UILayer.ts | 31 ++++++++-- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 3 + src/core/execution/DeleteUnitExecution.ts | 34 +++++++---- .../execution/UpgradeStructureExecution.ts | 2 +- src/core/game/Game.ts | 5 +- src/core/game/GameUpdates.ts | 1 + src/core/game/GameView.ts | 15 +++-- src/core/game/PlayerImpl.ts | 13 +++-- src/core/game/UnitImpl.ts | 27 +++++++++ tests/DeleteUnitExecution.test.ts | 38 +++++++++--- tests/util/TestConfig.ts | 4 ++ 16 files changed, 225 insertions(+), 51 deletions(-) diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 2633f1cbd..1b5981bb1 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -550,6 +550,20 @@ export class RadialMenu implements Layer { .attr("x", arc.centroid(d)[0] - this.config.iconSize / 2) .attr("y", arc.centroid(d)[1] - this.config.iconSize / 2) .attr("opacity", disabled ? 0.5 : 1); + + if (this.params && d.data.cooldown?.(this.params)) { + const cooldown = Math.ceil(d.data.cooldown?.(this.params)); + content + .append("text") + .attr("class", `cooldown-text`) + .text(cooldown + "s") + .attr("fill", "white") + .attr("opacity", disabled ? 0.5 : 1) + .attr("font-size", "14px") + .attr("font-weight", "bold") + .attr("x", arc.centroid(d)[0] - this.config.iconSize / 4) + .attr("y", arc.centroid(d)[1] + this.config.iconSize / 2 + 7); + } } this.menuIcons.set(contentId, content as any); @@ -994,6 +1008,17 @@ export class RadialMenu implements Layer { if (!imageElement.empty()) { imageElement.attr("opacity", disabled ? 0.5 : 1); } + + // Update cooldown text if applicable + const cooldownElement = icon.select(".cooldown-text"); + if (this.params && !cooldownElement.empty() && item.cooldown) { + const cooldown = Math.ceil(item.cooldown(this.params)); + if (cooldown <= 0) { + cooldownElement.remove(); + } else { + cooldownElement.text(cooldown + "s"); + } + } } } }); diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 9e1fe21ef..ae4c29d9d 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -51,6 +51,7 @@ export interface MenuElement { tooltipItems?: TooltipItem[]; tooltipKeys?: TooltipKey[]; + cooldown?: (params: MenuElementParams) => number; disabled: (params: MenuElementParams) => boolean; action?: (params: MenuElementParams) => void; // For leaf items that perform actions subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus @@ -425,6 +426,7 @@ export const attackMenuElement: MenuElement = { export const deleteUnitElement: MenuElement = { id: Slot.Delete, name: "delete", + cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(), disabled: (params: MenuElementParams) => { const tileOwner = params.game.owner(params.tile); const isLand = params.game.isLand(params.tile); @@ -441,7 +443,7 @@ export const deleteUnitElement: MenuElement = { return true; } - if (!params.myPlayer.canDeleteUnit()) { + if (params.myPlayer.deleteUnitCooldown() > 0) { return true; } @@ -450,8 +452,10 @@ export const deleteUnitElement: MenuElement = { .units() .filter( (unit) => + unit.constructionType() === undefined && + unit.markedForDeletion() === false && params.game.manhattanDist(unit.tile(), params.tile) <= - DELETE_SELECTION_RADIUS, + DELETE_SELECTION_RADIUS, ); return myUnits.length === 0; diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts index a3693dc74..a8a7ef930 100644 --- a/src/client/graphics/layers/StructureDrawingUtils.ts +++ b/src/client/graphics/layers/StructureDrawingUtils.ts @@ -99,10 +99,7 @@ export class SpriteFactory { private invalidateTextureCache(unitType: UnitType) { for (const key of Array.from(this.textureCache.keys())) { - if ( - key.endsWith(`-${unitType}-icon`) || - key === `construction-${unitType}-icon` - ) { + if (key.includes(`-${unitType}`)) { this.textureCache.delete(key); } } @@ -115,7 +112,13 @@ export class SpriteFactory { structureType: UnitType, ): PIXI.Container { const parentContainer = new PIXI.Container(); - const texture = this.createTexture(structureType, player, false, true); + const texture = this.createTexture( + structureType, + player, + false, + false, + true, + ); const sprite = new PIXI.Sprite(texture); sprite.anchor.set(0.5); sprite.alpha = 0.5; @@ -139,6 +142,7 @@ export class SpriteFactory { const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos); + const isMarkedForDeletion = unit.markedForDeletion() !== false; const isConstruction = unit.type() === UnitType.Construction; const constructionType = unit.constructionType(); const structureType = isConstruction ? constructionType! : unit.type(); @@ -156,6 +160,7 @@ export class SpriteFactory { structureType, unit.owner(), isConstruction, + isMarkedForDeletion, type === "icon", ); const sprite = new PIXI.Sprite(texture); @@ -202,19 +207,30 @@ export class SpriteFactory { type: UnitType, owner: PlayerView, isConstruction: boolean, + isMarkedForDeletion: boolean, renderIcon: boolean, ): PIXI.Texture { - const cacheKey = isConstruction - ? `construction-${type}` + (renderIcon ? "-icon" : "") - : `${this.theme.territoryColor(owner).toRgbString()}-${type}` + - (renderIcon ? "-icon" : ""); + const cacheKeyBase = isConstruction + ? `construction-${type}` + : `${this.theme.territoryColor(owner).toRgbString()}-${type}`; + const cacheKey = + cacheKeyBase + + (renderIcon ? "-icon" : "") + + (isMarkedForDeletion ? "-deleted" : ""); if (this.textureCache.has(cacheKey)) { return this.textureCache.get(cacheKey)!; } const shape = STRUCTURE_SHAPES[type]; const texture = shape - ? this.createIcon(owner, type, isConstruction, shape, renderIcon) + ? this.createIcon( + owner, + type, + isConstruction, + isMarkedForDeletion, + shape, + renderIcon, + ) : PIXI.Texture.EMPTY; this.textureCache.set(cacheKey, texture); return texture; @@ -224,6 +240,7 @@ export class SpriteFactory { owner: PlayerView, structureType: UnitType, isConstruction: boolean, + isMarkedForDeletion: boolean, shape: string, renderIcon: boolean, ): PIXI.Texture { @@ -370,11 +387,8 @@ export class SpriteFactory { } const structureInfo = this.structuresInfos.get(structureType); - if (!structureInfo?.image) { - return PIXI.Texture.from(structureCanvas); - } - if (renderIcon) { + if (structureInfo?.image && renderIcon) { const SHAPE_OFFSETS = { triangle: [6, 11], square: [5, 5], @@ -390,6 +404,22 @@ export class SpriteFactory { offsetY, ); } + + if (isMarkedForDeletion) { + context.save(); + context.strokeStyle = "rgba(255, 64, 64, 0.95)"; + context.lineWidth = Math.max(2, Math.round(iconSize * 0.12)); + context.lineCap = "round"; + const padding = Math.max(2, iconSize * 0.12); + context.beginPath(); + context.moveTo(padding, padding); + context.lineTo(iconSize - padding, iconSize - padding); + context.moveTo(iconSize - padding, padding); + context.lineTo(padding, iconSize - padding); + context.stroke(); + context.restore(); + } + return PIXI.Texture.from(structureCanvas); } diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 4f86c8070..009316f69 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -417,6 +417,7 @@ export class StructureIconsLayer implements Layer { const render = this.findRenderByUnit(unitView); if (render) { this.checkForConstructionState(render, unitView); + this.checkForDeletionState(render, unitView); this.checkForOwnershipChange(render, unitView); this.checkForLevelChange(render, unitView); } @@ -466,6 +467,16 @@ export class StructureIconsLayer implements Layer { } } + private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) { + if (unit.markedForDeletion() !== false) { + render.iconContainer?.destroy(); + render.dotContainer?.destroy(); + render.iconContainer = this.createIconSprite(unit); + render.dotContainer = this.createDotSprite(unit); + this.modifyVisibility(render); + } + } + private checkForConstructionState( render: StructureRenderInfo, unit: UnitView, diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 5bda48ef6..43092a642 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -117,11 +117,18 @@ export class UILayer implements Layer { this.drawHealthBar(unit); break; } + case UnitType.City: + case UnitType.Factory: + case UnitType.DefensePost: + case UnitType.Port: case UnitType.MissileSilo: - this.createLoadingBar(unit); - break; case UnitType.SAMLauncher: - this.createLoadingBar(unit); + if ( + unit.markedForDeletion() !== false || + unit.missileReadinesss() < 1 + ) { + this.createLoadingBar(unit); + } break; default: return; @@ -329,12 +336,28 @@ export class UILayer implements Layer { } case UnitType.MissileSilo: case UnitType.SAMLauncher: - return unit.missileReadinesss(); + return !unit.markedForDeletion() + ? unit.missileReadinesss() + : this.deletionProgress(this.game, unit); + case UnitType.City: + case UnitType.Factory: + case UnitType.Port: + case UnitType.DefensePost: + return this.deletionProgress(this.game, unit); default: return 1; } } + private deletionProgress(game: GameView, unit: UnitView): number { + const deleteAt = unit.markedForDeletion(); + if (deleteAt === false) return 1; + return Math.max( + 0, + (deleteAt - game.ticks()) / game.config().deletionMarkDuration(), + ); + } + public createLoadingBar(unit: UnitView) { if (!this.context) { return; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index baea027af..9811680d8 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -130,6 +130,7 @@ export interface Config { emojiMessageCooldown(): Tick; emojiMessageDuration(): Tick; donateCooldown(): Tick; + deletionMarkDuration(): Tick; deleteUnitCooldown(): Tick; defaultDonationAmount(sender: Player): number; unitInfo(type: UnitType): UnitInfo; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 2a4388d11..ba5ccec6e 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -569,6 +569,9 @@ export class DefaultConfig implements Config { donateCooldown(): Tick { return 10 * 10; } + deletionMarkDuration(): Tick { + return 15 * 10; + } deleteUnitCooldown(): Tick { return 5 * 10; } diff --git a/src/core/execution/DeleteUnitExecution.ts b/src/core/execution/DeleteUnitExecution.ts index 424130eea..9ba68ee50 100644 --- a/src/core/execution/DeleteUnitExecution.ts +++ b/src/core/execution/DeleteUnitExecution.ts @@ -1,8 +1,9 @@ -import { Execution, Game, MessageType, Player } from "../game/Game"; +import { Execution, Game, MessageType, Player, Unit } from "../game/Game"; export class DeleteUnitExecution implements Execution { private active: boolean = true; private mg: Game; + private unit: Unit | null = null; constructor( private player: Player, @@ -33,6 +34,7 @@ export class DeleteUnitExecution implements Execution { this.active = false; return; } + this.unit = unit; const tileOwner = mg.owner(unit.tile()); if (!tileOwner.isPlayer() || tileOwner.id() !== this.player.id()) { @@ -61,19 +63,29 @@ export class DeleteUnitExecution implements Execution { return; } - unit.delete(false); this.player.recordDeleteUnit(); - - this.mg.displayMessage( - `events_display.unit_voluntarily_deleted`, - MessageType.UNIT_DESTROYED, - this.player.id(), - ); - - this.active = false; + unit.markForDeletion(); } - tick(ticks: number) {} + tick(ticks: number) { + if (!this.active || !this.unit) { + return; + } + if (!this.unit.isActive()) { + this.active = false; + return; + } + if (this.unit.isOverdueDeletion()) { + this.unit.delete(false); + + this.mg.displayMessage( + `events_display.unit_voluntarily_deleted`, + MessageType.UNIT_DESTROYED, + this.player.id(), + ); + this.active = false; + } + } isActive(): boolean { return this.active; diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts index 1d83f1c77..b0d575a30 100644 --- a/src/core/execution/UpgradeStructureExecution.ts +++ b/src/core/execution/UpgradeStructureExecution.ts @@ -19,7 +19,7 @@ export class UpgradeStructureExecution implements Execution { return; } - if (!this.player.canUpgradeUnit(this.structure.type())) { + if (!this.player.canUpgradeUnit(this.structure)) { console.warn( `[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`, ); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index db48e0a2f..2e490b1ef 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -432,6 +432,9 @@ export interface Unit { type(): UnitType; owner(): Player; info(): UnitInfo; + isMarkedForDeletion(): boolean; + markForDeletion(): void; + isOverdueDeletion(): boolean; delete(displayMessage?: boolean, destroyer?: Player): void; tile(): TileRef; lastTile(): TileRef; @@ -573,7 +576,7 @@ export interface Player { // New units of the same type can upgrade existing units. // e.g. if a place a new city here, can it upgrade an existing city? findUnitToUpgrade(type: UnitType, targetTile: TileRef): Unit | false; - canUpgradeUnit(unitType: UnitType): boolean; + canUpgradeUnit(unit: Unit): boolean; upgradeUnit(unit: Unit): void; captureUnit(unit: Unit): void; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index f117e210a..922212923 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -123,6 +123,7 @@ export interface UnitUpdate { reachedTarget: boolean; retreating: boolean; targetable: boolean; + markedForDeletion: number | false; targetUnitId?: number; // Only for trade ships targetTile?: TileRef; // Only for nukes health?: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d6ed3730d..ccceacef9 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -87,6 +87,10 @@ export class UnitView { return this.data.targetable; } + markedForDeletion(): number | false { + return this.data.markedForDeletion; + } + type(): UnitType { return this.data.unitType; } @@ -430,10 +434,13 @@ export class PlayerView { return this.data.lastDeleteUnitTick; } - canDeleteUnit(): boolean { + deleteUnitCooldown(): number { return ( - this.game.ticks() + 1 - this.lastDeleteUnitTick() >= - this.game.config().deleteUnitCooldown() + Math.max( + 0, + this.game.config().deleteUnitCooldown() - + (this.game.ticks() + 1 - this.lastDeleteUnitTick()), + ) / 10 ); } } @@ -573,7 +580,7 @@ export class GameView implements GameMap { tile: TileRef, searchRange: number, type: UnitType, - playerId: PlayerID, + playerId?: PlayerID, ) { return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId); } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index d9ba8ba7c..fb531f94e 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -853,20 +853,23 @@ export class PlayerImpl implements Player { return false; } const unit = existing[0].unit; - if (!this.canUpgradeUnit(unit.type())) { + if (!this.canUpgradeUnit(unit)) { return false; } return unit; } - public canUpgradeUnit(unitType: UnitType): boolean { - if (!this.mg.config().unitInfo(unitType).upgradable) { + public canUpgradeUnit(unit: Unit): boolean { + if (unit.isMarkedForDeletion()) { return false; } - if (this.mg.config().isUnitDisabled(unitType)) { + if (!this.mg.config().unitInfo(unit.type()).upgradable) { return false; } - if (this._gold < this.mg.config().unitInfo(unitType).cost(this)) { + if (this.mg.config().isUnitDisabled(unit.type())) { + return false; + } + if (this._gold < this.mg.config().unitInfo(unit.type()).cost(this)) { return false; } return true; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index fc1eab97a..917d0af67 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -39,6 +39,7 @@ export class UnitImpl implements Unit { // Nuke only private _trajectoryIndex: number = 0; private _trajectory: TrajectoryTile[]; + private _deletionAt: number | null = null; constructor( private _type: UnitType, @@ -126,6 +127,7 @@ export class UnitImpl implements Unit { reachedTarget: this._reachedTarget, retreating: this._retreating, pos: this._tile, + markedForDeletion: this._deletionAt ?? false, targetable: this._targetable, lastPos: this._lastTile, health: this.hasHealth() ? Number(this._health) : undefined, @@ -182,6 +184,7 @@ export class UnitImpl implements Unit { } setOwner(newOwner: PlayerImpl): void { + this.clearPendingDeletion(); switch (this._type) { case UnitType.Warship: case UnitType.Port: @@ -221,6 +224,30 @@ export class UnitImpl implements Unit { } } + clearPendingDeletion(): void { + this._deletionAt = null; + } + + isMarkedForDeletion(): boolean { + return this._deletionAt !== null; + } + + markForDeletion(): void { + if (!this.isActive()) { + return; + } + this._deletionAt = + this.mg.ticks() + this.mg.config().deletionMarkDuration(); + this.mg.addUpdate(this.toUpdate()); + } + + isOverdueDeletion(): boolean { + if (!this.isActive()) { + return false; + } + return this._deletionAt !== null && this.mg.ticks() - this._deletionAt > 0; + } + delete(displayMessage?: boolean, destroyer?: Player): void { if (!this.isActive()) { throw new Error(`cannot delete ${this} not active`); diff --git a/tests/DeleteUnitExecution.test.ts b/tests/DeleteUnitExecution.test.ts index c8486a1fe..a931c140b 100644 --- a/tests/DeleteUnitExecution.test.ts +++ b/tests/DeleteUnitExecution.test.ts @@ -10,6 +10,7 @@ import { } from "../src/core/game/Game"; import { TileRef } from "../src/core/game/GameMap"; import { setup } from "./util/Setup"; +import { executeTicks } from "./util/utils"; describe("DeleteUnitExecution Security Tests", () => { let game: Game; @@ -79,6 +80,7 @@ describe("DeleteUnitExecution Security Tests", () => { execution.init(game, 0); expect(execution.isActive()).toBe(false); + expect(enemyUnit.isMarkedForDeletion()).toBe(false); }); it("should prevent deleting units on enemy territory", () => { @@ -90,6 +92,7 @@ describe("DeleteUnitExecution Security Tests", () => { execution.init(game, 0); expect(execution.isActive()).toBe(false); + expect(unit.isMarkedForDeletion()).toBe(false); } }); @@ -100,15 +103,7 @@ describe("DeleteUnitExecution Security Tests", () => { execution.init(game, 0); expect(execution.isActive()).toBe(false); - }); - - it("should allow deleting the last city (suicide)", () => { - jest.spyOn(game, "inSpawnPhase").mockReturnValue(false); - - const execution = new DeleteUnitExecution(player, unit.id()); - execution.init(game, 0); - - expect(unit.isActive()).toBe(false); + expect(unit.isMarkedForDeletion()).toBe(false); }); it("should allow deleting units when all conditions are met", () => { @@ -117,7 +112,32 @@ describe("DeleteUnitExecution Security Tests", () => { const execution = new DeleteUnitExecution(player, unit.id()); execution.init(game, 0); + expect(unit.isMarkedForDeletion()).toBe(true); + }); + + it("should delete after deletion delay", () => { + jest.spyOn(game, "inSpawnPhase").mockReturnValue(false); + + const execution = new DeleteUnitExecution(player, unit.id()); + game.addExecution(execution); + + game.executeNextTick(); + expect(unit.isMarkedForDeletion()).toBe(true); + expect(unit.isOverdueDeletion()).toBe(false); + executeTicks(game, game.config().deletionMarkDuration() + 1); expect(unit.isActive()).toBe(false); }); + + it("should reset deletion if captured", () => { + jest.spyOn(game, "inSpawnPhase").mockReturnValue(false); + + const execution = new DeleteUnitExecution(player, unit.id()); + game.addExecution(execution); + game.executeNextTick(); + expect(unit.isMarkedForDeletion()).toBe(true); + unit.setOwner(enemyPlayer); + expect(unit.isMarkedForDeletion()).toBe(false); + expect(unit.isActive()).toBe(true); + }); }); }); diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts index f0b10ac37..252f10bdd 100644 --- a/tests/util/TestConfig.ts +++ b/tests/util/TestConfig.ts @@ -46,6 +46,10 @@ export class TestConfig extends DefaultConfig { return 20; } + deletionMarkDuration(): number { + return 5; + } + defaultSamRange(): number { return 20; }