mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 20:06:46 +00:00
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:
+23
-31
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user