mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:51:30 +00:00
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>
This commit is contained in:
@@ -454,6 +454,7 @@ export class SpriteFactory {
|
||||
stage: PIXI.Container,
|
||||
pos: { x: number; y: number },
|
||||
level?: number,
|
||||
targetingAlly: boolean = false,
|
||||
): PIXI.Container | null {
|
||||
if (stage === undefined) throw new Error("Not initialized");
|
||||
const parentContainer = new PIXI.Container();
|
||||
@@ -478,10 +479,18 @@ export class SpriteFactory {
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
// Add warning colors (red/orange) when targeting an ally to indicate alliance will break
|
||||
const isNuke = type === UnitType.AtomBomb || type === UnitType.HydrogenBomb;
|
||||
const fillColor = targetingAlly && isNuke ? 0xff6b35 : 0xffffff;
|
||||
const fillAlpha = targetingAlly && isNuke ? 0.35 : 0.2;
|
||||
const strokeColor = targetingAlly && isNuke ? 0xff4444 : 0xffffff;
|
||||
const strokeAlpha = targetingAlly && isNuke ? 0.8 : 0.5;
|
||||
const strokeWidth = targetingAlly && isNuke ? 2 : 1;
|
||||
|
||||
circle
|
||||
.circle(0, 0, radius)
|
||||
.fill({ color: 0xffffff, alpha: 0.2 })
|
||||
.stroke({ width: 1, color: 0xffffff, alpha: 0.5 });
|
||||
.fill({ color: fillColor, alpha: fillAlpha })
|
||||
.stroke({ width: strokeWidth, color: strokeColor, alpha: strokeAlpha });
|
||||
parentContainer.addChild(circle);
|
||||
parentContainer.position.set(pos.x, pos.y);
|
||||
parentContainer.scale.set(this.transformHandler.scale);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OutlineFilter } from "pixi-filters";
|
||||
import * as PIXI from "pixi.js";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { wouldNukeBreakAlliance } from "../../../core/execution/Util";
|
||||
import {
|
||||
BuildableUnit,
|
||||
Cell,
|
||||
@@ -65,6 +66,7 @@ export class StructureIconsLayer implements Layer {
|
||||
priceBox: { height: number; y: number; paddingX: number; minWidth: number };
|
||||
range: PIXI.Container | null;
|
||||
rangeLevel?: number;
|
||||
targetingAlly?: boolean;
|
||||
buildableUnit: BuildableUnit;
|
||||
} | null = null;
|
||||
private pixicanvas: HTMLCanvasElement;
|
||||
@@ -258,6 +260,29 @@ export class StructureIconsLayer implements Layer {
|
||||
tileRef = this.game.ref(tile.x, tile.y);
|
||||
}
|
||||
|
||||
// Check if targeting an ally (for nuke warning visual)
|
||||
// Uses shared logic with NukeExecution.maybeBreakAlliances()
|
||||
let targetingAlly = false;
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const nukeType = this.ghostUnit.buildableUnit.type;
|
||||
if (
|
||||
tileRef &&
|
||||
myPlayer &&
|
||||
(nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb)
|
||||
) {
|
||||
// Only check if player has allies
|
||||
const allies = myPlayer.allies();
|
||||
if (allies.length > 0) {
|
||||
targetingAlly = wouldNukeBreakAlliance({
|
||||
gm: this.game,
|
||||
targetTile: tileRef,
|
||||
magnitude: this.game.config().nukeMagnitudes(nukeType),
|
||||
allySmallIds: new Set(allies.map((a) => a.smallID())),
|
||||
threshold: this.game.config().nukeAllianceBreakThreshold(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.game
|
||||
?.myPlayer()
|
||||
?.actions(tileRef)
|
||||
@@ -292,7 +317,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.updateGhostPrice(unit.cost ?? 0, showPrice);
|
||||
|
||||
const targetLevel = this.resolveGhostRangeLevel(unit);
|
||||
this.updateGhostRange(targetLevel);
|
||||
this.updateGhostRange(targetLevel, targetingAlly);
|
||||
|
||||
if (unit.canUpgrade) {
|
||||
this.potentialUpgrade = this.renders.find(
|
||||
@@ -470,18 +495,23 @@ export class StructureIconsLayer implements Layer {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private updateGhostRange(level?: number) {
|
||||
private updateGhostRange(level?: number, targetingAlly: boolean = false) {
|
||||
if (!this.ghostUnit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ghostUnit.range && this.ghostUnit.rangeLevel === level) {
|
||||
if (
|
||||
this.ghostUnit.range &&
|
||||
this.ghostUnit.rangeLevel === level &&
|
||||
this.ghostUnit.targetingAlly === targetingAlly
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ghostUnit.range?.destroy();
|
||||
this.ghostUnit.range = null;
|
||||
this.ghostUnit.rangeLevel = level;
|
||||
this.ghostUnit.targetingAlly = targetingAlly;
|
||||
|
||||
const position = this.ghostUnit.container.position;
|
||||
const range = this.factory.createRange(
|
||||
@@ -489,6 +519,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.ghostStage,
|
||||
{ x: position.x, y: position.y },
|
||||
level,
|
||||
targetingAlly,
|
||||
);
|
||||
if (range) {
|
||||
this.ghostUnit.range = range;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TileRef } from "../game/GameMap";
|
||||
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { NukeType } from "../StatsSchemas";
|
||||
import { computeNukeBlastCounts } from "./Util";
|
||||
|
||||
const SPRITE_RADIUS = 16;
|
||||
|
||||
@@ -45,24 +46,6 @@ export class NukeExecution implements Execution {
|
||||
return this.mg.owner(this.dst);
|
||||
}
|
||||
|
||||
private tilesInRange(): Map<TileRef, number> {
|
||||
if (this.nuke === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
const tilesInRange = new Map<TileRef, number>();
|
||||
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
|
||||
const inner2 = magnitude.inner * magnitude.inner;
|
||||
this.mg.circleSearch(
|
||||
this.dst,
|
||||
magnitude.outer,
|
||||
(t: TileRef, d2: number) => {
|
||||
tilesInRange.set(t, d2 <= inner2 ? 1 : 0.5);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
return tilesInRange;
|
||||
}
|
||||
|
||||
private tilesToDestroy(): Set<TileRef> {
|
||||
if (this.tilesToDestroyCache !== undefined) {
|
||||
return this.tilesToDestroyCache;
|
||||
@@ -82,37 +65,44 @@ export class NukeExecution implements Execution {
|
||||
}
|
||||
|
||||
/**
|
||||
* Break alliances based on all tiles in range.
|
||||
* Tiles are weighted roughly based on their chance of being destroyed.
|
||||
* Break alliances with players significantly affected by the nuke strike.
|
||||
* Uses weighted tile counting (inner=1, outer=0.5).
|
||||
*/
|
||||
private maybeBreakAlliances(inRange: Map<TileRef, number>) {
|
||||
private maybeBreakAlliances() {
|
||||
if (this.nuke === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
const attacked = new Map<Player, number>();
|
||||
for (const [tile, weight] of inRange.entries()) {
|
||||
const owner = this.mg.owner(tile);
|
||||
if (owner.isPlayer()) {
|
||||
const prev = attacked.get(owner) ?? 0;
|
||||
attacked.set(owner, prev + weight);
|
||||
}
|
||||
if (this.nuke.type() === UnitType.MIRVWarhead) {
|
||||
// MIRV warheads shouldn't break alliances
|
||||
return;
|
||||
}
|
||||
|
||||
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
|
||||
const threshold = this.mg.config().nukeAllianceBreakThreshold();
|
||||
for (const [attackedPlayer, totalWeight] of attacked) {
|
||||
if (
|
||||
totalWeight > threshold &&
|
||||
this.nuke.type() !== UnitType.MIRVWarhead
|
||||
) {
|
||||
|
||||
// Use shared utility to compute weighted tile counts per player
|
||||
const blastCounts = computeNukeBlastCounts({
|
||||
gm: this.mg,
|
||||
targetTile: this.dst,
|
||||
magnitude,
|
||||
});
|
||||
|
||||
for (const [playerSmallId, totalWeight] of blastCounts) {
|
||||
if (totalWeight > threshold) {
|
||||
const attackedPlayer = this.mg.playerBySmallID(playerSmallId);
|
||||
if (!attackedPlayer.isPlayer()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolves exploit of alliance breaking in which a pending alliance request
|
||||
// was accepted in the middle of a missile attack.
|
||||
const allianceRequest = attackedPlayer
|
||||
.incomingAllianceRequests()
|
||||
.find((ar) => ar.requestor() === this.player);
|
||||
if (allianceRequest) {
|
||||
allianceRequest?.reject();
|
||||
allianceRequest.reject();
|
||||
}
|
||||
// Mirv warheads shouldn't break alliances
|
||||
|
||||
const alliance = this.player.allianceWith(attackedPlayer);
|
||||
if (alliance !== null) {
|
||||
this.player.breakAlliance(alliance);
|
||||
@@ -145,7 +135,7 @@ export class NukeExecution implements Execution {
|
||||
trajectory: this.getTrajectory(this.dst),
|
||||
});
|
||||
if (this.nuke.type() !== UnitType.MIRVWarhead) {
|
||||
this.maybeBreakAlliances(this.tilesInRange());
|
||||
this.maybeBreakAlliances();
|
||||
}
|
||||
if (this.mg.hasOwner(this.dst)) {
|
||||
const target = this.mg.owner(this.dst);
|
||||
|
||||
@@ -1,6 +1,77 @@
|
||||
import { NukeMagnitude } from "../configuration/Config";
|
||||
import { Game, Player } from "../game/Game";
|
||||
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
|
||||
|
||||
export interface NukeBlastParams {
|
||||
gm: GameMap;
|
||||
targetTile: TileRef;
|
||||
magnitude: NukeMagnitude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts how many tiles each player has in the nuke's blast zone.
|
||||
*
|
||||
* returns Map of player ID and weighted tile count
|
||||
*/
|
||||
export function computeNukeBlastCounts(
|
||||
params: NukeBlastParams,
|
||||
): Map<number, number> {
|
||||
const { gm, targetTile, magnitude } = params;
|
||||
|
||||
const inner2 = magnitude.inner * magnitude.inner;
|
||||
const counts = new Map<number, number>();
|
||||
|
||||
gm.circleSearch(targetTile, magnitude.outer, (tile: TileRef, d2: number) => {
|
||||
const ownerSmallId = gm.ownerID(tile);
|
||||
if (ownerSmallId > 0) {
|
||||
const weight = d2 <= inner2 ? 1 : 0.5;
|
||||
const prev = counts.get(ownerSmallId) ?? 0;
|
||||
counts.set(ownerSmallId, prev + weight);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
export interface NukeAllianceCheckParams extends NukeBlastParams {
|
||||
allySmallIds: Set<number>;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
// Checks if nuking this tile would break an alliance.
|
||||
export function wouldNukeBreakAlliance(
|
||||
params: NukeAllianceCheckParams,
|
||||
): boolean {
|
||||
const { gm, targetTile, magnitude, allySmallIds, threshold } = params;
|
||||
|
||||
if (allySmallIds.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inner2 = magnitude.inner * magnitude.inner;
|
||||
const allyTileCounts = new Map<number, number>();
|
||||
|
||||
let result = false;
|
||||
|
||||
gm.circleSearch(targetTile, magnitude.outer, (tile: TileRef, d2: number) => {
|
||||
const ownerSmallId = gm.ownerID(tile);
|
||||
if (ownerSmallId > 0 && allySmallIds.has(ownerSmallId)) {
|
||||
const weight = d2 <= inner2 ? 1 : 0.5;
|
||||
const newCount = (allyTileCounts.get(ownerSmallId) ?? 0) + weight;
|
||||
allyTileCounts.set(ownerSmallId, newCount);
|
||||
|
||||
if (newCount > threshold) {
|
||||
result = true;
|
||||
return false; // Found one! Stop searching.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
|
||||
return Array.from(gm.bfs(tile, euclDistFN(tile, 4, true))).filter(
|
||||
(t) => !gm.hasOwner(t) && gm.isLand(t),
|
||||
|
||||
Reference in New Issue
Block a user