SAM smart targeting (#1618)

## Description:

Current SAM behavior is to shoot a missile as soon as a nuke is in
range.
Players can exploit it by overshooting behind the SAM, so the SAM
missile will take way longer to reach the nuke, usually too late to
prevent its explosion.

This PR introduces a "smart" targeting system that allows SAM to
calculate an optimal interception tile along the nuke's trajectory. They
can also preshot before the nuke becomes vulnerable, as long as the
interception tile will be within the vulnerable window.

This change makes SAM range enforcement much more strict.

Changes:
- Nukes now precompute their full trajectory on creation and update
their current position index every tick.
- SAMs use this trajectory data and their own missile speed to calculate
the ideal interception tile.
- SAM missiles now aim directly at that interception point rather than
chasing the nuke.

Small changes on the fly:
- `BezierCurve` now uses a provided increment so the curve LUT is the
optimal size
- Increased nuke opacity when untargetable: 0.4 → 0.5
- Slightly extended nuke vulnerability range to SAMs: 120 → 150


===

Preshot an incoming nuke still in the unfocusable state. Notice how the
nuke is destroyed as soon as becomes focusable:



https://github.com/user-attachments/assets/9fbf1ae4-33b4-4fa0-9b53-cb53f3adc17b


Shooting right at the range limit:


https://github.com/user-attachments/assets/d68793ac-b249-45fe-88bf-e20f70758449


Shooting behind the SAM:


https://github.com/user-attachments/assets/800cd7ff-d9d9-40f3-aba8-fa3ab526b3b2




## 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 have read and accepted the CLA agreement (only required once).

## Please put your Discord username so you can be contacted if a bug or
regression is found:

IngloriousTom
This commit is contained in:
DevelopingTom
2025-08-03 00:03:29 +02:00
committed by GitHub
parent 3a703b0b4b
commit eeeb7e4b4e
11 changed files with 350 additions and 136 deletions
+35 -11
View File
@@ -5,6 +5,7 @@ import {
MessageType,
Player,
TerraNullius,
TrajectoryTile,
Unit,
UnitType,
} from "../game/Game";
@@ -20,8 +21,6 @@ export class NukeExecution implements Execution {
private mg: Game;
private nuke: Unit | null = null;
private tilesToDestroyCache: Set<TileRef> | undefined;
private random: PseudoRandom;
private pathFinder: ParabolaPathFinder;
constructor(
@@ -35,7 +34,6 @@ export class NukeExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.random = new PseudoRandom(ticks);
if (this.speed === -1) {
this.speed = this.mg.config().defaultNukeSpeed();
}
@@ -107,10 +105,12 @@ export class NukeExecution implements Execution {
this.pathFinder.computeControlPoints(
spawn,
this.dst,
this.speed,
this.nukeType !== UnitType.MIRVWarhead,
);
this.nuke = this.player.buildUnit(this.nukeType, spawn, {
targetTile: this.dst,
trajectory: this.getTrajectory(this.dst),
});
this.maybeBreakAlliances(this.tilesToDestroy());
if (this.mg.hasOwner(this.dst)) {
@@ -169,6 +169,8 @@ export class NukeExecution implements Execution {
} else {
this.updateNukeTargetable();
this.nuke.move(nextTile);
// Update index so SAM can interpolate future position
this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex());
}
}
@@ -176,21 +178,43 @@ export class NukeExecution implements Execution {
return this.nuke;
}
private getTrajectory(target: TileRef): TrajectoryTile[] {
const trajectoryTiles: TrajectoryTile[] = [];
const targetRangeSquared =
this.mg.config().defaultNukeTargetableRange() ** 2;
const allTiles: TileRef[] = this.pathFinder.allTiles();
for (const tile of allTiles) {
trajectoryTiles.push({
tile,
targetable: this.isTargetable(target, tile, targetRangeSquared),
});
}
return trajectoryTiles;
}
private isTargetable(
targetTile: TileRef,
nukeTile: TileRef,
targetRangeSquared: number,
): boolean {
return (
this.mg.euclideanDistSquared(nukeTile, targetTile) < targetRangeSquared ||
(this.src !== undefined &&
this.src !== null &&
this.mg.euclideanDistSquared(this.src, nukeTile) < targetRangeSquared)
);
}
private updateNukeTargetable() {
if (this.nuke === null || this.nuke.targetTile() === undefined) {
return;
}
const targetRangeSquared =
this.mg.config().defaultNukeTargetableRange() *
this.mg.config().defaultNukeTargetableRange();
this.mg.config().defaultNukeTargetableRange() ** 2;
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),
this.isTargetable(targetTile!, this.nuke.tile(), targetRangeSquared),
);
}
+143 -57
View File
@@ -10,6 +10,118 @@ import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { SAMMissileExecution } from "./SAMMissileExecution";
type Target = {
unit: Unit;
tile: TileRef;
};
/**
* Smart SAM targeting system preshoting nukes so its range is strictly enforced
*/
class SAMTargetingSystem {
// Store unreachable nukes so the SAM won't compute an interception point for them every frame
private nukesToIgnore: Set<number> = new Set();
constructor(
private mg: Game,
private player: Player,
private sam: Unit,
) {}
updateUnreachableNukes(nearbyUnits: { unit: Unit; distSquared: number }[]) {
const nearbyUnitSet = new Set(nearbyUnits.map((u) => u.unit.id()));
for (const nukeId of this.nukesToIgnore) {
if (!nearbyUnitSet.has(nukeId)) {
this.nukesToIgnore.delete(nukeId);
}
}
}
private storeUnreachableNukes(nukeId: number) {
this.nukesToIgnore.add(nukeId);
}
private isInRange(tile: TileRef) {
const samTile = this.sam.tile();
const rangeSquared = this.mg.config().defaultSamRange() ** 2;
return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared;
}
private tickToReach(currentTile: TileRef, tile: TileRef): number {
const missileSpeed = this.mg.config().defaultSamMissileSpeed();
return Math.ceil(this.mg.manhattanDist(currentTile, tile) / missileSpeed);
}
private computeInterceptionTile(unit: Unit): TileRef | undefined {
const trajectory = unit.trajectory();
const samTile = this.sam.tile();
const currentIndex = unit.trajectoryIndex();
const explosionTick: number = trajectory.length - currentIndex;
for (let i = unit.trajectoryIndex(); i < trajectory.length; i++) {
const trajectoryTile = trajectory[i];
if (trajectoryTile.targetable && this.isInRange(trajectoryTile.tile)) {
const nukeTickToReach = i - currentIndex;
const samTickToReach = this.tickToReach(samTile, trajectoryTile.tile);
const reachableOnTime = Math.abs(nukeTickToReach - samTickToReach) <= 1;
if (reachableOnTime && samTickToReach < explosionTick) {
return trajectoryTile.tile;
}
}
}
return undefined;
}
public getSingleTarget(): Target | null {
// Look beyond the SAM range so it can preshot nukes
const detectionRange = this.mg.config().defaultSamRange() * 1.5;
const nukes = this.mg.nearbyUnits(
this.sam.tile(),
detectionRange,
[UnitType.AtomBomb, UnitType.HydrogenBomb],
({ unit }) => {
return (
unit.owner() !== this.player && !this.player.isFriendly(unit.owner())
);
},
);
// Clear unreachable nukes that went out of range
this.updateUnreachableNukes(nukes);
const targets: Array<Target> = [];
for (const nuke of nukes) {
if (this.nukesToIgnore.has(nuke.unit.id())) {
continue;
}
const interceptionTile = this.computeInterceptionTile(nuke.unit);
if (interceptionTile !== undefined) {
targets.push({ unit: nuke.unit, tile: interceptionTile });
} else {
// Store unreachable nukes in order to prevent useless interception computation
this.storeUnreachableNukes(nuke.unit.id());
}
}
return (
targets.sort((a: Target, b: Target) => {
// Prioritize Hydrogen Bombs
if (
a.unit.type() === UnitType.HydrogenBomb &&
b.unit.type() !== UnitType.HydrogenBomb
)
return -1;
if (
a.unit.type() !== UnitType.HydrogenBomb &&
b.unit.type() === UnitType.HydrogenBomb
)
return 1;
return 0;
})[0] ?? null
);
}
}
export class SAMLauncherExecution implements Execution {
private mg: Game;
private active: boolean = true;
@@ -18,6 +130,7 @@ export class SAMLauncherExecution implements Execution {
// shoot the one targeting very close (MIRVWarheadProtectionRadius)
private MIRVWarheadSearchRadius = 400;
private MIRVWarheadProtectionRadius = 50;
private targetingSystem: SAMTargetingSystem;
private pseudoRandom: PseudoRandom | undefined;
@@ -35,41 +148,6 @@ export class SAMLauncherExecution implements Execution {
this.mg = mg;
}
private getSingleTarget(): Unit | null {
if (this.sam === null) return null;
const nukes = this.mg.nearbyUnits(
this.sam.tile(),
this.mg.config().defaultSamRange(),
[UnitType.AtomBomb, UnitType.HydrogenBomb],
({ unit }) =>
unit.owner() !== this.player &&
!this.player.isFriendly(unit.owner()) &&
unit.isTargetable(),
);
return (
nukes.sort((a, b) => {
const { unit: unitA, distSquared: distA } = a;
const { unit: unitB, distSquared: distB } = b;
// Prioritize Hydrogen Bombs
if (
unitA.type() === UnitType.HydrogenBomb &&
unitB.type() !== UnitType.HydrogenBomb
)
return -1;
if (
unitA.type() !== UnitType.HydrogenBomb &&
unitB.type() === UnitType.HydrogenBomb
)
return 1;
// If both are the same type, sort by distance (lower `distSquared` means closer)
return distA - distB;
})[0]?.unit ?? null
);
}
private isHit(type: UnitType, random: number): boolean {
if (type === UnitType.AtomBomb) {
return true;
@@ -98,6 +176,26 @@ export class SAMLauncherExecution implements Execution {
}
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {});
}
this.targetingSystem ??= new SAMTargetingSystem(
this.mg,
this.player,
this.sam,
);
if (this.sam.isInCooldown()) {
const frontTime = this.sam.missileTimerQueue()[0];
if (frontTime === undefined) {
return;
}
const cooldown =
this.mg.config().SAMCooldown() - (this.mg.ticks() - frontTime);
if (cooldown <= 0) {
this.sam.reloadMissile();
}
return;
}
if (!this.sam.isActive()) {
this.active = false;
return;
@@ -126,19 +224,18 @@ export class SAMLauncherExecution implements Execution {
},
);
let target: Unit | null = null;
let target: Target | null = null;
if (mirvWarheadTargets.length === 0) {
target = this.getSingleTarget();
target = this.targetingSystem.getSingleTarget();
}
const isSingleTarget = target && !target.targetedBySAM();
if (
(isSingleTarget || mirvWarheadTargets.length > 0) &&
!this.sam.isInCooldown()
) {
const isSingleTarget = target && !target.unit.targetedBySAM();
if (isSingleTarget || mirvWarheadTargets.length > 0) {
this.sam.launch();
const type =
mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target?.type();
mirvWarheadTargets.length > 0
? UnitType.MIRVWarhead
: target?.unit.type();
if (type === undefined) throw new Error("Unknown unit type");
const random = this.pseudoRandom.next();
const hit = this.isHit(type, random);
@@ -172,31 +269,20 @@ export class SAMLauncherExecution implements Execution {
mirvWarheadTargets.length,
);
} else if (target !== null) {
target.setTargetedBySAM(true);
target.unit.setTargetedBySAM(true);
this.mg.addExecution(
new SAMMissileExecution(
this.sam.tile(),
this.sam.owner(),
this.sam,
target,
target.unit,
target.tile,
),
);
} else {
throw new Error("target is null");
}
}
const frontTime = this.sam.missileTimerQueue()[0];
if (frontTime === undefined) {
return;
}
const cooldown =
this.mg.config().SAMCooldown() - (this.mg.ticks() - frontTime);
if (cooldown <= 0) {
this.sam.reloadMissile();
}
}
isActive(): boolean {
+4 -2
View File
@@ -16,18 +16,20 @@ export class SAMMissileExecution implements Execution {
private pathFinder: AirPathFinder;
private SAMMissile: Unit | undefined;
private mg: Game;
private speed: number = 0;
constructor(
private spawn: TileRef,
private _owner: Player,
private ownerUnit: Unit,
private target: Unit,
private speed: number = 12,
private targetTile: TileRef,
) {}
init(mg: Game, ticks: number): void {
this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks()));
this.mg = mg;
this.speed = this.mg.config().defaultSamMissileSpeed();
}
tick(ticks: number): void {
@@ -55,7 +57,7 @@ export class SAMMissileExecution implements Execution {
for (let i = 0; i < this.speed; i++) {
const result = this.pathFinder.nextTile(
this.SAMMissile.tile(),
this.target.tile(),
this.targetTile,
);
if (result === true) {
this.mg.displayMessage(