Files
OpenFrontIO/src/core/game/UnitImpl.ts
T
DevelopingTom eeeb7e4b4e 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
2025-08-02 22:03:29 +00:00

415 lines
10 KiB
TypeScript

import { simpleHash, toInt, withinInt } from "../Util";
import {
AllUnitParams,
MessageType,
Player,
Tick,
TrainType,
TrajectoryTile,
Unit,
UnitInfo,
UnitType,
} from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { GameUpdateType, UnitUpdate } from "./GameUpdates";
import { PlayerImpl } from "./PlayerImpl";
export class UnitImpl implements Unit {
private _active = true;
private _targetTile: TileRef | undefined;
private _targetUnit: Unit | undefined;
private _health: bigint;
private _lastTile: TileRef;
private _retreating: boolean = false;
private _targetedBySAM = false;
private _reachedTarget = false;
private _lastSetSafeFromPirates: number; // Only for trade ships
private _constructionType: UnitType | undefined;
private _lastOwner: PlayerImpl | null = null;
private _troops: number;
// Number of missiles in cooldown, if empty all missiles are ready.
private _missileTimerQueue: number[] = [];
private _hasTrainStation: boolean = false;
private _patrolTile: TileRef | undefined;
private _level: number = 1;
private _targetable: boolean = true;
private _loaded: boolean | undefined;
private _trainType: TrainType | undefined;
// Nuke only
private _trajectoryIndex: number = 0;
private _trajectory: TrajectoryTile[];
constructor(
private _type: UnitType,
private mg: GameImpl,
private _tile: TileRef,
private _id: number,
public _owner: PlayerImpl,
params: AllUnitParams = {},
) {
this._lastTile = _tile;
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._targetTile =
"targetTile" in params ? (params.targetTile ?? undefined) : undefined;
this._trajectory = "trajectory" in params ? (params.trajectory ?? []) : [];
this._troops = "troops" in params ? (params.troops ?? 0) : 0;
this._lastSetSafeFromPirates =
"lastSetSafeFromPirates" in params
? (params.lastSetSafeFromPirates ?? 0)
: 0;
this._patrolTile =
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
this._targetUnit =
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
this._loaded =
"loaded" in params ? (params.loaded ?? undefined) : undefined;
this._trainType = "trainType" in params ? params.trainType : undefined;
switch (this._type) {
case UnitType.Warship:
case UnitType.Port:
case UnitType.MissileSilo:
case UnitType.DefensePost:
case UnitType.SAMLauncher:
case UnitType.City:
this.mg.stats().unitBuild(_owner, this._type);
}
}
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;
}
patrolTile(): TileRef | undefined {
return this._patrolTile;
}
isUnit(): this is Unit {
return true;
}
touch(): void {
this.mg.addUpdate(this.toUpdate());
}
setTileTarget(tile: TileRef | undefined): void {
this._targetTile = tile;
}
tileTarget(): TileRef | undefined {
return this._targetTile;
}
id() {
return this._id;
}
toUpdate(): UnitUpdate {
return {
type: GameUpdateType.Unit,
unitType: this._type,
id: this._id,
troops: this._troops,
ownerID: this._owner.smallID(),
lastOwnerID: this._lastOwner?.smallID(),
isActive: this._active,
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,
targetUnitId: this._targetUnit?.id() ?? undefined,
targetTile: this.targetTile() ?? undefined,
missileTimerQueue: this._missileTimerQueue,
level: this.level(),
hasTrainStation: this._hasTrainStation,
trainType: this._trainType,
loaded: this._loaded,
};
}
type(): UnitType {
return this._type;
}
lastTile(): TileRef {
return this._lastTile;
}
move(tile: TileRef): void {
if (tile === null) {
throw new Error("tile cannot be null");
}
this._lastTile = this._tile;
this._tile = tile;
this.mg.updateUnitTile(this);
this.mg.addUpdate(this.toUpdate());
}
setTroops(troops: number): void {
this._troops = troops;
}
troops(): number {
return this._troops;
}
health(): number {
return Number(this._health);
}
hasHealth(): boolean {
return this.info().maxHealth !== undefined;
}
tile(): TileRef {
return this._tile;
}
owner(): PlayerImpl {
return this._owner;
}
info(): UnitInfo {
return this.mg.unitInfo(this._type);
}
setOwner(newOwner: PlayerImpl): void {
switch (this._type) {
case UnitType.Warship:
case UnitType.Port:
case UnitType.MissileSilo:
case UnitType.DefensePost:
case UnitType.SAMLauncher:
case UnitType.City:
this.mg.stats().unitCapture(newOwner, this._type);
this.mg.stats().unitLose(this._owner, this._type);
break;
}
this._lastOwner = this._owner;
this._lastOwner._units = this._lastOwner._units.filter((u) => u !== this);
this._owner = newOwner;
this._owner._units.push(this);
this.mg.addUpdate(this.toUpdate());
this.mg.displayMessage(
`Your ${this.type()} was captured by ${newOwner.displayName()}`,
MessageType.UNIT_CAPTURED_BY_ENEMY,
this._lastOwner.id(),
);
this.mg.displayMessage(
`Captured ${this.type()} from ${this._lastOwner.displayName()}`,
MessageType.CAPTURED_ENEMY_UNIT,
newOwner.id(),
);
}
modifyHealth(delta: number, attacker?: Player): void {
this._health = withinInt(
this._health + toInt(delta),
0n,
toInt(this.info().maxHealth ?? 1),
);
if (this._health === 0n) {
this.delete(true, attacker);
}
}
delete(displayMessage?: boolean, destroyer?: Player): void {
if (!this.isActive()) {
throw new Error(`cannot delete ${this} not active`);
}
this._owner._units = this._owner._units.filter((b) => b !== this);
this._active = false;
this.mg.addUpdate(this.toUpdate());
this.mg.removeUnit(this);
if (displayMessage !== false && this._type !== UnitType.MIRVWarhead) {
this.mg.displayMessage(
`Your ${this._type} was destroyed`,
MessageType.UNIT_DESTROYED,
this.owner().id(),
);
}
if (destroyer !== undefined) {
switch (this._type) {
case UnitType.TransportShip:
this.mg
.stats()
.boatDestroyTroops(destroyer, this._owner, this._troops);
break;
case UnitType.TradeShip:
this.mg.stats().boatDestroyTrade(destroyer, this._owner);
break;
case UnitType.City:
case UnitType.DefensePost:
case UnitType.MissileSilo:
case UnitType.Port:
case UnitType.SAMLauncher:
case UnitType.Warship:
case UnitType.Factory:
this.mg.stats().unitDestroy(destroyer, this._type);
this.mg.stats().unitLose(this.owner(), this._type);
break;
}
}
}
isActive(): boolean {
return this._active;
}
retreating(): boolean {
return this._retreating;
}
orderBoatRetreat() {
if (this.type() !== UnitType.TransportShip) {
throw new Error(`Cannot retreat ${this.type()}`);
}
this._retreating = true;
}
constructionType(): UnitType | null {
if (this.type() !== UnitType.Construction) {
throw new Error(`Cannot get construction type on ${this.type()}`);
}
return this._constructionType ?? null;
}
setConstructionType(type: UnitType): void {
if (this.type() !== UnitType.Construction) {
throw new Error(`Cannot set construction type on ${this.type()}`);
}
this._constructionType = type;
this.mg.addUpdate(this.toUpdate());
}
hash(): number {
return this.tile() + simpleHash(this.type()) * this._id;
}
toString(): string {
return `Unit:${this._type},owner:${this.owner().name()}`;
}
launch(): void {
this._missileTimerQueue.push(this.mg.ticks());
this.mg.addUpdate(this.toUpdate());
}
ticksLeftInCooldown(): Tick | undefined {
return this._missileTimerQueue[0];
}
isInCooldown(): boolean {
return this._missileTimerQueue.length === this._level;
}
missileTimerQueue(): number[] {
return this._missileTimerQueue;
}
reloadMissile(): void {
this._missileTimerQueue.shift();
this.mg.addUpdate(this.toUpdate());
}
setTargetTile(targetTile: TileRef | undefined) {
this._targetTile = targetTile;
}
targetTile(): TileRef | undefined {
return this._targetTile;
}
setTrajectoryIndex(i: number): void {
const max = this._trajectory.length - 1;
this._trajectoryIndex = i < 0 ? 0 : i > max ? max : i;
}
trajectoryIndex(): number {
return this._trajectoryIndex;
}
trajectory(): TrajectoryTile[] {
return this._trajectory;
}
setTargetUnit(target: Unit | undefined): void {
this._targetUnit = target;
}
targetUnit(): Unit | undefined {
return this._targetUnit;
}
setTargetedBySAM(targeted: boolean): void {
this._targetedBySAM = targeted;
}
targetedBySAM(): boolean {
return this._targetedBySAM;
}
setReachedTarget(): void {
this._reachedTarget = true;
}
reachedTarget(): boolean {
return this._reachedTarget;
}
setSafeFromPirates(): void {
this._lastSetSafeFromPirates = this.mg.ticks();
}
isSafeFromPirates(): boolean {
return (
this.mg.ticks() - this._lastSetSafeFromPirates <
this.mg.config().safeFromPiratesCooldownMax()
);
}
level(): number {
return this._level;
}
setTrainStation(trainStation: boolean): void {
this._hasTrainStation = trainStation;
this.mg.addUpdate(this.toUpdate());
}
hasTrainStation(): boolean {
return this._hasTrainStation;
}
increaseLevel(): void {
this._level++;
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
this._missileTimerQueue.push(this.mg.ticks());
}
this.mg.addUpdate(this.toUpdate());
}
trainType(): TrainType | undefined {
return this._trainType;
}
isLoaded(): boolean | undefined {
return this._loaded;
}
setLoaded(loaded: boolean): void {
if (this._loaded !== loaded) {
this._loaded = loaded;
this.mg.addUpdate(this.toUpdate());
}
}
}