mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:10:46 +00:00
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:
+21
-12
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user