Extend SAM Range to cover Hydros when stacked (#2351)

## Description:

Implements SAM range extension for stacked SAMs to cover hydros as
requested in #2347 and many times from users in discord.
This implementation is as simple as possible: At level 5 and higher,
SAMs extend their range to the range of a hydrogen bomb + 10 for a small
safety margin. Levels 2-4 are interpolated between.

Screenshot to show the sizes compared to a hydro:
<img width="400" alt="image"
src="https://github.com/user-attachments/assets/a857d66c-e3d4-467f-855f-3539cc90b719"
/>

Everything works together with the new range UI, although I might need
to unify with / rebase on #2350. Not yet tested with #2348, but
shouldn't be an issue.

## Input needed:
- Should I add tests for this?
- This is in effect a massive buff to SAMs, might be too strong. Popular
ideas / suggestions from Discord to balance things:
  - Cap the SAM upgrade level at the maximum range (easy to do)
- Alternative, instead of capping the level, decrease the range when
missiles are reloading
- Increase the cost scaling for SAMs per stack, and scale way higher
(e.g. 1.5M > 3M > 4.5M > 6M or something like that) (UI integration
unclear, breaks with existing cost logic)
  - Decrease SAM hit probability for Hydros

I'm happy to implement any of these paths, or just roll with the simple
way it's set up now, just let me know.

## 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

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

newyearnewphil

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
Philipp Allweyer
2025-11-05 20:15:00 +01:00
committed by GitHub
parent 6fe81cbcb6
commit 5dde4cc16d
7 changed files with 70 additions and 8 deletions
+1 -1
View File
@@ -163,7 +163,7 @@ export class SAMRadiusLayer implements Layer {
return {
x: this.game.x(tile),
y: this.game.y(tile),
r: this.game.config().defaultSamRange(),
r: this.game.config().samRange(sam.level()),
owner: sam.owner().smallID(),
};
});
@@ -428,6 +428,7 @@ export class SpriteFactory {
type: UnitType,
stage: PIXI.Container,
pos: { x: number; y: number },
level?: number,
): PIXI.Container | null {
if (stage === undefined) throw new Error("Not initialized");
const parentContainer = new PIXI.Container();
@@ -435,7 +436,7 @@ export class SpriteFactory {
let radius = 0;
switch (type) {
case UnitType.SAMLauncher:
radius = this.game.config().defaultSamRange();
radius = this.game.config().samRange(level ?? 1);
break;
case UnitType.Factory:
radius = this.game.config().trainStationMaxRange();
@@ -59,6 +59,7 @@ export class StructureIconsLayer implements Layer {
private ghostUnit: {
container: PIXI.Container;
range: PIXI.Container | null;
rangeLevel?: number;
buildableUnit: BuildableUnit;
} | null = null;
private pixicanvas: HTMLCanvasElement;
@@ -278,6 +279,9 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.buildableUnit = unit;
const targetLevel = this.resolveGhostRangeLevel(unit);
this.updateGhostRange(targetLevel);
if (unit.canUpgrade) {
this.potentialUpgrade = this.renders.find(
(r) =>
@@ -370,12 +374,11 @@ export class StructureIconsLayer implements Layer {
{ x: localX, y: localY },
type,
),
range: this.factory.createRange(type, this.ghostStage, {
x: localX,
y: localY,
}),
range: null,
buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n },
};
const baseLevel = this.resolveGhostRangeLevel(this.ghostUnit.buildableUnit);
this.updateGhostRange(baseLevel);
}
private clearGhostStructure() {
@@ -397,6 +400,49 @@ export class StructureIconsLayer implements Layer {
this.eventBus.emit(new GhostStructureChangedEvent(null));
}
private resolveGhostRangeLevel(
buildableUnit: BuildableUnit,
): number | undefined {
if (buildableUnit.type !== UnitType.SAMLauncher) {
return undefined;
}
if (buildableUnit.canUpgrade !== false) {
const existing = this.game.unit(buildableUnit.canUpgrade);
if (existing) {
return existing.level() + 1;
} else {
console.error("Failed to find existing SAMLauncher for upgrade");
}
}
return 1;
}
private updateGhostRange(level?: number) {
if (!this.ghostUnit) {
return;
}
if (this.ghostUnit.range && this.ghostUnit.rangeLevel === level) {
return;
}
this.ghostUnit.range?.destroy();
this.ghostUnit.range = null;
this.ghostUnit.rangeLevel = level;
const position = this.ghostUnit.container.position;
const range = this.factory.createRange(
this.ghostUnit.buildableUnit.type,
this.ghostStage,
{ x: position.x, y: position.y },
level,
);
if (range) {
this.ghostUnit.range = range;
}
}
private toggleStructures(toggleStructureType: UnitType[] | null): void {
for (const [structureType, infos] of this.structures) {
infos.visible =
+2
View File
@@ -171,6 +171,8 @@ export interface Config {
defaultNukeTargetableRange(): number;
defaultSamMissileSpeed(): number;
defaultSamRange(): number;
samRange(level: number): number;
maxSamRange(): number;
nukeDeathFactor(
nukeType: NukeType,
humans: number,
+9
View File
@@ -920,6 +920,15 @@ export class DefaultConfig implements Config {
return 70;
}
samRange(level: number): number {
// rational growth function (level 1 = 70, level 5 just above hydro range, asymptotically approaches 150)
return this.maxSamRange() - 480 / (level + 5);
}
maxSamRange(): number {
return 150;
}
defaultSamMissileSpeed(): number {
return 12;
}
+2 -2
View File
@@ -50,7 +50,7 @@ class SAMTargetingSystem {
private isInRange(tile: TileRef) {
const samTile = this.sam.tile();
const range = this.mg.config().defaultSamRange();
const range = this.mg.config().samRange(this.sam.level());
const rangeSquared = range * range;
return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared;
}
@@ -82,7 +82,7 @@ class SAMTargetingSystem {
public getSingleTarget(ticks: number): Target | null {
// Look beyond the SAM range so it can preshot nukes
const detectionRange = this.mg.config().defaultSamRange() * 2;
const detectionRange = this.mg.config().maxSamRange() * 2;
const nukes = this.mg.nearbyUnits(
this.sam.tile(),
detectionRange,
+4
View File
@@ -54,6 +54,10 @@ export class TestConfig extends DefaultConfig {
return 20;
}
samRange(level: number): number {
return 20;
}
spawnImmunityDuration(): Tick {
return 0;
}