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 <evanpelle@gmail.com>
This commit is contained in:
Vivacious Box
2025-10-21 19:07:14 +02:00
committed by GitHub
parent 19597a37d9
commit dddf54be0b
16 changed files with 225 additions and 51 deletions
+25
View File
@@ -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");
}
}
}
}
});
@@ -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;
@@ -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);
}
@@ -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,
+27 -4
View File
@@ -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;
+1
View File
@@ -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;
+3
View File
@@ -569,6 +569,9 @@ export class DefaultConfig implements Config {
donateCooldown(): Tick {
return 10 * 10;
}
deletionMarkDuration(): Tick {
return 15 * 10;
}
deleteUnitCooldown(): Tick {
return 5 * 10;
}
+23 -11
View File
@@ -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;
@@ -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`,
);
+4 -1
View File
@@ -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;
+1
View File
@@ -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;
+11 -4
View File
@@ -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);
}
+8 -5
View File
@@ -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;
+27
View File
@@ -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`);
+29 -9
View File
@@ -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);
});
});
});
+4
View File
@@ -46,6 +46,10 @@ export class TestConfig extends DefaultConfig {
return 20;
}
deletionMarkDuration(): number {
return 5;
}
defaultSamRange(): number {
return 20;
}