feat: Implement visual on-map pinging system

This commit is contained in:
Restart2008
2025-11-20 23:14:19 -08:00
parent c619129b3f
commit 162773a79b
15 changed files with 494 additions and 226 deletions
+54 -96
View File
@@ -9,7 +9,6 @@ import {
GameMapSize,
GameMapType,
GameMode,
HumansVsNations,
Quads,
Trios,
UnitType,
@@ -25,7 +24,6 @@ import {
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
import "./components/LobbyTeamView";
import "./components/Maps";
import { JoinLobbyEvent } from "./Main";
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
@@ -49,7 +47,6 @@ export class HostLobbyModal extends LitElement {
@state() private maxTimer: boolean = false;
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
@state() private compactMap: boolean = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
@@ -287,18 +284,7 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[
2,
3,
4,
5,
6,
7,
Quads,
Trios,
Duos,
HumansVsNations,
].map(
${[2, 3, 4, 5, 6, 7, Quads, Trios, Duos].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -308,9 +294,7 @@ export class HostLobbyModal extends LitElement {
>
<div class="option-card-title">
${typeof o === "string"
? o === HumansVsNations
? translateText("public_lobby.teams_hvn")
: translateText(`public_lobby.teams_${o}`)
? translateText(`public_lobby.teams_${o}`)
: translateText("public_lobby.teams", {
num: o,
})}
@@ -329,53 +313,42 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.options_title")}
</div>
<div class="option-cards">
<label for="bots-count" class="option-card">
<label for="bots-count" class="option-card">
<input
type="range"
id="bots-count"
min="0"
max="400"
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${String(this.bots)}"
/>
<div class="option-card-title">
<span>${translateText("host_modal.bots")}</span>${
this.bots === 0
? translateText("host_modal.bots_disabled")
: this.bots
}
</div>
</label>
<label
for="disable-npcs"
class="option-card ${this.disableNPCs ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="range"
id="bots-count"
min="0"
max="400"
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${String(this.bots)}"
type="checkbox"
id="disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">
<span>${translateText("host_modal.bots")}</span>${
this.bots === 0
? translateText("host_modal.bots_disabled")
: this.bots
}
${translateText("host_modal.disable_nations")}
</div>
</label>
${
!(
this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
)
? html`
<label
for="disable-npcs"
class="option-card ${this.disableNPCs
? "selected"
: ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">
${translateText("host_modal.disable_nations")}
</div>
</label>
`
: ""
}
<label
for="instant-build"
class="option-card ${this.instantBuild ? "selected" : ""}"
@@ -392,22 +365,6 @@ export class HostLobbyModal extends LitElement {
</div>
</label>
<label
for="random-spawn"
class="option-card ${this.randomSpawn ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="random-spawn"
@change=${this.handleRandomSpawnChange}
.checked=${this.randomSpawn}
/>
<div class="option-card-title">
${translateText("host_modal.random_spawn")}
</div>
</label>
<label
for="donate-gold"
class="option-card ${this.donateGold ? "selected" : ""}"
@@ -555,13 +512,27 @@ export class HostLobbyModal extends LitElement {
}
</div>
<lobby-team-view
.gameMode=${this.gameMode}
.clients=${this.clients}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.teamCount=${this.teamCount}
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
></lobby-team-view>
<div class="players-list">
${this.clients.map(
(client) => html`
<span class="player-tag">
${client.username}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
>`
: html`
<button
class="remove-player-btn"
@click=${() => this.kickPlayer(client.clientID)}
title="Remove ${client.username}"
>
×
</button>
`}
</span>
`,
)}
</div>
<div class="start-game-button-container">
@@ -672,11 +643,6 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
@@ -752,24 +718,16 @@ export class HostLobbyModal extends LitElement {
? GameMapSize.Compact
: GameMapSize.Normal,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
donateGold: this.donateGold,
infiniteTroops: this.infiniteTroops,
donateTroops: this.donateTroops,
instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
? {
disableNPCs: false,
}
: {
disableNPCs: this.disableNPCs,
}),
maxTimerValue:
this.maxTimer === true ? this.maxTimerValue : undefined,
} satisfies Partial<GameConfig>),
+42
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 {
@@ -125,6 +127,10 @@ export class AutoUpgradeEvent implements GameEvent {
) {}
}
export class PingSelectedEvent implements GameEvent {
constructor(public readonly pingType: PingType | null) {}
}
export class TickMetricsEvent implements GameEvent {
constructor(
public readonly tickExecutionDuration?: number,
@@ -132,6 +138,14 @@ export class TickMetricsEvent implements GameEvent {
) {}
}
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;
private lastPointerY: number = 0;
@@ -160,6 +174,7 @@ export class InputHandler {
public uiState: UIState,
private canvas: HTMLCanvasElement,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {}
initialize() {
@@ -481,6 +496,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();
+18 -70
View File
@@ -9,7 +9,6 @@ import {
GameMapType,
GameMode,
GameType,
HumansVsNations,
Quads,
Trios,
UnitType,
@@ -44,7 +43,6 @@ export class SinglePlayerModal extends LitElement {
@state() private maxTimer: boolean = false;
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@@ -197,18 +195,7 @@ export class SinglePlayerModal extends LitElement {
${translateText("host_modal.team_count")}
</div>
<div class="option-cards">
${[
2,
3,
4,
5,
6,
7,
Quads,
Trios,
Duos,
HumansVsNations,
].map(
${[2, 3, 4, 5, 6, 7, Quads, Trios, Duos].map(
(o) => html`
<div
class="option-card ${this.teamCount === o
@@ -218,9 +205,7 @@ export class SinglePlayerModal extends LitElement {
>
<div class="option-card-title">
${typeof o === "string"
? o === HumansVsNations
? translateText("public_lobby.teams_hvn")
: translateText(`public_lobby.teams_${o}`)
? translateText(`public_lobby.teams_${o}`)
: translateText(`public_lobby.teams`, { num: o })}
</div>
</div>
@@ -255,29 +240,21 @@ export class SinglePlayerModal extends LitElement {
</div>
</label>
${!(
this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
)
? html`
<label
for="singleplayer-modal-disable-npcs"
class="option-card ${this.disableNPCs ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">
${translateText("single_modal.disable_nations")}
</div>
</label>
`
: ""}
<label
for="singleplayer-modal-disable-npcs"
class="option-card ${this.disableNPCs ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">
${translateText("single_modal.disable_nations")}
</div>
</label>
<label
for="singleplayer-modal-instant-build"
class="option-card ${this.instantBuild ? "selected" : ""}"
@@ -294,22 +271,6 @@ export class SinglePlayerModal extends LitElement {
</div>
</label>
<label
for="singleplayer-modal-random-spawn"
class="option-card ${this.randomSpawn ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-random-spawn"
@change=${this.handleRandomSpawnChange}
.checked=${this.randomSpawn}
/>
<div class="option-card-title">
${translateText("single_modal.random_spawn")}
</div>
</label>
<label
for="singleplayer-modal-infinite-gold"
class="option-card ${this.infiniteGold ? "selected" : ""}"
@@ -457,10 +418,6 @@ export class SinglePlayerModal extends LitElement {
this.instantBuild = Boolean((e.target as HTMLInputElement).checked);
}
private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
}
private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
}
@@ -577,6 +534,7 @@ export class SinglePlayerModal extends LitElement {
gameMode: this.gameMode,
playerTeams: this.teamCount,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
maxTimerValue: this.maxTimer ? this.maxTimerValue : undefined,
bots: this.bots,
infiniteGold: this.infiniteGold,
@@ -584,20 +542,10 @@ export class SinglePlayerModal extends LitElement {
donateTroops: true,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn,
disabledUnits: this.disabledUnits
.map((u) => Object.values(UnitType).find((ut) => ut === u))
.filter((ut): ut is UnitType => ut !== undefined),
...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations
? {
disableNPCs: false,
}
: {
disableNPCs: this.disableNPCs,
}),
},
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
},
} satisfies JoinLobbyEvent,
bubbles: true,
+17 -4
View File
@@ -2,7 +2,7 @@ import { EventBus } from "../../core/EventBus";
import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { InputHandler, RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
@@ -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 { PingTargetPreviewLayer } from "./layers/PingTargetPreviewLayer";
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
@@ -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,10 @@ 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,6 +335,11 @@ export class GameRenderer {
rafId = requestAnimationFrame(() => this.renderGame());
});
}
destroy() {
this.redrawEventCleanup?.();
this.layers.forEach((l) => l.destroy?.());
}
resizeCanvas() {
this.canvas.width = window.innerWidth;
+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
}
}
+19 -1
View File
@@ -18,6 +18,9 @@ import { SpriteFx } from "../fx/SpriteFx";
import { TargetFx } from "../fx/TargetFx";
import { TextFx } from "../fx/TextFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { EventBus } from "../../../core/EventBus";
import { PingPlacedEvent } from "../../../core/game/Ping";
import { PingFx } from "../fx/PingFx";
import { Layer } from "./Layer";
export class FxLayer implements Layer {
private canvas: HTMLCanvasElement;
@@ -33,7 +36,7 @@ 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 +348,7 @@ export class FxLayer implements Layer {
this.allFx.push(shockwave);
}
private pingEventCleanup?: () => void;
async init() {
this.redraw();
try {
@@ -353,6 +357,20 @@ export class FxLayer implements Layer {
} catch (err) {
console.error("Failed to load FX sprites:", err);
}
this.pingEventCleanup?.();
this.pingEventCleanup = this.eventBus.on(PingPlacedEvent, (event) => {
const pingFx = new PingFx(
this.game,
event.type,
event.tile,
);
this.allFx.push(pingFx);
});
}
destroy() {
this.pingEventCleanup?.();
}
redraw(): void {
+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,121 @@
import { EventBus } from "../../../core/EventBus";
import { GameView } from "../../../core/game/GameView";
import { TileRef } from "../../../core/game/GameMap";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { MouseMoveEvent, PingSelectedEvent } from "../../InputHandler";
import { PingType } from "../../../core/game/Ping";
export class PingTargetPreviewLayer 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]),
+1 -7
View File
@@ -88,7 +88,6 @@ export interface Config {
infiniteTroops(): boolean;
donateTroops(): boolean;
instantBuild(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
playerTeams(): TeamCountConfig;
@@ -137,7 +136,6 @@ export interface Config {
deleteUnitCooldown(): Tick;
defaultDonationAmount(sender: Player): number;
unitInfo(type: UnitType): UnitInfo;
tradeShipShortRangeDebuff(): number;
tradeShipGold(dist: number, numPorts: number): Gold;
tradeShipSpawnRate(
numTradeShips: number,
@@ -153,7 +151,7 @@ export interface Config {
defensePostRange(): number;
SAMCooldown(): number;
SiloCooldown(): number;
defensePostDefenseBonus(): number;
defensePostDefenseBonus(level: number): number;
defensePostSpeedBonus(): number;
falloutDefenseModifier(percentOfFallout: number): number;
difficultyModifier(difficulty: Difficulty): number;
@@ -172,8 +170,6 @@ export interface Config {
defaultNukeTargetableRange(): number;
defaultSamMissileSpeed(): number;
defaultSamRange(): number;
samRange(level: number): number;
maxSamRange(): number;
nukeDeathFactor(
nukeType: NukeType,
humans: number,
@@ -190,8 +186,6 @@ export interface Theme {
// Don't call directly, use PlayerView
territoryColor(playerInfo: PlayerView): Colord;
// Don't call directly, use PlayerView
structureColors(territoryColor: Colord): { light: Colord; dark: Colord };
// Don't call directly, use PlayerView
borderColor(territoryColor: Colord): Colord;
// Don't call directly, use PlayerView
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
+10 -15
View File
@@ -1,6 +1,5 @@
import { Config } from "../configuration/Config";
import { AllPlayersStats, ClientID } from "../Schemas";
import { getClanTag } from "../Util";
import { GameMap, TileRef } from "./GameMap";
import {
GameUpdate,
@@ -53,7 +52,6 @@ export type Team = string;
export const Duos = "Duos" as const;
export const Trios = "Trios" as const;
export const Quads = "Quads" as const;
export const HumansVsNations = "Humans Vs Nations" as const;
export const ColoredTeams: Record<string, Team> = {
Red: "Red",
@@ -64,8 +62,6 @@ export const ColoredTeams: Record<string, Team> = {
Orange: "Orange",
Green: "Green",
Bot: "Bot",
Humans: "Humans",
Nations: "Nations",
} as const;
export enum GameMapType {
@@ -100,8 +96,6 @@ export enum GameMapType {
Pluto = "Pluto",
Montreal = "Montreal",
Achiran = "Achiran",
BaikalNukeWars = "Baikal (Nuke Wars)",
FourIslands = "Four Islands",
}
export type GameMapName = keyof typeof GameMapType;
@@ -143,8 +137,6 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Mars,
GameMapType.DeglaciatedAntarctica,
GameMapType.Achiran,
GameMapType.BaikalNukeWars,
GameMapType.FourIslands,
],
};
@@ -274,9 +266,7 @@ export interface UnitParamsMap {
[UnitType.City]: Record<string, never>;
[UnitType.MIRV]: {
targetTile?: number;
};
[UnitType.MIRV]: Record<string, never>;
[UnitType.MIRVWarhead]: {
targetTile?: number;
@@ -417,7 +407,13 @@ export class PlayerInfo {
public readonly id: PlayerID,
public readonly nation?: Nation | null,
) {
this.clan = getClanTag(name);
// Compute clan from name
if (!name.includes("[") || !name.includes("]")) {
this.clan = null;
} else {
const clanMatch = name.match(/\[([a-zA-Z0-9]{2,5})\]/);
this.clan = clanMatch ? clanMatch[1].toUpperCase() : null;
}
}
}
@@ -453,7 +449,6 @@ export interface Unit {
toUpdate(): UnitUpdate;
hasTrainStation(): boolean;
setTrainStation(trainStation: boolean): void;
wasDestroyedByEnemy(): boolean;
// Train
trainType(): TrainType | undefined;
@@ -597,7 +592,7 @@ export interface Player {
decayRelations(): void;
isOnSameTeam(other: Player): boolean;
// Either allied or on same team.
isFriendly(other: Player, treatAFKFriendly?: boolean): boolean;
isFriendly(other: Player): boolean;
team(): Team | null;
clan(): string | null;
incomingAllianceRequests(): AllianceRequest[];
@@ -610,7 +605,6 @@ export interface Player {
canSendAllianceRequest(other: Player): boolean;
breakAlliance(alliance: Alliance): void;
createAllianceRequest(recipient: Player): AllianceRequest | null;
betrayals(): number;
// Targeting
canTarget(other: Player): boolean;
@@ -659,6 +653,7 @@ export interface Player {
// Misc
toUpdate(): PlayerUpdate;
playerProfile(): PlayerProfile;
tradingPorts(port: Unit): Unit[];
// WARNING: this operation is expensive.
bestTransportShipSpawn(tile: TileRef): TileRef | false;
}
+1 -33
View File
@@ -15,7 +15,6 @@ import {
Game,
GameMode,
GameUpdates,
HumansVsNations,
MessageType,
MutableAlliance,
Nation,
@@ -106,13 +105,6 @@ export class GameImpl implements Game {
private populateTeams() {
let numPlayerTeams = this._config.playerTeams();
// HumansVsNations mode always has exactly 2 teams
if (numPlayerTeams === HumansVsNations) {
this.playerTeams = [ColoredTeams.Humans, ColoredTeams.Nations];
return;
}
if (typeof numPlayerTeams !== "number") {
const players = this._humans.length + this._nations.length;
switch (numPlayerTeams) {
@@ -147,21 +139,11 @@ export class GameImpl implements Game {
}
private addPlayers() {
if (this.config().gameConfig().gameMode === GameMode.FFA) {
if (this.config().gameConfig().gameMode !== GameMode.Team) {
this._humans.forEach((p) => this.addPlayer(p));
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
return;
}
if (this._config.playerTeams() === HumansVsNations) {
this._humans.forEach((p) => this.addPlayer(p, ColoredTeams.Humans));
this._nations.forEach((n) =>
this.addPlayer(n.playerInfo, ColoredTeams.Nations),
);
return;
}
// Team mode
const allPlayers = [
...this._humans,
...this._nations.map((n) => n.playerInfo),
@@ -895,20 +877,6 @@ export class GameImpl implements Game {
return this._railNetwork;
}
conquerPlayer(conqueror: Player, conquered: Player) {
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
const ships = conquered
.units()
.filter(
(u) =>
u.type() === UnitType.Warship ||
u.type() === UnitType.TransportShip,
);
for (const ship of ships) {
conqueror.captureUnit(ship);
}
}
const gold = conquered.gold();
this.displayMessage(
`Conquered ${conquered.displayName()} received ${renderNumber(
@@ -14,6 +14,10 @@ describe("ProgressBar", () => {
ctx = canvas.getContext("2d")!;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should initialize and draw the background", () => {
const spyClearRect = jest.spyOn(ctx, "clearRect");
const spyFillRect = jest.spyOn(ctx, "fillRect");
+4
View File
@@ -32,6 +32,10 @@ describe("UILayer", () => {
transformHandler = {};
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should initialize and redraw canvas", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();