Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
248 changes: 240 additions & 8 deletions src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding";
import { GhostStructureChangedEvent, MouseMoveEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
Expand All @@ -15,10 +16,15 @@ 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
// 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<number, UnitView> = new Map();

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

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

Expand All @@ -59,6 +66,81 @@ 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 (
!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);
}
}
}
}

/**
* 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 +292,62 @@ 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;
// 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) {
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 +368,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 +429,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();
}
}
Loading
Loading