Add nuke trail preview (#2350)

## Description:

Implements trajectory preview for nuke selection as requested in #2346.
When selecting an AtomBomb or HydrogenBomb, a dashed line preview now
shows the trajectory path from the launch silo to the target location
before launching. Line uses player's territory color with 70% opacity
and dashed pattern for clear visibility

## Please complete the following:

- [x] I have added screenshots for all UI updates
<img width="716" height="483" alt="image"
src="https://github.com/user-attachments/assets/4c263230-34ba-4e56-9502-4a59c84b5943"
/>
<img width="1199" height="965" alt="image"
src="https://github.com/user-attachments/assets/72eda758-e192-45a0-b01d-5a8f413a07d5"
/>

- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (No new text strings added)
- [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:

kerverse

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
Kerod Kibatu
2025-11-05 13:25:01 -05:00
committed by GitHub
parent e8a04d9a72
commit 7b85114194
5 changed files with 293 additions and 13 deletions
+21 -12
View File
@@ -79,6 +79,10 @@ export class ToggleStructureEvent implements GameEvent {
constructor(public readonly structureTypes: UnitType[] | null) {}
}
export class GhostStructureChangedEvent implements GameEvent {
constructor(public readonly ghostStructure: UnitType | null) {}
}
export class ShowBuildMenuEvent implements GameEvent {
constructor(
public readonly x: number,
@@ -291,7 +295,7 @@ export class InputHandler {
if (e.code === "Escape") {
e.preventDefault();
this.eventBus.emit(new CloseViewEvent());
this.uiState.ghostStructure = null;
this.setGhostStructure(null);
}
if (
@@ -359,52 +363,52 @@ export class InputHandler {
if (e.code === this.keybinds.buildCity) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.City;
this.setGhostStructure(UnitType.City);
}
if (e.code === this.keybinds.buildFactory) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.Factory;
this.setGhostStructure(UnitType.Factory);
}
if (e.code === this.keybinds.buildPort) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.Port;
this.setGhostStructure(UnitType.Port);
}
if (e.code === this.keybinds.buildDefensePost) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.DefensePost;
this.setGhostStructure(UnitType.DefensePost);
}
if (e.code === this.keybinds.buildMissileSilo) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.MissileSilo;
this.setGhostStructure(UnitType.MissileSilo);
}
if (e.code === this.keybinds.buildSamLauncher) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.SAMLauncher;
this.setGhostStructure(UnitType.SAMLauncher);
}
if (e.code === this.keybinds.buildAtomBomb) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.AtomBomb;
this.setGhostStructure(UnitType.AtomBomb);
}
if (e.code === this.keybinds.buildHydrogenBomb) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.HydrogenBomb;
this.setGhostStructure(UnitType.HydrogenBomb);
}
if (e.code === this.keybinds.buildWarship) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.Warship;
this.setGhostStructure(UnitType.Warship);
}
if (e.code === this.keybinds.buildMIRV) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.MIRV;
this.setGhostStructure(UnitType.MIRV);
}
// Shift-D to toggle performance overlay
@@ -545,12 +549,17 @@ export class InputHandler {
private onContextMenu(event: MouseEvent) {
event.preventDefault();
if (this.uiState.ghostStructure !== null) {
this.uiState.ghostStructure = null;
this.setGhostStructure(null);
return;
}
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
}
private setGhostStructure(ghostStructure: UnitType | null) {
this.uiState.ghostStructure = ghostStructure;
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
}
private getPinchDistance(): number {
const pointerEvents = Array.from(this.pointers.values());
const dx = pointerEvents[0].clientX - pointerEvents[1].clientX;
+2
View File
@@ -22,6 +22,7 @@ import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer";
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
@@ -243,6 +244,7 @@ export function createRenderer(
new UnitLayer(game, eventBus, transformHandler),
new FxLayer(game),
new UILayer(game, eventBus, transformHandler),
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new NameLayer(game, transformHandler, eventBus),
eventsDisplay,
@@ -0,0 +1,262 @@
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding";
import { GhostStructureChangedEvent, MouseMoveEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
/**
* Layer responsible for rendering the nuke trajectory preview line
* when a nuke type (AtomBomb or HydrogenBomb) is selected and the user hovers over potential targets.
*/
export class NukeTrajectoryPreviewLayer implements Layer {
// Trajectory preview state
private mousePos = { x: 0, y: 0 };
private trajectoryPoints: TileRef[] = [];
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
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {}
shouldTransform(): boolean {
return true;
}
init() {
this.eventBus.on(MouseMoveEvent, (e) => {
this.mousePos.x = e.x;
this.mousePos.y = e.y;
});
this.eventBus.on(GhostStructureChangedEvent, (e) => {
this.currentGhostStructure = e.ghostStructure;
// Clear trajectory if ghost structure changed
if (
e.ghostStructure !== UnitType.AtomBomb &&
e.ghostStructure !== UnitType.HydrogenBomb
) {
this.trajectoryPoints = [];
this.lastTargetTile = null;
this.cachedSpawnTile = null;
}
});
}
tick() {
this.updateTrajectoryPreview();
}
renderLayer(context: CanvasRenderingContext2D) {
// Update trajectory path each frame for smooth responsiveness
this.updateTrajectoryPath();
this.drawTrajectoryPreview(context);
}
/**
* 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
*/
private updateTrajectoryPreview() {
const ghostStructure = this.currentGhostStructure;
const isNukeType =
ghostStructure === UnitType.AtomBomb ||
ghostStructure === UnitType.HydrogenBomb;
// Clear trajectory if not a nuke type
if (!isNukeType) {
this.cachedSpawnTile = null;
return;
}
// Throttle updates (similar to StructureIconsLayer.renderGhost)
const now = performance.now();
if (now - this.lastTrajectoryUpdate < 50) {
return;
}
this.lastTrajectoryUpdate = now;
const player = this.game.myPlayer();
if (!player) {
this.trajectoryPoints = [];
this.lastTargetTile = null;
this.cachedSpawnTile = null;
return;
}
// Convert mouse position to world coordinates
const rect = this.transformHandler.boundingRect();
if (!rect) {
this.trajectoryPoints = [];
this.cachedSpawnTile = null;
return;
}
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
const worldCoords = this.transformHandler.screenToWorldCoordinates(
localX,
localY,
);
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
this.trajectoryPoints = [];
this.lastTargetTile = null;
this.cachedSpawnTile = null;
return;
}
const targetTile = this.game.ref(worldCoords.x, worldCoords.y);
// Only recalculate if target tile changed
if (this.lastTargetTile === targetTile) {
return;
}
this.lastTargetTile = targetTile;
// Get buildable units to find spawn tile (expensive call - only on tick when tile changes)
player
.actions(targetTile)
.then((actions) => {
// Ignore stale results if target changed
if (this.lastTargetTile !== targetTile) {
return;
}
const buildableUnit = actions.buildableUnits.find(
(bu) => bu.type === ghostStructure,
);
if (!buildableUnit || buildableUnit.canBuild === false) {
this.cachedSpawnTile = null;
return;
}
const spawnTile = buildableUnit.canBuild;
if (!spawnTile) {
this.cachedSpawnTile = null;
return;
}
// Cache the spawn tile for use in updateTrajectoryPath()
this.cachedSpawnTile = spawnTile;
})
.catch(() => {
// Handle errors silently
this.cachedSpawnTile = null;
});
}
/**
* Update trajectory path - called from renderLayer() each frame for smooth visual feedback
* Uses cached spawn tile to avoid expensive player.actions() calls
*/
private updateTrajectoryPath() {
const ghostStructure = this.currentGhostStructure;
const isNukeType =
ghostStructure === UnitType.AtomBomb ||
ghostStructure === UnitType.HydrogenBomb;
// Clear trajectory if not a nuke type or no cached spawn tile
if (!isNukeType || !this.cachedSpawnTile) {
this.trajectoryPoints = [];
return;
}
const player = this.game.myPlayer();
if (!player) {
this.trajectoryPoints = [];
return;
}
// Convert mouse position to world coordinates
const rect = this.transformHandler.boundingRect();
if (!rect) {
this.trajectoryPoints = [];
return;
}
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
const worldCoords = this.transformHandler.screenToWorldCoordinates(
localX,
localY,
);
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
this.trajectoryPoints = [];
return;
}
const targetTile = this.game.ref(worldCoords.x, worldCoords.y);
// Calculate trajectory using ParabolaPathFinder with cached spawn tile
const pathFinder = new ParabolaPathFinder(this.game);
const speed = this.game.config().defaultNukeSpeed();
const distanceBasedHeight = true; // AtomBomb/HydrogenBomb use distance-based height
pathFinder.computeControlPoints(
this.cachedSpawnTile,
targetTile,
speed,
distanceBasedHeight,
);
this.trajectoryPoints = pathFinder.allTiles();
}
/**
* Draw trajectory preview line on the canvas
*/
private drawTrajectoryPreview(context: CanvasRenderingContext2D) {
const ghostStructure = this.currentGhostStructure;
const isNukeType =
ghostStructure === UnitType.AtomBomb ||
ghostStructure === UnitType.HydrogenBomb;
if (!isNukeType || this.trajectoryPoints.length === 0) {
return;
}
const player = this.game.myPlayer();
if (!player) {
return;
}
const territoryColor = player.territoryColor();
const lineColor = territoryColor.alpha(0.7).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.lineWidth = 1.5;
context.setLineDash([8, 4]);
context.beginPath();
// Draw line connecting trajectory points
for (let i = 0; i < this.trajectoryPoints.length; i++) {
const tile = this.trajectoryPoints[i];
const x = this.game.x(tile) + offsetX;
const y = this.game.y(tile) + offsetY;
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.stroke();
context.restore();
}
}
@@ -16,6 +16,7 @@ import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import {
GhostStructureChangedEvent,
MouseMoveEvent,
MouseUpEvent,
ToggleStructureEvent as ToggleStructuresEvent,
@@ -393,6 +394,7 @@ export class StructureIconsLayer implements Layer {
private removeGhostStructure() {
this.clearGhostStructure();
this.uiState.ghostStructure = null;
this.eventBus.emit(new GhostStructureChangedEvent(null));
}
private toggleStructures(toggleStructureType: UnitType[] | null): void {
+6 -1
View File
@@ -13,7 +13,10 @@ import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
import { Gold, PlayerActions, UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ToggleStructureEvent } from "../../InputHandler";
import {
GhostStructureChangedEvent,
ToggleStructureEvent,
} from "../../InputHandler";
import { renderNumber, translateText } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
@@ -267,8 +270,10 @@ export class UnitDisplay extends LitElement implements Layer {
@click=${() => {
if (selected) {
this.uiState.ghostStructure = null;
this.eventBus?.emit(new GhostStructureChangedEvent(null));
} else if (this.canBuild(unitType)) {
this.uiState.ghostStructure = unitType;
this.eventBus?.emit(new GhostStructureChangedEvent(unitType));
}
this.requestUpdate();
}}