Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 204 additions & 6 deletions src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,10 +16,13 @@ export class NukeTrajectoryPreviewLayer implements Layer {
// Trajectory preview state
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<number, number> = new Map(); // Track SAM launcher IDs -> ownerSmallID

constructor(
private game: GameView,
Expand Down Expand Up @@ -50,6 +54,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
}

tick() {
//this.updateSAMs();
this.updateTrajectoryPreview();
}

Expand All @@ -59,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
Expand Down Expand Up @@ -210,6 +249,71 @@ export class NukeTrajectoryPreviewLayer implements Layer {
);

this.trajectoryPoints = pathFinder.allTiles();

// 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().defaultNukeInvulnerability()
? this.game.config().defaultNukeTargetableRange() ** 2
: Number.MAX_VALUE;

// 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) {
if (
this.game.euclideanDistSquared(tile, this.cachedSpawnTile) >
targetRangeSquared
) {
if (
this.game.euclideanDistSquared(tile, targetTile) <
targetRangeSquared
) {
// overlapping spawn & target range
break;
} else {
this.untargetableSegmentBounds[0] = i;
}
}
} else if (
this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared
) {
this.untargetableSegmentBounds[1] = i;
break;
}
}
// 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) {
const samTile = sam.tile();
const r = this.game.config().samRange(sam.level());
if (this.game.euclideanDistSquared(tile, samTile) <= r ** 2) {
this.targetedIndex = i;
break;
}
}
if (this.targetedIndex !== this.trajectoryPoints.length) break;
// Jump over untargetable segment
if (i === this.untargetableSegmentBounds[0])
i = this.untargetableSegmentBounds[1] - 1;
}
}

/**
Expand All @@ -230,17 +334,54 @@ export class NukeTrajectoryPreviewLayer implements Layer {
return;
}

const territoryColor = player.territoryColor();
const lineColor = territoryColor.alpha(0.7).toRgbString();
// 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 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.strokeStyle = lineColor;
context.lineWidth = 1.5;
context.setLineDash([8, 4]);
context.beginPath();

// Draw line connecting trajectory points
Expand All @@ -254,9 +395,66 @@ export class NukeTrajectoryPreviewLayer implements Layer {
} else {
context.lineTo(x, y);
}
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
context.beginPath();
if (i >= this.targetedIndex) {
currentOutlineColor = targetedOutlineColor;
currentLineColor = untargetableAndTargetedLineColor;
currentLineDash = untargetableAndTargetedLineDash;
} else {
currentOutlineColor = untargetedOutlineColor;
currentLineColor = untargetableAndUntargetedLineColor;
currentLineDash = untargetableAndUntargetedLineDash;
}
} 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
context.beginPath();
if (i >= this.targetedIndex) {
currentOutlineColor = targetedOutlineColor;
currentLineColor = targetableAndTargetedLineColor;
currentLineDash = targetableAndTargetedLineDash;
} else {
currentOutlineColor = untargetedOutlineColor;
currentLineColor = targetableAndUntargetedLineColor;
currentLineDash = targetableAndUntargetedLineDash;
}
}
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
context.beginPath();
// Always in the targetable zone by definition.
currentLineColor = targetableAndTargetedLineColor;
currentLineDash = targetableAndTargetedLineDash;
}
}

context.stroke();
outlineAndStroke();
context.restore();
}
}
48 changes: 36 additions & 12 deletions src/client/graphics/layers/SAMRadiusLayer.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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";
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;
Expand Down Expand Up @@ -157,14 +157,14 @@ 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 {
x: this.game.x(tile),
y: this.game.y(tile),
r: this.game.config().samRange(sam.level()),
owner: sam.owner().smallID(),
owner: sam.owner(),
};
});

Expand All @@ -176,13 +176,19 @@ 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)";
// 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();
Expand All @@ -199,10 +205,6 @@ export class SAMRadiusLayer implements Layer {
if (!this.showStroke) return;

ctx.save();
ctx.lineWidth = 2;
ctx.setLineDash([12, 6]);
ctx.lineDashOffset = this.dashOffset;
ctx.strokeStyle = strokeStyleOuter;

const TWO_PI = Math.PI * 2;

Expand Down Expand Up @@ -258,7 +260,8 @@ export class SAMRadiusLayer implements Layer {
// 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;
Expand Down Expand Up @@ -318,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();
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/configuration/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/core/configuration/DefaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,10 @@ export class DefaultConfig implements Config {
return 6;
}

defaultNukeInvulnerability(): boolean {
return true;
}

defaultNukeTargetableRange(): number {
return 150;
}
Expand Down
Loading
Loading