mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +00:00
feat: Nuke Trajectory SAM intercept prediction (#2541)
## Description: Overhaul to nuke previews: - Nuke previews now show when they become invulnerable (if invulnerability setting enabled). - Nuke previews now show if a SAM defense could intercept it, and where the intercept occurs. - SAMs range is now color-coded and outlined to be able to know if missile will be intercepted by it. <img width="1326" height="862" alt="image" src="https://github.com/user-attachments/assets/4cc43a0b-ebac-4707-85bb-4d422f23da28" /> Colors/outlines aren't meant to be final, and there are constants to play around with at the start of both files. I also kept the naive implementation since the performance cost isn't too high from my testing and any improvements I can think of are small and require a bigger rewrite. ## 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: bibizu
This commit is contained in:
@@ -15,10 +15,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
|
||||
// Cache spawn tile to avoid expensive player.actions() calls
|
||||
private cachedSpawnTile: TileRef | null = null;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -210,6 +213,72 @@ 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().defaultNukeTargetableRange() ** 2;
|
||||
|
||||
// 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.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;
|
||||
}
|
||||
}
|
||||
if (this.targetedIndex !== this.trajectoryPoints.length) break;
|
||||
// Jump over untargetable segment
|
||||
if (i === this.untargetableSegmentBounds[0])
|
||||
i = this.untargetableSegmentBounds[1] - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,17 +299,78 @@ 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 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)";
|
||||
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;
|
||||
const XLineWidth = 2;
|
||||
const XSize = 6;
|
||||
|
||||
// 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;
|
||||
let currentLineWidth = lineWidth;
|
||||
|
||||
// Take in set of "current" parameters and draw both outline and line.
|
||||
const outlineAndStroke = () => {
|
||||
context.lineWidth = currentLineWidth + outlineExtraWidth;
|
||||
context.setLineDash(outlineDash(currentLineDash, outlineExtraWidth));
|
||||
context.lineDashOffset = outlineExtraWidth / 2;
|
||||
context.strokeStyle = currentOutlineColor;
|
||||
context.stroke();
|
||||
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;
|
||||
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
|
||||
@@ -254,9 +384,46 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
} else {
|
||||
context.lineTo(x, y);
|
||||
}
|
||||
if (i === this.untargetableSegmentBounds[0]) {
|
||||
outlineAndStroke();
|
||||
drawUntargetableCircle(x, y);
|
||||
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();
|
||||
drawUntargetableCircle(x, y);
|
||||
context.beginPath();
|
||||
if (i >= this.targetedIndex) {
|
||||
currentOutlineColor = targetedOutlineColor;
|
||||
currentLineColor = targetableAndTargetedLineColor;
|
||||
currentLineDash = targetableAndTargetedLineDash;
|
||||
} else {
|
||||
currentOutlineColor = untargetedOutlineColor;
|
||||
currentLineColor = targetableAndUntargetedLineColor;
|
||||
currentLineDash = targetableAndUntargetedLineDash;
|
||||
}
|
||||
}
|
||||
if (i === this.targetedIndex) {
|
||||
outlineAndStroke();
|
||||
drawTargetedX(x, y);
|
||||
context.beginPath();
|
||||
// Always in the targetable zone by definition.
|
||||
currentOutlineColor = targetedOutlineColor;
|
||||
currentLineColor = targetableAndTargetedLineColor;
|
||||
currentLineDash = targetableAndTargetedLineDash;
|
||||
currentLineWidth = lineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
outlineAndStroke();
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (a.owner.isMe()) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user