Skip to content

Commit 09a1cf8

Browse files
Add red warning circle when nuke would break alliance (#2728)
## Description: When placing a nuke (Atom Bomb or Hydrogen Bomb), the range circle now turns red to warn players when the attack would break an alliance. <img width="456" height="333" alt="Screenshot 2025-12-28 211927" src="https://github.com/user-attachments/assets/dfe6f874-3f8b-4662-8877-0af30aa20139" /> ## 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: abodcraft1 --------- Co-authored-by: iamlewis <lewismmmm@gmail.com>
1 parent 4f3d9df commit 09a1cf8

File tree

4 files changed

+142
-41
lines changed

4 files changed

+142
-41
lines changed

src/client/graphics/layers/StructureDrawingUtils.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ export class SpriteFactory {
454454
stage: PIXI.Container,
455455
pos: { x: number; y: number },
456456
level?: number,
457+
targetingAlly: boolean = false,
457458
): PIXI.Container | null {
458459
if (stage === undefined) throw new Error("Not initialized");
459460
const parentContainer = new PIXI.Container();
@@ -478,10 +479,18 @@ export class SpriteFactory {
478479
default:
479480
return null;
480481
}
482+
// Add warning colors (red/orange) when targeting an ally to indicate alliance will break
483+
const isNuke = type === UnitType.AtomBomb || type === UnitType.HydrogenBomb;
484+
const fillColor = targetingAlly && isNuke ? 0xff6b35 : 0xffffff;
485+
const fillAlpha = targetingAlly && isNuke ? 0.35 : 0.2;
486+
const strokeColor = targetingAlly && isNuke ? 0xff4444 : 0xffffff;
487+
const strokeAlpha = targetingAlly && isNuke ? 0.8 : 0.5;
488+
const strokeWidth = targetingAlly && isNuke ? 2 : 1;
489+
481490
circle
482491
.circle(0, 0, radius)
483-
.fill({ color: 0xffffff, alpha: 0.2 })
484-
.stroke({ width: 1, color: 0xffffff, alpha: 0.5 });
492+
.fill({ color: fillColor, alpha: fillAlpha })
493+
.stroke({ width: strokeWidth, color: strokeColor, alpha: strokeAlpha });
485494
parentContainer.addChild(circle);
486495
parentContainer.position.set(pos.x, pos.y);
487496
parentContainer.scale.set(this.transformHandler.scale);

src/client/graphics/layers/StructureIconsLayer.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { OutlineFilter } from "pixi-filters";
44
import * as PIXI from "pixi.js";
55
import { Theme } from "../../../core/configuration/Config";
66
import { EventBus } from "../../../core/EventBus";
7+
import { wouldNukeBreakAlliance } from "../../../core/execution/Util";
78
import {
89
BuildableUnit,
910
Cell,
@@ -65,6 +66,7 @@ export class StructureIconsLayer implements Layer {
6566
priceBox: { height: number; y: number; paddingX: number; minWidth: number };
6667
range: PIXI.Container | null;
6768
rangeLevel?: number;
69+
targetingAlly?: boolean;
6870
buildableUnit: BuildableUnit;
6971
} | null = null;
7072
private pixicanvas: HTMLCanvasElement;
@@ -258,6 +260,29 @@ export class StructureIconsLayer implements Layer {
258260
tileRef = this.game.ref(tile.x, tile.y);
259261
}
260262

263+
// Check if targeting an ally (for nuke warning visual)
264+
// Uses shared logic with NukeExecution.maybeBreakAlliances()
265+
let targetingAlly = false;
266+
const myPlayer = this.game.myPlayer();
267+
const nukeType = this.ghostUnit.buildableUnit.type;
268+
if (
269+
tileRef &&
270+
myPlayer &&
271+
(nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb)
272+
) {
273+
// Only check if player has allies
274+
const allies = myPlayer.allies();
275+
if (allies.length > 0) {
276+
targetingAlly = wouldNukeBreakAlliance({
277+
gm: this.game,
278+
targetTile: tileRef,
279+
magnitude: this.game.config().nukeMagnitudes(nukeType),
280+
allySmallIds: new Set(allies.map((a) => a.smallID())),
281+
threshold: this.game.config().nukeAllianceBreakThreshold(),
282+
});
283+
}
284+
}
285+
261286
this.game
262287
?.myPlayer()
263288
?.actions(tileRef)
@@ -292,7 +317,7 @@ export class StructureIconsLayer implements Layer {
292317
this.updateGhostPrice(unit.cost ?? 0, showPrice);
293318

294319
const targetLevel = this.resolveGhostRangeLevel(unit);
295-
this.updateGhostRange(targetLevel);
320+
this.updateGhostRange(targetLevel, targetingAlly);
296321

297322
if (unit.canUpgrade) {
298323
this.potentialUpgrade = this.renders.find(
@@ -470,25 +495,31 @@ export class StructureIconsLayer implements Layer {
470495
return 1;
471496
}
472497

473-
private updateGhostRange(level?: number) {
498+
private updateGhostRange(level?: number, targetingAlly: boolean = false) {
474499
if (!this.ghostUnit) {
475500
return;
476501
}
477502

478-
if (this.ghostUnit.range && this.ghostUnit.rangeLevel === level) {
503+
if (
504+
this.ghostUnit.range &&
505+
this.ghostUnit.rangeLevel === level &&
506+
this.ghostUnit.targetingAlly === targetingAlly
507+
) {
479508
return;
480509
}
481510

482511
this.ghostUnit.range?.destroy();
483512
this.ghostUnit.range = null;
484513
this.ghostUnit.rangeLevel = level;
514+
this.ghostUnit.targetingAlly = targetingAlly;
485515

486516
const position = this.ghostUnit.container.position;
487517
const range = this.factory.createRange(
488518
this.ghostUnit.buildableUnit.type,
489519
this.ghostStage,
490520
{ x: position.x, y: position.y },
491521
level,
522+
targetingAlly,
492523
);
493524
if (range) {
494525
this.ghostUnit.range = range;

src/core/execution/NukeExecution.ts

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { TileRef } from "../game/GameMap";
1313
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
1414
import { PseudoRandom } from "../PseudoRandom";
1515
import { NukeType } from "../StatsSchemas";
16+
import { computeNukeBlastCounts } from "./Util";
1617

1718
const SPRITE_RADIUS = 16;
1819

@@ -45,24 +46,6 @@ export class NukeExecution implements Execution {
4546
return this.mg.owner(this.dst);
4647
}
4748

48-
private tilesInRange(): Map<TileRef, number> {
49-
if (this.nuke === null) {
50-
throw new Error("Not initialized");
51-
}
52-
const tilesInRange = new Map<TileRef, number>();
53-
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
54-
const inner2 = magnitude.inner * magnitude.inner;
55-
this.mg.circleSearch(
56-
this.dst,
57-
magnitude.outer,
58-
(t: TileRef, d2: number) => {
59-
tilesInRange.set(t, d2 <= inner2 ? 1 : 0.5);
60-
return true;
61-
},
62-
);
63-
return tilesInRange;
64-
}
65-
6649
private tilesToDestroy(): Set<TileRef> {
6750
if (this.tilesToDestroyCache !== undefined) {
6851
return this.tilesToDestroyCache;
@@ -82,37 +65,44 @@ export class NukeExecution implements Execution {
8265
}
8366

8467
/**
85-
* Break alliances based on all tiles in range.
86-
* Tiles are weighted roughly based on their chance of being destroyed.
68+
* Break alliances with players significantly affected by the nuke strike.
69+
* Uses weighted tile counting (inner=1, outer=0.5).
8770
*/
88-
private maybeBreakAlliances(inRange: Map<TileRef, number>) {
71+
private maybeBreakAlliances() {
8972
if (this.nuke === null) {
9073
throw new Error("Not initialized");
9174
}
92-
const attacked = new Map<Player, number>();
93-
for (const [tile, weight] of inRange.entries()) {
94-
const owner = this.mg.owner(tile);
95-
if (owner.isPlayer()) {
96-
const prev = attacked.get(owner) ?? 0;
97-
attacked.set(owner, prev + weight);
98-
}
75+
if (this.nuke.type() === UnitType.MIRVWarhead) {
76+
// MIRV warheads shouldn't break alliances
77+
return;
9978
}
10079

80+
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
10181
const threshold = this.mg.config().nukeAllianceBreakThreshold();
102-
for (const [attackedPlayer, totalWeight] of attacked) {
103-
if (
104-
totalWeight > threshold &&
105-
this.nuke.type() !== UnitType.MIRVWarhead
106-
) {
82+
83+
// Use shared utility to compute weighted tile counts per player
84+
const blastCounts = computeNukeBlastCounts({
85+
gm: this.mg,
86+
targetTile: this.dst,
87+
magnitude,
88+
});
89+
90+
for (const [playerSmallId, totalWeight] of blastCounts) {
91+
if (totalWeight > threshold) {
92+
const attackedPlayer = this.mg.playerBySmallID(playerSmallId);
93+
if (!attackedPlayer.isPlayer()) {
94+
continue;
95+
}
96+
10797
// Resolves exploit of alliance breaking in which a pending alliance request
10898
// was accepted in the middle of a missile attack.
10999
const allianceRequest = attackedPlayer
110100
.incomingAllianceRequests()
111101
.find((ar) => ar.requestor() === this.player);
112102
if (allianceRequest) {
113-
allianceRequest?.reject();
103+
allianceRequest.reject();
114104
}
115-
// Mirv warheads shouldn't break alliances
105+
116106
const alliance = this.player.allianceWith(attackedPlayer);
117107
if (alliance !== null) {
118108
this.player.breakAlliance(alliance);
@@ -145,7 +135,7 @@ export class NukeExecution implements Execution {
145135
trajectory: this.getTrajectory(this.dst),
146136
});
147137
if (this.nuke.type() !== UnitType.MIRVWarhead) {
148-
this.maybeBreakAlliances(this.tilesInRange());
138+
this.maybeBreakAlliances();
149139
}
150140
if (this.mg.hasOwner(this.dst)) {
151141
const target = this.mg.owner(this.dst);

src/core/execution/Util.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,77 @@
1+
import { NukeMagnitude } from "../configuration/Config";
12
import { Game, Player } from "../game/Game";
23
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
34

5+
export interface NukeBlastParams {
6+
gm: GameMap;
7+
targetTile: TileRef;
8+
magnitude: NukeMagnitude;
9+
}
10+
11+
/**
12+
* Counts how many tiles each player has in the nuke's blast zone.
13+
*
14+
* returns Map of player ID and weighted tile count
15+
*/
16+
export function computeNukeBlastCounts(
17+
params: NukeBlastParams,
18+
): Map<number, number> {
19+
const { gm, targetTile, magnitude } = params;
20+
21+
const inner2 = magnitude.inner * magnitude.inner;
22+
const counts = new Map<number, number>();
23+
24+
gm.circleSearch(targetTile, magnitude.outer, (tile: TileRef, d2: number) => {
25+
const ownerSmallId = gm.ownerID(tile);
26+
if (ownerSmallId > 0) {
27+
const weight = d2 <= inner2 ? 1 : 0.5;
28+
const prev = counts.get(ownerSmallId) ?? 0;
29+
counts.set(ownerSmallId, prev + weight);
30+
}
31+
return true;
32+
});
33+
34+
return counts;
35+
}
36+
37+
export interface NukeAllianceCheckParams extends NukeBlastParams {
38+
allySmallIds: Set<number>;
39+
threshold: number;
40+
}
41+
42+
// Checks if nuking this tile would break an alliance.
43+
export function wouldNukeBreakAlliance(
44+
params: NukeAllianceCheckParams,
45+
): boolean {
46+
const { gm, targetTile, magnitude, allySmallIds, threshold } = params;
47+
48+
if (allySmallIds.size === 0) {
49+
return false;
50+
}
51+
52+
const inner2 = magnitude.inner * magnitude.inner;
53+
const allyTileCounts = new Map<number, number>();
54+
55+
let result = false;
56+
57+
gm.circleSearch(targetTile, magnitude.outer, (tile: TileRef, d2: number) => {
58+
const ownerSmallId = gm.ownerID(tile);
59+
if (ownerSmallId > 0 && allySmallIds.has(ownerSmallId)) {
60+
const weight = d2 <= inner2 ? 1 : 0.5;
61+
const newCount = (allyTileCounts.get(ownerSmallId) ?? 0) + weight;
62+
allyTileCounts.set(ownerSmallId, newCount);
63+
64+
if (newCount > threshold) {
65+
result = true;
66+
return false; // Found one! Stop searching.
67+
}
68+
}
69+
return true;
70+
});
71+
72+
return result;
73+
}
74+
475
export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
576
return Array.from(gm.bfs(tile, euclDistFN(tile, 4, true))).filter(
677
(t) => !gm.hasOwner(t) && gm.isLand(t),

0 commit comments

Comments
 (0)