diff --git a/resources/lang/en.json b/resources/lang/en.json index b3bbfce223..0cf33aaed7 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -803,6 +803,8 @@ }, "leaderboard": { "cities": "Cities", + "columns": "Columns", + "configure_columns": "Configure columns", "gold": "Gold", "launchers": "Launchers", "maxtroops": "Max troops", diff --git a/src/client/hud/layers/Leaderboard.ts b/src/client/hud/layers/Leaderboard.ts index 34588ad3ab..d666eb4595 100644 --- a/src/client/hud/layers/Leaderboard.ts +++ b/src/client/hud/layers/Leaderboard.ts @@ -2,12 +2,43 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; import { renderTroops, translateText } from "../../../client/Utils"; +import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; +import { + LeaderboardColumnKey, + UserSettings, +} from "../../../core/game/UserSettings"; import { Controller } from "../../Controller"; import { GoToPlayerEvent } from "../../TransformHandler"; import { formatPercentage, renderNumber } from "../../Utils"; import { GameView, PlayerView } from "../../view"; +const settingsIcon = assetUrl("images/SettingIconWhite.svg"); + +interface ColumnDefinition { + key: LeaderboardColumnKey; + labelKey: string; + width: string; +} + +const COLUMN_DEFINITIONS: ColumnDefinition[] = [ + { + key: "tiles", + labelKey: "leaderboard.owned", + width: "minmax(45px, 70px)", + }, + { + key: "gold", + labelKey: "leaderboard.gold", + width: "minmax(40px, 55px)", + }, + { + key: "maxtroops", + labelKey: "leaderboard.maxtroops", + width: "minmax(55px, 105px)", + }, +]; + interface Entry { name: string; position: number; @@ -24,17 +55,25 @@ export class Leaderboard extends LitElement implements Controller { public game: GameView | null = null; public eventBus: EventBus | null = null; + private readonly userSettings = new UserSettings(); + players: Entry[] = []; @property({ type: Boolean }) visible = false; private showTopFive = true; @state() - private _sortKey: "tiles" | "gold" | "maxtroops" = "tiles"; + private _sortKey: LeaderboardColumnKey = "tiles"; @state() private _sortOrder: "asc" | "desc" = "desc"; + @state() + private showColumnSettings = false; + + @state() + private visibleColumnKeys = this.userSettings.leaderboardColumns(); + createRenderRoot() { return this; // use light DOM for Tailwind support } @@ -57,7 +96,7 @@ export class Leaderboard extends LitElement implements Controller { this.updateLeaderboard(); } - private setSort(key: "tiles" | "gold" | "maxtroops") { + private setSort(key: LeaderboardColumnKey) { if (this._sortKey === key) { this._sortOrder = this._sortOrder === "asc" ? "desc" : "asc"; } else { @@ -67,9 +106,56 @@ export class Leaderboard extends LitElement implements Controller { this.updateLeaderboard(); } + private visibleColumns(): ColumnDefinition[] { + const visible = new Set(this.visibleColumnKeys); + const columns = COLUMN_DEFINITIONS.filter((column) => + visible.has(column.key), + ); + return columns.length > 0 ? columns : [COLUMN_DEFINITIONS[0]]; + } + + private ensureVisibleSortKey(columns: readonly ColumnDefinition[]): void { + if (!columns.some((column) => column.key === this._sortKey)) { + this._sortKey = columns[0].key; + this._sortOrder = "desc"; + } + } + + private toggleColumn(key: LeaderboardColumnKey) { + this.visibleColumnKeys = this.userSettings.toggleLeaderboardColumn(key); + this.ensureVisibleSortKey(this.visibleColumns()); + this.updateLeaderboard(); + } + + private entryValue(entry: Entry, key: LeaderboardColumnKey): string { + switch (key) { + case "gold": + return entry.gold; + case "maxtroops": + return entry.maxTroops; + case "tiles": + return entry.score; + } + } + + private sortIndicator(key: LeaderboardColumnKey) { + if (this._sortKey !== key) return ""; + return this._sortOrder === "asc" ? "⬆️" : "⬇️"; + } + + private gridTemplateColumns(columns: readonly ColumnDefinition[]): string { + return [ + "minmax(24px, 30px)", + "minmax(60px, 100px)", + ...columns.map((column) => column.width), + ].join(" "); + } + private updateLeaderboard() { if (this.game === null) throw new Error("Not initialized"); const myPlayer = this.game.myPlayer(); + const columns = this.visibleColumns(); + this.ensureVisibleSortKey(columns); interface PlayerViewTroopsCache { pv: PlayerView; @@ -83,6 +169,7 @@ export class Leaderboard extends LitElement implements Controller { const sorted: PlayerViewTroopsCache[] = this.game .playerViews() + .filter((p) => p.isAlive()) .map((p) => ({ pv: p, maxTroops: maxTroops(p) })); switch (this._sortKey) { @@ -103,10 +190,7 @@ export class Leaderboard extends LitElement implements Controller { const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); - const alivePlayers = sorted.filter((player) => player.pv.isAlive()); - const playersToShow = this.showTopFive - ? alivePlayers.slice(0, 5) - : alivePlayers; + const playersToShow = this.showTopFive ? sorted.slice(0, 5) : sorted; this.players = playersToShow.map((playerCache, index) => { const player = playerCache.pv; @@ -165,133 +249,160 @@ export class Leaderboard extends LitElement implements Controller { this.eventBus.emit(new GoToPlayerEvent(player)); } + private stopGameInput(event: Event) { + event.stopPropagation(); + } + + private renderColumnSettings() { + if (!this.showColumnSettings) return html``; + const selected = new Set(this.visibleColumnKeys); + return html` +
+
+ ${translateText("leaderboard.columns")} +
+
+ ${COLUMN_DEFINITIONS.map( + (column) => html` + + `, + )} +
+
+ `; + } + render() { if (!this.visible) { return html``; } + const columns = this.visibleColumns(); return html`
e.preventDefault()} + @click=${this.stopGameInput} + @pointerdown=${this.stopGameInput} + @pointerup=${this.stopGameInput} + @pointercancel=${this.stopGameInput} + @contextmenu=${(e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }} >
e.preventDefault()} > -
-
- # -
-
- ${translateText("leaderboard.player")} -
-
this.setSort("tiles")} - > - ${translateText("leaderboard.owned")} - ${this._sortKey === "tiles" - ? this._sortOrder === "asc" - ? "⬆️" - : "⬇️" - : ""} -
-
this.setSort("gold")} - > - ${translateText("leaderboard.gold")} - ${this._sortKey === "gold" - ? this._sortOrder === "asc" - ? "⬆️" - : "⬇️" - : ""} -
-
this.setSort("maxtroops")} - > - ${translateText("leaderboard.maxtroops")} - ${this._sortKey === "maxtroops" - ? this._sortOrder === "asc" - ? "⬆️" - : "⬇️" - : ""} -
-
- - ${repeat( - this.players, - (p) => p.player.id(), - (player, index) => html` +
+
+
+ # +
this.handleRowClickPlayer(player.player)} + class="py-1 md:py-2 text-center border-b border-slate-500 truncate" > + ${translateText("leaderboard.player")} +
+ ${columns.map( + (column) => html` +
this.setSort(column.key)} + > + ${translateText(column.labelKey)} + ${this.sortIndicator(column.key)} +
+ `, + )} +
+ + ${repeat( + this.players, + (p) => p.player.id(), + (player, index) => html`
- ${player.position} -
-
- ${player.name} -
-
- ${player.score} -
-
- ${player.gold} -
-
this.handleRowClickPlayer(player.player)} > - ${player.maxTroops} +
+ ${player.position} +
+
+ ${player.name} +
+ ${columns.map( + (column) => html` +
+ ${this.entryValue(player, column.key)} +
+ `, + )}
-
- `, - )} + `, + )} +
- - +
+ + +
+ ${this.renderColumnSettings()} + `; } } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 9ef3807b7c..608a1a0882 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -58,8 +58,30 @@ export const COLOR_KEY = "settings.territoryColor"; export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay"; export const KEYBINDS_KEY = "settings.keybinds"; export const GRAPHICS_KEY = "settings.graphics"; +export const LEADERBOARD_COLUMNS_KEY = "settings.leaderboardColumns"; export const EFFECTS_KEY = "settings.effects"; +export type LeaderboardColumnKey = "tiles" | "gold" | "maxtroops"; + +const LEADERBOARD_COLUMN_KEYS: LeaderboardColumnKey[] = [ + "tiles", + "gold", + "maxtroops", +]; + +const DEFAULT_LEADERBOARD_COLUMNS: LeaderboardColumnKey[] = [ + "tiles", + "gold", + "maxtroops", +]; + +function isLeaderboardColumnKey(value: unknown): value is LeaderboardColumnKey { + return ( + typeof value === "string" && + LEADERBOARD_COLUMN_KEYS.includes(value as LeaderboardColumnKey) + ); +} + export class UserSettings { private static cache = new Map(); @@ -122,6 +144,47 @@ export class UserSettings { this.setCached(key, value); } + leaderboardColumns(): LeaderboardColumnKey[] { + const raw = this.getString(LEADERBOARD_COLUMNS_KEY, ""); + if (!raw) return DEFAULT_LEADERBOARD_COLUMNS.slice(); + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const columns = LEADERBOARD_COLUMN_KEYS.filter((key) => + parsed.includes(key), + ); + if (columns.length > 0) return columns; + } + } catch { + // Fall back to defaults below. + } + return DEFAULT_LEADERBOARD_COLUMNS.slice(); + } + + setLeaderboardColumns(columns: readonly LeaderboardColumnKey[]): void { + const normalized = LEADERBOARD_COLUMN_KEYS.filter((key) => + columns.includes(key), + ); + this.setString( + LEADERBOARD_COLUMNS_KEY, + JSON.stringify( + normalized.length > 0 ? normalized : DEFAULT_LEADERBOARD_COLUMNS, + ), + ); + } + + toggleLeaderboardColumn(key: LeaderboardColumnKey): LeaderboardColumnKey[] { + if (!isLeaderboardColumnKey(key)) return this.leaderboardColumns(); + const columns = this.leaderboardColumns(); + const next = columns.includes(key) + ? columns.length === 1 + ? columns + : columns.filter((column) => column !== key) + : [...columns, key]; + this.setLeaderboardColumns(next); + return this.leaderboardColumns(); + } + private getFloat(key: string, defaultValue: number): number { const value = this.getCached(key); if (!value) return defaultValue; diff --git a/tests/UserSettings.test.ts b/tests/UserSettings.test.ts index c475b6255a..a550631146 100644 --- a/tests/UserSettings.test.ts +++ b/tests/UserSettings.test.ts @@ -1,13 +1,21 @@ -import { EFFECTS_KEY, UserSettings } from "../src/core/game/UserSettings"; +import { + EFFECTS_KEY, + LEADERBOARD_COLUMNS_KEY, + UserSettings, +} from "../src/core/game/UserSettings"; + +const DEFAULT_LEADERBOARD_COLUMNS = ["tiles", "gold", "maxtroops"]; + +function clearUserSettingsCache(): void { + ( + UserSettings as unknown as { cache: Map } + ).cache.clear(); +} describe("UserSettings effect selection", () => { beforeEach(() => { localStorage.clear(); - // UserSettings keeps a static in-memory cache; reset it too so each test - // reads fresh from the (cleared) localStorage. - ( - UserSettings as unknown as { cache: Map } - ).cache.clear(); + clearUserSettingsCache(); }); it("sets and reads a per-effectType selection", () => { @@ -46,3 +54,50 @@ describe("UserSettings effect selection", () => { expect(new UserSettings().getSelectedEffects()).toEqual({}); }); }); + +describe("UserSettings leaderboard columns", () => { + beforeEach(() => { + localStorage.clear(); + clearUserSettingsCache(); + }); + + it("falls back to defaults for invalid JSON", () => { + localStorage.setItem(LEADERBOARD_COLUMNS_KEY, "not-json"); + + expect(new UserSettings().leaderboardColumns()).toEqual( + DEFAULT_LEADERBOARD_COLUMNS, + ); + }); + + it("filters unknown keys", () => { + localStorage.setItem( + LEADERBOARD_COLUMNS_KEY, + JSON.stringify(["gold", "unknown", "maxtroops"]), + ); + + expect(new UserSettings().leaderboardColumns()).toEqual([ + "gold", + "maxtroops", + ]); + }); + + it("falls back to defaults for empty or fully invalid selections", () => { + const settings = new UserSettings(); + + settings.setLeaderboardColumns([]); + expect(settings.leaderboardColumns()).toEqual(DEFAULT_LEADERBOARD_COLUMNS); + + clearUserSettingsCache(); + localStorage.setItem(LEADERBOARD_COLUMNS_KEY, JSON.stringify(["unknown"])); + expect(new UserSettings().leaderboardColumns()).toEqual( + DEFAULT_LEADERBOARD_COLUMNS, + ); + }); + + it("keeps the last selected column enabled", () => { + const settings = new UserSettings(); + + settings.setLeaderboardColumns(["gold"]); + expect(settings.toggleLeaderboardColumn("gold")).toEqual(["gold"]); + }); +});