diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 59f2d86fc..86af112f6 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -154,6 +154,7 @@ export class InputHandler { private pointers: Map = new Map(); private lastPinchDistance: number = 0; + private lastContextMenuWorldCoords: { x: number; y: number } | null = null; private pointerDown: boolean = false; @@ -249,7 +250,19 @@ export class InputHandler { this.pointers.clear(); this.eventBus.on(PingSelectedEvent, (event) => { - this.uiState.currentPingType = event.pingType; + if (event.pingType && this.lastContextMenuWorldCoords) { + this.eventBus.emit( + new PingPlacedEvent( + event.pingType, + this.lastContextMenuWorldCoords.x, + this.lastContextMenuWorldCoords.y, + ), + ); + this.lastContextMenuWorldCoords = null; + this.uiState.currentPingType = null; + } else { + this.uiState.currentPingType = event.pingType; + } }); this.moveInterval = setInterval(() => { @@ -499,36 +512,6 @@ export class InputHandler { Math.abs(event.y - this.lastPointerDownY); if (dist < 10) { if (this.uiState.currentPingType !== null) { - const rect = this.transformHandler.boundingRect(); - if (!rect) { - this.uiState.currentPingType = null; - this.eventBus.emit(new PingSelectedEvent(null)); - return; - } - const localX = event.clientX - rect.left; - const localY = event.clientY - rect.top; - const worldCoords = this.transformHandler.screenToWorldCoordinates( - localX, - localY, - ); - console.log("emitting PingPlacedEvent", { - type: this.uiState.currentPingType, - x: worldCoords.x, - y: worldCoords.y, - }); - console.log( - "Emitting PingPlacedEvent with type:", - this.uiState.currentPingType, - "at world coordinates:", - worldCoords, - ); - this.eventBus.emit( - new PingPlacedEvent( - this.uiState.currentPingType, - worldCoords.x, - worldCoords.y, - ), - ); this.uiState.currentPingType = null; this.eventBus.emit(new PingSelectedEvent(null)); return; @@ -611,6 +594,15 @@ export class InputHandler { this.setGhostStructure(null); return; } + + const rect = this.transformHandler.boundingRect(); + if (rect) { + const localX = event.clientX - rect.left; + const localY = event.clientY - rect.top; + this.lastContextMenuWorldCoords = + this.transformHandler.screenToWorldCoordinates(localX, localY); + } + this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 273565659..d8d7c1a63 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -25,6 +25,7 @@ import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer"; import { PerformanceOverlay } from "./layers/PerformanceOverlay"; +import { PingMarkerLayer } from "./layers/PingMarkerLayer"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; @@ -247,6 +248,7 @@ export function createRenderer( new FxLayer(game, eventBus), new UILayer(game, eventBus, transformHandler), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler), + new PingMarkerLayer(game, eventBus, transformHandler), new StructureIconsLayer(game, eventBus, uiState, transformHandler), new NameLayer(game, transformHandler, eventBus), diff --git a/src/client/graphics/fx/PingFx.ts b/src/client/graphics/fx/PingFx.ts deleted file mode 100644 index 44ed2addd..000000000 --- a/src/client/graphics/fx/PingFx.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { TileRef } from "../../../core/game/GameMap"; -import { GameView } from "../../../core/game/GameView"; -import { PingType } from "../../../core/game/Ping"; -import { Fx } from "./Fx"; - -export class PingFx implements Fx { - private readonly durationMs: number = 3000; // Ping visible for 3 seconds - private startTime: number; - private readonly pingColor: string; - private get icon(): HTMLImageElement | null { - return PingFx.iconCache.get(this.pingType) ?? null; - } - - constructor( - private game: GameView, - private pingType: PingType, - private tile: TileRef, - ) { - this.startTime = performance.now(); - this.pingColor = this.getPingColor(pingType); - // Trigger preload but don't store the result - const iconPath = this.getIconPath(pingType); - if (iconPath) { - PingFx.preloadIcon(pingType, iconPath); - } - } - - private getPingColor(pingType: PingType): string { - switch (pingType) { - case "attack": - return "rgba(255, 0, 0, 0.7)"; // Red - case "retreat": - return "rgba(0, 255, 0, 0.7)"; // Green - case "defend": - return "rgba(0, 0, 255, 0.7)"; // Blue - case "watchOut": - return "rgba(255, 255, 0, 0.7)"; // Yellow - default: - return "rgba(128, 128, 128, 0.7)"; // Default to gray - } - } - - private getIconPath(pingType: PingType): string | null { - switch (pingType) { - case "attack": - return "/resources/images/SwordIconWhite.svg"; - case "retreat": - return "/resources/images/BackIconWhite.svg"; - case "defend": - return "/resources/images/ShieldIconWhite.svg"; - case "watchOut": - return "/resources/images/ExclamationMarkIcon.svg"; - default: - return null; - } - } - private static iconCache = new Map(); - private static preloadIcon(pingType: PingType, iconPath: string): void { - if (!PingFx.iconCache.has(pingType)) { - PingFx.iconCache.set(pingType, null); // Reserve spot immediately - const img = new Image(); - img.onload = () => { - PingFx.iconCache.set(pingType, img); - }; - img.onerror = () => { - console.error(`Failed to load ping icon: ${iconPath}`); - PingFx.iconCache.set(pingType, null); // Mark as failed - }; - img.src = iconPath; - } - } - - private static readonly PING_RADIUS = 15; - private static readonly ICON_SIZE = 20; - - renderTick(_duration: number, context: CanvasRenderingContext2D): boolean { - const elapsed = performance.now() - this.startTime; - if (elapsed > this.durationMs) { - return false; // Fx is finished - } - - const x = this.game.x(this.tile); - const y = this.game.y(this.tile); - - // Calculate offset to center coordinates (same as canvas drawing) - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - - context.save(); - context.globalAlpha = 1 - elapsed / this.durationMs; // Fade out effect - - // Draw colored circle - context.fillStyle = this.pingColor; - context.beginPath(); - context.arc(x + offsetX, y + offsetY, PingFx.PING_RADIUS, 0, 2 * Math.PI); - context.fill(); - - // Draw icon - if (this.icon && this.icon.complete) { - context.drawImage( - this.icon, - x + offsetX - PingFx.ICON_SIZE / 2, - y + offsetY - PingFx.ICON_SIZE / 2, - PingFx.ICON_SIZE, - PingFx.ICON_SIZE, - ); - } - - context.restore(); - return true; // Fx is still active - } -} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index 48856b109..4556157b3 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -8,7 +8,6 @@ import { RailroadUpdate, } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; -import { PingPlacedEvent } from "../../../core/game/Ping"; import SoundManager, { SoundEffect } from "../../sound/SoundManager"; import { renderNumber } from "../../Utils"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; @@ -16,7 +15,6 @@ import { conquestFxFactory } from "../fx/ConquestFx"; import { Fx, FxType } from "../fx/Fx"; import { NukeAreaFx } from "../fx/NukeAreaFx"; import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; -import { PingFx } from "../fx/PingFx"; import { SpriteFx } from "../fx/SpriteFx"; import { TargetFx } from "../fx/TargetFx"; import { TextFx } from "../fx/TextFx"; @@ -353,7 +351,7 @@ export class FxLayer implements Layer { } private pingEventCleanup?: () => void; - dispose() { + destroy() { if (this.pingEventCleanup) { this.pingEventCleanup(); this.pingEventCleanup = undefined; @@ -367,14 +365,6 @@ export class FxLayer implements Layer { } catch (err) { console.error("Failed to load FX sprites:", err); } - this.pingEventCleanup = this.eventBus.on( - PingPlacedEvent, - (event: PingPlacedEvent) => { - console.log("received PingPlacedEvent in FxLayer", event); - const pingFx = new PingFx(this.game, event.type, event.tile); - this.allFx.push(pingFx); - }, - ); } redraw(): void { @@ -407,7 +397,6 @@ export class FxLayer implements Layer { renderAllFx(context: CanvasRenderingContext2D, delta: number) { if (this.allFx.length > 0) { - console.log("allFx array:", this.allFx); this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); this.renderContextFx(delta); } diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index f87ccd0a1..1ec776a62 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -77,8 +77,8 @@ export class NameLayer implements Layer { this.container = document.createElement("div"); this.container.style.position = "fixed"; - this.container.style.left = "50%"; - this.container.style.top = "50%"; + this.container.style.left = "0px"; + this.container.style.top = "0px"; this.container.style.pointerEvents = "none"; this.container.style.zIndex = "2"; document.body.appendChild(this.container); @@ -161,21 +161,10 @@ export class NameLayer implements Layer { } public renderLayer(mainContex: CanvasRenderingContext2D) { - const screenPosOld = this.transformHandler.worldToScreenCoordinates( - new Cell(0, 0), - ); - const screenPos = new Cell( - screenPosOld.x - window.innerWidth / 2, - screenPosOld.y - window.innerHeight / 2, - ); - this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`; - const now = Date.now(); - if (now > this.lastChecked + this.renderCheckRate) { - this.lastChecked = now; - for (const render of this.renders) { - this.renderPlayerInfo(render); - } + this.lastChecked = now; + for (const render of this.renders) { + this.renderPlayerInfo(render); } mainContex.drawImage( @@ -190,10 +179,13 @@ export class NameLayer implements Layer { private createPlayerElement(player: PlayerView): HTMLDivElement { const element = document.createElement("div"); element.style.position = "absolute"; + element.style.left = "0"; // Will be set in renderPlayerInfo + element.style.top = "0"; // Will be set in renderPlayerInfo element.style.display = "flex"; element.style.flexDirection = "column"; element.style.alignItems = "center"; element.style.gap = "0px"; + element.style.transform = "translate(-50%, -50%)"; // Always centered on its (left,top) const iconsDiv = document.createElement("div"); iconsDiv.classList.add("player-icons"); @@ -294,7 +286,6 @@ export class NameLayer implements Layer { return; } - const oldLocation = render.location; render.location = new Cell( render.player.nameLocation().x, render.player.nameLocation().y, @@ -302,7 +293,10 @@ export class NameLayer implements Layer { // Calculate base size and scale const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size)); - render.fontSize = Math.max(4, Math.floor(baseSize * 0.4)); + render.fontSize = Math.max( + 4, + Math.floor(baseSize * 0.8 * this.transformHandler.scale), + ); // Scale font size directly render.fontColor = this.theme.textColor(render.player); // Update element visibility (handles Ctrl key, size, and screen position) @@ -313,12 +307,9 @@ export class NameLayer implements Layer { return; } - // Throttle updates + // Throttling removed for smoother updates const now = Date.now(); - if (now - render.lastRenderCalc <= this.renderRefreshRate) { - return; - } - render.lastRenderCalc = now + this.rand.nextInt(0, 100); + render.lastRenderCalc = now; // Update text sizes const nameDiv = render.element.querySelector( @@ -515,9 +506,14 @@ export class NameLayer implements Layer { } // Position element with scale - if (render.location && render.location !== oldLocation) { - const scale = Math.min(baseSize * 0.25, 3); - render.element.style.transform = `translate(${render.location.x}px, ${render.location.y}px) translate(-50%, -50%) scale(${scale})`; + if (render.location) { + // Check if render.location is valid + const screenCoords = this.transformHandler.worldToScreenCoordinates( + render.location, + ); + render.element.style.left = `${screenCoords.x}px`; + render.element.style.top = `${screenCoords.y}px`; + // The translate(-50%, -50%) is already set in createPlayerElement for centering } } diff --git a/src/client/graphics/layers/PingMarkerLayer.ts b/src/client/graphics/layers/PingMarkerLayer.ts new file mode 100644 index 000000000..766690f97 --- /dev/null +++ b/src/client/graphics/layers/PingMarkerLayer.ts @@ -0,0 +1,220 @@ +import { Colord, colord } from "colord"; +import * as PIXI from "pixi.js"; +import { EventBus } from "../../../core/EventBus"; +import { Cell } from "../../../core/game/Game"; +import { GameView } from "../../../core/game/GameView"; +import { PingType } from "../../../core/game/Ping"; +import { PingPlacedEvent } from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; + +// URL imports for bundled assets +import retreatIconUrl from "../../../../resources/images/BackIconWhite.svg"; +import watchOutIconUrl from "../../../../resources/images/QuestionMarkIcon.svg"; +import defendIconUrl from "../../../../resources/images/ShieldIconWhite.svg"; +import attackIconUrl from "../../../../resources/images/SwordIconWhite.svg"; + +// Configuration for pings +const PING_DURATION_MS = 3000; // 3 seconds +const PING_COLORS: Record = { + attack: colord("#ff0000"), + retreat: colord("#ffa600"), + defend: colord("#0000ff"), + watchOut: colord("#ffff00"), +}; +const PING_RING_MIN_RADIUS = 8; +const PING_RING_MAX_RADIUS = 32; + +// The core class for a single ping marker, handles its own animation and rendering +class Ping { + public readonly container: PIXI.Container; + private readonly circle: PIXI.Graphics; + private readonly sprite: PIXI.Sprite; + private readonly createdAt: number; + private readonly color: Colord; + + constructor( + public readonly pingType: PingType, + public readonly x: number, + public readonly y: number, + texture: PIXI.Texture, + ) { + this.createdAt = performance.now(); + this.color = PING_COLORS[pingType]; + this.container = new PIXI.Container(); + this.circle = new PIXI.Graphics(); + this.sprite = new PIXI.Sprite(texture); + this.sprite.anchor.set(0.5); + + const aspectRatio = texture.width / texture.height; + this.sprite.height = 24; + this.sprite.width = 24 * aspectRatio; + + this.container.addChild(this.circle, this.sprite); + } + + // Update animation state, returns true if still alive + update(now: number): boolean { + const elapsedTime = now - this.createdAt; + if (elapsedTime >= PING_DURATION_MS) { + return false; + } + + const progress = elapsedTime / PING_DURATION_MS; + + this.sprite.alpha = 1 - progress; // Fade out + + // Breathing ring animation + const ringRadius = + PING_RING_MIN_RADIUS + + (PING_RING_MAX_RADIUS - PING_RING_MIN_RADIUS) * + (0.5 + 0.5 * Math.sin(elapsedTime / 200)); + + this.drawBreathingRing( + PING_RING_MIN_RADIUS, + PING_RING_MAX_RADIUS, + ringRadius, + this.color.alpha(0.4), // Static outer ring + this.color.alpha(0.8), // Pulsing inner ring + ); + + return true; + } + + // Custom drawing logic for the breathing ring using PIXI.Graphics + private drawBreathingRing( + minRad: number, + maxRad: number, + currentRadius: number, + staticColor: Colord, + pulseColor: Colord, + ) { + this.circle.clear(); + + const progress = (currentRadius - minRad) / (maxRad - minRad); + const alpha = 1 - progress; + + // Outer static ring + this.circle.stroke({ width: 2, color: staticColor.toRgb(), alpha: 0.4 }); + this.circle.circle(0, 0, maxRad); + + // Inner pulsing ring + this.circle.stroke({ + width: 4, + color: pulseColor.toRgb(), + alpha: alpha * 0.8, + }); + this.circle.circle(0, 0, currentRadius); + } + + destroy() { + this.container.destroy({ children: true }); + } +} + +// The main layer for managing and rendering all ping markers +export class PingMarkerLayer implements Layer { + private pings: Ping[] = []; + private stage: PIXI.Container; + private renderer: PIXI.Renderer | undefined; + private textures: Record | undefined; + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + ) { + this.stage = new PIXI.Container(); + } + + async init() { + try { + // Setup renderer to match the game canvas environment + this.renderer = await PIXI.autoDetectRenderer({ + width: window.innerWidth, + height: window.innerHeight, + backgroundAlpha: 0, + antialias: true, + resolution: window.devicePixelRatio || 1, + }); + + // Load all necessary textures + this.textures = { + attack: await PIXI.Assets.load(attackIconUrl), + defend: await PIXI.Assets.load(defendIconUrl), + watchOut: await PIXI.Assets.load(watchOutIconUrl), + retreat: await PIXI.Assets.load(retreatIconUrl), + }; + + this.eventBus.on(PingPlacedEvent, this.handlePingPlaced); + window.addEventListener("resize", this.resizeCanvas); + } catch (error) { + console.error("Failed to initialize PingMarkerLayer:", error); + throw error; // Propagate failure + } + } + + destroy() { + this.eventBus.off(PingPlacedEvent, this.handlePingPlaced); + window.removeEventListener("resize", this.resizeCanvas); + this.renderer?.destroy(); + this.stage.destroy(true); + } + + private resizeCanvas = () => { + if (this.renderer) { + this.renderer.resize(window.innerWidth, window.innerHeight); + } + }; + + private handlePingPlaced = (event: PingPlacedEvent) => { + if (!this.textures || !this.game.isValidCoord(event.x, event.y)) { + return; + } + + const ping = new Ping( + event.pingType, + event.x, + event.y, + this.textures[event.pingType], + ); + this.pings.push(ping); + this.stage.addChild(ping.container); + }; + + tick() { + const now = performance.now(); + + // Filter out expired pings and remove them from the stage + const stillActivePings: Ping[] = []; + for (const ping of this.pings) { + if (ping.update(now)) { + stillActivePings.push(ping); + } else { + this.stage.removeChild(ping.container); + ping.destroy(); + } + } + this.pings = stillActivePings; + } + + renderLayer(context: CanvasRenderingContext2D): void { + if (!this.renderer) return; + + // Update positions of all pings based on camera transform + for (const ping of this.pings) { + const screenPos = this.transformHandler.worldToScreenCoordinates( + new Cell(ping.x, ping.y), + ); + ping.container.position.set(screenPos.x, screenPos.y); + } + + // Render the entire PIXI stage and draw it onto the main canvas + this.renderer.render(this.stage); + context.drawImage(this.renderer.canvas, 0, 0); + } + + shouldTransform(): boolean { + return false; // We handle our own transformations + } +} diff --git a/src/client/graphics/layers/PingTrajectoryPreviewLayer.ts b/src/client/graphics/layers/PingTrajectoryPreviewLayer.ts deleted file mode 100644 index 272c22e94..000000000 --- a/src/client/graphics/layers/PingTrajectoryPreviewLayer.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { EventBus } from "../../../core/EventBus"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameView } from "../../../core/game/GameView"; -import { PingType } from "../../../core/game/Ping"; -import { MouseMoveEvent, PingSelectedEvent } from "../../InputHandler"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -export class PingTrajectoryPreviewLayer implements Layer { - private mousePos = { x: 0, y: 0 }; - private pingTargetTile: TileRef | null = null; - private currentPingType: PingType | null = null; - private lastPingUpdate: number = 0; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) {} - - shouldTransform(): boolean { - return true; - } - - destroy() { - this.eventBus.off(MouseMoveEvent, this.handleMouseMove); - this.eventBus.off(PingSelectedEvent, this.handlePingSelected); - } - - init() { - this.eventBus.on(MouseMoveEvent, this.handleMouseMove); - this.eventBus.on(PingSelectedEvent, this.handlePingSelected); - } - - private handleMouseMove = (e: MouseMoveEvent) => { - this.mousePos.x = e.x; - this.mousePos.y = e.y; - }; - - private handlePingSelected = (e: PingSelectedEvent) => { - this.currentPingType = e.pingType; - }; - - tick() { - this.updatePingPreview(); - } - - renderLayer(context: CanvasRenderingContext2D) { - this.drawPingPreview(context); - } - - private updatePingPreview() { - if (this.currentPingType === null) { - this.pingTargetTile = null; - return; - } - - const now = performance.now(); - if (now - this.lastPingUpdate < 50) { - return; - } - this.lastPingUpdate = now; - - const rect = this.transformHandler.boundingRect(); - if (!rect) { - this.pingTargetTile = 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.pingTargetTile = null; - return; - } - - this.pingTargetTile = this.game.ref(worldCoords.x, worldCoords.y); - } - - private getPingColor(): string { - switch (this.currentPingType) { - case "attack": - return "rgba(255, 0, 0, 0.7)"; // Red - case "retreat": - return "rgba(0, 255, 0, 0.7)"; // Green - case "defend": - return "rgba(0, 0, 255, 0.7)"; // Blue - case "watchOut": - return "rgba(255, 255, 0, 0.7)"; // Yellow - default: - return "rgba(128, 128, 128, 0.7)"; // Gray fallback - } - } - - private static readonly PING_PREVIEW_RADIUS = 10; - private drawPingPreview(context: CanvasRenderingContext2D) { - if (this.currentPingType === null || this.pingTargetTile === null) { - return; - } - - const pingColor = this.getPingColor(); - - const x = this.game.x(this.pingTargetTile); - const y = this.game.y(this.pingTargetTile); - - context.save(); - context.fillStyle = pingColor; - context.beginPath(); - context.arc( - x, - y, - PingTrajectoryPreviewLayer.PING_PREVIEW_RADIUS, - 0, - 2 * Math.PI, - ); - context.fill(); - context.restore(); - } -} diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts index daab545a1..f5b2e5dbf 100644 --- a/src/core/EventBus.ts +++ b/src/core/EventBus.ts @@ -10,7 +10,7 @@ export class EventBus { emit(event: T): void { const eventConstructor = event.constructor as EventConstructor; - console.log("EventBus.emit - eventConstructor:", eventConstructor); + const callbacks = this.listeners.get(eventConstructor); if (callbacks) { for (const callback of callbacks) { @@ -23,7 +23,6 @@ export class EventBus { eventType: EventConstructor, callback: (event: T) => void, ): () => void { - console.log("EventBus.on - eventType:", eventType); if (!this.listeners.has(eventType)) { this.listeners.set(eventType, []); }