diff --git a/.husky/pre-commit b/.husky/pre-commit index a282f31f5..ce62b7b28 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,3 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" # Add PATH setup to ensure npx is found export PATH="/usr/local/bin:$HOME/.npm-global/bin:$HOME/.nvm/versions/node/$(node -v)/bin:$PATH" diff --git a/package-lock.json b/package-lock.json index 3223dfaef..325ea7728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3822,9 +3822,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 26d8f6c27..4731cd4a6 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -2,7 +2,9 @@ import { EventBus, GameEvent } from "../core/EventBus"; import { UnitType } from "../core/game/Game"; import { UnitView } from "../core/game/GameView"; import { UserSettings } from "../core/game/UserSettings"; +import { PingType } from "../core/game/Ping"; import { UIState } from "./graphics/UIState"; +import { TransformHandler } from "./graphics/TransformHandler"; import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; export class MouseUpEvent implements GameEvent { @@ -124,6 +126,9 @@ export class AutoUpgradeEvent implements GameEvent { public readonly y: number, ) {} } +export class PingSelectedEvent implements GameEvent { + constructor(public readonly pingType: PingType | null) {} +} export class TickMetricsEvent implements GameEvent { constructor( @@ -131,6 +136,13 @@ export class TickMetricsEvent implements GameEvent { public readonly tickDelay?: number, ) {} } +export class PingPlacedEvent implements GameEvent { + constructor( + public readonly pingType: PingType, + public readonly x: number, + public readonly y: number, + ) {} +} export class InputHandler { private lastPointerX: number = 0; @@ -160,6 +172,7 @@ export class InputHandler { public uiState: UIState, private canvas: HTMLCanvasElement, private eventBus: EventBus, + private transformHandler: TransformHandler, ) {} initialize() { @@ -481,6 +494,33 @@ export class InputHandler { Math.abs(event.x - this.lastPointerDownX) + 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, + ); + this.eventBus.emit( + new PingPlacedEvent( + this.uiState.currentPingType, + worldCoords.x, + worldCoords.y, + ), + ); + } + this.uiState.currentPingType = null; + this.eventBus.emit(new PingSelectedEvent(null)); // Clear ping preview + return; + } + if (event.pointerType === "touch") { this.eventBus.emit(new TouchEvent(event.x, event.y)); event.preventDefault(); diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1410cdbbd..f6c82bfec 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 { PingTargetPreviewLayer } from "./layers/PingTargetPreviewLayer"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; import { RailroadLayer } from "./layers/RailroadLayer"; @@ -210,7 +211,11 @@ export function createRenderer( transformHandler, uiState, ); - + const pingTargetPreviewLayer = new PingTargetPreviewLayer( + game, + eventBus, + transformHandler, + ); const performanceOverlay = document.querySelector( "performance-overlay", ) as PerformanceOverlay; @@ -243,9 +248,10 @@ export function createRenderer( structureLayer, samRadiusLayer, new UnitLayer(game, eventBus, transformHandler), - new FxLayer(game), + new FxLayer(game, eventBus), new UILayer(game, eventBus, transformHandler), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler), + pingTargetPreviewLayer, new StructureIconsLayer(game, eventBus, uiState, transformHandler), new NameLayer(game, transformHandler, eventBus), eventsDisplay, @@ -307,8 +313,12 @@ export class GameRenderer { this.context = context; } + private redrawEventCleanup?: () => void; + initialize() { - this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); + this.redrawEventCleanup = this.eventBus.on(RedrawGraphicsEvent, () => + this.redraw(), + ); this.layers.forEach((l) => l.init?.()); document.body.appendChild(this.canvas); @@ -327,7 +337,12 @@ export class GameRenderer { rafId = requestAnimationFrame(() => this.renderGame()); }); } - + + destroy() { + this.redrawEventCleanup?.(); + this.layers.forEach((l) => l.destroy?.()); + } + resizeCanvas() { this.canvas.width = window.innerWidth; this.canvas.height = window.innerHeight; diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index 01c4a60cb..b7f147fb6 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -1,6 +1,8 @@ import { UnitType } from "../../core/game/Game"; +import { PingType } from "../../core/game/Ping"; export interface UIState { attackRatio: number; ghostStructure: UnitType | null; + currentPingType: PingType | null; } diff --git a/src/client/graphics/fx/PingFx.ts b/src/client/graphics/fx/PingFx.ts new file mode 100644 index 000000000..b16be2471 --- /dev/null +++ b/src/client/graphics/fx/PingFx.ts @@ -0,0 +1,112 @@ +import { GameView } from "../../../core/game/GameView"; +import { PingType } from "../../../core/game/Ping"; +import { TileRef } from "../../../core/game/GameMap"; +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 PingType.Attack: + return "rgba(255, 0, 0, 0.7)"; // Red + case PingType.Retreat: + return "rgba(0, 255, 0, 0.7)"; // Green + case PingType.Defend: + return "rgba(0, 0, 255, 0.7)"; // Blue + case PingType.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 PingType.Attack: + return "/resources/images/SwordIconWhite.svg"; + case PingType.Retreat: + return "/resources/images/BackIconWhite.svg"; + case PingType.Defend: + return "/resources/images/ShieldIconWhite.svg"; + case PingType.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)) { + 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; + } + } + + 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, 15, 0, 2 * Math.PI); + context.fill(); + + // Draw icon + if (this.icon && this.icon.complete) { + const iconSize = 20; + context.drawImage( + this.icon, + x + offsetX - iconSize / 2, + y + offsetY - iconSize / 2, + iconSize, + iconSize, + ); + } + + context.restore(); + return true; // Fx is still active + } +} \ No newline at end of file diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index e1c454e5c..4f1a43b57 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -1,4 +1,5 @@ import { Theme } from "../../../core/configuration/Config"; +import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; import { BonusEventUpdate, @@ -7,6 +8,7 @@ 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"; @@ -14,10 +16,12 @@ 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"; import { UnitExplosionFx } from "../fx/UnitExplosionFx"; + import { Layer } from "./Layer"; export class FxLayer implements Layer { private canvas: HTMLCanvasElement; @@ -33,7 +37,10 @@ export class FxLayer implements Layer { private boatTargetFxByUnitId: Map = new Map(); private nukeTargetFxByUnitId: Map = new Map(); - constructor(private game: GameView) { + constructor( + private game: GameView, + private eventBus: EventBus, + ) { this.theme = this.game.config().theme(); } @@ -345,6 +352,7 @@ export class FxLayer implements Layer { this.allFx.push(shockwave); } + private pingEventCleanup?: () => void; async init() { this.redraw(); try { @@ -355,6 +363,19 @@ export class FxLayer implements Layer { } } + if (this.pingEventCleanup) { + this.pingEventCleanup(); + if (this.pingEventCleanup) { + this.pingEventCleanup(); + this.pingEventCleanup = undefined; + } + this.pingEventCleanup = this.eventBus.on(PingPlacedEvent, (event) => { + const pingFx = new PingFx(this.game, event.type, event.tile); + this.allFx.push(pingFx); + }); + } + this.pingEventCleanup?.(); + redraw(): void { this.canvas = document.createElement("canvas"); const context = this.canvas.getContext("2d"); diff --git a/src/client/graphics/layers/PingMenu.ts b/src/client/graphics/layers/PingMenu.ts new file mode 100644 index 000000000..fd1ecf8e6 --- /dev/null +++ b/src/client/graphics/layers/PingMenu.ts @@ -0,0 +1,87 @@ +import { + MenuElement, + MenuElementParams, + COLORS, +} from "./RadialMenuElements"; +import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; +import retreatIcon from "../../../../resources/images/BackIconWhite.svg"; +import defendIcon from "../../../../resources/images/ShieldIconWhite.svg"; +import watchOutIcon from "../../../../resources/images/QuestionMarkIcon.svg"; +import { EventBus } from "../../../core/EventBus"; +import { PingType } from "../../../core/game/Ping"; +import { PingSelectedEvent } from "../../InputHandler"; + +export const PING_ICON = swordIcon; + +export const PING_COLORS = { + [PingType.Attack]: "#ff0000", + [PingType.Retreat]: "#ffa600", + [PingType.Defend]: "#0000ff", + [PingType.WatchOut]: "#ffff00", +}; + +function createPingElement( + id: string, + name: string, + icon: string, + pingType: PingType, + eventBus: EventBus, +): MenuElement { + return { + id, + name, + icon, + color: PING_COLORS[pingType], + disabled: () => false, + action: (params?: MenuElementParams) => { + if (!params) return; + eventBus.emit(new PingSelectedEvent(pingType)); + params.closeMenu(); + }, + }; +} + +export function createPingMenu(eventBus: EventBus): MenuElement { + const pingAttackElement = createPingElement( + "ping_attack", + "Attack", + swordIcon, + PingType.Attack, + eventBus, + ); + const pingRetreatElement = createPingElement( + "ping_retreat", + "Retreat", + retreatIcon, + PingType.Retreat, + eventBus, + ); + const pingDefendElement = createPingElement( + "ping_defend", + "Defend", + defendIcon, + PingType.Defend, + eventBus, + ); + const pingWatchOutElement = createPingElement( + "ping_watch_out", + "Watch out", + watchOutIcon, + PingType.WatchOut, + eventBus, + ); + + return { + id: "ping", + name: "Pings", + icon: PING_ICON, + color: COLORS.ally, + disabled: () => false, + subMenu: () => [ + pingAttackElement, + pingRetreatElement, + pingDefendElement, + pingWatchOutElement, + ], + }; +} \ No newline at end of file diff --git a/src/client/graphics/layers/PingTrajectoryPreviewLayer.ts b/src/client/graphics/layers/PingTrajectoryPreviewLayer.ts new file mode 100644 index 000000000..909cc4a98 --- /dev/null +++ b/src/client/graphics/layers/PingTrajectoryPreviewLayer.ts @@ -0,0 +1,127 @@ +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 PingType.Attack: + return "rgba(255, 0, 0, 0.7)"; // Red + case PingType.Retreat: + return "rgba(0, 255, 0, 0.7)"; // Green + case PingType.Defend: + return "rgba(0, 0, 255, 0.7)"; // Blue + case PingType.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 offsetX = -this.game.width() / 2; + const offsetY = -this.game.height() / 2; + + const x = this.game.x(this.pingTargetTile) + offsetX; + const y = this.game.y(this.pingTargetTile) + offsetY; + + context.save(); + context.fillStyle = pingColor; + context.beginPath(); + context.arc( + x, + y, + PingTrajectoryPreviewLayer.PING_PREVIEW_RADIUS, + 0, + 2 * Math.PI, + ); + context.fill(); + context.restore(); + } +} \ No newline at end of file diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 29e5bd379..2b1be2f24 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -24,6 +24,7 @@ import targetIcon from "../../../../resources/images/TargetIconWhite.svg"; import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; import xIcon from "../../../../resources/images/XIcon.svg"; import { EventBus } from "../../../core/EventBus"; +import { createPingMenu } from "./PingMenu"; export interface MenuElementParams { myPlayer: PlayerView; @@ -583,6 +584,7 @@ export const rootMenuElement: MenuElement = { const menuItems: (MenuElement | null)[] = [ infoMenuElement, + createPingMenu(params.eventBus), ...(isOwnTerritory ? [deleteUnitElement, ally, buildMenuElement] : [boatMenuElement, ally, attackMenuElement]),