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", () => {