mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:10:42 +00:00
eeeb7e4b4e
## 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
207 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|