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"]);
+ });
+});