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: image 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; }