diff --git a/src/client/graphics/layers/SAMRadiusLayer.ts b/src/client/graphics/layers/SAMRadiusLayer.ts index eec7564ad1..208d8e5a1f 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 52b887cf9a..09ac75814b 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 009316f690..371e0ea940 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -58,6 +58,7 @@ export class StructureIconsLayer implements Layer { private ghostUnit: { container: PIXI.Container; range: PIXI.Container | null; + rangeLevel?: number; buildableUnit: BuildableUnit; } | null = null; private pixicanvas: HTMLCanvasElement; @@ -277,6 +278,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) => @@ -369,12 +373,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() { @@ -395,6 +398,49 @@ export class StructureIconsLayer implements Layer { this.uiState.ghostStructure = 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 04d5c85cf1..273dfce089 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 d6d45489e5..2a4f1329e4 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 baabf0fb2a..33b909b108 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 21e9784c20..c5f514ab02 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; }