mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 00:15:23 +00:00
feat: Implement visual on-map pinging system
This commit is contained in:
@@ -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>),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user