Files
OpenFrontIO/src/core/pathfinding/PathFinding.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

207 lines
5.3 KiB
TypeScript

import { Game } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { DistanceBasedBezierCurve } from "../utilities/Line";
import { AStar, AStarResult, PathFindResultType } from "./AStar";
import { MiniAStar } from "./MiniAStar";
const parabolaMinHeight = 50;
export class ParabolaPathFinder {
constructor(private mg: GameMap) {}
private curve: DistanceBasedBezierCurve | undefined;
computeControlPoints(
orig: TileRef,
dst: TileRef,
increment: number = 3,
distanceBasedHeight = true,
) {
const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) };
const p3 = { x: this.mg.x(dst), y: this.mg.y(dst) };
const dx = p3.x - p0.x;
const dy = p3.y - p0.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const maxHeight = distanceBasedHeight
? Math.max(distance / 3, parabolaMinHeight)
: 0;
// Use a bezier curve always pointing up
const p1 = {
x: p0.x + (p3.x - p0.x) / 4,
y: Math.max(p0.y + (p3.y - p0.y) / 4 - maxHeight, 0),
};
const p2 = {
x: p0.x + ((p3.x - p0.x) * 3) / 4,
y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0),
};
this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment);
}
nextTile(speed: number): TileRef | true {
if (!this.curve) {
throw new Error("ParabolaPathFinder not initialized");
}
const nextPoint = this.curve.increment(speed);
if (!nextPoint) {
return true;
}
return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y));
}
currentIndex(): number {
if (!this.curve) {
return 0;
}
return this.curve.getCurrentIndex();
}
allTiles(): TileRef[] {
if (!this.curve) {
return [];
}
return this.curve
.getAllPoints()
.map((point) => this.mg.ref(Math.floor(point.x), Math.floor(point.y)));
}
}
export class AirPathFinder {
constructor(
private mg: GameMap,
private random: PseudoRandom,
) {}
nextTile(tile: TileRef, dst: TileRef): TileRef | true {
const x = this.mg.x(tile);
const y = this.mg.y(tile);
const dstX = this.mg.x(dst);
const dstY = this.mg.y(dst);
if (x === dstX && y === dstY) {
return true;
}
// Calculate next position
let nextX = x;
let nextY = y;
const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1));
if (this.random.chance(ratio) && x !== dstX) {
if (x < dstX) nextX++;
else if (x > dstX) nextX--;
} else {
if (y < dstY) nextY++;
else if (y > dstY) nextY--;
}
if (nextX === x && nextY === y) {
return true;
}
return this.mg.ref(nextX, nextY);
}
}
export class PathFinder {
private curr: TileRef | null = null;
private dst: TileRef | null = null;
private path: TileRef[] | null = null;
private aStar: AStar<TileRef>;
private computeFinished = true;
private constructor(
private game: Game,
private newAStar: (curr: TileRef, dst: TileRef) => AStar<TileRef>,
) {}
public static Mini(
game: Game,
iterations: number,
waterPath: boolean = true,
maxTries: number = 20,
) {
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
return new MiniAStar(
game.map(),
game.miniMap(),
curr,
dst,
iterations,
maxTries,
waterPath,
);
});
}
nextTile(
curr: TileRef | null,
dst: TileRef | null,
dist: number = 1,
): AStarResult<TileRef> {
if (curr === null) {
console.error("curr is null");
return { type: PathFindResultType.PathNotFound };
}
if (dst === null) {
console.error("dst is null");
return { type: PathFindResultType.PathNotFound };
}
if (this.game.manhattanDist(curr, dst) < dist) {
return { type: PathFindResultType.Completed, node: curr };
}
if (this.computeFinished) {
if (this.shouldRecompute(curr, dst)) {
this.curr = curr;
this.dst = dst;
this.path = null;
this.aStar = this.newAStar(curr, dst);
this.computeFinished = false;
return this.nextTile(curr, dst);
} else {
const tile = this.path?.shift();
if (tile === undefined) {
throw new Error("missing tile");
}
return { type: PathFindResultType.NextTile, node: tile };
}
}
switch (this.aStar.compute()) {
case PathFindResultType.Completed:
this.computeFinished = true;
this.path = this.aStar.reconstructPath();
// Remove the start tile
this.path.shift();
return this.nextTile(curr, dst);
case PathFindResultType.Pending:
return { type: PathFindResultType.Pending };
case PathFindResultType.PathNotFound:
return { type: PathFindResultType.PathNotFound };
default:
throw new Error("unexpected compute result");
}
}
private shouldRecompute(curr: TileRef, dst: TileRef) {
if (this.path === null || this.curr === null || this.dst === null) {
return true;
}
const dist = this.game.manhattanDist(curr, dst);
let tolerance = 10;
if (dist > 50) {
tolerance = 10;
} else if (dist > 25) {
tolerance = 5;
} else {
tolerance = 0;
}
if (this.game.manhattanDist(this.dst, dst) > tolerance) {
return true;
}
return false;
}
}