feat(ping): improve ping system and fix rendering issues

This commit introduces a new and improved ping system with the following changes:

- Replaced the old canvas-based ping rendering with a new PIXI.js-based implementation for better performance and visuals.
- Implemented a "breathing" halo effect for pings, inspired by the spawn highlight.
- Fixed the sizing of ping icons to be uniform across all ping types.
- Resolved an issue with blurry player and bot names by refactoring the `NameLayer` to avoid CSS scaling on text elements.

The new ping system is more visually appealing and the name rendering is now crisp.
This commit is contained in:
Restart2008
2025-11-24 01:15:13 -08:00
parent 647b22c55e
commit ec9c859add
8 changed files with 269 additions and 307 deletions
+23 -31
View File
@@ -154,6 +154,7 @@ export class InputHandler {
private pointers: Map<number, PointerEvent> = 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));
}
+2
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 { 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),
-112
View File
@@ -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<PingType, HTMLImageElement | null>();
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
}
}
+1 -12
View File
@@ -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);
}
+22 -26
View File
@@ -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
}
}
@@ -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<PingType, Colord> = {
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<PingType, PIXI.Texture> | 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
}
}
@@ -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();
}
}
+1 -2
View File
@@ -10,7 +10,7 @@ export class EventBus {
emit<T extends GameEvent>(event: T): void {
const eventConstructor = event.constructor as EventConstructor<T>;
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<T>,
callback: (event: T) => void,
): () => void {
console.log("EventBus.on - eventType:", eventType);
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, []);
}