From 5dde4cc16db5216faccd624ad0a670e85b4c87f1 Mon Sep 17 00:00:00 2001
From: Philipp Allweyer <51273856+NewYearNewPhil@users.noreply.github.com>
Date: Wed, 5 Nov 2025 20:15:00 +0100
Subject: [PATCH] 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:
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
---
src/client/graphics/layers/SAMRadiusLayer.ts | 2 +-
.../graphics/layers/StructureDrawingUtils.ts | 3 +-
.../graphics/layers/StructureIconsLayer.ts | 54 +++++++++++++++++--
src/core/configuration/Config.ts | 2 +
src/core/configuration/DefaultConfig.ts | 9 ++++
src/core/execution/SAMLauncherExecution.ts | 4 +-
tests/util/TestConfig.ts | 4 ++
7 files changed, 70 insertions(+), 8 deletions(-)
diff --git a/src/client/graphics/layers/SAMRadiusLayer.ts b/src/client/graphics/layers/SAMRadiusLayer.ts
index eec7564ad..208d8e5a1 100644
--- a/src/client/graphics/layers/SAMRadiusLayer.ts
+++ b/src/client/graphics/layers/SAMRadiusLayer.ts
@@ -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(),
};
});
diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts
index 52b887cf9..09ac75814 100644
--- a/src/client/graphics/layers/StructureDrawingUtils.ts
+++ b/src/client/graphics/layers/StructureDrawingUtils.ts
@@ -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();
diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts
index 20d2960f0..3504eed05 100644
--- a/src/client/graphics/layers/StructureIconsLayer.ts
+++ b/src/client/graphics/layers/StructureIconsLayer.ts
@@ -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 =
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 04d5c85cf..273dfce08 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -171,6 +171,8 @@ export interface Config {
defaultNukeTargetableRange(): number;
defaultSamMissileSpeed(): number;
defaultSamRange(): number;
+ samRange(level: number): number;
+ maxSamRange(): number;
nukeDeathFactor(
nukeType: NukeType,
humans: number,
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index d6d45489e..2a4f1329e 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -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;
}
diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts
index baabf0fb2..33b909b10 100644
--- a/src/core/execution/SAMLauncherExecution.ts
+++ b/src/core/execution/SAMLauncherExecution.ts
@@ -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,
diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts
index 21e9784c2..c5f514ab0 100644
--- a/tests/util/TestConfig.ts
+++ b/tests/util/TestConfig.ts
@@ -54,6 +54,10 @@ export class TestConfig extends DefaultConfig {
return 20;
}
+ samRange(level: number): number {
+ return 20;
+ }
+
spawnImmunityDuration(): Tick {
return 0;
}