From 7fa11ed035ba6d6939f11ae2af1e2aadbbcb32fe Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Sun, 15 Jun 2025 08:23:13 +0200 Subject: [PATCH] Set a targetable status for nukes (#1174) ## Description: Set a targetable status for units (specifically atom bomb and hydro) A nuke is targetable near launch and target but is untargetable mid air. An untargetable unit is half transparent to show that it cannot be destroyed. ![targetable](https://github.com/user-attachments/assets/cc6769ff-95ab-4294-9a8e-10f909711f68) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Vivacious Box --- src/client/graphics/layers/UnitLayer.ts | 8 +++++ src/core/execution/NukeExecution.ts | 24 +++++++++++++++ src/core/execution/SAMLauncherExecution.ts | 14 +-------- src/core/game/Game.ts | 2 ++ src/core/game/GameUpdates.ts | 1 + src/core/game/GameView.ts | 4 +++ src/core/game/UnitImpl.ts | 14 +++++++++ tests/SAM.test.ts | 33 +++++++++++++++++++-- tests/core/executions/NukeExecution.test.ts | 23 ++++++++++++++ 9 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 03afa99c9..091bc02ef 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -535,6 +535,11 @@ export class UnitLayer implements Layer { ); if (unit.isActive()) { + const targetable = unit.targetable(); + if (!targetable) { + this.context.save(); + this.context.globalAlpha = 0.4; + } this.context.drawImage( sprite, Math.round(x - sprite.width / 2), @@ -542,6 +547,9 @@ export class UnitLayer implements Layer { sprite.width, sprite.width, ); + if (!targetable) { + this.context.restore(); + } } } } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 437d6cf12..deefc9936 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -13,6 +13,8 @@ import { ParabolaPathFinder } from "../pathfinding/PathFinding"; import { PseudoRandom } from "../PseudoRandom"; import { NukeType } from "../StatsSchemas"; +const NUKE_TARGETABLE_RADIUS = 120; + const SPRITE_RADIUS = 16; export class NukeExecution implements Execution { @@ -99,6 +101,7 @@ export class NukeExecution implements Execution { this.active = false; return; } + this.src = spawn; this.pathFinder.computeControlPoints( spawn, this.dst, @@ -163,10 +166,31 @@ export class NukeExecution implements Execution { this.detonate(); return; } else { + this.updateNukeTargetable(); this.nuke.move(nextTile); } } + public getNuke(): Unit | null { + return this.nuke; + } + + private updateNukeTargetable() { + if (this.nuke === null || this.nuke.targetTile() === undefined) { + return; + } + const targetRangeSquared = NUKE_TARGETABLE_RADIUS * NUKE_TARGETABLE_RADIUS; + const targetTile = this.nuke.targetTile(); + this.nuke.setTargetable( + this.mg.euclideanDistSquared(this.nuke.tile(), targetTile!) < + targetRangeSquared || + (this.src !== undefined && + this.src !== null && + this.mg.euclideanDistSquared(this.src, this.nuke.tile()) < + targetRangeSquared), + ); + } + private detonate() { if (this.nuke === null) { throw new Error("Not initialized"); diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index e183c67cf..25518860e 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -37,18 +37,6 @@ export class SAMLauncherExecution implements Execution { this.mg = mg; } - private nukeTargetInRange(nuke: Unit) { - const targetTile = nuke.targetTile(); - if (this.sam === null || targetTile === undefined) { - return false; - } - const targetRangeSquared = this.targetRangeRadius * this.targetRangeRadius; - return ( - this.mg.euclideanDistSquared(this.sam.tile(), targetTile) < - targetRangeSquared - ); - } - private getSingleTarget(): Unit | null { if (this.sam === null) return null; const nukes = this.mg @@ -60,7 +48,7 @@ export class SAMLauncherExecution implements Execution { ({ unit }) => unit.owner() !== this.player && !this.player.isFriendly(unit.owner()) && - this.nukeTargetInRange(unit), + unit.isTargetable(), ); return ( diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index ab03b752e..173a0fb55 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -383,6 +383,8 @@ export interface Unit { targetedBySAM(): boolean; setReachedTarget(): void; reachedTarget(): boolean; + isTargetable(): boolean; + setTargetable(targetable: boolean): void; // Health hasHealth(): boolean; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 48e8fbd1f..46b402292 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -76,6 +76,7 @@ export interface UnitUpdate { isActive: boolean; reachedTarget: boolean; retreating: boolean; + targetable: boolean; 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 4b7b8df41..a3f8527cb 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -72,6 +72,10 @@ export class UnitView { return this.data.id; } + targetable(): boolean { + return this.data.targetable; + } + type(): UnitType { return this.data.unitType; } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 85ff736c7..c1dc89714 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -30,6 +30,8 @@ export class UnitImpl implements Unit { private _readyMissileCount: number = 1; private _patrolTile: TileRef | undefined; private _level: number = 1; + private _targetable: boolean = true; + constructor( private _type: UnitType, private mg: GameImpl, @@ -63,6 +65,17 @@ export class UnitImpl implements Unit { } } + setTargetable(targetable: boolean): void { + if (this._targetable !== targetable) { + this._targetable = targetable; + this.mg.addUpdate(this.toUpdate()); + } + } + + isTargetable(): boolean { + return this._targetable; + } + setPatrolTile(tile: TileRef): void { this._patrolTile = tile; } @@ -101,6 +114,7 @@ export class UnitImpl implements Unit { reachedTarget: this._reachedTarget, retreating: this._retreating, pos: this._tile, + targetable: this._targetable, lastPos: this._lastTile, health: this.hasHealth() ? Number(this._health) : undefined, constructionType: this._constructionType, diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts index ef3458940..0a14eae05 100644 --- a/tests/SAM.test.ts +++ b/tests/SAM.test.ts @@ -133,10 +133,37 @@ describe("SAM", () => { expect([sam1, sam2].filter((s) => s.isInCooldown())).toHaveLength(1); }); - test("SAMs should target only nukes aimed at nearby targets", async () => { + test("SAMs should target close to launch site", async () => { const targetDistance = 199; - // Close SAM: should not intercept anything - const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + // Close SAM: should intercept the nuke + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + const nukeExecution = new NukeExecution( + UnitType.AtomBomb, + attacker, + game.ref(targetDistance, 1), + null, + ); + game.addExecution(nukeExecution); + // Long distance nuke: compute the proper number of ticks + const ticksToExecute = Math.ceil( + targetDistance / game.config().defaultNukeSpeed(), + ); + executeTicks(game, ticksToExecute); + + expect(nukeExecution.isActive()).toBeFalsy(); + expect(sam.isInCooldown()).toBeTruthy(); + }); + + test("SAMs should target only nukes aimed at nearby targets if not close to launch site", async () => { + const targetDistance = 199; + // Middle SAM: should not intercept the nuke + const sam1 = defender.buildUnit( + UnitType.SAMLauncher, + game.ref(targetDistance / 2, 1), + {}, + ); game.addExecution(new SAMLauncherExecution(defender, null, sam1)); // Far SAM: Should intercept the nuke. Use the far_defender so the SAM can be built diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index 5cb1e6c9c..631777a37 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -69,4 +69,27 @@ describe("NukeExecution", () => { expect(sam.touch).toHaveBeenCalled(); expect(defensePost.touch).not.toHaveBeenCalled(); }); + + test("nuke should only be targetable near src and dst", async () => { + const nukeExec = new NukeExecution( + UnitType.AtomBomb, + player, + game.ref(199, 199), + game.ref(1, 1), + ); + game.addExecution(nukeExec); + // targetable distance is 14400 + + //near launch should be targetable (distance src < 14400) + executeTicks(game, 2); + expect(nukeExec.getNuke()!.isTargetable()).toBeTruthy(); + + //mid air should not be targetable (distance src > 14400, distance target > 14400) + executeTicks(game, 38); + expect(nukeExec.getNuke()!.isTargetable()).toBeFalsy(); + + //near target should be targetable (distance target < 14400) + executeTicks(game, 10); + expect(nukeExec.getNuke()!.isTargetable()).toBeTruthy(); + }); });