diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index e1e441cc9..9c55e837a 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -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; diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 6d6f0a6d9..f398dccba 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -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, diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts new file mode 100644 index 000000000..996e18209 --- /dev/null +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -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(); + } +} diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 009316f69..20d2960f0 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -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 { diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts index d2ae6a93f..55b7d7ccb 100644 --- a/src/client/graphics/layers/UnitDisplay.ts +++ b/src/client/graphics/layers/UnitDisplay.ts @@ -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(); }}