From eeeb7e4b4e16a9579680efd9af909fb5479325a8 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Sun, 3 Aug 2025 00:03:29 +0200 Subject: [PATCH] SAM smart targeting (#1618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/client/graphics/layers/UnitLayer.ts | 2 +- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 8 +- src/core/execution/NukeExecution.ts | 46 +++- src/core/execution/SAMLauncherExecution.ts | 200 +++++++++++++----- src/core/execution/SAMMissileExecution.ts | 6 +- src/core/game/Game.ts | 9 + src/core/game/UnitImpl.ts | 18 ++ src/core/pathfinding/PathFinding.ts | 19 +- src/core/utilities/Line.ts | 129 ++++++----- .../executions/SAMLauncherExecution.test.ts | 48 ++++- 11 files changed, 350 insertions(+), 136 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 14afbf708..725eb1be6 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -545,7 +545,7 @@ export class UnitLayer implements Layer { const targetable = unit.targetable(); if (!targetable) { this.context.save(); - this.context.globalAlpha = 0.4; + this.context.globalAlpha = 0.5; } this.context.drawImage( sprite, diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index c26fd4f31..16d6e9584 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -156,6 +156,7 @@ export interface Config { nukeAllianceBreakThreshold(): number; defaultNukeSpeed(): number; defaultNukeTargetableRange(): number; + defaultSamMissileSpeed(): number; defaultSamRange(): number; nukeDeathFactor( nukeType: NukeType, diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index e4d4404e2..3d8f43628 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -802,11 +802,15 @@ export class DefaultConfig implements Config { } defaultNukeTargetableRange(): number { - return 120; + return 150; } defaultSamRange(): number { - return 80; + return 70; + } + + defaultSamMissileSpeed(): number { + return 12; } // Humans can be soldiers, soldiers attacking, soldiers in boat etc. diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 864992a57..b7ac4a32f 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -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 | 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), ); } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index a897711fa..484028aec 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -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 = 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 = []; + 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 { diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index 4cf680805..e313acd8c 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -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( diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 894b894ae..2a4991afd 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -189,6 +189,10 @@ export interface OwnerComp { owner: Player; } +export type TrajectoryTile = { + tile: TileRef; + targetable: boolean; +}; export interface UnitParamsMap { [UnitType.TransportShip]: { troops?: number; @@ -207,10 +211,12 @@ export interface UnitParamsMap { [UnitType.AtomBomb]: { targetTile?: number; + trajectory: TrajectoryTile[]; }; [UnitType.HydrogenBomb]: { targetTile?: number; + trajectory: TrajectoryTile[]; }; [UnitType.TradeShip]: { @@ -420,6 +426,9 @@ export interface Unit { // Targeting setTargetTile(cell: TileRef | undefined): void; targetTile(): TileRef | undefined; + setTrajectoryIndex(i: number): void; + trajectoryIndex(): number; + trajectory(): TrajectoryTile[]; setTargetUnit(unit: Unit | undefined): void; targetUnit(): Unit | undefined; setTargetedBySAM(targeted: boolean): void; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index ea871e170..899e56d79 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -5,6 +5,7 @@ import { Player, Tick, TrainType, + TrajectoryTile, Unit, UnitInfo, UnitType, @@ -35,6 +36,9 @@ export class UnitImpl implements Unit { 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, @@ -48,6 +52,7 @@ export class UnitImpl implements Unit { 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 @@ -323,6 +328,19 @@ export class UnitImpl implements Unit { 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; } diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 80356f0d2..8d5193ed4 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -14,6 +14,7 @@ export class ParabolaPathFinder { computeControlPoints( orig: TileRef, dst: TileRef, + increment: number = 3, distanceBasedHeight = true, ) { const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) }; @@ -34,7 +35,7 @@ export class ParabolaPathFinder { y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0), }; - this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3); + this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment); } nextTile(speed: number): TileRef | true { @@ -47,6 +48,22 @@ export class ParabolaPathFinder { } 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 { diff --git a/src/core/utilities/Line.ts b/src/core/utilities/Line.ts index 2e1fea97b..e8c673533 100644 --- a/src/core/utilities/Line.ts +++ b/src/core/utilities/Line.ts @@ -78,76 +78,103 @@ export class CubicBezierCurve { */ export class DistanceBasedBezierCurve extends CubicBezierCurve { private totalDistance: number = 0; - private distanceLUT: Array<{ t: number; distance: number }> = []; - private lastFoundIndex: number = 0; // To keep track of the last found index + private cachedPoints: Point[] = []; + private currentIndex: number = 0; + constructor( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + distanceIncrement: number, + ) { + super(p0, p1, p2, p3); + this.computeAllPoints(distanceIncrement, 0.002); + } + + getAllPoints(): Point[] { + return this.cachedPoints; + } + /** + * Move forward along the curve by the given distance. + * Returns the next cached point, or null if at the end. + */ increment(distance: number): Point | null { this.totalDistance += distance; - const targetDistance = Math.min( - this.totalDistance, - this.distanceLUT[this.distanceLUT.length - 1]?.distance || - this.totalDistance, - ); - const t = this.computeTForDistance(targetDistance); - if (t >= 1) { - return null; // end reached + + // Step forward through cached points until we're at the correct distance + while ( + this.currentIndex < this.cachedPoints.length - 1 && + this.getDistanceUpToIndex(this.currentIndex + 1) < this.totalDistance + ) { + this.currentIndex++; } - return this.getPointAt(t); + + if (this.currentIndex >= this.cachedPoints.length - 1) { + return null; // End of curve + } + + return this.cachedPoints[this.currentIndex]; + } + + getCurrentIndex(): number { + return this.currentIndex; } /** - * Generate @p numSteps segments, starting from the beginning of the curve - * Each segment size is added in the LUT + * Precompute all points spaced @p pixelSpacing apart */ - generateCumulativeDistanceLUT(numSteps: number = 500): void { - this.distanceLUT = []; - let cumulativeDistance = 0; - let prevPoint = this.getPointAt(0); + computeAllPoints(pixelSpacing: number, precision): void { + this.cachedPoints = []; + this.totalDistance = 0; + this.currentIndex = 0; - for (let i = 1; i <= numSteps; i++) { - const t = i / numSteps; + let t = 0; + let prevPoint = this.getPointAt(t); + this.cachedPoints.push(prevPoint); + + let cumulativeDistance = 0; + + while (t < 1) { + t = Math.min(t + precision, 1); const currentPoint = this.getPointAt(t); const dx = currentPoint.x - prevPoint.x; const dy = currentPoint.y - prevPoint.y; const segmentLength = Math.sqrt(dx * dx + dy * dy); - cumulativeDistance += segmentLength; - this.distanceLUT.push({ t, distance: cumulativeDistance }); + + if (cumulativeDistance >= pixelSpacing) { + this.cachedPoints.push(currentPoint); + cumulativeDistance = 0; + } + prevPoint = currentPoint; } + + // Make sure the last point is exactly at t=1 + const finalPoint = this.getPointAt(1); + if ( + this.cachedPoints.length === 0 || + finalPoint.x !== this.cachedPoints[this.cachedPoints.length - 1].x || + finalPoint.y !== this.cachedPoints[this.cachedPoints.length - 1].y + ) { + this.cachedPoints.push(finalPoint); + } } - computeTForDistance(distance: number): number { - if (this.distanceLUT.length === 0) { - this.generateCumulativeDistanceLUT(); + /** + * Optional helper: get distance along the cached points up to a given index + */ + private getDistanceUpToIndex(index: number): number { + let dist = 0; + for (let i = 1; i <= index; i++) { + const p1 = this.cachedPoints[i - 1]; + const p2 = this.cachedPoints[i]; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + dist += Math.sqrt(dx * dx + dy * dy); } - if (distance <= 0) return 0; - if (distance >= this.distanceLUT[this.distanceLUT.length - 1].distance) { - return 1; - } - - let lowerIndex = this.lastFoundIndex; - let upperIndex = this.distanceLUT.length - 1; - // Binary search for the closest range - while (upperIndex - lowerIndex > 1) { - const midIndex = Math.floor((upperIndex + lowerIndex) / 2); - if (this.distanceLUT[midIndex].distance < distance) { - lowerIndex = midIndex; - } else { - upperIndex = midIndex; - } - } - - const lower = this.distanceLUT[lowerIndex]; - const upper = this.distanceLUT[upperIndex]; - this.lastFoundIndex = lowerIndex; - - // Linear interpolation of t based on the distance - const t = - lower.t + - ((distance - lower.distance) * (upper.t - lower.t)) / - (upper.distance - lower.distance); - return t; + return dist; } } diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 2802c3b76..0e7fc0d98 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -81,10 +81,16 @@ describe("SAM", () => { test("one sam should take down one nuke", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam)); - attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { - targetTile: game.ref(2, 1), - }); + // Sam will only target nukes it can destroy before it reaches its target + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(3, 1), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], + }); executeTicks(game, 3); expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0); @@ -94,10 +100,20 @@ describe("SAM", () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam)); attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), { - targetTile: game.ref(2, 1), + targetTile: game.ref(3, 1), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], }); attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { - targetTile: game.ref(1, 2), + targetTile: game.ref(1, 3), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(1, 2), targetable: true }, + { tile: game.ref(1, 3), targetable: true }, + ], }); expect(attacker.units(UnitType.AtomBomb)).toHaveLength(2); @@ -111,8 +127,13 @@ describe("SAM", () => { game.addExecution(new SAMLauncherExecution(defender, null, sam)); expect(sam.isInCooldown()).toBeFalsy(); - const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { - targetTile: game.ref(1, 2), + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(1, 3), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], }); executeTicks(game, 3); @@ -134,8 +155,13 @@ describe("SAM", () => { game.addExecution(new SAMLauncherExecution(defender, null, sam1)); const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam2)); - const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), { - targetTile: game.ref(2, 2), + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(1, 3), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(1, 2), targetable: true }, + { tile: game.ref(1, 3), targetable: true }, + ], }); executeTicks(game, 3); @@ -159,7 +185,7 @@ describe("SAM", () => { game.addExecution(nukeExecution); // Long distance nuke: compute the proper number of ticks const ticksToExecute = Math.ceil( - targetDistance / game.config().defaultNukeSpeed(), + targetDistance / game.config().defaultNukeSpeed() + 1, ); executeTicks(game, ticksToExecute); @@ -194,7 +220,7 @@ describe("SAM", () => { game.addExecution(nukeExecution); // Long distance nuke: compute the proper number of ticks const ticksToExecute = Math.ceil( - targetDistance / game.config().defaultNukeSpeed(), + targetDistance / game.config().defaultNukeSpeed() + 1, ); executeTicks(game, ticksToExecute); expect(nukeExecution.isActive()).toBeFalsy();