Merge branch 'main' into icslucas-patch-1

This commit is contained in:
icslucas
2025-08-02 17:00:12 +02:00
committed by GitHub
77 changed files with 1162 additions and 773 deletions
+10 -4
View File
@@ -13,6 +13,7 @@ import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { PlayerActions, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
ErrorUpdate,
GameUpdateType,
@@ -33,6 +34,7 @@ import {
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentID } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
@@ -58,12 +60,11 @@ export interface LobbyConfig {
}
export function joinLobby(
eventBus: EventBus,
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
): () => void {
const eventBus = new EventBus();
console.log(
`joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
);
@@ -82,7 +83,7 @@ export function joinLobby(
const onmessage = (message: ServerMessage) => {
if (message.type === "prestart") {
console.log(`lobby: game prestarting: ${JSON.stringify(message)}`);
terrainLoad = loadTerrainMap(message.gameMap);
terrainLoad = loadTerrainMap(message.gameMap, terrainMapFileLoader);
onPrestart();
}
if (message.type === "start") {
@@ -98,6 +99,7 @@ export function joinLobby(
transport,
userSettings,
terrainLoad,
terrainMapFileLoader,
).then((r) => r.start());
}
if (message.type === "error") {
@@ -125,6 +127,7 @@ async function createClientGame(
transport: Transport,
userSettings: UserSettings,
terrainLoad: Promise<TerrainMapData> | null,
mapLoader: GameMapLoader,
): Promise<ClientGameRunner> {
if (lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
@@ -139,7 +142,10 @@ async function createClientGame(
if (terrainLoad) {
gameMap = await terrainLoad;
} else {
gameMap = await loadTerrainMap(lobbyConfig.gameStartInfo.config.gameMap);
gameMap = await loadTerrainMap(
lobbyConfig.gameStartInfo.config.gameMap,
mapLoader,
);
}
const worker = new WorkerClient(
lobbyConfig.gameStartInfo,
+13
View File
@@ -68,8 +68,21 @@ export class FlagInput extends LitElement {
super.connectedCallback();
this.flag = this.getStoredFlag();
this.dispatchFlagEvent();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.showModal = false;
}
};
createRenderRoot() {
return this;
}
+17 -4
View File
@@ -15,6 +15,23 @@ export class HelpModal extends LitElement {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
render() {
return html`
<o-modal
@@ -166,11 +183,7 @@ export class HelpModal extends LitElement {
<div>
<p class="mb-4">${translateText("help_modal.ui_control_desc")}</p>
<ul>
<li class="mb-4">${translateText("help_modal.ui_pop")}</li>
<li class="mb-4">${translateText("help_modal.ui_gold")}</li>
<li class="mb-4">
${translateText("help_modal.ui_troops_workers")}
</li>
<li class="mb-4">
${translateText("help_modal.ui_attack_ratio")}
</li>
+70 -16
View File
@@ -14,7 +14,12 @@ import {
mapCategories,
} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { GameConfig, GameInfo, TeamCountConfig } from "../core/Schemas";
import {
ClientInfo,
GameConfig,
GameInfo,
TeamCountConfig,
} from "../core/Schemas";
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
@@ -40,9 +45,10 @@ export class HostLobbyModal extends LitElement {
@state() private instantBuild: boolean = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
@state() private players: string[] = [];
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [UnitType.Factory];
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyIdVisible: boolean = true;
private playersInterval: NodeJS.Timeout | null = null;
@@ -50,6 +56,23 @@ export class HostLobbyModal extends LitElement {
private botsUpdateTimer: number | null = null;
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
render() {
return html`
<o-modal title=${translateText("host_modal.title")}>
@@ -111,7 +134,7 @@ export class HostLobbyModal extends LitElement {
<span class="lobby-id" @click=${this.copyToClipboard} style="cursor: pointer;">
${this.lobbyIdVisible ? this.lobbyId : "••••••••"}
</span>
<!-- Copy icon/success indicator -->
<div @click=${this.copyToClipboard} style="margin-left: 8px; cursor: pointer;">
${
@@ -395,29 +418,45 @@ export class HostLobbyModal extends LitElement {
<!-- Lobby Selection -->
<div class="options-section">
<div class="option-title">
${this.players.length}
${this.clients.length}
${
this.players.length === 1
this.clients.length === 1
? translateText("host_modal.player")
: translateText("host_modal.players")
}
</div>
<div class="players-list">
${this.players.map(
(player) => html`<span class="player-tag">${player}</span>`,
${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>
<div class="start-game-button-container">
<button
@click=${this.startGame}
?disabled=${this.players.length < 2}
?disabled=${this.clients.length < 2}
class="start-game-button"
>
${
this.players.length === 1
this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")
}
@@ -434,12 +473,13 @@ export class HostLobbyModal extends LitElement {
}
public open() {
this.lobbyCreatorClientID = generateID();
this.lobbyIdVisible = this.userSettings.get(
"settings.lobbyIdVisibility",
true,
);
createLobby()
createLobby(this.lobbyCreatorClientID)
.then((lobby) => {
this.lobbyId = lobby.gameID;
// join lobby
@@ -449,7 +489,7 @@ export class HostLobbyModal extends LitElement {
new CustomEvent("join-lobby", {
detail: {
gameID: this.lobbyId,
clientID: generateID(),
clientID: this.lobbyCreatorClientID,
} as JoinLobbyEvent,
bubbles: true,
composed: true,
@@ -633,17 +673,29 @@ export class HostLobbyModal extends LitElement {
.then((response) => response.json())
.then((data: GameInfo) => {
console.log(`got game info response: ${JSON.stringify(data)}`);
this.players = data.clients?.map((p) => p.username) ?? [];
this.clients = data.clients ?? [];
});
}
private kickPlayer(clientID: string) {
// Dispatch event to be handled by WebSocket instead of HTTP
this.dispatchEvent(
new CustomEvent("kick-player", {
detail: { target: clientID },
bubbles: true,
composed: true,
}),
);
}
}
async function createLobby(): Promise<GameInfo> {
async function createLobby(creatorClientID: string): Promise<GameInfo> {
const config = await getServerConfigFromClient();
try {
const id = generateID();
const response = await fetch(
`/${config.workerPath(id)}/api/create_game/${id}`,
`/${config.workerPath(id)}/api/create_game/${id}?creatorClientID=${encodeURIComponent(creatorClientID)}`,
{
method: "POST",
headers: {
@@ -654,6 +706,8 @@ async function createLobby(): Promise<GameInfo> {
);
if (!response.ok) {
const errorText = await response.text();
console.error("Server error response:", errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -663,6 +717,6 @@ async function createLobby(): Promise<GameInfo> {
return data as GameInfo;
} catch (error) {
console.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
throw error;
}
}
+22 -1
View File
@@ -72,6 +72,8 @@ export class CloseViewEvent implements GameEvent {}
export class RefreshGraphicsEvent implements GameEvent {}
export class TogglePerformanceOverlayEvent implements GameEvent {}
export class ToggleStructureEvent implements GameEvent {
constructor(public readonly structureType: UnitType | null) {}
}
@@ -183,6 +185,14 @@ export class InputHandler {
let deltaX = 0;
let deltaY = 0;
// Skip if shift is held down
if (
this.activeKeys.has("ShiftLeft") ||
this.activeKeys.has("ShiftRight")
) {
return;
}
if (
this.activeKeys.has(this.keybinds.moveUp) ||
this.activeKeys.has("ArrowUp")
@@ -258,6 +268,8 @@ export class InputHandler {
this.keybinds.centerCamera,
"ControlLeft",
"ControlRight",
"ShiftLeft",
"ShiftRight",
].includes(e.code)
) {
this.activeKeys.add(e.code);
@@ -300,6 +312,14 @@ export class InputHandler {
this.eventBus.emit(new CenterCameraEvent());
}
// Shift-D to toggle performance overlay
console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey);
if (e.code === "KeyD" && e.shiftKey) {
e.preventDefault();
console.log("TogglePerformanceOverlayEvent");
this.eventBus.emit(new TogglePerformanceOverlayEvent());
}
this.activeKeys.delete(e.code);
});
}
@@ -371,7 +391,8 @@ export class InputHandler {
private onShiftScroll(event: WheelEvent) {
if (event.shiftKey) {
const ratio = event.deltaY > 0 ? -10 : 10;
const scrollValue = event.deltaY === 0 ? event.deltaX : event.deltaY;
const ratio = scrollValue > 0 ? -10 : 10;
this.eventBus.emit(new AttackRatioEvent(ratio));
}
}
+17
View File
@@ -20,6 +20,23 @@ export class JoinPrivateLobbyModal extends LitElement {
private playersInterval: NodeJS.Timeout | null = null;
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
render() {
return html`
<o-modal title=${translateText("private_lobby.title")}>
+1 -1
View File
@@ -292,7 +292,7 @@ export class LangSelector extends LitElement {
<button
id="lang-selector"
@click=${this.openModal}
class="text-center appearance-none w-full bg-blue-100 hover:bg-blue-200 text-blue-900 p-3 sm:p-4 lg:p-5 font-medium text-sm sm:text-base lg:text-lg rounded-md border-none cursor-pointer transition-colors duration-300 flex items-center gap-2 justify-center"
class="text-center appearance-none w-full bg-blue-100 dark:bg-gray-700 hover:bg-blue-200 dark:hover:bg-gray-600 text-blue-900 dark:text-gray-100 p-3 sm:p-4 lg:p-5 font-medium text-sm sm:text-base lg:text-lg rounded-md border-none cursor-pointer transition-colors duration-300 flex items-center gap-2 justify-center"
>
<img
id="lang-flag"
+53 -122
View File
@@ -1,4 +1,4 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../client/Utils";
@@ -8,117 +8,9 @@ export class LanguageModal extends LitElement {
@property({ type: Array }) languageList: any[] = [];
@property({ type: String }) currentLang = "en";
static styles = css`
.c-modal {
position: fixed;
padding: 1rem;
z-index: 1000;
left: 0;
bottom: 0;
right: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.5);
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
}
.c-modal__wrapper {
background: #23232382;
border-radius: 8px;
min-width: 340px;
max-width: 480px;
width: 100%;
}
.c-modal__header {
position: relative;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
font-size: 18px;
background: #000000a1;
text-align: center;
color: #fff;
padding: 1rem 2.4rem 1rem 1.4rem;
}
.c-modal__close {
cursor: pointer;
position: absolute;
right: 1rem;
top: 1rem;
font-weight: bold;
}
.c-modal__content {
position: relative;
color: #fff;
padding: 1.4rem;
max-height: 60dvh;
overflow-y: auto;
backdrop-filter: blur(8px);
}
.lang-button {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
border-radius: 0.375rem;
transition: background-color 0.3s;
border: 1px solid #aaa;
background-color: #505050;
color: #fff;
}
.lang-button:hover {
background-color: #969696;
}
.lang-button.active {
background-color: #aaaaaa;
border-color: #bbb;
color: #000;
}
.flag-icon {
width: 24px;
height: 16px;
object-fit: contain;
}
@keyframes rainbow {
0% {
background-color: #990033;
}
20% {
background-color: #996600;
}
40% {
background-color: #336600;
}
60% {
background-color: #008080;
}
80% {
background-color: #1c3f99;
}
100% {
background-color: #5e0099;
}
}
.lang-button.debug {
animation: rainbow 10s infinite;
font-weight: bold;
color: #fff;
border: 2px dashed aqua;
box-shadow: 0 0 4px aqua;
}
`;
createRenderRoot() {
return this; // Use Light DOM for TailwindCSS classes
}
private close = () => {
this.dispatchEvent(
@@ -139,11 +31,24 @@ export class LanguageModal extends LitElement {
}
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("keydown", this.handleKeyDown);
document.body.style.overflow = "auto";
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
private selectLanguage = (lang: string) => {
this.dispatchEvent(
new CustomEvent("language-selected", {
@@ -158,27 +63,53 @@ export class LanguageModal extends LitElement {
if (!this.visible) return null;
return html`
<aside class="c-modal">
<div class="c-modal__wrapper">
<header class="c-modal__header">
<aside
class="fixed p-4 z-[1000] inset-0 bg-black/50 overflow-y-auto flex items-center justify-center"
>
<div
class="bg-gray-800/80 dark:bg-gray-900/90 backdrop-blur-md rounded-lg min-w-[340px] max-w-[480px] w-full"
>
<header
class="relative rounded-t-md text-lg bg-black/60 dark:bg-black/80 text-center text-white px-6 py-4 pr-10"
>
${translateText("select_lang.title")}
<div class="c-modal__close" @click=${this.close}>✕</div>
<div
class="cursor-pointer absolute right-4 top-4 font-bold hover:text-gray-300"
@click=${this.close}
>
</div>
</header>
<section class="c-modal__content">
<section
class="relative text-white dark:text-gray-100 p-6 max-h-[60dvh] overflow-y-auto"
>
${this.languageList.map((lang) => {
const isActive = this.currentLang === lang.code;
const isDebug = lang.code === "debug";
let buttonClasses =
"w-full flex items-center gap-2 p-2 mb-2 rounded-md transition-colors duration-300 border";
if (isDebug) {
buttonClasses +=
" animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-lg shadow-cyan-400/25 bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600";
} else if (isActive) {
buttonClasses +=
" bg-gray-400 dark:bg-gray-500 border-gray-300 dark:border-gray-400 text-black dark:text-white";
} else {
buttonClasses +=
" bg-gray-600 dark:bg-gray-700 border-gray-500 dark:border-gray-600 text-white dark:text-gray-100 hover:bg-gray-500 dark:hover:bg-gray-600";
}
return html`
<button
class="lang-button ${isActive ? "active" : ""} ${lang.code ===
"debug"
? "debug"
: ""}"
class="${buttonClasses}"
@click=${() => this.selectLanguage(lang.code)}
>
<img
src="/flags/${lang.svg}.svg"
class="flag-icon"
class="w-6 h-4 object-contain"
alt="${lang.code}"
/>
<span>${lang.native} (${lang.en})</span>
-5
View File
@@ -97,11 +97,6 @@ export class LocalServer {
return;
}
if (this.paused) {
if (clientMsg.intent.type === "troop_ratio") {
// Store troop change events because otherwise they are
// not registered when game is paused.
this.intents.push(clientMsg.intent);
}
return;
}
this.intents.push(clientMsg.intent);
+15
View File
@@ -1,6 +1,7 @@
import favicon from "../../resources/images/Favicon.svg";
import version from "../../resources/version.txt";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
import { ServerConfig } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
@@ -24,6 +25,7 @@ import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { SendKickPlayerIntentEvent } from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { UsernameInput } from "./UsernameInput";
@@ -77,6 +79,7 @@ export interface JoinLobbyEvent {
class Client {
private gameStop: (() => void) | null = null;
private eventBus: EventBus = new EventBus();
private usernameInput: UsernameInput | null = null;
private flagInput: FlagInput | null = null;
@@ -163,11 +166,13 @@ class Client {
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
const spModal = document.querySelector(
"single-player-modal",
) as SinglePlayerModal;
spModal instanceof SinglePlayerModal;
const singlePlayer = document.getElementById("single-player");
if (singlePlayer === null) throw new Error("Missing single-player");
singlePlayer.addEventListener("click", () => {
@@ -429,6 +434,7 @@ class Client {
const config = await getServerConfigFromClient();
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
@@ -514,6 +520,15 @@ class Client {
this.gameStop = null;
this.publicLobby.leaveLobby();
}
private handleKickPlayer(event: CustomEvent) {
const { target } = event.detail;
// Forward to eventBus if available
if (this.eventBus) {
this.eventBus.emit(new SendKickPlayerIntentEvent(target));
}
}
}
// Initialize the client when the DOM is loaded
+17
View File
@@ -13,6 +13,23 @@ export class NewsModal extends LitElement {
close: () => void;
};
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
@property({ type: String }) markdown = "Loading...";
private initialized: boolean = false;
+1 -1
View File
@@ -2,10 +2,10 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { GameMapType, GameMode } from "../core/game/Game";
import { terrainMapFileLoader } from "../core/game/TerrainMapFileLoader";
import { GameID, GameInfo } from "../core/Schemas";
import { generateID } from "../core/Util";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
@customElement("public-lobby")
export class PublicLobby extends LitElement {
+17
View File
@@ -47,6 +47,23 @@ export class SinglePlayerModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
window.removeEventListener("keydown", this.handleKeyDown);
super.disconnectedCallback();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
};
render() {
return html`
<o-modal title=${translateText("single_modal.title")}>
+4
View File
@@ -0,0 +1,4 @@
import version from "../../resources/version.txt";
import { FetchGameMapLoader } from "../core/game/FetchGameMapLoader";
export const terrainMapFileLoader = new FetchGameMapLoader(`/maps`, version);
+27 -8
View File
@@ -37,14 +37,16 @@ export class TerritoryPatternsModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
private isActive = false;
constructor() {
super();
}
connectedCallback() {
super.connectedCallback();
this.selectedPattern = this.userSettings.getSelectedPattern();
window.addEventListener("keydown", this.handleKeyDown);
this.selectedPattern = this.userSettings.getSelectedPattern();
this.updateComplete.then(() => {
const containers = this.renderRoot.querySelectorAll(".preview-container");
if (this.resizeObserver) {
@@ -54,12 +56,12 @@ export class TerritoryPatternsModal extends LitElement {
}
this.updatePreview();
});
this.open();
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("keydown", this.handleKeyDown);
this.resizeObserver.disconnect();
super.disconnectedCallback();
}
async onUserMe(userMeResponse: UserMeResponse | null) {
@@ -69,6 +71,11 @@ export class TerritoryPatternsModal extends LitElement {
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
const key = e.key.toLowerCase();
const nextSequence = [...this.keySequence, key].slice(-5);
this.keySequence = nextSequence;
@@ -220,6 +227,7 @@ export class TerritoryPatternsModal extends LitElement {
}
render() {
if (!this.isActive) return html``;
return html`
${this.renderTooltip()}
<o-modal
@@ -233,10 +241,15 @@ export class TerritoryPatternsModal extends LitElement {
public open() {
this.modalEl?.open();
window.addEventListener("keydown", this.handleKeyDown);
this.isActive = true;
}
public close() {
this.modalEl?.close();
window.removeEventListener("keydown", this.handleKeyDown);
this.resizeObserver?.disconnect();
this.isActive = false;
}
private selectPattern(pattern: string | undefined) {
@@ -336,6 +349,7 @@ export class TerritoryPatternsModal extends LitElement {
}
}
const patternCache = new Map<string, string>();
const DEFAULT_PATTERN_B64 = "AAAAAA"; // Empty 2x2 pattern
const COLOR_SET = [0, 0, 0, 255]; // Black
const COLOR_UNSET = [255, 255, 255, 255]; // White
@@ -344,11 +358,14 @@ export function generatePreviewDataUrl(
width?: number,
height?: number,
): string {
pattern ??= DEFAULT_PATTERN_B64;
if (patternCache.has(pattern)) {
return patternCache.get(pattern)!;
}
// Calculate canvas size
const decoder = new PatternDecoder(
pattern ?? DEFAULT_PATTERN_B64,
base64url.decode,
);
const decoder = new PatternDecoder(pattern, base64url.decode);
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
@@ -384,5 +401,7 @@ export function generatePreviewDataUrl(
// Create a data URL
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(pattern, dataUrl);
return dataUrl;
}
+15 -15
View File
@@ -141,10 +141,6 @@ export class CancelBoatIntentEvent implements GameEvent {
constructor(public readonly unitID: number) {}
}
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
export class SendWinnerEvent implements GameEvent {
constructor(
public readonly winner: Winner,
@@ -165,6 +161,10 @@ export class MoveWarshipIntentEvent implements GameEvent {
) {}
}
export class SendKickPlayerIntentEvent implements GameEvent {
constructor(public readonly target: string) {}
}
export class Transport {
private socket: WebSocket | null = null;
@@ -223,9 +223,6 @@ export class Transport {
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) =>
this.onSendSetTargetTroopRatioEvent(e),
);
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e));
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
@@ -241,6 +238,9 @@ export class Transport {
this.eventBus.on(MoveWarshipIntentEvent, (e) => {
this.onMoveWarshipEvent(e);
});
this.eventBus.on(SendKickPlayerIntentEvent, (e) =>
this.onSendKickPlayerIntent(e),
);
}
private startPing() {
@@ -525,14 +525,6 @@ export class Transport {
});
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
clientID: this.lobbyConfig.clientID,
ratio: event.ratio,
});
}
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
this.sendIntent({
type: "build_unit",
@@ -611,6 +603,14 @@ export class Transport {
});
}
private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) {
this.sendIntent({
type: "kick_player",
clientID: this.lobbyConfig.clientID,
target: event.target,
});
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
+21 -11
View File
@@ -51,6 +51,11 @@ export class UserSettingModal extends LitElement {
private handleKeyDown = (e: KeyboardEvent) => {
if (!this.modalEl?.isModalOpen || this.showEasterEggSettings) return;
if (e.code === "Escape") {
e.preventDefault();
this.close();
}
const key = e.key.toLowerCase();
const nextSequence = [...this.keySequence, key].slice(-4);
this.keySequence = nextSequence;
@@ -176,6 +181,13 @@ export class UserSettingModal extends LitElement {
console.log("🏳️ Territory Patterns:", enabled ? "ON" : "OFF");
}
private togglePerformanceOverlay(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.performanceOverlay", enabled);
}
private handleKeybindChange(
e: CustomEvent<{ action: string; value: string }>,
) {
@@ -315,6 +327,15 @@ export class UserSettingModal extends LitElement {
@change=${this.toggleTerritoryPatterns}
></setting-toggle>
<!-- 📱 Performance Overlay -->
<setting-toggle
label="${translateText("user_setting.performance_overlay_label")}"
description="${translateText("user_setting.performance_overlay_desc")}"
id="performance-overlay-toggle"
.checked=${this.userSettings.performanceOverlay()}
@change=${this.togglePerformanceOverlay}
></setting-toggle>
<!-- ⚔️ Attack Ratio -->
<setting-slider
label="${translateText("user_setting.attack_ratio_label")}"
@@ -326,17 +347,6 @@ export class UserSettingModal extends LitElement {
@change=${this.sliderAttackRatio}
></setting-slider>
<!-- 🪖🛠️ Troop Ratio -->
<setting-slider
label="${translateText("user_setting.troop_ratio_label")}"
description="${translateText("user_setting.troop_ratio_desc")}"
min="1"
max="100"
.value=${Number(localStorage.getItem("settings.troopRatio") ?? "0.95") *
100}
@change=${this.sliderTroopRatio}
></setting-slider>
${this.showEasterEggSettings
? html`
<setting-slider
+1 -1
View File
@@ -1,7 +1,7 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { GameMapType } from "../../core/game/Game";
import { terrainMapFileLoader } from "../../core/game/TerrainMapFileLoader";
import { terrainMapFileLoader } from "../TerrainMapFileLoader";
import { translateText } from "../Utils";
// Add map descriptions
+1 -1
View File
@@ -48,7 +48,7 @@ export class NewsButton extends LitElement {
@click=${this.handleClick}
>
<img
class="size-[48px]"
class="size-[48px] dark:invert"
src="${megaphone}"
alt=${translateText("news.title")}
/>
+34 -14
View File
@@ -12,6 +12,7 @@ import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
import { FPSDisplay } from "./layers/FPSDisplay";
import { FxLayer } from "./layers/FxLayer";
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
@@ -60,10 +61,9 @@ export function createRenderer(
if (!emojiTable || !(emojiTable instanceof EmojiTable)) {
console.error("EmojiTable element not found in the DOM");
}
emojiTable.eventBus = eventBus;
emojiTable.transformHandler = transformHandler;
emojiTable.game = game;
emojiTable.initEventBus();
emojiTable.initEventBus(eventBus);
const buildMenu = document.querySelector("build-menu") as BuildMenu;
if (!buildMenu || !(buildMenu instanceof BuildMenu)) {
@@ -173,7 +173,7 @@ export function createRenderer(
console.error("player panel not found");
}
playerPanel.g = game;
playerPanel.eventBus = eventBus;
playerPanel.initEventBus(eventBus);
playerPanel.emojiTable = emojiTable;
playerPanel.uiState = uiState;
@@ -182,7 +182,7 @@ export function createRenderer(
console.error("chat modal not found");
}
chatModal.g = game;
chatModal.eventBus = eventBus;
chatModal.initEventBus(eventBus);
const multiTabModal = document.querySelector(
"multi-tab-modal",
@@ -202,6 +202,13 @@ export function createRenderer(
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
const fpsDisplay = document.querySelector("fps-display") as FPSDisplay;
if (!(fpsDisplay instanceof FPSDisplay)) {
console.error("fps display not found");
}
fpsDisplay.eventBus = eventBus;
fpsDisplay.userSettings = userSettings;
const spawnAd = document.querySelector("spawn-ad") as SpawnAd;
if (!(spawnAd instanceof SpawnAd)) {
console.error("spawn ad not found");
@@ -261,6 +268,7 @@ export function createRenderer(
spawnAd,
gutterAdModal,
alertFrame,
fpsDisplay,
];
return new GameRenderer(
@@ -270,6 +278,7 @@ export function createRenderer(
transformHandler,
uiState,
layers,
fpsDisplay,
);
}
@@ -283,6 +292,7 @@ export class GameRenderer {
public transformHandler: TransformHandler,
public uiState: UIState,
private layers: Layer[],
private fpsDisplay: FPSDisplay,
) {
const context = canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
@@ -290,14 +300,7 @@ export class GameRenderer {
}
initialize() {
this.eventBus.on(RedrawGraphicsEvent, (e) => {
this.layers.forEach((l) => {
if (l.redraw) {
l.redraw();
}
});
});
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
this.layers.forEach((l) => l.init?.());
document.body.appendChild(this.canvas);
@@ -307,7 +310,14 @@ export class GameRenderer {
//show whole map on startup
this.transformHandler.centerAll(0.9);
requestAnimationFrame(() => this.renderGame());
let rafId = requestAnimationFrame(() => this.renderGame());
this.canvas.addEventListener("contextlost", () => {
cancelAnimationFrame(rafId);
});
this.canvas.addEventListener("contextrestored", () => {
this.redraw();
rafId = requestAnimationFrame(() => this.renderGame());
});
}
resizeCanvas() {
@@ -317,6 +327,14 @@ export class GameRenderer {
//this.redraw()
}
redraw() {
this.layers.forEach((l) => {
if (l.redraw) {
l.redraw();
}
});
}
renderGame() {
const start = performance.now();
// Set background
@@ -356,8 +374,10 @@ export class GameRenderer {
this.transformHandler.resetChanged();
requestAnimationFrame(() => this.renderGame());
const duration = performance.now() - start;
this.fpsDisplay.updateFPS(duration);
if (duration > 50) {
console.warn(
`tick ${this.game.ticks()} took ${duration}ms to render frame`,
+10
View File
@@ -6,6 +6,7 @@ import { GameView, PlayerView } from "../../../core/game/GameView";
import quickChatData from "../../../../resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
@@ -172,6 +173,15 @@ export class ChatModal extends LitElement {
`;
}
initEventBus(eventBus: EventBus) {
this.eventBus = eventBus;
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.close();
}
});
}
private selectCategory(categoryId: string) {
this.selectedCategory = categoryId;
this.selectedPhraseText = null;
+18 -105
View File
@@ -6,7 +6,6 @@ import { Gold } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { AttackRatioEvent } from "../../InputHandler";
import { SendSetTargetTroopRatioEvent } from "../../Transport";
import { renderNumber, renderTroops } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
@@ -22,54 +21,29 @@ export class ControlPanel extends LitElement implements Layer {
private attackRatio: number = 0.2;
@state()
private targetTroopRatio = 0.95;
private _maxTroops: number;
@state()
private currentTroopRatio = 0.95;
@state()
private _population: number;
@state()
private _maxPopulation: number;
@state()
private popRate: number;
private troopRate: number;
@state()
private _troops: number;
@state()
private _workers: number;
@state()
private _isVisible = false;
@state()
private _manpower: number = 0;
@state()
private _gold: Gold;
@state()
private _goldPerSecond: Gold;
private _troopRateIsIncreasing: boolean = true;
private _popRateIsIncreasing: boolean = true;
private _lastPopulationIncreaseRate: number;
private init_: boolean = false;
private _lastTroopIncreaseRate: number;
init() {
this.attackRatio = Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
);
this.targetTroopRatio = Number(
localStorage.getItem("settings.troopRatio") ?? "0.95",
);
this.init_ = true;
this.uiState.attackRatio = this.attackRatio;
this.currentTroopRatio = this.targetTroopRatio;
this.eventBus.on(AttackRatioEvent, (event) => {
let newAttackRatio =
(parseInt(
@@ -97,13 +71,6 @@ export class ControlPanel extends LitElement implements Layer {
}
tick() {
if (this.init_) {
this.eventBus.emit(
new SendSetTargetTroopRatioEvent(this.targetTroopRatio),
);
this.init_ = false;
}
if (!this._isVisible && !this.game.inSpawnPhase()) {
this.setVisibile(true);
}
@@ -115,28 +82,24 @@ export class ControlPanel extends LitElement implements Layer {
}
if (this.game.ticks() % 5 === 0) {
this.updatePopulationIncrease();
this.updateTroopIncrease();
}
this._population = player.population();
this._maxPopulation = this.game.config().maxPopulation(player);
this._troops = player.troops();
this._maxTroops = this.game.config().maxTroops(player);
this._gold = player.gold();
this._troops = player.troops();
this._workers = player.workers();
this.popRate = this.game.config().populationIncreaseRate(player) * 10;
this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n;
this.currentTroopRatio = player.troops() / player.population();
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;
this.requestUpdate();
}
private updatePopulationIncrease() {
private updateTroopIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = this.game.config().populationIncreaseRate(player);
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
const troopIncreaseRate = this.game.config().troopIncreaseRate(player);
this._troopRateIsIncreasing =
troopIncreaseRate >= this._lastTroopIncreaseRate;
this._lastTroopIncreaseRate = troopIncreaseRate;
}
onAttackRatioChange(newRatio: number) {
@@ -156,19 +119,6 @@ export class ControlPanel extends LitElement implements Layer {
this.requestUpdate();
}
targetTroops(): number {
return this._manpower * this.targetTroopRatio;
}
onTroopChange(newRatio: number) {
this.eventBus.emit(new SendSetTargetTroopRatioEvent(newRatio));
}
delta(): number {
const d = this._population - this.targetTroops();
return d;
}
render() {
return html`
<style>
@@ -219,17 +169,16 @@ export class ControlPanel extends LitElement implements Layer {
<div class="block bg-black/30 text-white mb-4 p-2 rounded">
<div class="flex justify-between mb-1">
<span class="font-bold"
>${translateText("control_panel.pop")}:</span
>${translateText("control_panel.troops")}:</span
>
<span translate="no"
>${renderTroops(this._population)} /
${renderTroops(this._maxPopulation)}
>${renderTroops(this._troops)} / ${renderTroops(this._maxTroops)}
<span
class="${this._popRateIsIncreasing
class="${this._troopRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
translate="no"
>(+${renderTroops(this.popRate)})</span
>(+${renderTroops(this.troopRate)})</span
></span
>
</div>
@@ -237,43 +186,7 @@ export class ControlPanel extends LitElement implements Layer {
<span class="font-bold"
>${translateText("control_panel.gold")}:</span
>
<span translate="no"
>${renderNumber(this._gold)}
(+${renderNumber(this._goldPerSecond)})</span
>
</div>
</div>
<div class="relative mb-4 sm:mb-4">
<label class="block text-white mb-1" translate="no"
>${translateText("control_panel.troops")}:
<span translate="no">${renderTroops(this._troops)}</span> |
${translateText("control_panel.workers")}:
<span translate="no">${renderTroops(this._workers)}</span></label
>
<div class="relative h-8">
<!-- Background track -->
<div
class="absolute left-0 right-0 top-3 h-2 bg-white/20 rounded"
></div>
<!-- Fill track -->
<div
class="absolute left-0 top-3 h-2 bg-blue-500/60 rounded transition-all duration-300"
style="width: ${this.currentTroopRatio * 100}%"
></div>
<!-- Range input - exactly overlaying the visual elements -->
<input
type="range"
min="1"
max="100"
.value=${(this.targetTroopRatio * 100).toString()}
@input=${(e: Event) => {
this.targetTroopRatio =
parseInt((e.target as HTMLInputElement).value) / 100;
this.onTroopChange(this.targetTroopRatio);
}}
class="absolute left-0 right-0 top-2 m-0 h-4 cursor-pointer targetTroopRatio"
/>
<span translate="no">${renderNumber(this._gold)}</span>
</div>
</div>
+9 -5
View File
@@ -5,19 +5,18 @@ import { AllPlayers } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
import { emojiTable, flattenedEmojiTable } from "../../../core/Util";
import { ShowEmojiMenuEvent } from "../../InputHandler";
import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler";
import { SendEmojiIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
@customElement("emoji-table")
export class EmojiTable extends LitElement {
@state() public isVisible = false;
public eventBus: EventBus;
public transformHandler: TransformHandler;
public game: GameView;
initEventBus() {
this.eventBus.on(ShowEmojiMenuEvent, (e) => {
initEventBus(eventBus: EventBus) {
eventBus.on(ShowEmojiMenuEvent, (e) => {
this.isVisible = true;
const cell = this.transformHandler.screenToWorldCoordinates(e.x, e.y);
if (!this.game.isValidCoord(cell.x, cell.y)) {
@@ -40,7 +39,7 @@ export class EmojiTable extends LitElement {
targetPlayer === this.game.myPlayer()
? AllPlayers
: (targetPlayer as PlayerView);
this.eventBus.emit(
eventBus.emit(
new SendEmojiIntentEvent(
recipient,
flattenedEmojiTable.indexOf(emoji),
@@ -49,6 +48,11 @@ export class EmojiTable extends LitElement {
this.hideTable();
});
});
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.hideTable();
}
});
}
private onEmojiClicked: (emoji: string) => void = () => {};
@@ -475,7 +475,6 @@ export class EventsDisplay extends LitElement implements Layer {
const recipient = this.game.playerBySmallID(
update.request.recipientID,
) as PlayerView;
this.addEvent({
description: translateText("events_display.alliance_request_status", {
name: recipient.name(),
+268
View File
@@ -0,0 +1,268 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { TogglePerformanceOverlayEvent } from "../../InputHandler";
import { Layer } from "./Layer";
@customElement("fps-display")
export class FPSDisplay extends LitElement implements Layer {
@property({ type: Object })
public eventBus!: EventBus;
@property({ type: Object })
public userSettings!: UserSettings;
@state()
private currentFPS: number = 0;
@state()
private averageFPS: number = 0;
@state()
private frameTime: number = 0;
@state()
private isVisible: boolean = false;
@state()
private isDragging: boolean = false;
@state()
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
private frameCount: number = 0;
private lastTime: number = 0;
private frameTimes: number[] = [];
private fpsHistory: number[] = [];
private lastSecondTime: number = 0;
private framesThisSecond: number = 0;
private dragStart: { x: number; y: number } = { x: 0, y: 0 };
static styles = css`
.fps-display {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
z-index: 9999;
user-select: none;
cursor: move;
transition: none;
}
.fps-display.dragging {
cursor: grabbing;
transition: none;
opacity: 0.5;
}
.fps-line {
margin: 2px 0;
}
.fps-good {
color: #4ade80; /* green-400 */
}
.fps-warning {
color: #fbbf24; /* amber-400 */
}
.fps-bad {
color: #f87171; /* red-400 */
}
.close-button {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 4px;
color: white;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
pointer-events: auto;
}
`;
constructor() {
super();
}
init() {
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
this.userSettings.togglePerformanceOverlay();
});
}
setVisible(visible: boolean) {
this.isVisible = visible;
}
private handleClose() {
this.userSettings.togglePerformanceOverlay();
}
private handleMouseDown = (e: MouseEvent) => {
// Don't start dragging if clicking on close button
if ((e.target as HTMLElement).classList.contains("close-button")) {
return;
}
this.isDragging = true;
this.dragStart = {
x: e.clientX - this.position.x,
y: e.clientY - this.position.y,
};
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("mouseup", this.handleMouseUp);
e.preventDefault();
};
private handleMouseMove = (e: MouseEvent) => {
if (!this.isDragging) return;
const newX = e.clientX - this.dragStart.x;
const newY = e.clientY - this.dragStart.y;
// Convert to percentage of viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
this.position = {
x: Math.max(0, Math.min(viewportWidth - 100, newX)), // Keep within viewport bounds
y: Math.max(0, Math.min(viewportHeight - 100, newY)),
};
this.requestUpdate();
};
private handleMouseUp = () => {
this.isDragging = false;
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
};
updateFPS(frameDuration: number) {
this.isVisible = this.userSettings.performanceOverlay();
if (!this.isVisible) return;
const now = performance.now();
// Initialize timing on first call
if (this.lastTime === 0) {
this.lastTime = now;
this.lastSecondTime = now;
return;
}
const deltaTime = now - this.lastTime;
// Track frame times for current FPS calculation (last 60 frames)
this.frameTimes.push(deltaTime);
if (this.frameTimes.length > 60) {
this.frameTimes.shift();
}
// Calculate current FPS based on average frame time
if (this.frameTimes.length > 0) {
const avgFrameTime =
this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
this.currentFPS = Math.round(1000 / avgFrameTime);
this.frameTime = Math.round(avgFrameTime);
}
// Track FPS for 60-second average
this.framesThisSecond++;
// Update every second
if (now - this.lastSecondTime >= 1000) {
this.fpsHistory.push(this.framesThisSecond);
if (this.fpsHistory.length > 60) {
this.fpsHistory.shift();
}
// Calculate 60-second average
if (this.fpsHistory.length > 0) {
this.averageFPS = Math.round(
this.fpsHistory.reduce((a, b) => a + b, 0) / this.fpsHistory.length,
);
}
this.framesThisSecond = 0;
this.lastSecondTime = now;
}
this.lastTime = now;
this.frameCount++;
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
private getFPSColor(fps: number): string {
if (fps >= 55) return "fps-good";
if (fps >= 30) return "fps-warning";
return "fps-bad";
}
render() {
if (!this.isVisible) {
return html``;
}
const style = `
left: ${this.position.x}px;
top: ${this.position.y}px;
transform: none;
`;
return html`
<div
class="fps-display ${this.isDragging ? "dragging" : ""}"
style="${style}"
@mousedown="${this.handleMouseDown}"
>
<button class="close-button" @click="${this.handleClose}">×</button>
<div class="fps-line">
FPS:
<span class="${this.getFPSColor(this.currentFPS)}"
>${this.currentFPS}</span
>
</div>
<div class="fps-line">
Avg (60s):
<span class="${this.getFPSColor(this.averageFPS)}"
>${this.averageFPS}</span
>
</div>
<div class="fps-line">
Frame:
<span class="${this.getFPSColor(1000 / this.frameTime)}"
>${this.frameTime}ms</span
>
</div>
</div>
`;
}
}
+1 -4
View File
@@ -269,9 +269,6 @@ export class Leaderboard extends LitElement implements Layer {
function formatPercentage(value: number): string {
const perc = value * 100;
if (perc > 99.5) return "100%";
if (perc < 0.01) return "0%";
if (perc < 0.1) return perc.toPrecision(1) + "%";
if (Number.isNaN(perc)) return "0%";
return perc.toPrecision(2) + "%";
return perc.toFixed(1) + "%";
}
+11
View File
@@ -137,6 +137,11 @@ export class OptionsMenu extends LitElement implements Layer {
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
}
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
@@ -251,6 +256,12 @@ export class OptionsMenu extends LitElement implements Layer {
? "Opens menu"
: "Attack"),
})}
${button({
onClick: this.onTogglePerformanceOverlayButtonClick,
title: "Performance Overlay",
children:
"🚀: " + (this.userSettings.performanceOverlay() ? "On" : "Off"),
})}
<!-- ${button({
onClick: this.onToggleFocusLockedButtonClick,
title: "Lock Focus",
+13 -1
View File
@@ -13,7 +13,7 @@ import { AllPlayers, PlayerActions } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { flattenedEmojiTable } from "../../../core/Util";
import { MouseUpEvent } from "../../InputHandler";
import { CloseViewEvent, MouseUpEvent } from "../../InputHandler";
import {
SendAllianceRequestIntentEvent,
SendBreakAllianceIntentEvent,
@@ -164,8 +164,20 @@ export class PlayerPanel extends LitElement implements Layer {
private ctModal: ChatModal;
initEventBus(eventBus: EventBus) {
this.eventBus = eventBus;
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.hide();
}
});
}
init() {
this.eventBus.on(MouseUpEvent, () => this.hide());
this.eventBus.on(CloseViewEvent, (e) => {
this.hide();
});
this.ctModal = document.querySelector("chat-modal") as ChatModal;
}
+4
View File
@@ -1,6 +1,7 @@
import * as d3 from "d3";
import backIcon from "../../../../resources/images/BackIconWhite.svg";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { Layer } from "./Layer";
import {
CenterButtonElement,
@@ -102,6 +103,9 @@ export class RadialMenu implements Layer {
init() {
this.createMenuElement();
this.createTooltipElement();
this.eventBus.on(CloseViewEvent, (e) => {
this.hideRadialMenu();
});
}
private createMenuElement() {
@@ -114,6 +114,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
}
private onExitButtonClick() {
// redirect to the home page
window.location.href = "/";
@@ -298,6 +303,35 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onTogglePerformanceOverlayButtonClick}"
>
<img
src=${settingsIcon}
alt="performanceIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.performance_overlay_label")}
</div>
<div class="text-sm text-slate-400">
${this.userSettings.performanceOverlay()
? translateText("user_setting.performance_overlay_enabled")
: translateText(
"user_setting.performance_overlay_disabled",
)}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.performanceOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<div class="border-t border-slate-600 pt-3 mt-4">
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-red-600/20 rounded text-red-400 transition-colors"
+8 -1
View File
@@ -135,7 +135,14 @@ export class StructureLayer implements Layer {
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
this.game.units().forEach((u) => this.handleUnitRendering(u));
Promise.all(
Array.from(this.unitIcons.values()).map((img) =>
img.decode?.().catch(() => {}),
),
).finally(() => {
this.game.units().forEach((u) => this.handleUnitRendering(u));
});
}
renderLayer(context: CanvasRenderingContext2D) {
+1 -3
View File
@@ -167,8 +167,6 @@ export class TeamStats extends LitElement implements Layer {
function formatPercentage(value: number): string {
const perc = value * 100;
if (perc > 99.5) return "100%";
if (perc < 0.01) return "0%";
if (perc < 0.1) return perc.toPrecision(1) + "%";
if (Number.isNaN(perc)) return "0%";
return perc.toPrecision(2) + "%";
}
+2 -35
View File
@@ -1,6 +1,5 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import logo from "../../../../resources/images/ofm/logo_MASTER_2025.png";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
@@ -22,9 +21,6 @@ export class WinModal extends LitElement implements Layer {
@state()
showButtons = false;
@state()
private showSteamContent = Math.random() > 0.5;
private _title: string;
// Override to prevent shadow DOM creation
@@ -142,9 +138,7 @@ export class WinModal extends LitElement implements Layer {
return html`
<div class="win-modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title || ""}</h2>
${this.showSteamContent
? this.steamWishlist()
: this.openfrontMasters()}
${this.innerHtml()}
<div
class="button-container ${this.showButtons ? "visible" : "hidden"}"
>
@@ -159,7 +153,7 @@ export class WinModal extends LitElement implements Layer {
`;
}
steamWishlist() {
innerHtml() {
return html`<p>
<a
href="https://store.steampowered.com/app/3560670"
@@ -180,33 +174,6 @@ export class WinModal extends LitElement implements Layer {
</p>`;
}
openfrontMasters() {
return html`<p>
<img
src="${logo}"
alt="OpenFront Masters"
style="max-width: 100%; height: auto; margin-bottom: 16px;"
/>
<a
href="https://discord.gg/gStsGh5vWR"
target="_blank"
rel="noopener noreferrer"
style="
color: #4a9eff;
text-decoration: underline;
font-weight: 500;
transition: color 0.2s ease;
font-size: 24px;
"
onmouseover="this.style.color='#6db3ff'"
onmouseout="this.style.color='#4a9eff'"
>
Watch the best compete in the
<span style="font-weight: bold;">OpenFront Masters</span>
</a>
</p>`;
}
show() {
this.eventBus.emit(new GutterAdModalEvent(true));
setTimeout(() => {
+6 -1
View File
@@ -42,7 +42,7 @@
}
.dark .bg-image {
filter: blur(4px) brightness(0.7);
filter: blur(4px) brightness(0.4) saturate(0.3) contrast(1.2);
}
/* display:none if child has class parent-hidden since we can't use shadow DOM in Lit due to Tailwind */
@@ -386,6 +386,7 @@
<news-modal></news-modal>
<game-left-sidebar></game-left-sidebar>
<spawn-ad></spawn-ad>
<fps-display></fps-display>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
@@ -412,6 +413,10 @@
document.documentElement.classList.remove("preload");
});
});
window.addEventListener("beforeunload", function (e) {
e.preventDefault();
e.returnValue = "Are you sure you want to leave?";
});
</script>
<!-- Playwire ads -->
+31 -18
View File
@@ -251,15 +251,15 @@ label.option-card:hover {
}
.player-tag {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
padding: 4px 16px;
gap: 8px;
background: #2a2a2a;
color: #fff;
padding: 8px 12px;
margin: 4px;
border-radius: 16px;
font-size: 14px;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
#bots-count,
@@ -625,18 +625,6 @@ label.option-card:hover {
padding: 0 16px;
}
.player-tag {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
padding: 4px 16px;
border-radius: 16px;
font-size: 14px;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* News Button Notification */
news-button .active button {
position: relative;
@@ -670,3 +658,28 @@ news-button .active button::after {
transform: scale(1.4);
}
}
.host-badge {
font-size: 11px;
color: #4caf50;
font-weight: bold;
}
.remove-player-btn {
width: 16px;
height: 16px;
border: none;
background: #ff4444;
color: white;
border-radius: 50%;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
}
.remove-player-btn:hover {
background: #ff6666;
}
+27
View File
@@ -55,3 +55,30 @@
margin: 0 auto;
}
}
.dark .c-button {
background: var(--primaryColorDark);
color: var(--fontColorLight);
}
.dark .c-button:hover,
.dark .c-button:active,
.dark .c-button:focus {
background: var(--primaryColorHoverDark);
}
.dark .c-button:disabled {
background: var(--primaryColorDisabledDark);
opacity: 0.7;
}
.dark .c-button--secondary {
background: var(--secondaryColorDark);
color: var(--fontColorDark);
}
.dark .c-button--secondary:hover,
.dark .c-button--secondary:active,
.dark .c-button--secondary:focus {
background: var(--secondaryColorHoverDark);
}
+7
View File
@@ -16,4 +16,11 @@
--secondaryColor: #dbeafe;
--secondaryColorHover: #bfdbfe;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--primaryColorDark: #3b82f6;
--primaryColorHoverDark: #2563eb;
--primaryColorDisabledDark: #4b5563;
--secondaryColorDark: #374151;
--secondaryColorHoverDark: #4b5563;
--fontColorDark: #f3f4f6;
}