From 1db9b939506d3efdf5a7118ac8847f3982e24e90 Mon Sep 17 00:00:00 2001 From: Tyler Hanavan Date: Mon, 28 Jul 2025 15:46:34 -0400 Subject: [PATCH 01/25] Allow additional modals to close when clicking the Escape key (#1604) ## Description: Allows ChatModal, EmojiTable, PlayerPanel and RadialMenu to accept the CloseViewEvent, thereby enabling the modals/menus/tables to be closed by clicking the Escape key. As described in #1586, there are a number of modals that cannot be closed by pressing Escape, such as ChatModal, EmojiTable, PlayerPanel and RadialMenu, while other modals can already, such as the CTRL + Click build menu. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: slyty --- src/client/graphics/GameRenderer.ts | 1 + src/client/graphics/layers/ChatModal.ts | 9 +++++++++ src/client/graphics/layers/EmojiTable.ts | 7 ++++++- src/client/graphics/layers/PlayerPanel.ts | 6 +++++- src/client/graphics/layers/RadialMenu.ts | 4 ++++ 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 16d8e0285..52c75106e 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -183,6 +183,7 @@ export function createRenderer( } chatModal.g = game; chatModal.eventBus = eventBus; + chatModal.initEventBus(); const multiTabModal = document.querySelector( "multi-tab-modal", diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 6c0413f59..a8fe2e862 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -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,14 @@ export class ChatModal extends LitElement { `; } + initEventBus() { + this.eventBus.on(CloseViewEvent, (e) => { + if (!this.hidden) { + this.close(); + } + }); + } + private selectCategory(categoryId: string) { this.selectedCategory = categoryId; this.selectedPhraseText = null; diff --git a/src/client/graphics/layers/EmojiTable.ts b/src/client/graphics/layers/EmojiTable.ts index 0236d33fd..73a59a032 100644 --- a/src/client/graphics/layers/EmojiTable.ts +++ b/src/client/graphics/layers/EmojiTable.ts @@ -5,7 +5,7 @@ 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"; @@ -49,6 +49,11 @@ export class EmojiTable extends LitElement { this.hideTable(); }); }); + this.eventBus.on(CloseViewEvent, (e) => { + if (!this.hidden) { + this.hideTable(); + } + }); } private onEmojiClicked: (emoji: string) => void = () => {}; diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 50201fbf0..17da243f2 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -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, @@ -167,6 +167,10 @@ export class PlayerPanel extends LitElement implements Layer { init() { this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide()); + this.eventBus.on(CloseViewEvent, (e) => { + this.hide(); + }); + this.ctModal = document.querySelector("chat-modal") as ChatModal; } diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index b8e26af9f..652bff522 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -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() { From bf13c6565717676ba52c8d4b5fbf2985fedbdc27 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 28 Jul 2025 13:08:07 -0700 Subject: [PATCH 02/25] fix territory modal performance (#1600) ## Description: The territory patterns modal was listening for keypresses, and would regenerate all patterns on each render(). So do: 1. Cache territory patterns to prevent regeneration 2. disable handler on close ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/TerritoryPatternsModal.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index b7b3938a8..24226c0f9 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -37,6 +37,8 @@ export class TerritoryPatternsModal extends LitElement { private userSettings: UserSettings = new UserSettings(); + private isActive = false; + constructor() { super(); } @@ -44,7 +46,6 @@ export class TerritoryPatternsModal extends LitElement { connectedCallback() { super.connectedCallback(); this.selectedPattern = this.userSettings.getSelectedPattern(); - window.addEventListener("keydown", this.handleKeyDown); this.updateComplete.then(() => { const containers = this.renderRoot.querySelectorAll(".preview-container"); if (this.resizeObserver) { @@ -54,12 +55,11 @@ export class TerritoryPatternsModal extends LitElement { } this.updatePreview(); }); + this.open(); } disconnectedCallback() { super.disconnectedCallback(); - window.removeEventListener("keydown", this.handleKeyDown); - this.resizeObserver.disconnect(); } async onUserMe(userMeResponse: UserMeResponse | null) { @@ -220,6 +220,7 @@ export class TerritoryPatternsModal extends LitElement { } render() { + if (!this.isActive) return html``; return html` ${this.renderTooltip()} (); 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 +351,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 +394,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; } From c5484696f7f5122e42a66b376112b8f97e160a84 Mon Sep 17 00:00:00 2001 From: "maxime.io" Date: Wed, 30 Jul 2025 21:28:38 +0200 Subject: [PATCH 03/25] Display FPS monitor overview (#1573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Display an FPS monitor to track performance on each new feature. It only appears in the development environment, positioned at the top center to remain visible—especially on mobile. The display can be closed via a close button. I already use it to evaluate my other PR on low end device. image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA aggreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: devalnor --- resources/lang/en.json | 2 + src/client/InputHandler.ts | 20 ++ src/client/UserSettingModal.ts | 16 ++ src/client/graphics/GameRenderer.ts | 15 +- src/client/graphics/layers/FPSDisplay.ts | 268 ++++++++++++++++++++ src/client/graphics/layers/OptionsMenu.ts | 11 + src/client/graphics/layers/SettingsModal.ts | 34 +++ src/client/index.html | 1 + src/core/game/UserSettings.ts | 8 + 9 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/client/graphics/layers/FPSDisplay.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 7bb19a434..51157c2e7 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -292,6 +292,8 @@ "troop_ratio_desc": "Adjust the balance between troops (for combat) and workers (for gold production) (1–100%)", "territory_patterns_label": "🏳️ Territory Patterns", "territory_patterns_desc": "Choose whether to display territory pattern designs in game", + "performance_overlay_label": "Performance Overlay", + "performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.", "easter_writing_speed_label": "Writing Speed Multiplier", "easter_writing_speed_desc": "Adjust how fast you pretend to code (x1–x100)", "easter_bug_count_label": "Bug Count", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 3a835e3a5..1ab1a3a67 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -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); }); } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 5006c4e9a..0a85cc42c 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -176,6 +176,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 +322,15 @@ export class UserSettingModal extends LitElement { @change=${this.toggleTerritoryPatterns} > + + + 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`, diff --git a/src/client/graphics/layers/FPSDisplay.ts b/src/client/graphics/layers/FPSDisplay.ts new file mode 100644 index 000000000..37b3bb3cd --- /dev/null +++ b/src/client/graphics/layers/FPSDisplay.ts @@ -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` +
+ +
+ FPS: + ${this.currentFPS} +
+
+ Avg (60s): + ${this.averageFPS} +
+
+ Frame: + ${this.frameTime}ms +
+
+ `; + } +} diff --git a/src/client/graphics/layers/OptionsMenu.ts b/src/client/graphics/layers/OptionsMenu.ts index 66ea8f3ae..e0ee2004f 100644 --- a/src/client/graphics/layers/OptionsMenu.ts +++ b/src/client/graphics/layers/OptionsMenu.ts @@ -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"), + })}
${ diff --git a/src/client/Main.ts b/src/client/Main.ts index 640ae8d93..c7b00c106 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -168,6 +168,7 @@ class Client { "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", () => { diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index fa6490e55..e94ed794b 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -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 { diff --git a/src/client/TerrainMapFileLoader.ts b/src/client/TerrainMapFileLoader.ts new file mode 100644 index 000000000..957a5ea1c --- /dev/null +++ b/src/client/TerrainMapFileLoader.ts @@ -0,0 +1,4 @@ +import version from "../../resources/version.txt"; +import { FetchGameMapLoader } from "../core/game/FetchGameMapLoader"; + +export const terrainMapFileLoader = new FetchGameMapLoader(`/maps`, version); diff --git a/src/client/components/Maps.ts b/src/client/components/Maps.ts index 840c866fc..bbb9c46e7 100644 --- a/src/client/components/Maps.ts +++ b/src/client/components/Maps.ts @@ -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 diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index c2567aeb3..d2fd60c6e 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -19,6 +19,7 @@ import { } from "./game/Game"; import { createGame } from "./game/GameImpl"; import { TileRef } from "./game/GameMap"; +import { GameMapLoader } from "./game/GameMapLoader"; import { ErrorUpdate, GameUpdateType, @@ -33,10 +34,11 @@ import { fixProfaneUsername } from "./validations/username"; export async function createGameRunner( gameStart: GameStartInfo, clientID: ClientID, + mapLoader: GameMapLoader, callBack: (gu: GameUpdateViewData) => void, ): Promise { const config = await getConfig(gameStart.config, null); - const gameMap = await loadGameMap(gameStart.config.gameMap); + const gameMap = await loadGameMap(gameStart.config.gameMap, mapLoader); const random = new PseudoRandom(simpleHash(gameStart.gameID)); const humans = gameStart.players.map( diff --git a/src/core/game/TerrainMapFileLoader.ts b/src/core/game/BinaryLoaderGameMapLoader.ts similarity index 72% rename from src/core/game/TerrainMapFileLoader.ts rename to src/core/game/BinaryLoaderGameMapLoader.ts index 62bca0a2c..2107b6faa 100644 --- a/src/core/game/TerrainMapFileLoader.ts +++ b/src/core/game/BinaryLoaderGameMapLoader.ts @@ -1,13 +1,7 @@ import { GameMapType } from "./Game"; +import { GameMapLoader, MapData } from "./GameMapLoader"; import { MapManifest } from "./TerrainMapLoader"; -interface MapData { - mapBin: () => Promise; - miniMapBin: () => Promise; - manifest: () => Promise; - webpPath: () => Promise; -} - export interface BinModule { default: string; } @@ -16,7 +10,7 @@ interface NationMapModule { default: MapManifest; } -class GameMapLoader { +export class BinaryLoaderGameMapLoader implements GameMapLoader { private maps: Map; constructor() { @@ -31,7 +25,7 @@ class GameMapLoader { }; } - public getMapData(map: GameMapType): MapData { + getMapData(map: GameMapType): MapData { const cachedMap = this.maps.get(map); if (cachedMap) { return cachedMap; @@ -46,14 +40,14 @@ class GameMapLoader { import( `!!binary-loader!../../../resources/maps/${fileName}/map.bin` ) as Promise - ).then((m) => m.default), + ).then((m) => this.toUInt8Array(m.default)), ), miniMapBin: this.createLazyLoader(() => ( import( `!!binary-loader!../../../resources/maps/${fileName}/mini_map.bin` ) as Promise - ).then((m) => m.default), + ).then((m) => this.toUInt8Array(m.default)), ), manifest: this.createLazyLoader(() => ( @@ -74,6 +68,18 @@ class GameMapLoader { this.maps.set(map, mapData); return mapData; } -} -export const terrainMapFileLoader = new GameMapLoader(); + /** + * Converts a given string into a UInt8Array where each character in the string + * is represented as an 8-bit unsigned integer. + */ + private toUInt8Array(data: string) { + const rawData = new Uint8Array(data.length); + + for (let i = 0; i < data.length; i++) { + rawData[i] = data.charCodeAt(i); + } + + return rawData; + } +} diff --git a/src/core/game/FetchGameMapLoader.ts b/src/core/game/FetchGameMapLoader.ts new file mode 100644 index 000000000..5d6c72367 --- /dev/null +++ b/src/core/game/FetchGameMapLoader.ts @@ -0,0 +1,69 @@ +import { GameMapType } from "./Game"; +import { GameMapLoader, MapData } from "./GameMapLoader"; + +export class FetchGameMapLoader implements GameMapLoader { + private maps: Map; + + public constructor( + private readonly prefix: string, + private readonly cacheBuster?: string, + ) { + this.maps = new Map(); + } + + public getMapData(map: GameMapType): MapData { + const cachedMap = this.maps.get(map); + if (cachedMap) { + return cachedMap; + } + + const key = Object.keys(GameMapType).find((k) => GameMapType[k] === map); + const fileName = key?.toLowerCase(); + + if (!fileName) { + throw new Error(`Unknown map: ${map}`); + } + + const mapData = { + mapBin: () => this.loadBinaryFromUrl(this.url(fileName, "map.bin")), + miniMapBin: () => + this.loadBinaryFromUrl(this.url(fileName, "mini_map.bin")), + manifest: () => this.loadJsonFromUrl(this.url(fileName, "manifest.json")), + webpPath: async () => this.url(fileName, "thumbnail.webp"), + } satisfies MapData; + + this.maps.set(map, mapData); + return mapData; + } + + private url(map: string, path: string) { + let url = `${this.prefix}/${map}/${path}`; + + if (this.cacheBuster) { + url += `${url.includes("?") ? "&" : "?"}v=${this.cacheBuster}`; + } + + return url; + } + + private async loadBinaryFromUrl(url: string) { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to load ${url}: ${response.statusText}`); + } + + const data = await response.arrayBuffer(); + return new Uint8Array(data); + } + + private async loadJsonFromUrl(url: string) { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to load ${url}: ${response.statusText}`); + } + + return response.json(); + } +} diff --git a/src/core/game/GameMapLoader.ts b/src/core/game/GameMapLoader.ts new file mode 100644 index 000000000..abf52ccf9 --- /dev/null +++ b/src/core/game/GameMapLoader.ts @@ -0,0 +1,13 @@ +import { GameMapType } from "./Game"; +import { MapManifest } from "./TerrainMapLoader"; + +export interface GameMapLoader { + getMapData(map: GameMapType): MapData; +} + +export interface MapData { + mapBin: () => Promise; + miniMapBin: () => Promise; + manifest: () => Promise; + webpPath: () => Promise; +} diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index fd54cd954..766007ed7 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -1,6 +1,6 @@ import { GameMapType } from "./Game"; import { GameMap, GameMapImpl } from "./GameMap"; -import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { GameMapLoader } from "./GameMapLoader"; export type TerrainMapData = { manifest: MapManifest; @@ -32,6 +32,7 @@ export interface Nation { export async function loadTerrainMap( map: GameMapType, + terrainMapFileLoader: GameMapLoader, ): Promise { const cached = loadedMaps.get(map); if (cached !== undefined) return cached; @@ -57,7 +58,7 @@ export async function loadTerrainMap( export async function genTerrainFromBin( mapData: MapMetadata, - data: string, + data: Uint8Array, ): Promise { if (data.length !== mapData.width * mapData.height) { throw new Error( @@ -65,19 +66,10 @@ export async function genTerrainFromBin( ); } - // Store raw data in Uint8Array - const rawData = new Uint8Array(mapData.width * mapData.height); - - // Copy data starting after the header - for (let i = 0; i < mapData.width * mapData.height; i++) { - const packedByte = data.charCodeAt(i); - rawData[i] = packedByte; - } - return new GameMapImpl( mapData.width, mapData.height, - rawData, + data, mapData.num_land_tiles, ); } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 4a065f45b..29e67f214 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -1,4 +1,6 @@ +import version from "../../../resources/version.txt"; import { createGameRunner, GameRunner } from "../GameRunner"; +import { FetchGameMapLoader } from "../game/FetchGameMapLoader"; import { GameUpdateViewData } from "../game/GameUpdates"; import { AttackAveragePositionResultMessage, @@ -13,6 +15,7 @@ import { const ctx: Worker = self as any; let gameRunner: Promise | null = null; +const mapLoader = new FetchGameMapLoader(`/maps`, version); function gameUpdate(gu: GameUpdateViewData) { sendMessage({ @@ -37,6 +40,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { gameRunner = createGameRunner( message.gameStartInfo, message.clientID, + mapLoader, gameUpdate, ).then((gr) => { sendMessage({ diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index c552d7d4f..20e8d67d5 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -48,14 +48,10 @@ export async function setup( fs.readFileSync(manifestPath, "utf8"), ) satisfies MapManifest; - // Convert Buffer to string (binary encoding) - const mapBinString = mapBinBuffer.toString("binary"); - const miniMapBinString = miniMapBinBuffer.toString("binary"); - - const gameMap = await genTerrainFromBin(manifest.map, mapBinString); + const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer); const miniGameMap = await genTerrainFromBin( manifest.mini_map, - miniMapBinString, + miniMapBinBuffer, ); // Configure the game diff --git a/webpack.config.js b/webpack.config.js index df742f7b3..0820e8cd0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -136,9 +136,6 @@ export default async (env, argv) => { from: path.resolve(__dirname, "resources"), to: path.resolve(__dirname, "static"), noErrorOnMissing: true, - globOptions: { - ignore: ["resources/maps/**/*"], - }, }, ], options: { concurrency: 100 }, From bd59cd61cbb67a9f8205af70f354a3734b1f2a7b Mon Sep 17 00:00:00 2001 From: floriankilian <34104015+floriankilian@users.noreply.github.com> Date: Fri, 1 Aug 2025 07:01:10 +0200 Subject: [PATCH 13/25] Private Lobbies: Add kick player functionality (#1436) ## Description: Added player management features so lobby hosts can kick players from private games. This includes both UI changes and backend work. ### What's new: - Hosts can now kick players from private lobbies with a simple button - Added host badges and remove buttons to the UI - Made sure only hosts can kick people, and hosts can't kick themselves ### How it works: - When someone creates a private game, they automatically become the host - Kicking happens through WebSocket "kick-player" events - Server checks that you're actually the host before letting you kick anyone Screenshot 2025-07-15 002114 ### Known Issues: - Kicked player gets general message (same when kicked for multi tab) ### Other Issues: - Host abandoment still existent (host clicks on x; or is closing tab) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: [UN]nvm --------- Co-authored-by: floriankilian --- resources/lang/en.json | 3 +- src/client/ClientGameRunner.ts | 3 +- src/client/HostLobbyModal.ts | 67 ++++++++++++++++++++++++++-------- src/client/Main.ts | 14 +++++++ src/client/Transport.ts | 15 ++++++++ src/client/styles.css | 49 ++++++++++++++++--------- src/core/Schemas.ts | 11 +++++- src/server/GameManager.ts | 39 +++++++++++++------- src/server/GameServer.ts | 51 +++++++++++++++++++++++++- src/server/Worker.ts | 13 ++++++- 10 files changed, 211 insertions(+), 54 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 51157c2e7..7395d4e66 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -215,7 +215,8 @@ "player": "Player", "players": "Players", "waiting": "Waiting for players...", - "start": "Start Game" + "start": "Start Game", + "host_badge": "Host" }, "team_colors": { "red": "Red", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 9904822c4..b0c3150d8 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -60,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}`, ); diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 0296c29dc..5362311b8 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -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; @@ -395,29 +401,45 @@ export class HostLobbyModal extends LitElement {
- ${this.players.length} + ${this.clients.length} ${ - this.players.length === 1 + this.clients.length === 1 ? translateText("host_modal.player") : translateText("host_modal.players") }
- ${this.players.map( - (player) => html`${player}`, + ${this.clients.map( + (client) => html` + + ${client.username} + ${client.clientID === this.lobbyCreatorClientID + ? html`(${translateText("host_modal.host_badge")})` + : html` + + `} + + `, )} -