diff --git a/resources/images/ExclamationMarkIcon.svg b/resources/images/ExclamationMarkIcon.svg new file mode 100644 index 000000000..8c417fc73 --- /dev/null +++ b/resources/images/ExclamationMarkIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1410cdbbd..2ccccb39a 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -24,6 +24,7 @@ import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer"; +import { PingTrajectoryPreviewLayer } from "./layers/PingTrajectoryPreviewLayer"; import { PerformanceOverlay } from "./layers/PerformanceOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; @@ -204,13 +205,13 @@ export function createRenderer( headsUpMessage.game = game; const structureLayer = new StructureLayer(game, eventBus, transformHandler); - const samRadiusLayer = new SAMRadiusLayer( - game, - eventBus, - transformHandler, - uiState, - ); - + const samRadiusLayer = new SAMRadiusLayer( + game, + eventBus, + transformHandler, + uiState, + ); + const pingTrajectoryPreviewLayer = new PingTrajectoryPreviewLayer(game, eventBus, transformHandler); const performanceOverlay = document.querySelector( "performance-overlay", ) as PerformanceOverlay; @@ -243,9 +244,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), + pingTrajectoryPreviewLayer, new StructureIconsLayer(game, eventBus, uiState, transformHandler), new NameLayer(game, transformHandler, eventBus), eventsDisplay, @@ -292,6 +294,7 @@ export function createRenderer( export class GameRenderer { private context: CanvasRenderingContext2D; + private inputHandler: InputHandler; constructor( private game: GameView, @@ -305,9 +308,11 @@ export class GameRenderer { const context = canvas.getContext("2d"); if (context === null) throw new Error("2d context not supported"); this.context = context; + this.inputHandler = new InputHandler(uiState, canvas, eventBus, transformHandler); } initialize() { + this.inputHandler.initialize(); this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); this.layers.forEach((l) => l.init?.()); diff --git a/src/client/graphics/fx/PingFx.ts b/src/client/graphics/fx/PingFx.ts new file mode 100644 index 000000000..872632046 --- /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/QuestionMarkIcon.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 + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index e1c454e5c..f243590a7 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -18,6 +18,11 @@ import { SpriteFx } from "../fx/SpriteFx"; import { TargetFx } from "../fx/TargetFx"; import { TextFx } from "../fx/TextFx"; import { UnitExplosionFx } from "../fx/UnitExplosionFx"; +import { PingPlacedEvent, PingType } from "../../../core/game/Ping"; +import { PingFx } from "../fx/PingFx"; +import { EventBus } from "../../../core/EventBus"; +import { PingPlacedEvent, PingType } from "../../../core/game/Ping"; +import { PingFx } from "../fx/PingFx"; import { Layer } from "./Layer"; export class FxLayer implements Layer { private canvas: HTMLCanvasElement; @@ -33,7 +38,7 @@ 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(); } @@ -353,6 +358,16 @@ export class FxLayer implements Layer { } catch (err) { console.error("Failed to load FX sprites:", err); } + + this.eventBus.on(PingPlacedEvent, (event) => { + const pingFx = new PingFx( + this.game, + this.animatedSpriteLoader, + event.type, + event.tile, + ); + this.allFx.push(pingFx); + }); } redraw(): void { diff --git a/src/core/game/Ping.ts b/src/core/game/Ping.ts new file mode 100644 index 000000000..81e3d4147 --- /dev/null +++ b/src/core/game/Ping.ts @@ -0,0 +1,17 @@ +import { TileRef } from "./GameMap"; + +export enum PingType { + Attack, + Retreat, + Defend, + WatchOut, +} + +export class Ping { + constructor( + public type: PingType, + public tile: TileRef, + ) {} +} + +export class PingPlacedEvent extends Ping {} diff --git a/tests/client/graphics/ProgressBar.test.ts b/tests/client/graphics/ProgressBar.test.ts index 50189a9e3..11a86a14c 100644 --- a/tests/client/graphics/ProgressBar.test.ts +++ b/tests/client/graphics/ProgressBar.test.ts @@ -11,17 +11,39 @@ describe("ProgressBar", () => { canvas = document.createElement("canvas"); canvas.width = 100; canvas.height = 20; - ctx = canvas.getContext("2d")!; + ctx = { + clearRect: jest.fn(), + fillRect: jest.fn(), + beginPath: jest.fn(), + arc: jest.fn(), + fill: jest.fn(), + stroke: jest.fn(), + measureText: jest.fn(() => ({ width: 10 })), + fillText: jest.fn(), + save: jest.fn(), + restore: jest.fn(), + translate: jest.fn(), + rotate: jest.fn(), + drawImage: jest.fn(), + setTransform: jest.fn(), + globalAlpha: 1, + fillStyle: "", + strokeStyle: "", + lineWidth: 1, + font: "", + } as unknown as CanvasRenderingContext2D; + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(ctx); }); it("should initialize and draw the background", () => { const spyClearRect = jest.spyOn(ctx, "clearRect"); const spyFillRect = jest.spyOn(ctx, "fillRect"); - const spyFillStyle = jest.spyOn(ctx, "fillStyle", "set"); const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 0.5); expect(spyClearRect).toHaveBeenCalledWith(0, 0, 82, 12); expect(spyFillRect).toHaveBeenCalledWith(1, 1, 80, 10); - expect(spyFillStyle).toHaveBeenCalledWith("#00ff00"); + expect(ctx.fillStyle).toBe("#00ff00"); expect(bar.getX()).toBe(2); expect(bar.getY()).toBe(2); }); diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts index c899ca079..e40fc57c2 100644 --- a/tests/client/graphics/UILayer.test.ts +++ b/tests/client/graphics/UILayer.test.ts @@ -30,6 +30,31 @@ describe("UILayer", () => { }; eventBus = { on: jest.fn() }; transformHandler = {}; + + // Mock the HTMLCanvasElement.prototype.getContext method + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ + clearRect: jest.fn(), + fillRect: jest.fn(), + beginPath: jest.fn(), + arc: jest.fn(), + fill: jest.fn(), + stroke: jest.fn(), + measureText: jest.fn(() => ({ width: 10 })), + fillText: jest.fn(), + save: jest.fn(), + restore: jest.fn(), + translate: jest.fn(), + rotate: jest.fn(), + drawImage: jest.fn(), + setTransform: jest.fn(), + globalAlpha: 1, + fillStyle: "", + strokeStyle: "", + lineWidth: 1, + font: "", + } as unknown as CanvasRenderingContext2D); }); it("should initialize and redraw canvas", () => {