feat: Add ping feature

This commit introduces a new ping system that allows players to communicate on the map.

- Adds a radial ping menu to select different ping types (Attack, Retreat, Defend, Watch Out).
- Implements the logic to place pings on the map.
- Adds visual effects for pings, including a fading circle and an icon.
- Shows a preview of the ping location before placing it.
This commit is contained in:
Restart2008
2025-11-21 20:39:36 -08:00
parent 33810e41c5
commit 2157bfc5bc
10 changed files with 414 additions and 10 deletions
-2
View File
@@ -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"
+3 -3
View File
@@ -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": {
+40
View File
@@ -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();
+19 -4
View File
@@ -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;
+2
View File
@@ -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;
}
+112
View File
@@ -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<PingType, HTMLImageElement | null>();
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
}
}
+22 -1
View File
@@ -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<number, TargetFx> = new Map();
private nukeTargetFxByUnitId: Map<number, NukeAreaFx> = 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");
+87
View File
@@ -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,
],
};
}
@@ -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();
}
}
@@ -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]),