From 92b130bbf34c70e1cb611e219d3217d833d4cf1f Mon Sep 17 00:00:00 2001 From: bibizu Date: Thu, 27 Nov 2025 01:46:11 -0500 Subject: [PATCH 01/12] enhance: Nuke trajectory shows SAM targetability --- .../layers/NukeTrajectoryPreviewLayer.ts | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 996e18209e..ff239427e2 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -15,6 +15,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { // Trajectory preview state private mousePos = { x: 0, y: 0 }; private trajectoryPoints: TileRef[] = []; + private targetableSwitchPointIndex: [number, number] = [-1, -1]; private lastTrajectoryUpdate: number = 0; private lastTargetTile: TileRef | null = null; private currentGhostStructure: UnitType | null = null; @@ -210,6 +211,35 @@ export class NukeTrajectoryPreviewLayer implements Layer { ); this.trajectoryPoints = pathFinder.allTiles(); + + // Calculate points when bomb targetability switches + const targetRangeSquared = + this.game.config().defaultNukeTargetableRange() ** 2; + + this.targetableSwitchPointIndex = [-1, -1]; + for (let i = 0; i < this.trajectoryPoints.length; i++) { + const tile = this.trajectoryPoints[i]; + if (this.targetableSwitchPointIndex[0] === -1) { + if ( + this.game.euclideanDistSquared(tile, this.cachedSpawnTile) > + targetRangeSquared + ) { + if ( + this.game.euclideanDistSquared(tile, targetTile) < + targetRangeSquared + ) { + break; + } else { + this.targetableSwitchPointIndex[0] = i; + } + } + } else if ( + this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared + ) { + this.targetableSwitchPointIndex[1] = i; + break; + } + } } /** @@ -231,14 +261,21 @@ export class NukeTrajectoryPreviewLayer implements Layer { } const territoryColor = player.territoryColor(); - const lineColor = territoryColor.alpha(0.7).toRgbString(); + const untargetableLineColor = territoryColor + .alpha(0.8) + .saturate(0.8) + .toRgbString(); + const targetableLineColor = territoryColor + .alpha(0.8) + .saturate(0.5) + .toRgbString(); // Calculate offset to center coordinates (same as canvas drawing) const offsetX = -this.game.width() / 2; const offsetY = -this.game.height() / 2; context.save(); - context.strokeStyle = lineColor; + context.strokeStyle = targetableLineColor; context.lineWidth = 1.5; context.setLineDash([8, 4]); context.beginPath(); @@ -254,6 +291,29 @@ export class NukeTrajectoryPreviewLayer implements Layer { } else { context.lineTo(x, y); } + if (i === this.targetableSwitchPointIndex[0]) { + context.stroke(); + + context.beginPath(); + context.setLineDash([]); + context.arc(x, y, 4, 0, 2 * Math.PI, false); + context.stroke(); + + context.beginPath(); + context.strokeStyle = untargetableLineColor; + context.setLineDash([2, 6]); + } else if (i === this.targetableSwitchPointIndex[1]) { + context.stroke(); + + context.beginPath(); + context.strokeStyle = targetableLineColor; + context.setLineDash([]); + context.arc(x, y, 4, 0, 2 * Math.PI, false); + context.stroke(); + + context.beginPath(); + context.setLineDash([8, 4]); + } } context.stroke(); From 3a3f9c5692c1752a6a55c8e03b1cd652d593211e Mon Sep 17 00:00:00 2001 From: bibizu Date: Sat, 29 Nov 2025 11:10:07 -0500 Subject: [PATCH 02/12] fix: code organizaion nitpicks --- .../layers/NukeTrajectoryPreviewLayer.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index ff239427e2..cfe4099de4 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -15,7 +15,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { // Trajectory preview state private mousePos = { x: 0, y: 0 }; private trajectoryPoints: TileRef[] = []; - private targetableSwitchPointIndex: [number, number] = [-1, -1]; + private untargetableSegmentBounds: [number, number] = [-1, -1]; private lastTrajectoryUpdate: number = 0; private lastTargetTile: TileRef | null = null; private currentGhostStructure: UnitType | null = null; @@ -216,10 +216,13 @@ export class NukeTrajectoryPreviewLayer implements Layer { const targetRangeSquared = this.game.config().defaultNukeTargetableRange() ** 2; - this.targetableSwitchPointIndex = [-1, -1]; + this.untargetableSegmentBounds = [-1, -1]; + // Find two switch points where bomb transitions: + // [0]: leaves spawn range, enters untargetable zone + // [1]: enters target range, becomes targetable again for (let i = 0; i < this.trajectoryPoints.length; i++) { const tile = this.trajectoryPoints[i]; - if (this.targetableSwitchPointIndex[0] === -1) { + if (this.untargetableSegmentBounds[0] === -1) { if ( this.game.euclideanDistSquared(tile, this.cachedSpawnTile) > targetRangeSquared @@ -228,15 +231,16 @@ export class NukeTrajectoryPreviewLayer implements Layer { this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared ) { + // overlapping spawn & target range break; } else { - this.targetableSwitchPointIndex[0] = i; + this.untargetableSegmentBounds[0] = i; } } } else if ( this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared ) { - this.targetableSwitchPointIndex[1] = i; + this.untargetableSegmentBounds[1] = i; break; } } @@ -291,7 +295,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { } else { context.lineTo(x, y); } - if (i === this.targetableSwitchPointIndex[0]) { + if (i === this.untargetableSegmentBounds[0]) { context.stroke(); context.beginPath(); @@ -302,7 +306,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { context.beginPath(); context.strokeStyle = untargetableLineColor; context.setLineDash([2, 6]); - } else if (i === this.targetableSwitchPointIndex[1]) { + } else if (i === this.untargetableSegmentBounds[1]) { context.stroke(); context.beginPath(); From 02df13ca7a8b39eab9a38b4503ae64213c250fbd Mon Sep 17 00:00:00 2001 From: bibizu Date: Sat, 29 Nov 2025 12:32:13 -0500 Subject: [PATCH 03/12] enhance: SAM range colored by alliance type --- src/client/graphics/layers/SAMRadiusLayer.ts | 22 ++++++++++++++------ src/core/game/GameView.ts | 4 ++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/client/graphics/layers/SAMRadiusLayer.ts b/src/client/graphics/layers/SAMRadiusLayer.ts index 208d8e5a1f..807dd1c16a 100644 --- a/src/client/graphics/layers/SAMRadiusLayer.ts +++ b/src/client/graphics/layers/SAMRadiusLayer.ts @@ -1,7 +1,7 @@ import type { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; -import type { GameView } from "../../../core/game/GameView"; +import type { GameView, PlayerView } from "../../../core/game/GameView"; import { ToggleStructureEvent } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; import { UIState } from "../UIState"; @@ -164,7 +164,7 @@ export class SAMRadiusLayer implements Layer { x: this.game.x(tile), y: this.game.y(tile), r: this.game.config().samRange(sam.level()), - owner: sam.owner().smallID(), + owner: sam.owner(), }; }); @@ -176,13 +176,15 @@ export class SAMRadiusLayer implements Layer { * so overlapping circles appear as one combined shape. */ private drawCirclesUnion( - circles: Array<{ x: number; y: number; r: number; owner: number }>, + circles: Array<{ x: number; y: number; r: number; owner: PlayerView }>, ) { const ctx = this.context; if (circles.length === 0) return; // styles - const strokeStyleOuter = "rgba(0, 0, 0, 1)"; + const strokeStyleOuterSelf = "rgba(255, 255, 255, 1)"; + const strokeStyleOuterUnfriendly = "rgba(0, 0, 0, 1)"; + const strokeStyleOuterFriendly = "rgba(155, 155, 155, 1)"; // 1) Fill union simply by drawing all full circle paths and filling once ctx.save(); @@ -202,7 +204,6 @@ export class SAMRadiusLayer implements Layer { ctx.lineWidth = 2; ctx.setLineDash([12, 6]); ctx.lineDashOffset = this.dashOffset; - ctx.strokeStyle = strokeStyleOuter; const TWO_PI = Math.PI * 2; @@ -253,12 +254,21 @@ export class SAMRadiusLayer implements Layer { const covered: Array<[number, number]> = []; let fullyCovered = false; + if (this.game.isMyPlayer(a.owner)) { + ctx.strokeStyle = strokeStyleOuterSelf; + } else if (this.game.myPlayer()?.isFriendly(a.owner)) { + ctx.strokeStyle = strokeStyleOuterFriendly; + } else { + ctx.strokeStyle = strokeStyleOuterUnfriendly; + } + for (let j = 0; j < circles.length; j++) { if (i === j) continue; // Only consider coverage from circles owned by the same player. // This shows separate boundaries for different players' SAM coverage, // making contested areas visually distinct. - if (a.owner !== circles[j].owner) continue; + if (a.owner.smallID() !== circles[j].owner.smallID()) continue; + const b = circles[j]; const dx = b.x - a.x; const dy = b.y - a.y; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 63ce987de5..8274d85e97 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -604,6 +604,10 @@ export class GameView implements GameMap { return this._myPlayer; } + isMyPlayer(player: PlayerView): boolean { + return this.myPlayer()?.smallID() === player.smallID(); + } + player(id: PlayerID): PlayerView { const player = this._players.get(id); if (player === undefined) { From 0793fd3a51638c70fa728bdba27feb42900b9b18 Mon Sep 17 00:00:00 2001 From: bibizu Date: Sat, 29 Nov 2025 14:07:35 -0500 Subject: [PATCH 04/12] feat: SAM intercept prediction --- .../layers/NukeTrajectoryPreviewLayer.ts | 145 ++++++++++++++++-- src/client/graphics/layers/SAMRadiusLayer.ts | 4 +- 2 files changed, 134 insertions(+), 15 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index cfe4099de4..4736efc735 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -1,6 +1,7 @@ import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding"; import { GhostStructureChangedEvent, MouseMoveEvent } from "../../InputHandler"; @@ -16,10 +17,12 @@ export class NukeTrajectoryPreviewLayer implements Layer { private mousePos = { x: 0, y: 0 }; private trajectoryPoints: TileRef[] = []; private untargetableSegmentBounds: [number, number] = [-1, -1]; + private targetedIndex = -1; private lastTrajectoryUpdate: number = 0; private lastTargetTile: TileRef | null = null; private currentGhostStructure: UnitType | null = null; private cachedSpawnTile: TileRef | null = null; // Cache spawn tile to avoid expensive player.actions() calls + private readonly samLaunchers: Map = new Map(); // Track SAM launcher IDs -> ownerSmallID constructor( private game: GameView, @@ -51,6 +54,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { } tick() { + this.updateSAMs(); this.updateTrajectoryPreview(); } @@ -60,6 +64,40 @@ export class NukeTrajectoryPreviewLayer implements Layer { this.drawTrajectoryPreview(context); } + /** + * Update the list of SAMS for intercept prediction + */ + private updateSAMs() { + // Check for updates to SAM launchers + const updates = this.game.updatesSinceLastTick(); + const unitUpdates = updates?.[GameUpdateType.Unit]; + + if (unitUpdates) { + for (const update of unitUpdates) { + const unit = this.game.unit(update.id); + if (unit && unit.type() === UnitType.SAMLauncher) { + const wasTracked = this.samLaunchers.has(update.id); + const shouldTrack = unit.isActive(); + const owner = unit.owner().smallID(); + + if (wasTracked && !shouldTrack) { + // SAM was destroyed + this.samLaunchers.delete(update.id); + } else if (!wasTracked && shouldTrack) { + // New SAM was built + this.samLaunchers.set(update.id, owner); + } else if (wasTracked && shouldTrack) { + // SAM still exists; check if owner changed + const prevOwner = this.samLaunchers.get(update.id); + if (prevOwner !== owner) { + this.samLaunchers.set(update.id, owner); + } + } + } + } + } + } + /** * Update trajectory preview - called from tick() to cache spawn tile via expensive player.actions() call * This only runs when target tile changes, minimizing worker thread communication @@ -212,14 +250,17 @@ export class NukeTrajectoryPreviewLayer implements Layer { this.trajectoryPoints = pathFinder.allTiles(); + // TODO: I believe the following are expensive calculations in rendering + // But trajectory is already calculated here and needed for prediction + // Calculate points when bomb targetability switches const targetRangeSquared = this.game.config().defaultNukeTargetableRange() ** 2; - this.untargetableSegmentBounds = [-1, -1]; // Find two switch points where bomb transitions: // [0]: leaves spawn range, enters untargetable zone // [1]: enters target range, becomes targetable again + this.untargetableSegmentBounds = [-1, -1]; for (let i = 0; i < this.trajectoryPoints.length; i++) { const tile = this.trajectoryPoints[i]; if (this.untargetableSegmentBounds[0] === -1) { @@ -244,6 +285,33 @@ export class NukeTrajectoryPreviewLayer implements Layer { break; } } + // Find the point where SAM can intercept + this.targetedIndex = this.trajectoryPoints.length; + // Get all active SAM launchers + const samLaunchers = this.game + .units(UnitType.SAMLauncher) + .filter((unit) => unit.isActive()); + // Update our tracking set + this.samLaunchers.clear(); + samLaunchers.forEach((sam) => + this.samLaunchers.set(sam.id(), sam.owner().smallID()), + ); + // Check trajectory + for (let i = 0; i < this.trajectoryPoints.length; i++) { + const tile = this.trajectoryPoints[i]; + for (const sam of samLaunchers) { + const samTile = sam.tile(); + const r = this.game.config().samRange(sam.level()); + if (this.game.euclideanDistSquared(tile, samTile) <= r ** 2) { + this.targetedIndex = i; + break; + } + } + // Jump over untargetable segment + if (i === this.untargetableSegmentBounds[0]) + i = this.untargetableSegmentBounds[1] - 1; + if (this.targetedIndex !== this.trajectoryPoints.length) break; + } } /** @@ -265,23 +333,39 @@ export class NukeTrajectoryPreviewLayer implements Layer { } const territoryColor = player.territoryColor(); - const untargetableLineColor = territoryColor + // Set of line colors, targeted is after SAM intercept is detected. + const targettedLocationColor = "rgba(255, 0, 0, 1)"; + const untargetableAndUntargetedLineColor = territoryColor .alpha(0.8) .saturate(0.8) .toRgbString(); - const targetableLineColor = territoryColor + const targetableAndUntargetedLineColor = territoryColor .alpha(0.8) .saturate(0.5) .toRgbString(); + const untargetableAndTargetedLineColor = territoryColor + .alpha(0.7) + .desaturate(0.4) + .toRgbString(); + const targetableAndTargetedLineColor = territoryColor + .alpha(0.7) + .desaturate(0.4) + .toRgbString(); + + // Set of line dashes + const untargetableAndUntargetedLineDash = [2, 6]; + const targetableAndUntargetedLineDash = [8, 4]; + const untargetableAndTargetedLineDash = [2, 6]; + const targetableAndTargetedLineDash = [8, 4]; // Calculate offset to center coordinates (same as canvas drawing) const offsetX = -this.game.width() / 2; const offsetY = -this.game.height() / 2; context.save(); - context.strokeStyle = targetableLineColor; context.lineWidth = 1.5; - context.setLineDash([8, 4]); + context.strokeStyle = targetableAndUntargetedLineColor; + context.setLineDash(targetableAndUntargetedLineDash); context.beginPath(); // Draw line connecting trajectory points @@ -297,26 +381,61 @@ export class NukeTrajectoryPreviewLayer implements Layer { } if (i === this.untargetableSegmentBounds[0]) { context.stroke(); - + // Draw Circle context.beginPath(); + context.strokeStyle = targetableAndUntargetedLineColor; context.setLineDash([]); context.arc(x, y, 4, 0, 2 * Math.PI, false); context.stroke(); - + // Start New Line context.beginPath(); - context.strokeStyle = untargetableLineColor; - context.setLineDash([2, 6]); + if (i >= this.targetedIndex) { + context.strokeStyle = untargetableAndTargetedLineColor; + context.setLineDash(untargetableAndTargetedLineDash); + } else { + context.strokeStyle = untargetableAndUntargetedLineColor; + context.setLineDash(untargetableAndUntargetedLineDash); + } } else if (i === this.untargetableSegmentBounds[1]) { context.stroke(); - + // Draw Circle context.beginPath(); - context.strokeStyle = targetableLineColor; + context.strokeStyle = targetableAndUntargetedLineColor; context.setLineDash([]); context.arc(x, y, 4, 0, 2 * Math.PI, false); context.stroke(); - + // Start New Line context.beginPath(); - context.setLineDash([8, 4]); + if (i >= this.targetedIndex) { + context.strokeStyle = targetableAndTargetedLineColor; + context.setLineDash(targetableAndTargetedLineDash); + } else { + context.strokeStyle = targetableAndUntargetedLineColor; + context.setLineDash(targetableAndUntargetedLineDash); + } + } else if (i === this.targetedIndex) { + context.stroke(); + // Draw X + context.beginPath(); + context.strokeStyle = targettedLocationColor; + context.setLineDash([]); + context.moveTo(x - 3, y - 3); + context.lineTo(x + 3, y + 3); + context.moveTo(x - 3, y + 3); + context.lineTo(x + 3, y - 3); + context.stroke(); + // Start New Line + context.beginPath(); + if ( + i >= this.untargetableSegmentBounds[0] && + i <= this.untargetableSegmentBounds[1] + ) { + context.strokeStyle = untargetableAndTargetedLineColor; + context.setLineDash(untargetableAndTargetedLineDash); + } else { + context.strokeStyle = untargetableAndTargetedLineColor; + context.setLineDash(untargetableAndTargetedLineDash); + } } } diff --git a/src/client/graphics/layers/SAMRadiusLayer.ts b/src/client/graphics/layers/SAMRadiusLayer.ts index 807dd1c16a..ee2d65b687 100644 --- a/src/client/graphics/layers/SAMRadiusLayer.ts +++ b/src/client/graphics/layers/SAMRadiusLayer.ts @@ -8,7 +8,7 @@ import { UIState } from "../UIState"; import { Layer } from "./Layer"; /** - * Layer responsible for rendering SAM launcher defense radiuses + * Layer responsible for rendering SAM launcher defense radii */ export class SAMRadiusLayer implements Layer { private readonly canvas: HTMLCanvasElement; @@ -157,7 +157,7 @@ export class SAMRadiusLayer implements Layer { this.samLaunchers.set(sam.id(), sam.owner().smallID()), ); - // Draw union of SAM radiuses. Collect circle data then draw union outer arcs only + // Draw union of SAM radii. Collect circle data then draw union outer arcs only const circles = samLaunchers.map((sam) => { const tile = sam.tile(); return { From c4b062278e3d761e3b7c57584d61489b4c039204 Mon Sep 17 00:00:00 2001 From: bibizu Date: Sat, 29 Nov 2025 17:46:23 -0500 Subject: [PATCH 05/12] enhance: Outlines and Invulnerability Setting --- .../layers/NukeTrajectoryPreviewLayer.ts | 155 ++++++++++-------- src/client/graphics/layers/SAMRadiusLayer.ts | 44 +++-- src/core/configuration/Config.ts | 2 + src/core/configuration/DefaultConfig.ts | 4 + src/core/execution/NukeExecution.ts | 10 +- tests/util/TestConfig.ts | 4 + 6 files changed, 130 insertions(+), 89 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 4736efc735..7d8e77d363 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -54,7 +54,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { } tick() { - this.updateSAMs(); + //this.updateSAMs(); this.updateTrajectoryPreview(); } @@ -250,12 +250,14 @@ export class NukeTrajectoryPreviewLayer implements Layer { this.trajectoryPoints = pathFinder.allTiles(); - // TODO: I believe the following are expensive calculations in rendering - // But trajectory is already calculated here and needed for prediction + // NOTE: This is a lot to do in the rendering method, naive + // But trajectory is already calculated here and needed for prediction. + // From testing, does not seem to have much effect, so I keep it this way. // Calculate points when bomb targetability switches - const targetRangeSquared = - this.game.config().defaultNukeTargetableRange() ** 2; + const targetRangeSquared = this.game.config().defaultNukeInvulnerability() + ? this.game.config().defaultNukeTargetableRange() ** 2 + : Number.MAX_VALUE; // Find two switch points where bomb transitions: // [0]: leaves spawn range, enters untargetable zone @@ -287,15 +289,15 @@ export class NukeTrajectoryPreviewLayer implements Layer { } // Find the point where SAM can intercept this.targetedIndex = this.trajectoryPoints.length; - // Get all active SAM launchers + // Get all active unfriendly SAM launchers const samLaunchers = this.game .units(UnitType.SAMLauncher) - .filter((unit) => unit.isActive()); - // Update our tracking set - this.samLaunchers.clear(); - samLaunchers.forEach((sam) => - this.samLaunchers.set(sam.id(), sam.owner().smallID()), - ); + .filter( + (unit) => + unit.isActive() && + !this.game.isMyPlayer(unit.owner()) && + !this.game.myPlayer()?.isFriendly(unit.owner()), + ); // Check trajectory for (let i = 0; i < this.trajectoryPoints.length; i++) { const tile = this.trajectoryPoints[i]; @@ -307,10 +309,10 @@ export class NukeTrajectoryPreviewLayer implements Layer { break; } } + if (this.targetedIndex !== this.trajectoryPoints.length) break; // Jump over untargetable segment if (i === this.untargetableSegmentBounds[0]) i = this.untargetableSegmentBounds[1] - 1; - if (this.targetedIndex !== this.trajectoryPoints.length) break; } } @@ -332,40 +334,54 @@ export class NukeTrajectoryPreviewLayer implements Layer { return; } - const territoryColor = player.territoryColor(); // Set of line colors, targeted is after SAM intercept is detected. - const targettedLocationColor = "rgba(255, 0, 0, 1)"; - const untargetableAndUntargetedLineColor = territoryColor - .alpha(0.8) - .saturate(0.8) - .toRgbString(); - const targetableAndUntargetedLineColor = territoryColor - .alpha(0.8) - .saturate(0.5) - .toRgbString(); - const untargetableAndTargetedLineColor = territoryColor - .alpha(0.7) - .desaturate(0.4) - .toRgbString(); - const targetableAndTargetedLineColor = territoryColor - .alpha(0.7) - .desaturate(0.4) - .toRgbString(); + const untargetedOutlineColor = "rgba(140, 140, 140, 1)"; + const targetedOutlineColor = "rgba(150, 90, 90, 1)"; + const targetedLocationColor = "rgba(255, 0, 0, 1)"; + const untargetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)"; + const targetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)"; + const untargetableAndTargetedLineColor = "rgba(255, 80, 80, 1)"; + const targetableAndTargetedLineColor = "rgba(255, 80, 80, 1)"; + + // Set of line widths + const outlineExtraWidth = 1.5; // adds onto below + const lineWidth = 1.25; // Set of line dashes + // Outline dashes calculated automatically const untargetableAndUntargetedLineDash = [2, 6]; const targetableAndUntargetedLineDash = [8, 4]; const untargetableAndTargetedLineDash = [2, 6]; const targetableAndTargetedLineDash = [8, 4]; + const outlineDash = (dash: number[], extra: number) => { + return [dash[0] + extra, Math.max(dash[1] - extra, 0)]; + }; + + // Tracks the change of color and dash length throughout + let currentOutlineColor = untargetedOutlineColor; + let currentLineColor = targetableAndUntargetedLineColor; + let currentLineDash = targetableAndUntargetedLineDash; + + // Take in set of "current" parameters and draw both outline and line. + const outlineAndStroke = () => { + context.lineWidth = lineWidth + outlineExtraWidth; + context.setLineDash(outlineDash(currentLineDash, outlineExtraWidth)); + context.lineDashOffset = outlineExtraWidth / 2; + context.strokeStyle = currentOutlineColor; + context.stroke(); + context.lineWidth = lineWidth; + context.setLineDash(currentLineDash); + context.lineDashOffset = 0; + context.strokeStyle = currentLineColor; + context.stroke(); + }; + // Calculate offset to center coordinates (same as canvas drawing) const offsetX = -this.game.width() / 2; const offsetY = -this.game.height() / 2; context.save(); - context.lineWidth = 1.5; - context.strokeStyle = targetableAndUntargetedLineColor; - context.setLineDash(targetableAndUntargetedLineDash); context.beginPath(); // Draw line connecting trajectory points @@ -380,66 +396,65 @@ export class NukeTrajectoryPreviewLayer implements Layer { context.lineTo(x, y); } if (i === this.untargetableSegmentBounds[0]) { - context.stroke(); + outlineAndStroke(); // Draw Circle context.beginPath(); - context.strokeStyle = targetableAndUntargetedLineColor; - context.setLineDash([]); context.arc(x, y, 4, 0, 2 * Math.PI, false); - context.stroke(); + currentLineColor = targetableAndUntargetedLineColor; + currentLineDash = [1, 0]; + outlineAndStroke(); // Start New Line context.beginPath(); if (i >= this.targetedIndex) { - context.strokeStyle = untargetableAndTargetedLineColor; - context.setLineDash(untargetableAndTargetedLineDash); + currentOutlineColor = targetedOutlineColor; + currentLineColor = untargetableAndTargetedLineColor; + currentLineDash = untargetableAndTargetedLineDash; } else { - context.strokeStyle = untargetableAndUntargetedLineColor; - context.setLineDash(untargetableAndUntargetedLineDash); + currentOutlineColor = untargetedOutlineColor; + currentLineColor = untargetableAndUntargetedLineColor; + currentLineDash = untargetableAndUntargetedLineDash; } } else if (i === this.untargetableSegmentBounds[1]) { - context.stroke(); + outlineAndStroke(); // Draw Circle context.beginPath(); - context.strokeStyle = targetableAndUntargetedLineColor; - context.setLineDash([]); context.arc(x, y, 4, 0, 2 * Math.PI, false); - context.stroke(); + currentLineColor = targetableAndUntargetedLineColor; + currentLineDash = [1, 0]; + outlineAndStroke(); // Start New Line context.beginPath(); if (i >= this.targetedIndex) { - context.strokeStyle = targetableAndTargetedLineColor; - context.setLineDash(targetableAndTargetedLineDash); + currentOutlineColor = targetedOutlineColor; + currentLineColor = targetableAndTargetedLineColor; + currentLineDash = targetableAndTargetedLineDash; } else { - context.strokeStyle = targetableAndUntargetedLineColor; - context.setLineDash(targetableAndUntargetedLineDash); + currentOutlineColor = untargetedOutlineColor; + currentLineColor = targetableAndUntargetedLineColor; + currentLineDash = targetableAndUntargetedLineDash; } - } else if (i === this.targetedIndex) { - context.stroke(); + } + if (i === this.targetedIndex) { + outlineAndStroke(); // Draw X context.beginPath(); - context.strokeStyle = targettedLocationColor; - context.setLineDash([]); - context.moveTo(x - 3, y - 3); - context.lineTo(x + 3, y + 3); - context.moveTo(x - 3, y + 3); - context.lineTo(x + 3, y - 3); - context.stroke(); + context.moveTo(x - 4, y - 4); + context.lineTo(x + 4, y + 4); + context.moveTo(x - 4, y + 4); + context.lineTo(x + 4, y - 4); + currentOutlineColor = targetedOutlineColor; + currentLineColor = targetedLocationColor; + currentLineDash = [1, 0]; + outlineAndStroke(); // Start New Line context.beginPath(); - if ( - i >= this.untargetableSegmentBounds[0] && - i <= this.untargetableSegmentBounds[1] - ) { - context.strokeStyle = untargetableAndTargetedLineColor; - context.setLineDash(untargetableAndTargetedLineDash); - } else { - context.strokeStyle = untargetableAndTargetedLineColor; - context.setLineDash(untargetableAndTargetedLineDash); - } + // Always in the targetable zone by definition. + currentLineColor = targetableAndTargetedLineColor; + currentLineDash = targetableAndTargetedLineDash; } } - context.stroke(); + outlineAndStroke(); context.restore(); } } diff --git a/src/client/graphics/layers/SAMRadiusLayer.ts b/src/client/graphics/layers/SAMRadiusLayer.ts index ee2d65b687..93af60fc8e 100644 --- a/src/client/graphics/layers/SAMRadiusLayer.ts +++ b/src/client/graphics/layers/SAMRadiusLayer.ts @@ -181,10 +181,14 @@ export class SAMRadiusLayer implements Layer { const ctx = this.context; if (circles.length === 0) return; - // styles - const strokeStyleOuterSelf = "rgba(255, 255, 255, 1)"; - const strokeStyleOuterUnfriendly = "rgba(0, 0, 0, 1)"; - const strokeStyleOuterFriendly = "rgba(155, 155, 155, 1)"; + // Line Parameters + const outlineColor = "rgba(0, 0, 0, 1)"; + const lineColorSelf = "rgba(0, 255, 0, 1)"; + const lineColorEnemy = "rgba(255, 0, 0, 1)"; + const lineColorFriend = "rgba(255, 255, 0, 1)"; + const extraOutlineWidth = 1; // adds onto below + const lineWidth = 2; + const lineDash = [12, 6]; // 1) Fill union simply by drawing all full circle paths and filling once ctx.save(); @@ -201,9 +205,6 @@ export class SAMRadiusLayer implements Layer { if (!this.showStroke) return; ctx.save(); - ctx.lineWidth = 2; - ctx.setLineDash([12, 6]); - ctx.lineDashOffset = this.dashOffset; const TWO_PI = Math.PI * 2; @@ -254,14 +255,6 @@ export class SAMRadiusLayer implements Layer { const covered: Array<[number, number]> = []; let fullyCovered = false; - if (this.game.isMyPlayer(a.owner)) { - ctx.strokeStyle = strokeStyleOuterSelf; - } else if (this.game.myPlayer()?.isFriendly(a.owner)) { - ctx.strokeStyle = strokeStyleOuterFriendly; - } else { - ctx.strokeStyle = strokeStyleOuterUnfriendly; - } - for (let j = 0; j < circles.length; j++) { if (i === j) continue; // Only consider coverage from circles owned by the same player. @@ -328,6 +321,27 @@ export class SAMRadiusLayer implements Layer { if (e - s < 1e-3) continue; ctx.beginPath(); ctx.arc(a.x, a.y, a.r, s, e); + + // Outline + ctx.strokeStyle = outlineColor; + ctx.lineWidth = lineWidth + extraOutlineWidth; + ctx.setLineDash([ + lineDash[0] + extraOutlineWidth, + Math.max(lineDash[1] - extraOutlineWidth, 0), + ]); + ctx.lineDashOffset = this.dashOffset + extraOutlineWidth / 2; + ctx.stroke(); + // Inline + if (this.game.isMyPlayer(a.owner)) { + ctx.strokeStyle = lineColorSelf; + } else if (this.game.myPlayer()?.isFriendly(a.owner)) { + ctx.strokeStyle = lineColorFriend; + } else { + ctx.strokeStyle = lineColorEnemy; + } + ctx.lineWidth = lineWidth; + ctx.setLineDash(lineDash); + ctx.lineDashOffset = this.dashOffset; ctx.stroke(); } } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index abe0f0018c..91111eb037 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -170,6 +170,8 @@ export interface Config { // Number of tiles destroyed to break an alliance nukeAllianceBreakThreshold(): number; defaultNukeSpeed(): number; + // If nuke can ever be invulnerable. + defaultNukeInvulnerability(): boolean; defaultNukeTargetableRange(): number; defaultSamMissileSpeed(): number; defaultSamRange(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 4b8c89588d..7bce6c5314 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -920,6 +920,10 @@ export class DefaultConfig implements Config { return 6; } + defaultNukeInvulnerability(): boolean { + return true; + } + defaultNukeTargetableRange(): number { return 150; } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 923535f1fe..b4a2b20c4c 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -188,8 +188,9 @@ export class NukeExecution implements Execution { private getTrajectory(target: TileRef): TrajectoryTile[] { const trajectoryTiles: TrajectoryTile[] = []; - const targetRangeSquared = - this.mg.config().defaultNukeTargetableRange() ** 2; + const targetRangeSquared = this.mg.config().defaultNukeInvulnerability() + ? this.mg.config().defaultNukeTargetableRange() ** 2 + : Number.MAX_VALUE; const allTiles: TileRef[] = this.pathFinder.allTiles(); for (const tile of allTiles) { trajectoryTiles.push({ @@ -218,8 +219,9 @@ export class NukeExecution implements Execution { if (this.nuke === null || this.nuke.targetTile() === undefined) { return; } - const targetRangeSquared = - this.mg.config().defaultNukeTargetableRange() ** 2; + const targetRangeSquared = this.mg.config().defaultNukeInvulnerability() + ? this.mg.config().defaultNukeTargetableRange() ** 2 + : Number.MAX_VALUE; const targetTile = this.nuke.targetTile(); this.nuke.setTargetable( this.isTargetable(targetTile!, this.nuke.tile(), targetRangeSquared), diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts index c5f514ab02..98c3bc0497 100644 --- a/tests/util/TestConfig.ts +++ b/tests/util/TestConfig.ts @@ -42,6 +42,10 @@ export class TestConfig extends DefaultConfig { return this._defaultNukeSpeed; } + defaultNukeInvulnerability(): boolean { + return true; + } + defaultNukeTargetableRange(): number { return 20; } From affb44e953e3c9994ea2358ecc42c5336d97b367 Mon Sep 17 00:00:00 2001 From: bibizu Date: Sat, 29 Nov 2025 23:07:09 -0500 Subject: [PATCH 06/12] perf: Update SAM list only when needed --- .../layers/NukeTrajectoryPreviewLayer.ts | 98 +++++++++++++------ 1 file changed, 66 insertions(+), 32 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 7d8e77d363..4220b3d7b6 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -2,7 +2,7 @@ import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView } from "../../../core/game/GameView"; +import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding"; import { GhostStructureChangedEvent, MouseMoveEvent } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; @@ -21,8 +21,10 @@ export class NukeTrajectoryPreviewLayer implements Layer { private lastTrajectoryUpdate: number = 0; private lastTargetTile: TileRef | null = null; private currentGhostStructure: UnitType | null = null; - private cachedSpawnTile: TileRef | null = null; // Cache spawn tile to avoid expensive player.actions() calls - private readonly samLaunchers: Map = new Map(); // Track SAM launcher IDs -> ownerSmallID + // Cache spawn tile to avoid expensive player.actions() calls + private cachedSpawnTile: TileRef | null = null; + // Track SAM launcher IDs -> SAM launcher unit + private readonly enemySAMLaunchers: Map = new Map(); constructor( private game: GameView, @@ -54,7 +56,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { } tick() { - //this.updateSAMs(); + this.updateSAMs(); this.updateTrajectoryPreview(); } @@ -65,34 +67,75 @@ export class NukeTrajectoryPreviewLayer implements Layer { } /** - * Update the list of SAMS for intercept prediction + * Check for updates to the list of SAMS for intercept prediction */ private updateSAMs() { // Check for updates to SAM launchers const updates = this.game.updatesSinceLastTick(); const unitUpdates = updates?.[GameUpdateType.Unit]; + const allianceResponse = updates?.[GameUpdateType.AllianceRequestReply]; + const allianceBroke = updates?.[GameUpdateType.BrokeAlliance]; + const allianceExpired = updates?.[GameUpdateType.AllianceExpired]; if (unitUpdates) { for (const update of unitUpdates) { const unit = this.game.unit(update.id); - if (unit && unit.type() === UnitType.SAMLauncher) { - const wasTracked = this.samLaunchers.has(update.id); - const shouldTrack = unit.isActive(); - const owner = unit.owner().smallID(); - - if (wasTracked && !shouldTrack) { - // SAM was destroyed - this.samLaunchers.delete(update.id); - } else if (!wasTracked && shouldTrack) { - // New SAM was built - this.samLaunchers.set(update.id, owner); - } else if (wasTracked && shouldTrack) { - // SAM still exists; check if owner changed - const prevOwner = this.samLaunchers.get(update.id); - if (prevOwner !== owner) { - this.samLaunchers.set(update.id, owner); - } + if (!unit || unit.type() !== UnitType.SAMLauncher) continue; + if (this.enemySAMLaunchers.has(update.id) && !unit.isActive()) { + // SAM was destroyed + this.enemySAMLaunchers.delete(update.id); + } else if (unit.isActive()) { + // New SAM was built or owner swap, check if friendly. + if ( + !this.game.isMyPlayer(unit.owner()) && + !this.game.myPlayer()?.isFriendly(unit.owner()) + ) { + this.enemySAMLaunchers.set(update.id, unit); + } + } + } + if (allianceResponse) { + for (const update of allianceResponse) { + if (update.accepted) { + // check for good SAMs + this.enemySAMLaunchers.forEach((sam, sam_id) => { + if (this.game.myPlayer()?.isFriendly(sam.owner())) { + this.enemySAMLaunchers.delete(sam_id); + } + }); + break; + } + } + } + const checkPlayers: number[] = []; + if (allianceBroke) { + for (const update of allianceBroke) { + if (this.game.myPlayer()?.smallID() === update.traitorID) { + checkPlayers.push(update.betrayedID); + break; } + if (this.game.myPlayer()?.smallID() === update.betrayedID) { + checkPlayers.push(update.traitorID); + break; + } + } + } + if (allianceExpired) { + for (const update of allianceExpired) { + if (this.game.myPlayer()?.smallID() === update.player1ID) { + checkPlayers.push(update.player2ID); + break; + } + if (this.game.myPlayer()?.smallID() === update.player2ID) { + checkPlayers.push(update.player1ID); + break; + } + } + } + for (const playerID of checkPlayers) { + const player = this.game.playerBySmallID(playerID) as PlayerView; + for (const sam of player.units(UnitType.SAMLauncher)) { + this.enemySAMLaunchers.set(sam.id(), sam); } } } @@ -289,19 +332,10 @@ export class NukeTrajectoryPreviewLayer implements Layer { } // Find the point where SAM can intercept this.targetedIndex = this.trajectoryPoints.length; - // Get all active unfriendly SAM launchers - const samLaunchers = this.game - .units(UnitType.SAMLauncher) - .filter( - (unit) => - unit.isActive() && - !this.game.isMyPlayer(unit.owner()) && - !this.game.myPlayer()?.isFriendly(unit.owner()), - ); // Check trajectory for (let i = 0; i < this.trajectoryPoints.length; i++) { const tile = this.trajectoryPoints[i]; - for (const sam of samLaunchers) { + for (const [, sam] of this.enemySAMLaunchers.entries()) { const samTile = sam.tile(); const r = this.game.config().samRange(sam.level()); if (this.game.euclideanDistSquared(tile, samTile) <= r ** 2) { From 4c685266e62c1c72c4ccc7ec69b92e366d5716a8 Mon Sep 17 00:00:00 2001 From: bibizu Date: Sat, 29 Nov 2025 23:35:14 -0500 Subject: [PATCH 07/12] fix: takeover SAM case and indent typo --- .../layers/NukeTrajectoryPreviewLayer.ts | 70 +++++++++---------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 4220b3d7b6..fe47679c31 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -91,52 +91,48 @@ export class NukeTrajectoryPreviewLayer implements Layer { !this.game.myPlayer()?.isFriendly(unit.owner()) ) { this.enemySAMLaunchers.set(update.id, unit); + } else if (this.enemySAMLaunchers.has(update.id)) { + this.enemySAMLaunchers.delete(update.id); } } } - if (allianceResponse) { - for (const update of allianceResponse) { - if (update.accepted) { - // check for good SAMs - this.enemySAMLaunchers.forEach((sam, sam_id) => { - if (this.game.myPlayer()?.isFriendly(sam.owner())) { - this.enemySAMLaunchers.delete(sam_id); - } - }); - break; - } + } + if (allianceResponse) { + for (const update of allianceResponse) { + if (update.accepted) { + // check for good SAMs + this.enemySAMLaunchers.forEach((sam, sam_id) => { + if (this.game.myPlayer()?.isFriendly(sam.owner())) { + this.enemySAMLaunchers.delete(sam_id); + } + }); + break; } } - const checkPlayers: number[] = []; - if (allianceBroke) { - for (const update of allianceBroke) { - if (this.game.myPlayer()?.smallID() === update.traitorID) { - checkPlayers.push(update.betrayedID); - break; - } - if (this.game.myPlayer()?.smallID() === update.betrayedID) { - checkPlayers.push(update.traitorID); - break; - } + } + const checkPlayers: number[] = []; + if (allianceBroke) { + for (const update of allianceBroke) { + if (this.game.myPlayer()?.smallID() === update.traitorID) { + checkPlayers.push(update.betrayedID); + } else if (this.game.myPlayer()?.smallID() === update.betrayedID) { + checkPlayers.push(update.traitorID); } } - if (allianceExpired) { - for (const update of allianceExpired) { - if (this.game.myPlayer()?.smallID() === update.player1ID) { - checkPlayers.push(update.player2ID); - break; - } - if (this.game.myPlayer()?.smallID() === update.player2ID) { - checkPlayers.push(update.player1ID); - break; - } + } + if (allianceExpired) { + for (const update of allianceExpired) { + if (this.game.myPlayer()?.smallID() === update.player1ID) { + checkPlayers.push(update.player2ID); + } else if (this.game.myPlayer()?.smallID() === update.player2ID) { + checkPlayers.push(update.player1ID); } } - for (const playerID of checkPlayers) { - const player = this.game.playerBySmallID(playerID) as PlayerView; - for (const sam of player.units(UnitType.SAMLauncher)) { - this.enemySAMLaunchers.set(sam.id(), sam); - } + } + for (const playerID of checkPlayers) { + const player = this.game.playerBySmallID(playerID) as PlayerView; + for (const sam of player.units(UnitType.SAMLauncher)) { + this.enemySAMLaunchers.set(sam.id(), sam); } } } From 640611ed6989c17fb4be2519d26a4aefbab7dffa Mon Sep 17 00:00:00 2001 From: bibizu Date: Mon, 1 Dec 2025 00:42:34 -0500 Subject: [PATCH 08/12] fix: nullity checks compact --- .../layers/NukeTrajectoryPreviewLayer.ts | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index fe47679c31..0e9cf13ff8 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -97,40 +97,35 @@ export class NukeTrajectoryPreviewLayer implements Layer { } } } - if (allianceResponse) { - for (const update of allianceResponse) { - if (update.accepted) { - // check for good SAMs - this.enemySAMLaunchers.forEach((sam, sam_id) => { - if (this.game.myPlayer()?.isFriendly(sam.owner())) { - this.enemySAMLaunchers.delete(sam_id); - } - }); - break; - } + for (const update of allianceResponse ?? []) { + if (update.accepted) { + // check for good SAMs + this.enemySAMLaunchers.forEach((sam, sam_id) => { + if (this.game.myPlayer()?.isFriendly(sam.owner())) { + this.enemySAMLaunchers.delete(sam_id); + } + }); + break; } } const checkPlayers: number[] = []; - if (allianceBroke) { - for (const update of allianceBroke) { - if (this.game.myPlayer()?.smallID() === update.traitorID) { - checkPlayers.push(update.betrayedID); - } else if (this.game.myPlayer()?.smallID() === update.betrayedID) { - checkPlayers.push(update.traitorID); - } + for (const update of allianceBroke ?? []) { + if (this.game.myPlayer()?.smallID() === update.traitorID) { + checkPlayers.push(update.betrayedID); + } else if (this.game.myPlayer()?.smallID() === update.betrayedID) { + checkPlayers.push(update.traitorID); } } - if (allianceExpired) { - for (const update of allianceExpired) { - if (this.game.myPlayer()?.smallID() === update.player1ID) { - checkPlayers.push(update.player2ID); - } else if (this.game.myPlayer()?.smallID() === update.player2ID) { - checkPlayers.push(update.player1ID); - } + for (const update of allianceExpired ?? []) { + if (this.game.myPlayer()?.smallID() === update.player1ID) { + checkPlayers.push(update.player2ID); + } else if (this.game.myPlayer()?.smallID() === update.player2ID) { + checkPlayers.push(update.player1ID); } } for (const playerID of checkPlayers) { const player = this.game.playerBySmallID(playerID) as PlayerView; + if (!player) continue; for (const sam of player.units(UnitType.SAMLauncher)) { this.enemySAMLaunchers.set(sam.id(), sam); } From 12d598f2c37f7c49c79e23163b25190862dadfb0 Mon Sep 17 00:00:00 2001 From: bibizu Date: Mon, 1 Dec 2025 01:42:37 -0500 Subject: [PATCH 09/12] fix: isMyPlayer()-> isMe() --- src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts | 2 +- src/client/graphics/layers/SAMRadiusLayer.ts | 2 +- src/core/game/GameView.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 0e9cf13ff8..594caaf4a6 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -87,7 +87,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { } else if (unit.isActive()) { // New SAM was built or owner swap, check if friendly. if ( - !this.game.isMyPlayer(unit.owner()) && + !unit.owner().isMe() && !this.game.myPlayer()?.isFriendly(unit.owner()) ) { this.enemySAMLaunchers.set(update.id, unit); diff --git a/src/client/graphics/layers/SAMRadiusLayer.ts b/src/client/graphics/layers/SAMRadiusLayer.ts index 93af60fc8e..72fac0db94 100644 --- a/src/client/graphics/layers/SAMRadiusLayer.ts +++ b/src/client/graphics/layers/SAMRadiusLayer.ts @@ -332,7 +332,7 @@ export class SAMRadiusLayer implements Layer { ctx.lineDashOffset = this.dashOffset + extraOutlineWidth / 2; ctx.stroke(); // Inline - if (this.game.isMyPlayer(a.owner)) { + if (a.owner.isMe()) { ctx.strokeStyle = lineColorSelf; } else if (this.game.myPlayer()?.isFriendly(a.owner)) { ctx.strokeStyle = lineColorFriend; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 8274d85e97..8107c0a594 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -384,6 +384,10 @@ export class PlayerView { .reduce((a, b) => a + b, 0); } + isMe(): boolean { + return this.smallID() === this.game.myPlayer()?.smallID(); + } + isAlliedWith(other: PlayerView): boolean { return this.data.allies.some((n) => other.smallID() === n); } @@ -604,10 +608,6 @@ export class GameView implements GameMap { return this._myPlayer; } - isMyPlayer(player: PlayerView): boolean { - return this.myPlayer()?.smallID() === player.smallID(); - } - player(id: PlayerID): PlayerView { const player = this._players.get(id); if (player === undefined) { From c58d7ba775489fa90b61ce320dde1673a4df2a37 Mon Sep 17 00:00:00 2001 From: bibizu Date: Mon, 1 Dec 2025 22:26:14 -0500 Subject: [PATCH 10/12] fix: remove invulnerability config setting --- .../graphics/layers/NukeTrajectoryPreviewLayer.ts | 5 ++--- src/core/configuration/Config.ts | 2 -- src/core/configuration/DefaultConfig.ts | 4 ---- src/core/execution/NukeExecution.ts | 10 ++++------ tests/util/TestConfig.ts | 4 ---- 5 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 594caaf4a6..e4c834087e 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -289,9 +289,8 @@ export class NukeTrajectoryPreviewLayer implements Layer { // From testing, does not seem to have much effect, so I keep it this way. // Calculate points when bomb targetability switches - const targetRangeSquared = this.game.config().defaultNukeInvulnerability() - ? this.game.config().defaultNukeTargetableRange() ** 2 - : Number.MAX_VALUE; + const targetRangeSquared = + this.game.config().defaultNukeTargetableRange() ** 2; // Find two switch points where bomb transitions: // [0]: leaves spawn range, enters untargetable zone diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 91111eb037..abe0f0018c 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -170,8 +170,6 @@ export interface Config { // Number of tiles destroyed to break an alliance nukeAllianceBreakThreshold(): number; defaultNukeSpeed(): number; - // If nuke can ever be invulnerable. - defaultNukeInvulnerability(): boolean; defaultNukeTargetableRange(): number; defaultSamMissileSpeed(): number; defaultSamRange(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 7bce6c5314..4b8c89588d 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -920,10 +920,6 @@ export class DefaultConfig implements Config { return 6; } - defaultNukeInvulnerability(): boolean { - return true; - } - defaultNukeTargetableRange(): number { return 150; } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index b4a2b20c4c..923535f1fe 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -188,9 +188,8 @@ export class NukeExecution implements Execution { private getTrajectory(target: TileRef): TrajectoryTile[] { const trajectoryTiles: TrajectoryTile[] = []; - const targetRangeSquared = this.mg.config().defaultNukeInvulnerability() - ? this.mg.config().defaultNukeTargetableRange() ** 2 - : Number.MAX_VALUE; + const targetRangeSquared = + this.mg.config().defaultNukeTargetableRange() ** 2; const allTiles: TileRef[] = this.pathFinder.allTiles(); for (const tile of allTiles) { trajectoryTiles.push({ @@ -219,9 +218,8 @@ export class NukeExecution implements Execution { if (this.nuke === null || this.nuke.targetTile() === undefined) { return; } - const targetRangeSquared = this.mg.config().defaultNukeInvulnerability() - ? this.mg.config().defaultNukeTargetableRange() ** 2 - : Number.MAX_VALUE; + const targetRangeSquared = + this.mg.config().defaultNukeTargetableRange() ** 2; const targetTile = this.nuke.targetTile(); this.nuke.setTargetable( this.isTargetable(targetTile!, this.nuke.tile(), targetRangeSquared), diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts index 98c3bc0497..c5f514ab02 100644 --- a/tests/util/TestConfig.ts +++ b/tests/util/TestConfig.ts @@ -42,10 +42,6 @@ export class TestConfig extends DefaultConfig { return this._defaultNukeSpeed; } - defaultNukeInvulnerability(): boolean { - return true; - } - defaultNukeTargetableRange(): number { return 20; } From f9c54ca9891220a25c0e0a4c43a78826a505a37d Mon Sep 17 00:00:00 2001 From: bibizu Date: Tue, 2 Dec 2025 00:09:07 -0500 Subject: [PATCH 11/12] enhance: Bigger X and symbol drawing organization --- .../layers/NukeTrajectoryPreviewLayer.ts | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index e4c834087e..e5c5f4fb3e 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -361,6 +361,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { // Set of line colors, targeted is after SAM intercept is detected. const untargetedOutlineColor = "rgba(140, 140, 140, 1)"; const targetedOutlineColor = "rgba(150, 90, 90, 1)"; + const symbolOutlineColor = "rgba(0, 0, 0, 1)"; const targetedLocationColor = "rgba(255, 0, 0, 1)"; const untargetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)"; const targetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)"; @@ -370,6 +371,8 @@ export class NukeTrajectoryPreviewLayer implements Layer { // Set of line widths const outlineExtraWidth = 1.5; // adds onto below const lineWidth = 1.25; + const XLineWidth = 2; + const XSize = 6; // Set of line dashes // Outline dashes calculated automatically @@ -386,20 +389,41 @@ export class NukeTrajectoryPreviewLayer implements Layer { let currentOutlineColor = untargetedOutlineColor; let currentLineColor = targetableAndUntargetedLineColor; let currentLineDash = targetableAndUntargetedLineDash; + let currentLineWidth = lineWidth; // Take in set of "current" parameters and draw both outline and line. const outlineAndStroke = () => { - context.lineWidth = lineWidth + outlineExtraWidth; + context.lineWidth = currentLineWidth + outlineExtraWidth; context.setLineDash(outlineDash(currentLineDash, outlineExtraWidth)); context.lineDashOffset = outlineExtraWidth / 2; context.strokeStyle = currentOutlineColor; context.stroke(); - context.lineWidth = lineWidth; + context.lineWidth = currentLineWidth; context.setLineDash(currentLineDash); context.lineDashOffset = 0; context.strokeStyle = currentLineColor; context.stroke(); }; + const drawUntargetableCircle = (x: number, y: number) => { + context.beginPath(); + context.arc(x, y, 4, 0, 2 * Math.PI, false); + currentOutlineColor = untargetedOutlineColor; + currentLineColor = targetableAndUntargetedLineColor; + currentLineDash = [1, 0]; + outlineAndStroke(); + }; + const drawTargetedX = (x: number, y: number) => { + context.beginPath(); + context.moveTo(x - XSize, y - XSize); + context.lineTo(x + XSize, y + XSize); + context.moveTo(x - XSize, y + XSize); + context.lineTo(x + XSize, y - XSize); + currentOutlineColor = symbolOutlineColor; + currentLineColor = targetedLocationColor; + currentLineDash = [1, 0]; + currentLineWidth = XLineWidth; + outlineAndStroke(); + }; // Calculate offset to center coordinates (same as canvas drawing) const offsetX = -this.game.width() / 2; @@ -421,13 +445,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { } if (i === this.untargetableSegmentBounds[0]) { outlineAndStroke(); - // Draw Circle - context.beginPath(); - context.arc(x, y, 4, 0, 2 * Math.PI, false); - currentLineColor = targetableAndUntargetedLineColor; - currentLineDash = [1, 0]; - outlineAndStroke(); - // Start New Line + drawUntargetableCircle(x, y); context.beginPath(); if (i >= this.targetedIndex) { currentOutlineColor = targetedOutlineColor; @@ -440,13 +458,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { } } else if (i === this.untargetableSegmentBounds[1]) { outlineAndStroke(); - // Draw Circle - context.beginPath(); - context.arc(x, y, 4, 0, 2 * Math.PI, false); - currentLineColor = targetableAndUntargetedLineColor; - currentLineDash = [1, 0]; - outlineAndStroke(); - // Start New Line + drawUntargetableCircle(x, y); context.beginPath(); if (i >= this.targetedIndex) { currentOutlineColor = targetedOutlineColor; @@ -460,21 +472,13 @@ export class NukeTrajectoryPreviewLayer implements Layer { } if (i === this.targetedIndex) { outlineAndStroke(); - // Draw X - context.beginPath(); - context.moveTo(x - 4, y - 4); - context.lineTo(x + 4, y + 4); - context.moveTo(x - 4, y + 4); - context.lineTo(x + 4, y - 4); - currentOutlineColor = targetedOutlineColor; - currentLineColor = targetedLocationColor; - currentLineDash = [1, 0]; - outlineAndStroke(); - // Start New Line + drawTargetedX(x, y); context.beginPath(); // Always in the targetable zone by definition. + currentOutlineColor = targetedOutlineColor; currentLineColor = targetableAndTargetedLineColor; currentLineDash = targetableAndTargetedLineDash; + currentLineWidth = lineWidth; } } From d69f71ba09678265516177e18b19f0debb181db0 Mon Sep 17 00:00:00 2001 From: bibizu Date: Tue, 2 Dec 2025 00:15:19 -0500 Subject: [PATCH 12/12] perf: Remove SAM tracking Removes SAM tracking in favor of nearbyUnits() call. Unsure if perf has any noticeable change (might slightly worsen) but simplifies logic enough and scales better with more SAMs on map. --- .../layers/NukeTrajectoryPreviewLayer.ts | 91 ++++--------------- 1 file changed, 16 insertions(+), 75 deletions(-) diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index e5c5f4fb3e..c0e74af83e 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -1,8 +1,7 @@ import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; +import { GameView } from "../../../core/game/GameView"; import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding"; import { GhostStructureChangedEvent, MouseMoveEvent } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; @@ -23,8 +22,6 @@ export class NukeTrajectoryPreviewLayer implements Layer { private currentGhostStructure: UnitType | null = null; // Cache spawn tile to avoid expensive player.actions() calls private cachedSpawnTile: TileRef | null = null; - // Track SAM launcher IDs -> SAM launcher unit - private readonly enemySAMLaunchers: Map = new Map(); constructor( private game: GameView, @@ -56,7 +53,6 @@ export class NukeTrajectoryPreviewLayer implements Layer { } tick() { - this.updateSAMs(); this.updateTrajectoryPreview(); } @@ -66,72 +62,6 @@ export class NukeTrajectoryPreviewLayer implements Layer { this.drawTrajectoryPreview(context); } - /** - * Check for updates to the list of SAMS for intercept prediction - */ - private updateSAMs() { - // Check for updates to SAM launchers - const updates = this.game.updatesSinceLastTick(); - const unitUpdates = updates?.[GameUpdateType.Unit]; - const allianceResponse = updates?.[GameUpdateType.AllianceRequestReply]; - const allianceBroke = updates?.[GameUpdateType.BrokeAlliance]; - const allianceExpired = updates?.[GameUpdateType.AllianceExpired]; - - if (unitUpdates) { - for (const update of unitUpdates) { - const unit = this.game.unit(update.id); - if (!unit || unit.type() !== UnitType.SAMLauncher) continue; - if (this.enemySAMLaunchers.has(update.id) && !unit.isActive()) { - // SAM was destroyed - this.enemySAMLaunchers.delete(update.id); - } else if (unit.isActive()) { - // New SAM was built or owner swap, check if friendly. - if ( - !unit.owner().isMe() && - !this.game.myPlayer()?.isFriendly(unit.owner()) - ) { - this.enemySAMLaunchers.set(update.id, unit); - } else if (this.enemySAMLaunchers.has(update.id)) { - this.enemySAMLaunchers.delete(update.id); - } - } - } - } - for (const update of allianceResponse ?? []) { - if (update.accepted) { - // check for good SAMs - this.enemySAMLaunchers.forEach((sam, sam_id) => { - if (this.game.myPlayer()?.isFriendly(sam.owner())) { - this.enemySAMLaunchers.delete(sam_id); - } - }); - break; - } - } - const checkPlayers: number[] = []; - for (const update of allianceBroke ?? []) { - if (this.game.myPlayer()?.smallID() === update.traitorID) { - checkPlayers.push(update.betrayedID); - } else if (this.game.myPlayer()?.smallID() === update.betrayedID) { - checkPlayers.push(update.traitorID); - } - } - for (const update of allianceExpired ?? []) { - if (this.game.myPlayer()?.smallID() === update.player1ID) { - checkPlayers.push(update.player2ID); - } else if (this.game.myPlayer()?.smallID() === update.player2ID) { - checkPlayers.push(update.player1ID); - } - } - for (const playerID of checkPlayers) { - const player = this.game.playerBySmallID(playerID) as PlayerView; - if (!player) continue; - for (const sam of player.units(UnitType.SAMLauncher)) { - this.enemySAMLaunchers.set(sam.id(), sam); - } - } - } - /** * Update trajectory preview - called from tick() to cache spawn tile via expensive player.actions() call * This only runs when target tile changes, minimizing worker thread communication @@ -325,10 +255,21 @@ export class NukeTrajectoryPreviewLayer implements Layer { // Check trajectory for (let i = 0; i < this.trajectoryPoints.length; i++) { const tile = this.trajectoryPoints[i]; - for (const [, sam] of this.enemySAMLaunchers.entries()) { - const samTile = sam.tile(); - const r = this.game.config().samRange(sam.level()); - if (this.game.euclideanDistSquared(tile, samTile) <= r ** 2) { + for (const sam of this.game.nearbyUnits( + tile, + this.game.config().maxSamRange(), + UnitType.SAMLauncher, + )) { + if ( + sam.unit.owner().isMe() || + this.game.myPlayer()?.isFriendly(sam.unit.owner()) + ) { + continue; + } + if ( + sam.distSquared <= + this.game.config().samRange(sam.unit.level()) ** 2 + ) { this.targetedIndex = i; break; }