mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 21:16:21 +00:00
feat(client): configurable leaderboard and team stats columns (#4646)
## Description: - rips out some duped code, making tables use the same layout, where the only thing different is the row content. - updated text to be emojis to save space - updated columns to be as wide as they need to be - updated cells to have left/right thin borders to read easier - added more supported column types - removed the + button to always show everyone, where the current player is either at the bottom or in the top 5 - added a cogwheel to change options - can select different options for each menu type (player/team stats) <img width="582" height="630" alt="image" src="https://github.com/user-attachments/assets/e54dc3d5-eb2c-4bab-8732-06bea68aec45" /> works fine on mobile, will probably need some future love though (has h-scroll, so all options can be seen by scrolling): ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n
This commit is contained in:
@@ -378,8 +378,6 @@
|
||||
<game-left-sidebar></game-left-sidebar>
|
||||
<performance-overlay></performance-overlay>
|
||||
<player-info-overlay></player-info-overlay>
|
||||
<leader-board></leader-board>
|
||||
<team-stats></team-stats>
|
||||
<heads-up-message></heads-up-message>
|
||||
|
||||
<!-- Featured stream: direct <body> child (NOT inside the in-[.in-game]:hidden
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 64 64"><g><path d="M57.456,52.544C51.13,46.218,42.864,42.541,34,42.059V24h14c0.737,0,1.415-0.406,1.764-1.056 c0.348-0.65,0.31-1.439-0.1-2.053L46.403,16l3.261-4.891c0.409-0.614,0.447-1.403,0.1-2.053C49.415,8.406,48.737,8,48,8H32 c-1.104,0-2,0.896-2,2v32.059c-8.864,0.482-17.13,4.159-23.456,10.485c-0.778,0.777-0.74,2.079,0.033,2.861 C6.953,55.786,7.465,56,8,56h48c0.53,0,1.081-0.253,1.456-0.628C58.237,54.591,58.237,53.325,57.456,52.544z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 523 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>upper-limit</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="drop" fill="#000000" transform="translate(85.333333, 64.000000)">
|
||||
<path d="M170.666667,42.6666667 L28.992,160.704 L56.32,193.493333 L149.333333,115.989333 L149.333333,384 L192,384 L192,115.989333 L284.992,193.493333 L312.32,160.704 L170.666667,42.6666667 Z M1.42108547e-14,42.6666667 L341.333333,42.6666667 L341.333333,0 L1.42108547e-14,0 L1.42108547e-14,42.6666667 Z" id="Mask">
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 838 B |
@@ -893,16 +893,20 @@
|
||||
"svg": "uk_us_flag"
|
||||
},
|
||||
"leaderboard": {
|
||||
"allies": "Allies",
|
||||
"betrayals": "Betrayals",
|
||||
"cities": "Cities",
|
||||
"configure_columns": "Configure columns",
|
||||
"factories": "Factories",
|
||||
"gold": "Gold",
|
||||
"launchers": "Launchers",
|
||||
"maxtroops": "Max troops",
|
||||
"owned": "Owned",
|
||||
"player": "Player",
|
||||
"ports": "Ports",
|
||||
"sams": "SAMs",
|
||||
"show_control": "Show Control",
|
||||
"show_units": "Show Units",
|
||||
"team": "Team",
|
||||
"troops": "Troops",
|
||||
"warships": "Warships"
|
||||
},
|
||||
"leaderboard_modal": {
|
||||
|
||||
@@ -209,7 +209,8 @@ export class LangSelector extends LitElement {
|
||||
"host-lobby-modal",
|
||||
"join-lobby-modal",
|
||||
"emoji-table",
|
||||
"leader-board",
|
||||
"player-stats",
|
||||
"team-stats",
|
||||
"leaderboard-player-list",
|
||||
"leaderboard-clan-table",
|
||||
"build-menu",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const COLUMN_IDS = [
|
||||
"tiles",
|
||||
"gold",
|
||||
"troops",
|
||||
"maxtroops",
|
||||
"cities",
|
||||
"ports",
|
||||
"factories",
|
||||
"silos",
|
||||
"sams",
|
||||
"warships",
|
||||
"allies",
|
||||
"betrayals",
|
||||
] as const;
|
||||
|
||||
export type ColumnId = (typeof COLUMN_IDS)[number];
|
||||
|
||||
export const DEFAULT_STATS_COLUMNS = [
|
||||
"tiles",
|
||||
"gold",
|
||||
"maxtroops",
|
||||
] as const satisfies readonly ColumnId[];
|
||||
|
||||
export type StatsTableKind = "player" | "team";
|
||||
@@ -0,0 +1,402 @@
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import { profileIcon } from "../hud/HotbarIcons";
|
||||
import "../hud/layers/ColumnPicker";
|
||||
import {
|
||||
COLUMN_DEFS,
|
||||
type ColumnDef,
|
||||
columnById,
|
||||
} from "../hud/layers/lib/StatsColumns";
|
||||
import {
|
||||
type ColumnId,
|
||||
DEFAULT_STATS_COLUMNS,
|
||||
type StatsTableKind,
|
||||
} from "../StatsConstants";
|
||||
import { translateText } from "../Utils";
|
||||
import type { GameView } from "../view";
|
||||
|
||||
export interface StatsRow {
|
||||
key: string;
|
||||
name: string;
|
||||
values: ReadonlyMap<ColumnId, number>;
|
||||
emphasized?: boolean;
|
||||
pinned?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface RenderedStatsRow extends Omit<StatsRow, "values"> {
|
||||
position: number;
|
||||
cells: readonly string[];
|
||||
}
|
||||
|
||||
// Fallbacks cover the first render before measurement and jsdom (tests),
|
||||
// which reports zero element sizes.
|
||||
const FALLBACK_ROW_HEIGHT_PX = 24;
|
||||
const FALLBACK_VIEWPORT_HEIGHT_PX = 180;
|
||||
const OVERSCAN_ROWS = 4;
|
||||
// The pinned row only renders separately when the viewer sits below the
|
||||
// always-visible top ranks of the scroll window.
|
||||
const PINNED_VISIBLE_THRESHOLD = 4;
|
||||
|
||||
export abstract class StatsTable extends LitElement {
|
||||
public game: GameView | null = null;
|
||||
|
||||
@property({ type: Boolean }) visible = false;
|
||||
|
||||
protected abstract readonly tableKind: StatsTableKind;
|
||||
protected abstract readonly nameLabelKey: string;
|
||||
protected abstract buildRows(
|
||||
game: GameView,
|
||||
columns: readonly ColumnDef[],
|
||||
): StatsRow[];
|
||||
|
||||
private readonly userSettings = new UserSettings();
|
||||
private rows: StatsRow[] = [];
|
||||
|
||||
@state()
|
||||
private sortKey: ColumnId = DEFAULT_STATS_COLUMNS[0];
|
||||
|
||||
@state()
|
||||
private sortOrder: "asc" | "desc" = "desc";
|
||||
|
||||
@state()
|
||||
private scrollOffsetPx = 0;
|
||||
|
||||
private rowHeightPx = FALLBACK_ROW_HEIGHT_PX;
|
||||
private viewportHeightPx = FALLBACK_VIEWPORT_HEIGHT_PX;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
willUpdate(changed: Map<string, unknown>) {
|
||||
if (changed.has("visible") && this.visible) {
|
||||
// The scroll container is recreated at scroll offset 0 when the table
|
||||
// was hidden, so the remembered offset would misplace the window.
|
||||
this.scrollOffsetPx = 0;
|
||||
this.updateStats();
|
||||
}
|
||||
}
|
||||
|
||||
updated() {
|
||||
const scroller = this.querySelector(".stats-table-scroll");
|
||||
if (!(scroller instanceof HTMLElement)) return;
|
||||
const row = scroller.querySelector(".stats-table-row");
|
||||
const rowHeight = row instanceof HTMLElement ? row.offsetHeight : 0;
|
||||
const viewportHeight = scroller.clientHeight;
|
||||
let changed = false;
|
||||
if (rowHeight > 0 && rowHeight !== this.rowHeightPx) {
|
||||
this.rowHeightPx = rowHeight;
|
||||
changed = true;
|
||||
}
|
||||
if (viewportHeight > 0 && viewportHeight !== this.viewportHeightPx) {
|
||||
this.viewportHeightPx = viewportHeight;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) this.requestUpdate();
|
||||
}
|
||||
|
||||
private onScroll(event: Event) {
|
||||
this.scrollOffsetPx = (event.target as HTMLElement).scrollTop;
|
||||
}
|
||||
|
||||
refresh() {
|
||||
if (this.visible) this.updateStats();
|
||||
}
|
||||
|
||||
private selectedColumns(): ColumnDef[] {
|
||||
return this.userSettings.statsColumns(this.tableKind).map(columnById);
|
||||
}
|
||||
|
||||
private setSort(key: ColumnId) {
|
||||
if (this.sortKey === key) {
|
||||
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
this.sortKey = key;
|
||||
this.sortOrder = "desc";
|
||||
}
|
||||
this.updateStats();
|
||||
}
|
||||
|
||||
private onColumnsChanged(event: CustomEvent<ColumnId[]>) {
|
||||
this.userSettings.setStatsColumns(this.tableKind, event.detail);
|
||||
this.updateStats();
|
||||
}
|
||||
|
||||
private updateStats() {
|
||||
if (this.game === null) return;
|
||||
|
||||
const selected = this.selectedColumns();
|
||||
if (!selected.some((column) => column.id === this.sortKey)) {
|
||||
this.sortKey = selected[0].id;
|
||||
this.sortOrder = "desc";
|
||||
}
|
||||
|
||||
const direction = this.sortOrder === "asc" ? 1 : -1;
|
||||
this.rows = this.buildRows(this.game, selected).sort(
|
||||
(a, b) =>
|
||||
direction *
|
||||
((a.values.get(this.sortKey) ?? 0) - (b.values.get(this.sortKey) ?? 0)),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
const game = this.game;
|
||||
if (!this.visible || game === null) return html``;
|
||||
|
||||
const selected = this.selectedColumns();
|
||||
const toRendered = (
|
||||
{ values, ...row }: StatsRow,
|
||||
position: number,
|
||||
): RenderedStatsRow => ({
|
||||
...row,
|
||||
position,
|
||||
cells: selected.map((column) =>
|
||||
column.renderValue(values.get(column.id) ?? 0, game),
|
||||
),
|
||||
});
|
||||
const pinnedIndex = this.rows.findIndex((row) => row.pinned);
|
||||
const pinnedRow =
|
||||
pinnedIndex > PINNED_VISIBLE_THRESHOLD
|
||||
? toRendered(this.rows[pinnedIndex], pinnedIndex + 1)
|
||||
: null;
|
||||
const listRows =
|
||||
pinnedRow === null
|
||||
? this.rows
|
||||
: this.rows.filter((_, index) => index !== pinnedIndex);
|
||||
// Virtualize: only rows near the scroll viewport get DOM; spacers keep
|
||||
// the scrollbar geometry for the rest. Positions stay list-wide.
|
||||
const firstIndex = Math.max(
|
||||
0,
|
||||
Math.floor(this.scrollOffsetPx / this.rowHeightPx) - OVERSCAN_ROWS,
|
||||
);
|
||||
const lastIndex = Math.min(
|
||||
listRows.length,
|
||||
Math.ceil(
|
||||
(this.scrollOffsetPx + this.viewportHeightPx) / this.rowHeightPx,
|
||||
) + OVERSCAN_ROWS,
|
||||
);
|
||||
const scrollableRows = listRows
|
||||
.slice(firstIndex, lastIndex)
|
||||
.map((row, sliceIndex) => {
|
||||
const index = firstIndex + sliceIndex;
|
||||
return toRendered(
|
||||
row,
|
||||
pinnedRow !== null && index >= pinnedIndex ? index + 2 : index + 1,
|
||||
);
|
||||
});
|
||||
const topSpacerPx = firstIndex * this.rowHeightPx;
|
||||
const bottomSpacerPx = (listRows.length - lastIndex) * this.rowHeightPx;
|
||||
// Stat tracks stay content-sized intrinsically, then split only the spare
|
||||
// width supplied by a wider sibling. Rank, name, and picker remain fixed.
|
||||
const gridTemplate = `30px 100px${" auto".repeat(selected.length)} 32px`;
|
||||
const scrollHeight =
|
||||
pinnedRow === null
|
||||
? "max-h-[7.5rem] md:max-h-[10rem] lg:max-h-[11.25rem]"
|
||||
: "max-h-[6rem] md:max-h-[8rem] lg:max-h-[9rem]";
|
||||
|
||||
const renderRow = (
|
||||
row: RenderedStatsRow,
|
||||
borderClass: string,
|
||||
pinned = false,
|
||||
) => html`
|
||||
<div
|
||||
class="stats-table-row grid col-span-full hover:bg-slate-600/60 ${pinned
|
||||
? "stats-table-pinned-row bg-gray-700/95"
|
||||
: ""} ${row.emphasized ? "font-bold" : ""} ${row.onClick
|
||||
? "cursor-pointer"
|
||||
: ""}"
|
||||
style="grid-template-columns: subgrid; grid-column: 1 / -1;"
|
||||
role="row"
|
||||
@click=${row.onClick ?? nothing}
|
||||
>
|
||||
<div
|
||||
class="h-6 md:h-8 lg:h-9 flex items-center justify-center ${borderClass}"
|
||||
role="cell"
|
||||
>
|
||||
${row.position}
|
||||
</div>
|
||||
<div
|
||||
class="h-6 md:h-8 lg:h-9 min-w-0 px-1 flex items-center text-left ${borderClass}"
|
||||
role="cell"
|
||||
>
|
||||
<span class="block w-full truncate">${row.name}</span>
|
||||
</div>
|
||||
${repeat(
|
||||
row.cells,
|
||||
(_cell, index) => selected[index].id,
|
||||
(cell, index) => {
|
||||
const alignment =
|
||||
selected[index].valueAlignment === "center"
|
||||
? "justify-center text-center"
|
||||
: "justify-end text-right";
|
||||
return html`
|
||||
<div
|
||||
class="h-6 md:h-8 lg:h-9 px-1 flex items-center ${alignment} tabular-nums whitespace-nowrap border-l border-l-slate-600/40 ${borderClass}"
|
||||
role="cell"
|
||||
>
|
||||
${cell}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
)}
|
||||
<div
|
||||
class="h-6 md:h-8 lg:h-9 border-l border-l-slate-600/40 ${borderClass}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="stats-table relative mt-1 text-white text-xs lg:text-sm">
|
||||
<div
|
||||
class="overflow-x-auto rounded-lg bg-gray-800/85"
|
||||
@contextmenu=${(event: Event) => event.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="stats-table-content grid w-max min-w-full"
|
||||
style="grid-template-columns: ${gridTemplate};"
|
||||
role="table"
|
||||
>
|
||||
<div
|
||||
class="stats-table-header grid col-span-full font-bold bg-gray-700/95"
|
||||
style="grid-template-columns: subgrid; grid-column: 1 / -1;"
|
||||
role="row"
|
||||
>
|
||||
<div
|
||||
class="h-6 md:h-8 lg:h-9 flex items-center justify-center border-b border-b-slate-500"
|
||||
role="columnheader"
|
||||
>
|
||||
#
|
||||
</div>
|
||||
<div
|
||||
class="h-6 md:h-8 lg:h-9 min-w-0 px-1 flex items-center justify-center text-center border-b border-b-slate-500"
|
||||
role="columnheader"
|
||||
title=${translateText(this.nameLabelKey)}
|
||||
>
|
||||
<img
|
||||
class="size-[1.1rem] object-contain"
|
||||
src=${profileIcon}
|
||||
alt=${translateText(this.nameLabelKey)}
|
||||
/>
|
||||
</div>
|
||||
${repeat(
|
||||
selected,
|
||||
(column) => column.id,
|
||||
(column) => {
|
||||
const label = translateText(column.labelKey);
|
||||
return html`
|
||||
<div
|
||||
class="h-6 md:h-8 lg:h-9 px-1 flex items-center justify-center text-center border-b border-b-slate-500 border-l border-l-slate-500 whitespace-nowrap"
|
||||
role="columnheader"
|
||||
aria-sort=${this.sortKey === column.id
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center justify-center gap-1 hover:text-sky-200 transition-colors"
|
||||
title=${column.headerVisual === undefined
|
||||
? nothing
|
||||
: label}
|
||||
aria-label=${label}
|
||||
@click=${() => this.setSort(column.id)}
|
||||
>
|
||||
${column.headerVisual?.kind === "icon"
|
||||
? html`<span class="inline-flex items-start">
|
||||
<img
|
||||
class="size-[1.1rem] object-contain ${column
|
||||
.headerVisual.white === true
|
||||
? "brightness-0 invert"
|
||||
: ""}"
|
||||
src=${column.headerVisual.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>${column.headerVisual.superscript
|
||||
? html`<img
|
||||
class="size-[0.825rem] object-contain -ml-0.5 ${column
|
||||
.headerVisual.superscript.white === true
|
||||
? "brightness-0 invert"
|
||||
: ""}"
|
||||
src=${column.headerVisual.superscript.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>`
|
||||
: nothing}
|
||||
</span>`
|
||||
: column.headerVisual?.kind === "emoji"
|
||||
? html`<span
|
||||
class="text-[1.1rem] leading-none"
|
||||
aria-hidden="true"
|
||||
>${column.headerVisual.text}</span
|
||||
>`
|
||||
: html`<span>${label}</span>`}
|
||||
${this.sortKey === column.id
|
||||
? html`<span class="text-sky-300" aria-hidden="true"
|
||||
>${this.sortOrder === "asc" ? "↑" : "↓"}</span
|
||||
>`
|
||||
: nothing}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
)}
|
||||
<div
|
||||
class="h-6 md:h-8 lg:h-9 px-1 flex items-center justify-center border-b border-b-slate-500 border-l border-l-slate-500"
|
||||
role="columnheader"
|
||||
>
|
||||
<column-picker
|
||||
class="inline-flex"
|
||||
.columns=${COLUMN_DEFS}
|
||||
.selected=${selected.map((column) => column.id)}
|
||||
@columns-changed=${this.onColumnsChanged}
|
||||
></column-picker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="stats-table-scroll ${scrollHeight} grid col-span-full overflow-y-scroll overflow-x-hidden"
|
||||
style="grid-template-columns: subgrid; grid-column: 1 / -1;"
|
||||
role="rowgroup"
|
||||
@scroll=${this.onScroll}
|
||||
>
|
||||
${topSpacerPx > 0
|
||||
? html`<div
|
||||
class="stats-table-spacer col-span-full"
|
||||
style="height: ${topSpacerPx}px"
|
||||
aria-hidden="true"
|
||||
></div>`
|
||||
: nothing}
|
||||
${repeat(
|
||||
scrollableRows,
|
||||
(row) => row.key,
|
||||
(row, index) =>
|
||||
renderRow(
|
||||
row,
|
||||
index < scrollableRows.length - 1 ||
|
||||
pinnedRow !== null ||
|
||||
bottomSpacerPx > 0
|
||||
? "border-b border-b-slate-500"
|
||||
: "",
|
||||
),
|
||||
)}
|
||||
${bottomSpacerPx > 0
|
||||
? html`<div
|
||||
class="stats-table-spacer col-span-full"
|
||||
style="height: ${bottomSpacerPx}px"
|
||||
aria-hidden="true"
|
||||
></div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${pinnedRow === null ? nothing : renderRow(pinnedRow, "", true)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import { GraphicsSettingsModal } from "./layers/GraphicsSettingsModal";
|
||||
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
|
||||
import { ImmunityTimer } from "./layers/ImmunityTimer";
|
||||
import { InGamePromo } from "./layers/InGamePromo";
|
||||
import { Leaderboard } from "./layers/Leaderboard";
|
||||
import { MainRadialMenu } from "./layers/MainRadialMenu";
|
||||
import { MultiTabModal } from "./layers/MultiTabModal";
|
||||
import { NewLobbyPrompt } from "./layers/NewLobbyPrompt";
|
||||
@@ -40,7 +39,6 @@ import { PlayerPanel } from "./layers/PlayerPanel";
|
||||
import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SettingsModal } from "./layers/SettingsModal";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
import { TeamStats } from "./layers/TeamStats";
|
||||
import { UnitDisplay } from "./layers/UnitDisplay";
|
||||
import { WinModal } from "./layers/WinModal";
|
||||
import { loadAllSprites } from "./SpriteLoader";
|
||||
@@ -85,13 +83,6 @@ export function createRenderer(
|
||||
buildMenu.uiState = uiState;
|
||||
buildMenu.transformHandler = transformHandler;
|
||||
|
||||
const leaderboard = document.querySelector("leader-board") as Leaderboard;
|
||||
if (!leaderboard || !(leaderboard instanceof Leaderboard)) {
|
||||
console.error("LeaderBoard element not found in the DOM");
|
||||
}
|
||||
leaderboard.eventBus = eventBus;
|
||||
leaderboard.game = game;
|
||||
|
||||
const gameLeftSidebar = document.querySelector(
|
||||
"game-left-sidebar",
|
||||
) as GameLeftSidebar;
|
||||
@@ -101,13 +92,6 @@ export function createRenderer(
|
||||
gameLeftSidebar.game = game;
|
||||
gameLeftSidebar.eventBus = eventBus;
|
||||
|
||||
const teamStats = document.querySelector("team-stats") as TeamStats;
|
||||
if (!teamStats || !(teamStats instanceof TeamStats)) {
|
||||
console.error("TeamStats element not found in the DOM");
|
||||
}
|
||||
teamStats.eventBus = eventBus;
|
||||
teamStats.game = game;
|
||||
|
||||
const controlPanel = document.querySelector("control-panel") as ControlPanel;
|
||||
if (!(controlPanel instanceof ControlPanel)) {
|
||||
console.error("ControlPanel element not found in the DOM");
|
||||
@@ -325,7 +309,6 @@ export function createRenderer(
|
||||
),
|
||||
spawnTimer,
|
||||
immunityTimer,
|
||||
leaderboard,
|
||||
gameLeftSidebar,
|
||||
unitDisplay,
|
||||
gameRightSidebar,
|
||||
@@ -336,7 +319,6 @@ export function createRenderer(
|
||||
replayPanel,
|
||||
settingsModal,
|
||||
graphicsSettingsModal,
|
||||
teamStats,
|
||||
playerPanel,
|
||||
headsUpMessage,
|
||||
multiTabModal,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { assetUrl } from "../../core/AssetUrls";
|
||||
|
||||
export const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
|
||||
export const cityIcon = assetUrl("images/CityIconWhite.svg");
|
||||
export const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
|
||||
export const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
|
||||
export const mirvIcon = assetUrl("images/MIRVIcon.svg");
|
||||
export const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
|
||||
export const hydrogenBombIcon = assetUrl("images/MushroomCloudIconWhite.svg");
|
||||
export const atomBombIcon = assetUrl("images/NukeIconWhite.svg");
|
||||
export const portIcon = assetUrl("images/PortIcon.svg");
|
||||
export const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
|
||||
export const defensePostIcon = assetUrl("images/ShieldIconWhite.svg");
|
||||
export const soldierIcon = assetUrl("images/SoldierIcon.svg");
|
||||
export const claimIcon = assetUrl("images/ClaimIcon.svg");
|
||||
export const profileIcon = assetUrl("images/ProfileIcon.svg");
|
||||
export const upperLimitIcon = assetUrl("images/UpperLimitIcon.svg");
|
||||
export const allianceIcon = assetUrl("images/AllianceIcon.svg");
|
||||
export const traitorIcon = assetUrl("images/TraitorIcon.svg");
|
||||
@@ -0,0 +1,146 @@
|
||||
import { html, LitElement, render as litRender } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { ColumnId } from "../../StatsConstants";
|
||||
import { translateText } from "../../Utils";
|
||||
import type { ColumnDef } from "./lib/StatsColumns";
|
||||
|
||||
/**
|
||||
* ⚙️ button + checkbox popover for choosing which stat columns a panel
|
||||
* shows. Emits `columns-changed` (CustomEvent<ColumnId[]>) with the new
|
||||
* selection in registry order; the host persists and re-renders.
|
||||
*/
|
||||
@customElement("column-picker")
|
||||
export class ColumnPicker extends LitElement {
|
||||
@property({ attribute: false }) columns: readonly ColumnDef[] = [];
|
||||
@property({ attribute: false }) selected: readonly ColumnId[] = [];
|
||||
|
||||
@state() private open = false;
|
||||
private portal: HTMLDivElement | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // light DOM for Tailwind
|
||||
}
|
||||
|
||||
private onDocumentClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (this.open && !this.contains(target) && !this.portal?.contains(target)) {
|
||||
this.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
private onViewportChange = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
this.open &&
|
||||
!(target instanceof Node && this.portal?.contains(target))
|
||||
) {
|
||||
this.renderPortal();
|
||||
}
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("click", this.onDocumentClick);
|
||||
window.addEventListener("resize", this.onViewportChange);
|
||||
window.addEventListener("scroll", this.onViewportChange, true);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener("click", this.onDocumentClick);
|
||||
window.removeEventListener("resize", this.onViewportChange);
|
||||
window.removeEventListener("scroll", this.onViewportChange, true);
|
||||
this.removePortal();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
this.renderPortal();
|
||||
}
|
||||
|
||||
private toggle(id: ColumnId) {
|
||||
const isSelected = this.selected.includes(id);
|
||||
if (isSelected && this.selected.length === 1) return; // keep at least one
|
||||
const next = this.columns
|
||||
.map((c) => c.id)
|
||||
.filter((cid) =>
|
||||
cid === id ? !isSelected : this.selected.includes(cid),
|
||||
);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<ColumnId[]>("columns-changed", {
|
||||
detail: next,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderPortal() {
|
||||
if (!this.open || !this.isConnected) {
|
||||
this.removePortal();
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = this.querySelector("button");
|
||||
if (trigger === null) return;
|
||||
|
||||
if (this.portal === null) {
|
||||
this.portal = document.createElement("div");
|
||||
this.portal.className = "column-picker-portal";
|
||||
document.body.appendChild(this.portal);
|
||||
}
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const right = Math.max(8, window.innerWidth - rect.right);
|
||||
const top = rect.bottom + 4;
|
||||
const maxHeight = Math.max(
|
||||
80,
|
||||
Math.min(window.innerHeight * 0.4, window.innerHeight - top - 8),
|
||||
);
|
||||
|
||||
litRender(
|
||||
html`
|
||||
<div
|
||||
class="column-picker-popover fixed z-2000 bg-gray-800/95 border border-slate-500 rounded-md p-2 flex flex-col gap-1 overflow-y-auto whitespace-nowrap"
|
||||
style="top: ${top}px; right: ${right}px; max-height: ${maxHeight}px;"
|
||||
>
|
||||
${this.columns.map((column) => {
|
||||
const checked = this.selected.includes(column.id);
|
||||
return html`
|
||||
<label
|
||||
class="flex items-center gap-2 text-xs lg:text-sm text-white cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${checked}
|
||||
?disabled=${checked && this.selected.length === 1}
|
||||
@change=${() => this.toggle(column.id)}
|
||||
/>
|
||||
${translateText(column.labelKey)}
|
||||
</label>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
this.portal,
|
||||
);
|
||||
}
|
||||
|
||||
private removePortal() {
|
||||
this.portal?.remove();
|
||||
this.portal = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<button
|
||||
class="px-0.5 leading-none text-xs lg:text-sm border rounded-md border-slate-500 transition-colors text-white hover:bg-white/10 bg-gray-700/50"
|
||||
title=${translateText("leaderboard.configure_columns")}
|
||||
aria-expanded=${this.open}
|
||||
aria-haspopup="menu"
|
||||
@click=${() => (this.open = !this.open)}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
} from "../../Utils";
|
||||
import { GameView } from "../../view";
|
||||
import { PlayerView } from "../../view/PlayerView";
|
||||
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
|
||||
const soldierIcon = assetUrl("images/SoldierIcon.svg");
|
||||
import { goldCoinIcon, soldierIcon } from "../HotbarIcons";
|
||||
const swordIcon = assetUrl("images/SwordIcon.svg");
|
||||
|
||||
@customElement("control-panel")
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { Colord } from "colord";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameMode, Team } from "../../../core/game/Game";
|
||||
import { Controller } from "../../Controller";
|
||||
import type { EventBus } from "../../../core/EventBus";
|
||||
import { GameMode, type Team } from "../../../core/game/Game";
|
||||
import type { Controller } from "../../Controller";
|
||||
import { Platform } from "../../Platform";
|
||||
import { themeProvider } from "../../theme/ThemeProvider";
|
||||
import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils";
|
||||
import { GameView } from "../../view";
|
||||
import type { GameView } from "../../view";
|
||||
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
|
||||
import "./PlayerStats";
|
||||
import type { PlayerStats } from "./PlayerStats";
|
||||
import { SpawnBarVisibleEvent } from "./SpawnTimer";
|
||||
const leaderboardRegularIcon = assetUrl(
|
||||
import "./TeamStats";
|
||||
import type { TeamStats } from "./TeamStats";
|
||||
const playerStatsRegularIcon = assetUrl(
|
||||
"images/LeaderboardIconRegularWhite.svg",
|
||||
);
|
||||
const leaderboardSolidIcon = assetUrl("images/LeaderboardIconSolidWhite.svg");
|
||||
const teamRegularIcon = assetUrl("images/TeamIconRegularWhite.svg");
|
||||
const teamSolidIcon = assetUrl("images/TeamIconSolidWhite.svg");
|
||||
const playerStatsSolidIcon = assetUrl("images/LeaderboardIconSolidWhite.svg");
|
||||
const teamStatsRegularIcon = assetUrl("images/TeamIconRegularWhite.svg");
|
||||
const teamStatsSolidIcon = assetUrl("images/TeamIconSolidWhite.svg");
|
||||
|
||||
@customElement("game-left-sidebar")
|
||||
export class GameLeftSidebar extends LitElement implements Controller {
|
||||
@state()
|
||||
private isLeaderboardShow = false;
|
||||
private isPlayerStatsShown = false;
|
||||
@state()
|
||||
private isTeamLeaderboardShow = false;
|
||||
private isTeamStatsShown = false;
|
||||
@state()
|
||||
private isVisible = false;
|
||||
@state()
|
||||
@@ -36,9 +40,11 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
private immunityBarVisible = false;
|
||||
|
||||
private playerColor: Colord = new Colord("#FFFFFF");
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
private _shownOnInit = false;
|
||||
@property({ attribute: false }) public game: GameView | null = null;
|
||||
@property({ attribute: false }) public eventBus: EventBus | null = null;
|
||||
@query("player-stats") private playerStats?: PlayerStats;
|
||||
@query("team-stats") private teamStats?: TeamStats;
|
||||
private showPlayerStatsAfterSpawn = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -46,10 +52,10 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
|
||||
init() {
|
||||
this.isVisible = true;
|
||||
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
|
||||
this.eventBus?.on(SpawnBarVisibleEvent, (e) => {
|
||||
this.spawnBarVisible = e.visible;
|
||||
});
|
||||
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
|
||||
this.eventBus?.on(ImmunityBarVisibleEvent, (e) => {
|
||||
this.immunityBarVisible = e.visible;
|
||||
});
|
||||
if (this.isTeamGame) {
|
||||
@@ -57,43 +63,46 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
}
|
||||
// Make it visible by default on large screens
|
||||
if (Platform.isDesktopWidth) {
|
||||
// lg breakpoint
|
||||
this._shownOnInit = true;
|
||||
this.showPlayerStatsAfterSpawn = true;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
getTickIntervalMs() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.playerTeam && this.game.myPlayer()?.team()) {
|
||||
this.playerTeam = this.game.myPlayer()!.team();
|
||||
if (this.playerTeam) {
|
||||
this.playerColor = themeProvider.current().teamColor(this.playerTeam);
|
||||
this.requestUpdate();
|
||||
}
|
||||
if (this.game === null) return;
|
||||
|
||||
const team = this.game.myPlayer()?.team();
|
||||
if (this.playerTeam === null && team !== null && team !== undefined) {
|
||||
this.playerTeam = team;
|
||||
this.playerColor = themeProvider.current().teamColor(team);
|
||||
}
|
||||
|
||||
if (this._shownOnInit && !this.game.inSpawnPhase()) {
|
||||
this._shownOnInit = false;
|
||||
this.isLeaderboardShow = true;
|
||||
this.requestUpdate();
|
||||
if (this.showPlayerStatsAfterSpawn && !this.game.inSpawnPhase()) {
|
||||
this.showPlayerStatsAfterSpawn = false;
|
||||
this.isPlayerStatsShown = true;
|
||||
}
|
||||
|
||||
if (!this.game.inSpawnPhase() && this.isPlayerTeamLabelVisible) {
|
||||
this.isPlayerTeamLabelVisible = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
this.playerStats?.refresh();
|
||||
this.teamStats?.refresh();
|
||||
}
|
||||
|
||||
private get barOffset(): number {
|
||||
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
|
||||
}
|
||||
|
||||
private toggleLeaderboard(): void {
|
||||
this.isLeaderboardShow = !this.isLeaderboardShow;
|
||||
private togglePlayerStats(): void {
|
||||
this.isPlayerStatsShown = !this.isPlayerStatsShown;
|
||||
}
|
||||
|
||||
private toggleTeamLeaderboard(): void {
|
||||
this.isTeamLeaderboardShow = !this.isTeamLeaderboardShow;
|
||||
private toggleTeamStats(): void {
|
||||
this.isTeamStatsShown = !this.isTeamStatsShown;
|
||||
}
|
||||
|
||||
private get isTeamGame(): boolean {
|
||||
@@ -103,7 +112,7 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
render() {
|
||||
return html`
|
||||
<aside
|
||||
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
|
||||
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isPlayerStatsShown || this.isTeamStatsShown ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
|
||||
this.isVisible ? "translate-x-0" : "hidden"
|
||||
}`}
|
||||
style="margin-top: ${this.barOffset}px;"
|
||||
@@ -111,20 +120,20 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
<div class="flex items-center gap-4 xl:gap-6 text-white">
|
||||
<div
|
||||
class="cursor-pointer p-0.5 bg-gray-700/50 hover:bg-gray-600 border rounded-md border-slate-500 transition-colors"
|
||||
@click=${this.toggleLeaderboard}
|
||||
@click=${this.togglePlayerStats}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " " || e.code === "Space") {
|
||||
e.preventDefault();
|
||||
this.toggleLeaderboard();
|
||||
this.togglePlayerStats();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src=${this.isLeaderboardShow
|
||||
? leaderboardSolidIcon
|
||||
: leaderboardRegularIcon}
|
||||
src=${this.isPlayerStatsShown
|
||||
? playerStatsSolidIcon
|
||||
: playerStatsRegularIcon}
|
||||
alt=${translateText("help_modal.icon_alt_player_leaderboard") ||
|
||||
"Player Leaderboard Icon"}
|
||||
width="20"
|
||||
@@ -135,7 +144,7 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
? html`
|
||||
<div
|
||||
class="cursor-pointer p-0.5 bg-gray-700/50 hover:bg-gray-600 border rounded-md border-slate-500 transition-colors"
|
||||
@click=${this.toggleTeamLeaderboard}
|
||||
@click=${this.toggleTeamStats}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
@@ -145,14 +154,14 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
e.code === "Space"
|
||||
) {
|
||||
e.preventDefault();
|
||||
this.toggleTeamLeaderboard();
|
||||
this.toggleTeamStats();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src=${this.isTeamLeaderboardShow
|
||||
? teamSolidIcon
|
||||
: teamRegularIcon}
|
||||
src=${this.isTeamStatsShown
|
||||
? teamStatsSolidIcon
|
||||
: teamStatsRegularIcon}
|
||||
alt=${translateText(
|
||||
"help_modal.icon_alt_team_leaderboard",
|
||||
) || "Team Leaderboard Icon"}
|
||||
@@ -162,7 +171,7 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
${this.isLeaderboardShow || this.isTeamLeaderboardShow
|
||||
${this.isPlayerStatsShown || this.isTeamStatsShown
|
||||
? html`<span
|
||||
class="ml-auto text-[10px] text-slate-500 select-all leading-none self-start"
|
||||
title=${translateText("help_modal.game_id_tooltip")}
|
||||
@@ -187,13 +196,19 @@ export class GameLeftSidebar extends LitElement implements Controller {
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div
|
||||
class=${`block lg:flex flex-wrap overflow-x-auto min-w-0 w-full ${this.isLeaderboardShow && this.isTeamLeaderboardShow ? "gap-2" : ""}`}
|
||||
>
|
||||
<leader-board .visible=${this.isLeaderboardShow}></leader-board>
|
||||
<div class="flex flex-col gap-2 min-w-0 w-full">
|
||||
<player-stats
|
||||
class=${this.isPlayerStatsShown ? "block min-w-0" : "hidden"}
|
||||
.game=${this.game}
|
||||
.eventBus=${this.eventBus}
|
||||
.visible=${this.isPlayerStatsShown}
|
||||
></player-stats>
|
||||
<team-stats
|
||||
class="flex-1"
|
||||
.visible=${this.isTeamLeaderboardShow && this.isTeamGame}
|
||||
class=${this.isTeamStatsShown && this.isTeamGame
|
||||
? "block min-w-0"
|
||||
: "hidden"}
|
||||
.game=${this.game}
|
||||
.visible=${this.isTeamStatsShown && this.isTeamGame}
|
||||
></team-stats>
|
||||
</div>
|
||||
<slot></slot>
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
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 { EventBus } from "../../../core/EventBus";
|
||||
import { Controller } from "../../Controller";
|
||||
import { GoToPlayerEvent } from "../../TransformHandler";
|
||||
import { formatPercentage, renderNumber } from "../../Utils";
|
||||
import { GameView, PlayerView } from "../../view";
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
position: number;
|
||||
score: string;
|
||||
gold: string;
|
||||
maxTroops: string;
|
||||
isMyPlayer: boolean;
|
||||
isOnSameTeam: boolean;
|
||||
player: PlayerView;
|
||||
}
|
||||
|
||||
@customElement("leader-board")
|
||||
export class Leaderboard extends LitElement implements Controller {
|
||||
public game: GameView | null = null;
|
||||
public eventBus: EventBus | null = null;
|
||||
|
||||
players: Entry[] = [];
|
||||
|
||||
@property({ type: Boolean }) visible = false;
|
||||
private showTopFive = true;
|
||||
|
||||
@state()
|
||||
private _sortKey: "tiles" | "gold" | "maxtroops" = "tiles";
|
||||
|
||||
@state()
|
||||
private _sortOrder: "asc" | "desc" = "desc";
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // use light DOM for Tailwind support
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
willUpdate(changed: Map<string, unknown>) {
|
||||
if (changed.has("visible") && this.visible) {
|
||||
this.updateLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
getTickIntervalMs() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.game === null) throw new Error("Not initialized");
|
||||
if (!this.visible) return;
|
||||
this.updateLeaderboard();
|
||||
}
|
||||
|
||||
private setSort(key: "tiles" | "gold" | "maxtroops") {
|
||||
if (this._sortKey === key) {
|
||||
this._sortOrder = this._sortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
this._sortKey = key;
|
||||
this._sortOrder = "desc";
|
||||
}
|
||||
this.updateLeaderboard();
|
||||
}
|
||||
|
||||
private updateLeaderboard() {
|
||||
if (this.game === null) throw new Error("Not initialized");
|
||||
const myPlayer = this.game.myPlayer();
|
||||
|
||||
interface PlayerViewTroopsCache {
|
||||
pv: PlayerView;
|
||||
maxTroops: number;
|
||||
}
|
||||
|
||||
const compare = (a: number, b: number) =>
|
||||
this._sortOrder === "asc" ? a - b : b - a;
|
||||
|
||||
const maxTroops = (p: PlayerView) => this.game!.config().maxTroops(p);
|
||||
|
||||
const sorted: PlayerViewTroopsCache[] = this.game
|
||||
.playerViews()
|
||||
.map((p) => ({ pv: p, maxTroops: maxTroops(p) }));
|
||||
|
||||
switch (this._sortKey) {
|
||||
case "gold":
|
||||
sorted.sort((a, b) =>
|
||||
compare(Number(a.pv.gold()), Number(b.pv.gold())),
|
||||
);
|
||||
break;
|
||||
case "maxtroops":
|
||||
sorted.sort((a, b) => compare(a.maxTroops, b.maxTroops));
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) =>
|
||||
compare(a.pv.numTilesOwned(), b.pv.numTilesOwned()),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this.players = playersToShow.map((playerCache, index) => {
|
||||
const player = playerCache.pv;
|
||||
const maxTroops = playerCache.maxTroops;
|
||||
return {
|
||||
name: player.displayName(),
|
||||
position: index + 1,
|
||||
score: formatPercentage(
|
||||
player.numTilesOwned() / numTilesWithoutFallout,
|
||||
),
|
||||
gold: renderNumber(player.gold()),
|
||||
maxTroops: renderTroops(maxTroops),
|
||||
isMyPlayer: player === myPlayer,
|
||||
isOnSameTeam:
|
||||
myPlayer !== null &&
|
||||
(player === myPlayer || player.isOnSameTeam(myPlayer)),
|
||||
player: player,
|
||||
};
|
||||
});
|
||||
|
||||
if (
|
||||
myPlayer !== null &&
|
||||
this.players.find((p) => p.isMyPlayer) === undefined
|
||||
) {
|
||||
let place = 0;
|
||||
for (const p of sorted) {
|
||||
place++;
|
||||
if (p.pv === myPlayer) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (myPlayer.isAlive()) {
|
||||
const myPlayerMaxTroops = this.game!.config().maxTroops(myPlayer);
|
||||
this.players.pop();
|
||||
this.players.push({
|
||||
name: myPlayer.displayName(),
|
||||
position: place,
|
||||
score: formatPercentage(
|
||||
myPlayer.numTilesOwned() / this.game.numLandTiles(),
|
||||
),
|
||||
gold: renderNumber(myPlayer.gold()),
|
||||
maxTroops: renderTroops(myPlayerMaxTroops),
|
||||
isMyPlayer: true,
|
||||
isOnSameTeam: true,
|
||||
player: myPlayer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleRowClickPlayer(player: PlayerView) {
|
||||
if (this.eventBus === null) return;
|
||||
this.eventBus.emit(new GoToPlayerEvent(player));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-xs lg:text-sm md:max-h-[50vh] mt-2 ${this
|
||||
.visible
|
||||
? ""
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="grid bg-gray-800/85 w-full text-xs md:text-xs lg:text-sm rounded-lg overflow-hidden"
|
||||
style="grid-template-columns: minmax(24px, 30px) minmax(60px, 100px) minmax(45px, 70px) minmax(40px, 55px) minmax(55px, 105px);"
|
||||
>
|
||||
<div class="contents font-bold bg-gray-700/60">
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
#
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 truncate"
|
||||
>
|
||||
${translateText("leaderboard.player")}
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
|
||||
@click=${() => this.setSort("tiles")}
|
||||
>
|
||||
${translateText("leaderboard.owned")}
|
||||
${this._sortKey === "tiles"
|
||||
? this._sortOrder === "asc"
|
||||
? "⬆️"
|
||||
: "⬇️"
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
|
||||
@click=${() => this.setSort("gold")}
|
||||
>
|
||||
${translateText("leaderboard.gold")}
|
||||
${this._sortKey === "gold"
|
||||
? this._sortOrder === "asc"
|
||||
? "⬆️"
|
||||
: "⬇️"
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap truncate"
|
||||
@click=${() => this.setSort("maxtroops")}
|
||||
>
|
||||
${translateText("leaderboard.maxtroops")}
|
||||
${this._sortKey === "maxtroops"
|
||||
? this._sortOrder === "asc"
|
||||
? "⬆️"
|
||||
: "⬇️"
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${repeat(
|
||||
this.players,
|
||||
(p) => p.player.id(),
|
||||
(player, index) => html`
|
||||
<div
|
||||
class="contents hover:bg-slate-600/60 ${player.isOnSameTeam
|
||||
? "font-bold"
|
||||
: ""} cursor-pointer"
|
||||
@click=${() => this.handleRowClickPlayer(player.player)}
|
||||
>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""}"
|
||||
>
|
||||
${player.position}
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""} truncate"
|
||||
>
|
||||
${player.name}
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""}"
|
||||
>
|
||||
${player.score}
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""}"
|
||||
>
|
||||
${player.gold}
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""}"
|
||||
>
|
||||
${player.maxTroops}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-2 p-0.5 px-1.5 md:px-2 text-xs md:text-xs lg:text-sm
|
||||
border rounded-md border-slate-500 transition-colors
|
||||
text-white mx-auto block hover:bg-white/10 bg-gray-700/50"
|
||||
@click=${() => {
|
||||
this.showTopFive = !this.showTopFive;
|
||||
this.updateLeaderboard();
|
||||
}}
|
||||
>
|
||||
${this.showTopFive ? "+" : "-"}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import type { EventBus } from "../../../core/EventBus";
|
||||
import { type StatsRow, StatsTable } from "../../components/StatsTable";
|
||||
import { GoToPlayerEvent } from "../../TransformHandler";
|
||||
import type { GameView } from "../../view";
|
||||
import { type ColumnDef, columnValues } from "./lib/StatsColumns";
|
||||
|
||||
@customElement("player-stats")
|
||||
export class PlayerStats extends StatsTable {
|
||||
public eventBus: EventBus | null = null;
|
||||
|
||||
protected readonly tableKind = "player";
|
||||
protected readonly nameLabelKey = "leaderboard.player";
|
||||
|
||||
protected buildRows(
|
||||
game: GameView,
|
||||
columns: readonly ColumnDef[],
|
||||
): StatsRow[] {
|
||||
const myPlayer = game.myPlayer();
|
||||
|
||||
return game
|
||||
.playerViews()
|
||||
.filter((player) => player.isAlive())
|
||||
.map((player) => ({
|
||||
key: player.id(),
|
||||
name: player.displayName(),
|
||||
values: columnValues(player, game, columns),
|
||||
emphasized:
|
||||
myPlayer !== null &&
|
||||
(player === myPlayer || player.isOnSameTeam(myPlayer)),
|
||||
pinned: player === myPlayer,
|
||||
onClick: () => {
|
||||
if (this.eventBus !== null) {
|
||||
this.eventBus.emit(new GoToPlayerEvent(player));
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,250 +1,64 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameMode, Team, UnitType } from "../../../core/game/Game";
|
||||
import { Controller } from "../../Controller";
|
||||
import {
|
||||
formatPercentage,
|
||||
renderNumber,
|
||||
renderTroops,
|
||||
translateText,
|
||||
} from "../../Utils";
|
||||
import { GameView, PlayerView } from "../../view";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import type { Team } from "../../../core/game/Game";
|
||||
import { type StatsRow, StatsTable } from "../../components/StatsTable";
|
||||
import type { ColumnId } from "../../StatsConstants";
|
||||
import { translateText } from "../../Utils";
|
||||
import type { GameView, PlayerView } from "../../view";
|
||||
import type { ColumnDef } from "./lib/StatsColumns";
|
||||
|
||||
interface TeamEntry {
|
||||
teamName: string;
|
||||
isMyTeam: boolean;
|
||||
totalScoreStr: string;
|
||||
totalGold: string;
|
||||
totalMaxTroops: string;
|
||||
totalSAMs: string;
|
||||
totalLaunchers: string;
|
||||
totalWarShips: string;
|
||||
totalCities: string;
|
||||
totalScoreSort: number;
|
||||
players: PlayerView[];
|
||||
export function aggregateTeamValues(
|
||||
players: readonly PlayerView[],
|
||||
columns: readonly ColumnDef[],
|
||||
game: GameView,
|
||||
): ReadonlyMap<ColumnId, number> {
|
||||
const values = new Map<ColumnId, number>(
|
||||
columns.map((column) => [column.id, 0]),
|
||||
);
|
||||
|
||||
for (const player of players) {
|
||||
if (!player.isAlive()) continue;
|
||||
for (const column of columns) {
|
||||
values.set(
|
||||
column.id,
|
||||
(values.get(column.id) ?? 0) + column.value(player, game),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
@customElement("team-stats")
|
||||
export class TeamStats extends LitElement implements Controller {
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
export class TeamStats extends StatsTable {
|
||||
protected readonly tableKind = "team";
|
||||
protected readonly nameLabelKey = "leaderboard.team";
|
||||
|
||||
@property({ type: Boolean }) visible = false;
|
||||
teams: TeamEntry[] = [];
|
||||
private _shownOnInit = false;
|
||||
private showUnits = false;
|
||||
private _myTeam: Team | null = null;
|
||||
protected buildRows(
|
||||
game: GameView,
|
||||
columns: readonly ColumnDef[],
|
||||
): StatsRow[] {
|
||||
const teams = new Map<Team, PlayerView[]>();
|
||||
const myTeam = game.myPlayer()?.team() ?? null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // use light DOM for Tailwind
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
getTickIntervalMs() {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.game.config().gameConfig().gameMode !== GameMode.Team) return;
|
||||
|
||||
if (!this._shownOnInit && !this.game.inSpawnPhase()) {
|
||||
this._shownOnInit = true;
|
||||
this.updateTeamStats();
|
||||
for (const player of game.playerViews()) {
|
||||
const team = player.team();
|
||||
if (team === null) continue;
|
||||
const players = teams.get(team) ?? [];
|
||||
players.push(player);
|
||||
teams.set(team, players);
|
||||
}
|
||||
|
||||
if (!this.visible) return;
|
||||
return [...teams.entries()].map(([team, players]) => {
|
||||
const labelKey = `team_colors.${team.toLowerCase()}`;
|
||||
const translatedName = translateText(labelKey);
|
||||
|
||||
this.updateTeamStats();
|
||||
}
|
||||
|
||||
private updateTeamStats() {
|
||||
const players = this.game.playerViews();
|
||||
const grouped: Record<Team, PlayerView[]> = {};
|
||||
|
||||
if (this._myTeam === null) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
this._myTeam = myPlayer?.team() ?? null;
|
||||
}
|
||||
|
||||
for (const player of players) {
|
||||
const rawTeam = player.team();
|
||||
if (rawTeam === null) continue;
|
||||
grouped[rawTeam] ??= [];
|
||||
grouped[rawTeam].push(player);
|
||||
}
|
||||
|
||||
this.teams = Object.entries(grouped)
|
||||
.map(([rawTeam, teamPlayers]) => {
|
||||
const key = `team_colors.${rawTeam.toLowerCase()}`;
|
||||
const translated = translateText(key);
|
||||
const teamName = translated !== key ? translated : rawTeam;
|
||||
|
||||
let totalGold = 0n;
|
||||
let totalMaxTroops = 0;
|
||||
let totalScoreSort = 0;
|
||||
let totalSAMs = 0;
|
||||
let totalLaunchers = 0;
|
||||
let totalWarShips = 0;
|
||||
let totalCities = 0;
|
||||
|
||||
for (const p of teamPlayers) {
|
||||
if (p.isAlive()) {
|
||||
totalMaxTroops += this.game.config().maxTroops(p);
|
||||
totalGold += p.gold();
|
||||
totalScoreSort += p.numTilesOwned();
|
||||
totalLaunchers += p.totalUnitLevels(UnitType.MissileSilo);
|
||||
totalSAMs += p.totalUnitLevels(UnitType.SAMLauncher);
|
||||
totalWarShips += p.totalUnitLevels(UnitType.Warship);
|
||||
totalCities += p.totalUnitLevels(UnitType.City);
|
||||
}
|
||||
}
|
||||
|
||||
const numTilesWithoutFallout =
|
||||
this.game.numLandTiles() - this.game.numTilesWithFallout();
|
||||
const totalScorePercent = totalScoreSort / numTilesWithoutFallout;
|
||||
|
||||
return {
|
||||
teamName,
|
||||
isMyTeam: rawTeam === this._myTeam,
|
||||
totalScoreStr: formatPercentage(totalScorePercent),
|
||||
totalScoreSort,
|
||||
totalGold: renderNumber(totalGold),
|
||||
totalMaxTroops: renderTroops(totalMaxTroops),
|
||||
players: teamPlayers,
|
||||
|
||||
totalLaunchers: renderNumber(totalLaunchers),
|
||||
totalSAMs: renderNumber(totalSAMs),
|
||||
totalWarShips: renderNumber(totalWarShips),
|
||||
totalCities: renderNumber(totalCities),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.totalScoreSort - a.totalScoreSort);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) return html``;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="max-h-[30vh] overflow-x-hidden overflow-y-auto grid bg-slate-800/85 w-full text-white text-xs md:text-sm mt-2 rounded-lg"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="grid w-full grid-cols-[repeat(var(--cols),1fr)]"
|
||||
style="--cols:${this.showUnits ? 5 : 4};"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="contents font-bold bg-slate-700/60">
|
||||
<div class="p-1.5 md:p-2.5 text-center border-b border-slate-500">
|
||||
${translateText("leaderboard.team")}
|
||||
</div>
|
||||
${this.showUnits
|
||||
? html`
|
||||
<div
|
||||
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.launchers")}
|
||||
</div>
|
||||
<div
|
||||
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.sams")}
|
||||
</div>
|
||||
<div
|
||||
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.warships")}
|
||||
</div>
|
||||
<div
|
||||
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.cities")}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.owned")}
|
||||
</div>
|
||||
<div
|
||||
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.gold")}
|
||||
</div>
|
||||
<div
|
||||
class="p-1.5 md:p-2.5 text-center border-b border-slate-500"
|
||||
>
|
||||
${translateText("leaderboard.maxtroops")}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Data rows -->
|
||||
${this.teams.map((team) =>
|
||||
this.showUnits
|
||||
? html`
|
||||
<div
|
||||
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
|
||||
? "font-bold"
|
||||
: ""}"
|
||||
>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.teamName}
|
||||
</div>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.totalLaunchers}
|
||||
</div>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.totalSAMs}
|
||||
</div>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.totalWarShips}
|
||||
</div>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.totalCities}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
class="contents hover:bg-slate-600/60 text-center cursor-pointer ${team.isMyTeam
|
||||
? "font-bold"
|
||||
: ""}"
|
||||
>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.teamName}
|
||||
</div>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.totalScoreStr}
|
||||
</div>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.totalGold}
|
||||
</div>
|
||||
<div class="py-1.5 border-b border-slate-500">
|
||||
${team.totalMaxTroops}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
class="team-stats-button"
|
||||
aria-pressed=${String(this.showUnits)}
|
||||
@click=${() => {
|
||||
this.showUnits = !this.showUnits;
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${this.showUnits
|
||||
? translateText("leaderboard.show_control")
|
||||
: translateText("leaderboard.show_units")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return {
|
||||
key: team,
|
||||
name: translatedName === labelKey ? team : translatedName,
|
||||
values: aggregateTeamValues(players, columns, game),
|
||||
emphasized: team === myTeam,
|
||||
pinned: team === myTeam,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import {
|
||||
BuildableUnit,
|
||||
@@ -15,17 +14,19 @@ import { ToggleStructureEvent } from "../../InputHandler";
|
||||
import { UIState } from "../../UIState";
|
||||
import { renderNumber, translateText } from "../../Utils";
|
||||
import { GameView } from "../../view";
|
||||
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
|
||||
const cityIcon = assetUrl("images/CityIconWhite.svg");
|
||||
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
|
||||
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
|
||||
const mirvIcon = assetUrl("images/MIRVIcon.svg");
|
||||
const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
|
||||
const hydrogenBombIcon = assetUrl("images/MushroomCloudIconWhite.svg");
|
||||
const atomBombIcon = assetUrl("images/NukeIconWhite.svg");
|
||||
const portIcon = assetUrl("images/PortIcon.svg");
|
||||
const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
|
||||
const defensePostIcon = assetUrl("images/ShieldIconWhite.svg");
|
||||
import {
|
||||
atomBombIcon,
|
||||
cityIcon,
|
||||
defensePostIcon,
|
||||
factoryIcon,
|
||||
goldCoinIcon,
|
||||
hydrogenBombIcon,
|
||||
mirvIcon,
|
||||
missileSiloIcon,
|
||||
portIcon,
|
||||
samLauncherIcon,
|
||||
warshipIcon,
|
||||
} from "../HotbarIcons";
|
||||
|
||||
@customElement("unit-display")
|
||||
export class UnitDisplay extends LitElement implements Controller {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { UnitType } from "../../../../core/game/Game";
|
||||
import type { ColumnId } from "../../../StatsConstants";
|
||||
import { formatPercentage, renderNumber, renderTroops } from "../../../Utils";
|
||||
import type { GameView, PlayerView } from "../../../view";
|
||||
import {
|
||||
allianceIcon,
|
||||
cityIcon,
|
||||
claimIcon,
|
||||
factoryIcon,
|
||||
goldCoinIcon,
|
||||
missileSiloIcon,
|
||||
portIcon,
|
||||
samLauncherIcon,
|
||||
soldierIcon,
|
||||
traitorIcon,
|
||||
upperLimitIcon,
|
||||
warshipIcon,
|
||||
} from "../../HotbarIcons";
|
||||
|
||||
export {
|
||||
COLUMN_IDS,
|
||||
DEFAULT_STATS_COLUMNS,
|
||||
type ColumnId,
|
||||
} from "../../../StatsConstants";
|
||||
|
||||
type ValueGetter = (player: PlayerView, game: GameView) => number;
|
||||
type ValueRenderer = (value: number, game: GameView) => string;
|
||||
export type ColumnHeaderVisual =
|
||||
| {
|
||||
readonly kind: "icon";
|
||||
readonly src: string;
|
||||
readonly white?: true;
|
||||
/** Small icon rendered as a superscript exponent on the main icon. */
|
||||
readonly superscript?: { readonly src: string; readonly white?: true };
|
||||
}
|
||||
| { readonly kind: "emoji"; readonly text: string };
|
||||
export type ColumnValueAlignment = "center" | "end";
|
||||
|
||||
interface ColumnOptions {
|
||||
readonly headerVisual?: ColumnHeaderVisual;
|
||||
readonly valueAlignment?: ColumnValueAlignment;
|
||||
}
|
||||
|
||||
const troopHeaderVisual = {
|
||||
kind: "icon",
|
||||
src: soldierIcon,
|
||||
white: true,
|
||||
} as const satisfies ColumnHeaderVisual;
|
||||
|
||||
export interface ColumnDef {
|
||||
readonly id: ColumnId;
|
||||
readonly labelKey: string;
|
||||
readonly headerVisual?: ColumnHeaderVisual;
|
||||
readonly valueAlignment: ColumnValueAlignment;
|
||||
/** Raw numeric value used for sorting and team totals. */
|
||||
readonly value: ValueGetter;
|
||||
/** Formats either a player's value or an aggregated team total. */
|
||||
readonly renderValue: ValueRenderer;
|
||||
}
|
||||
|
||||
// renderNumber's second parameter is fixedPoints, so it cannot be a
|
||||
// ValueRenderer directly (which passes GameView second).
|
||||
const renderNum: ValueRenderer = (value) => renderNumber(value);
|
||||
|
||||
function column(
|
||||
id: ColumnId,
|
||||
labelKey: string,
|
||||
value: ValueGetter,
|
||||
renderValue: ValueRenderer,
|
||||
options: ColumnOptions = {},
|
||||
): ColumnDef {
|
||||
return {
|
||||
id,
|
||||
labelKey,
|
||||
...options,
|
||||
valueAlignment: options.valueAlignment ?? "end",
|
||||
value,
|
||||
renderValue,
|
||||
};
|
||||
}
|
||||
|
||||
function unitColumn(
|
||||
id: ColumnId,
|
||||
labelKey: string,
|
||||
unitType: UnitType,
|
||||
icon: string,
|
||||
): ColumnDef {
|
||||
return column(
|
||||
id,
|
||||
labelKey,
|
||||
(player) => player.totalUnitLevels(unitType),
|
||||
renderNum,
|
||||
{ headerVisual: { kind: "icon", src: icon }, valueAlignment: "center" },
|
||||
);
|
||||
}
|
||||
|
||||
// This registry is the source of truth for column IDs and display order.
|
||||
export const COLUMN_DEFS = [
|
||||
{
|
||||
id: "tiles",
|
||||
labelKey: "leaderboard.owned",
|
||||
headerVisual: { kind: "icon", src: claimIcon, white: true },
|
||||
valueAlignment: "end",
|
||||
value: (player) => player.numTilesOwned(),
|
||||
renderValue: (tiles, game) => {
|
||||
const validTiles = game.numLandTiles() - game.numTilesWithFallout();
|
||||
return formatPercentage(validTiles > 0 ? tiles / validTiles : 0);
|
||||
},
|
||||
},
|
||||
column(
|
||||
"gold",
|
||||
"leaderboard.gold",
|
||||
// Gold is a bigint, but game values remain safely below Number.MAX_SAFE_INTEGER.
|
||||
(player) => Number(player.gold()),
|
||||
renderNum,
|
||||
{ headerVisual: { kind: "icon", src: goldCoinIcon } },
|
||||
),
|
||||
column(
|
||||
"troops",
|
||||
"leaderboard.troops",
|
||||
(player) => player.troops(),
|
||||
renderTroops,
|
||||
{ headerVisual: troopHeaderVisual },
|
||||
),
|
||||
column(
|
||||
"maxtroops",
|
||||
"leaderboard.maxtroops",
|
||||
(player, game) => game.config().maxTroops(player),
|
||||
renderTroops,
|
||||
{
|
||||
headerVisual: {
|
||||
...troopHeaderVisual,
|
||||
superscript: { src: upperLimitIcon, white: true },
|
||||
},
|
||||
},
|
||||
),
|
||||
unitColumn("cities", "leaderboard.cities", UnitType.City, cityIcon),
|
||||
unitColumn("ports", "leaderboard.ports", UnitType.Port, portIcon),
|
||||
unitColumn(
|
||||
"factories",
|
||||
"leaderboard.factories",
|
||||
UnitType.Factory,
|
||||
factoryIcon,
|
||||
),
|
||||
unitColumn(
|
||||
"silos",
|
||||
"leaderboard.launchers",
|
||||
UnitType.MissileSilo,
|
||||
missileSiloIcon,
|
||||
),
|
||||
unitColumn("sams", "leaderboard.sams", UnitType.SAMLauncher, samLauncherIcon),
|
||||
unitColumn("warships", "leaderboard.warships", UnitType.Warship, warshipIcon),
|
||||
column(
|
||||
"allies",
|
||||
"leaderboard.allies",
|
||||
(player) => player.allies().length,
|
||||
renderNum,
|
||||
{
|
||||
headerVisual: { kind: "icon", src: allianceIcon },
|
||||
valueAlignment: "center",
|
||||
},
|
||||
),
|
||||
column(
|
||||
"betrayals",
|
||||
"leaderboard.betrayals",
|
||||
(player) => player.betrayals(),
|
||||
renderNum,
|
||||
{
|
||||
headerVisual: { kind: "icon", src: traitorIcon },
|
||||
valueAlignment: "center",
|
||||
},
|
||||
),
|
||||
] as const satisfies readonly ColumnDef[];
|
||||
|
||||
export function columnValues(
|
||||
player: PlayerView,
|
||||
game: GameView,
|
||||
columns: readonly ColumnDef[],
|
||||
): ReadonlyMap<ColumnId, number> {
|
||||
return new Map(
|
||||
columns.map((column) => [column.id, column.value(player, game)] as const),
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS_BY_ID = new Map<ColumnId, ColumnDef>(
|
||||
COLUMN_DEFS.map((column) => [column.id, column] as const),
|
||||
);
|
||||
|
||||
export function columnById(id: ColumnId): ColumnDef {
|
||||
return COLUMNS_BY_ID.get(id)!;
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
GraphicsPresets,
|
||||
GraphicsPresetsSchema,
|
||||
} from "../../client/render/gl/GraphicsOverrides";
|
||||
import {
|
||||
COLUMN_IDS,
|
||||
ColumnId,
|
||||
DEFAULT_STATS_COLUMNS,
|
||||
StatsTableKind,
|
||||
} from "../../client/StatsConstants";
|
||||
import { Cosmetics } from "../CosmeticSchemas";
|
||||
import { PlayerPattern } from "../Schemas";
|
||||
|
||||
@@ -63,6 +69,13 @@ export const KEYBINDS_KEY = "settings.keybinds";
|
||||
export const GRAPHICS_KEY = "settings.graphics";
|
||||
export const GRAPHICS_PRESETS_KEY = "settings.graphicsPresets";
|
||||
export const EFFECTS_KEY = "settings.effects";
|
||||
// Keep the existing storage key so the rename does not reset saved columns.
|
||||
export const PLAYER_STATS_COLUMNS_KEY = "settings.leaderboardColumns";
|
||||
export const TEAM_STATS_COLUMNS_KEY = "settings.teamStatsColumns";
|
||||
const STATS_COLUMNS_KEYS: Record<StatsTableKind, string> = {
|
||||
player: PLAYER_STATS_COLUMNS_KEY,
|
||||
team: TEAM_STATS_COLUMNS_KEY,
|
||||
};
|
||||
|
||||
export class UserSettings {
|
||||
private static cache = new Map<string, string | null>();
|
||||
@@ -367,6 +380,33 @@ export class UserSettings {
|
||||
else this.setString(EFFECTS_KEY, JSON.stringify(map));
|
||||
}
|
||||
|
||||
// Invalid/corrupt storage, unknown ids, or an empty result fall back to
|
||||
// defaults, matching the getSelectedEffects defensive pattern. Returned
|
||||
// order is registry (display) order regardless of stored order.
|
||||
private getColumnIds(key: string, defaults: readonly ColumnId[]): ColumnId[] {
|
||||
const raw = this.getString(key, "");
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const filtered = COLUMN_IDS.filter((id) => parsed.includes(id));
|
||||
if (filtered.length > 0) return filtered;
|
||||
}
|
||||
} catch {
|
||||
// fall through to defaults
|
||||
}
|
||||
}
|
||||
return [...defaults];
|
||||
}
|
||||
|
||||
statsColumns(kind: StatsTableKind): ColumnId[] {
|
||||
return this.getColumnIds(STATS_COLUMNS_KEYS[kind], DEFAULT_STATS_COLUMNS);
|
||||
}
|
||||
|
||||
setStatsColumns(kind: StatsTableKind, ids: ColumnId[]): void {
|
||||
this.setString(STATS_COLUMNS_KEYS[kind], JSON.stringify(ids));
|
||||
}
|
||||
|
||||
backgroundMusicVolume(): number {
|
||||
return this.getFloat("settings.backgroundMusicVolume", 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ColumnPicker } from "../src/client/hud/layers/ColumnPicker";
|
||||
import { COLUMN_DEFS } from "../src/client/hud/layers/lib/StatsColumns";
|
||||
import type { ColumnId } from "../src/client/StatsConstants";
|
||||
|
||||
describe("ColumnPicker", () => {
|
||||
it("renders its popup outside the sidebar overflow container", async () => {
|
||||
const sidebar = document.createElement("div");
|
||||
const picker = new ColumnPicker();
|
||||
picker.columns = COLUMN_DEFS;
|
||||
picker.selected = ["tiles"];
|
||||
sidebar.appendChild(picker);
|
||||
document.body.appendChild(sidebar);
|
||||
await picker.updateComplete;
|
||||
|
||||
picker.querySelector("button")?.click();
|
||||
await picker.updateComplete;
|
||||
|
||||
const popup = document.body.querySelector(".column-picker-popover");
|
||||
expect(popup).not.toBeNull();
|
||||
expect(sidebar.contains(popup)).toBe(false);
|
||||
expect(popup?.querySelectorAll('input[type="checkbox"]')).toHaveLength(
|
||||
COLUMN_DEFS.length,
|
||||
);
|
||||
|
||||
let selection: readonly ColumnId[] | null = null;
|
||||
picker.addEventListener("columns-changed", (event) => {
|
||||
selection = (event as CustomEvent<ColumnId[]>).detail;
|
||||
});
|
||||
(popup?.querySelectorAll("input")[1] as HTMLInputElement).click();
|
||||
expect(selection).toEqual(["tiles", "gold"]);
|
||||
|
||||
sidebar.remove();
|
||||
expect(document.body.querySelector(".column-picker-popover")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { GameLeftSidebar } from "../src/client/hud/layers/GameLeftSidebar";
|
||||
import type { PlayerStats } from "../src/client/hud/layers/PlayerStats";
|
||||
import type { TeamStats } from "../src/client/hud/layers/TeamStats";
|
||||
import type { GameView, PlayerView } from "../src/client/view";
|
||||
import { EventBus } from "../src/core/EventBus";
|
||||
import { GameMode } from "../src/core/game/Game";
|
||||
import { UserSettings } from "../src/core/game/UserSettings";
|
||||
|
||||
describe("GameLeftSidebar", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
(
|
||||
UserSettings as unknown as { cache: Map<string, string | null> }
|
||||
).cache.clear();
|
||||
});
|
||||
|
||||
it("owns the player and team stats tables", async () => {
|
||||
const game = {
|
||||
config: () => ({ gameConfig: () => ({ gameMode: GameMode.Team }) }),
|
||||
} as unknown as GameView;
|
||||
const eventBus = new EventBus();
|
||||
const sidebar = new GameLeftSidebar();
|
||||
sidebar.game = game;
|
||||
sidebar.eventBus = eventBus;
|
||||
document.body.append(sidebar);
|
||||
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const playerStats = sidebar.querySelector("player-stats");
|
||||
const teamStats = sidebar.querySelector("team-stats");
|
||||
expect(typeof (playerStats as PlayerStats).refresh).toBe("function");
|
||||
expect(typeof (teamStats as TeamStats).refresh).toBe("function");
|
||||
expect((playerStats as PlayerStats).game).toBe(game);
|
||||
expect((playerStats as PlayerStats).eventBus).toBe(eventBus);
|
||||
expect((teamStats as TeamStats).game).toBe(game);
|
||||
|
||||
sidebar.remove();
|
||||
});
|
||||
|
||||
it("renders the player stats table after toggling it", async () => {
|
||||
const player = {
|
||||
id: () => "player-1",
|
||||
displayName: () => "Player 1",
|
||||
numTilesOwned: () => 10,
|
||||
gold: () => 100n,
|
||||
isAlive: () => true,
|
||||
isOnSameTeam: () => false,
|
||||
team: () => null,
|
||||
} as unknown as PlayerView;
|
||||
const game = {
|
||||
config: () => ({
|
||||
gameConfig: () => ({ gameMode: GameMode.FFA }),
|
||||
maxTroops: () => 1_000,
|
||||
}),
|
||||
gameID: () => "test-game",
|
||||
inSpawnPhase: () => false,
|
||||
myPlayer: () => player,
|
||||
numLandTiles: () => 100,
|
||||
numTilesWithFallout: () => 0,
|
||||
playerViews: () => [player],
|
||||
} as unknown as GameView;
|
||||
const sidebar = new GameLeftSidebar();
|
||||
sidebar.game = game;
|
||||
sidebar.eventBus = new EventBus();
|
||||
document.body.append(sidebar);
|
||||
sidebar.init();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
(sidebar.querySelector('[role="button"]') as HTMLElement).click();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const playerStats = sidebar.querySelector("player-stats") as PlayerStats;
|
||||
await playerStats.updateComplete;
|
||||
expect(playerStats.visible).toBe(true);
|
||||
expect(playerStats.querySelector(".stats-table")).not.toBeNull();
|
||||
|
||||
expect(() => sidebar.tick()).not.toThrow();
|
||||
await playerStats.updateComplete;
|
||||
expect(playerStats.querySelector(".stats-table-row")).not.toBeNull();
|
||||
|
||||
sidebar.remove();
|
||||
});
|
||||
|
||||
it("stacks the player and team stats tables vertically at every width", async () => {
|
||||
const game = {
|
||||
config: () => ({ gameConfig: () => ({ gameMode: GameMode.Team }) }),
|
||||
} as unknown as GameView;
|
||||
const sidebar = new GameLeftSidebar();
|
||||
sidebar.game = game;
|
||||
sidebar.eventBus = new EventBus();
|
||||
document.body.append(sidebar);
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const wrapper = sidebar.querySelector("player-stats")?.parentElement;
|
||||
expect(wrapper?.classList).toContain("flex-col");
|
||||
expect(wrapper?.className).not.toMatch(/flex-wrap|lg:flex/);
|
||||
expect(sidebar.querySelector("team-stats")?.className).not.toContain(
|
||||
"lg:flex-initial",
|
||||
);
|
||||
|
||||
sidebar.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
goldCoinIcon,
|
||||
soldierIcon,
|
||||
upperLimitIcon,
|
||||
} from "../src/client/hud/HotbarIcons";
|
||||
import { PlayerStats } from "../src/client/hud/layers/PlayerStats";
|
||||
import type { GameView, PlayerView } from "../src/client/view";
|
||||
import { UserSettings } from "../src/core/game/UserSettings";
|
||||
|
||||
function player(id: string, tiles: number): PlayerView {
|
||||
return {
|
||||
id: () => id,
|
||||
displayName: () => id,
|
||||
numTilesOwned: () => tiles,
|
||||
gold: () => BigInt(tiles),
|
||||
troops: () => tiles,
|
||||
totalUnitLevels: () => tiles,
|
||||
isAlive: () => true,
|
||||
isOnSameTeam: () => false,
|
||||
} as unknown as PlayerView;
|
||||
}
|
||||
|
||||
describe("PlayerStats", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
(
|
||||
UserSettings as unknown as { cache: Map<string, string | null> }
|
||||
).cache.clear();
|
||||
});
|
||||
|
||||
it("locks rank, name, and picker while stat columns share spare width", async () => {
|
||||
const me = player("me", 3);
|
||||
const game = {
|
||||
myPlayer: () => me,
|
||||
playerViews: () => [me],
|
||||
config: () => ({ maxTroops: () => 100 }),
|
||||
numLandTiles: () => 100,
|
||||
numTilesWithFallout: () => 0,
|
||||
} as unknown as GameView;
|
||||
const playerStats = new PlayerStats();
|
||||
playerStats.game = game;
|
||||
playerStats.visible = true;
|
||||
document.body.append(playerStats);
|
||||
|
||||
playerStats.refresh();
|
||||
await playerStats.updateComplete;
|
||||
|
||||
const content = playerStats.querySelector(".stats-table-content");
|
||||
expect(content?.classList).toContain("min-w-full");
|
||||
// Only selected stat tracks share definite spare space. Rank, name, and
|
||||
// picker remain fixed and never participate in stretching.
|
||||
expect(content?.getAttribute("style")).toContain(
|
||||
"30px 100px auto auto auto 32px",
|
||||
);
|
||||
|
||||
playerStats.remove();
|
||||
});
|
||||
|
||||
it("keeps every player available without leaving a gap for the pinned row", async () => {
|
||||
const players = [
|
||||
player("one", 70),
|
||||
player("two", 60),
|
||||
player("three", 50),
|
||||
player("four", 40),
|
||||
player("five", 30),
|
||||
player("six", 20),
|
||||
player("me", 10),
|
||||
];
|
||||
const game = {
|
||||
myPlayer: () => players[6],
|
||||
playerViews: () => players,
|
||||
config: () => ({ maxTroops: () => 100 }),
|
||||
numLandTiles: () => 100,
|
||||
numTilesWithFallout: () => 0,
|
||||
} as unknown as GameView;
|
||||
const playerStats = new PlayerStats();
|
||||
playerStats.game = game;
|
||||
playerStats.visible = true;
|
||||
document.body.append(playerStats);
|
||||
|
||||
playerStats.refresh();
|
||||
await playerStats.updateComplete;
|
||||
|
||||
const scrollable = playerStats.querySelector(".stats-table-scroll");
|
||||
const pinned = playerStats.querySelector(".stats-table-pinned-row");
|
||||
const table = playerStats.querySelector(".stats-table");
|
||||
const picker = playerStats.querySelector("column-picker");
|
||||
expect(playerStats.querySelectorAll(".stats-table-row")).toHaveLength(7);
|
||||
expect(playerStats.children).toHaveLength(1);
|
||||
expect(picker?.closest(".stats-table")).toBe(table);
|
||||
expect(table?.querySelector(".stats-table-header")).not.toBeNull();
|
||||
const goldHeader = playerStats.querySelectorAll(
|
||||
'.stats-table-header > [role="columnheader"]',
|
||||
)[3];
|
||||
expect(goldHeader.querySelector("img")?.getAttribute("src")).toBe(
|
||||
goldCoinIcon,
|
||||
);
|
||||
expect(goldHeader.querySelector("button")?.getAttribute("aria-label")).toBe(
|
||||
"leaderboard.gold",
|
||||
);
|
||||
expect(scrollable).not.toBeNull();
|
||||
expect(pinned?.textContent).toContain("me");
|
||||
expect(scrollable?.contains(pinned)).toBe(false);
|
||||
expect(
|
||||
[...(scrollable?.querySelectorAll(".stats-table-row") ?? [])].map(
|
||||
(row) => row.textContent?.trim().split(/\s+/)[0],
|
||||
),
|
||||
).toEqual(["1", "2", "3", "4", "5", "6"]);
|
||||
expect(
|
||||
[...playerStats.querySelectorAll("button")].some((button) =>
|
||||
["+", "-"].includes(button.textContent?.trim() ?? ""),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
playerStats.remove();
|
||||
});
|
||||
|
||||
it("centers headers and discrete counts and renders white troop icons", async () => {
|
||||
new UserSettings().setStatsColumns("player", [
|
||||
"troops",
|
||||
"maxtroops",
|
||||
"cities",
|
||||
]);
|
||||
const me = player("me", 3);
|
||||
const game = {
|
||||
myPlayer: () => me,
|
||||
playerViews: () => [me],
|
||||
config: () => ({ maxTroops: () => 100 }),
|
||||
numLandTiles: () => 100,
|
||||
numTilesWithFallout: () => 0,
|
||||
} as unknown as GameView;
|
||||
const playerStats = new PlayerStats();
|
||||
playerStats.game = game;
|
||||
playerStats.visible = true;
|
||||
document.body.append(playerStats);
|
||||
|
||||
playerStats.refresh();
|
||||
await playerStats.updateComplete;
|
||||
|
||||
const headers = playerStats.querySelectorAll(
|
||||
'.stats-table-header > [role="columnheader"]',
|
||||
);
|
||||
const countCell = playerStats.querySelector(
|
||||
'.stats-table-row > [role="cell"]:nth-child(5)',
|
||||
);
|
||||
const troopIcon = headers[2].querySelector("img");
|
||||
const maxTroopIcons = headers[3].querySelectorAll("img");
|
||||
expect(headers[1].classList).toContain("justify-center");
|
||||
expect(headers[2].classList).toContain("justify-center");
|
||||
expect(headers[3].classList).toContain("justify-center");
|
||||
expect(headers[4].classList).toContain("justify-center");
|
||||
expect(troopIcon?.getAttribute("src")).toBe(soldierIcon);
|
||||
expect(troopIcon?.classList).toContain("brightness-0");
|
||||
expect(troopIcon?.classList).toContain("invert");
|
||||
// Max troops header reads as a troop icon raised to the upper-limit icon.
|
||||
expect(maxTroopIcons.length).toBe(2);
|
||||
expect(maxTroopIcons[0].getAttribute("src")).toBe(soldierIcon);
|
||||
expect(maxTroopIcons[0].classList).toContain("brightness-0");
|
||||
expect(maxTroopIcons[0].classList).toContain("invert");
|
||||
expect(maxTroopIcons[1].getAttribute("src")).toBe(upperLimitIcon);
|
||||
expect(maxTroopIcons[1].classList).toContain("brightness-0");
|
||||
expect(maxTroopIcons[1].classList).toContain("invert");
|
||||
expect(countCell?.classList).toContain("justify-center");
|
||||
expect(countCell?.classList).toContain("text-center");
|
||||
|
||||
playerStats.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
COLUMN_DEFS,
|
||||
columnById,
|
||||
} from "../src/client/hud/layers/lib/StatsColumns";
|
||||
import {
|
||||
COLUMN_IDS,
|
||||
DEFAULT_STATS_COLUMNS,
|
||||
} from "../src/client/StatsConstants";
|
||||
import { makeGameView, makePlayerView, stubConfig } from "./util/viewStubs";
|
||||
|
||||
describe("Stats column registry", () => {
|
||||
it("has unique column ids", () => {
|
||||
expect(new Set(COLUMN_IDS).size).toBe(COLUMN_DEFS.length);
|
||||
expect(COLUMN_DEFS.map((column) => column.id)).toEqual(COLUMN_IDS);
|
||||
});
|
||||
|
||||
it("columnById returns the matching def for every id", () => {
|
||||
for (const def of COLUMN_DEFS) {
|
||||
expect(columnById(def.id)).toBe(def);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults reference valid ids", () => {
|
||||
for (const id of DEFAULT_STATS_COLUMNS) {
|
||||
expect(COLUMN_IDS).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("evaluates and renders every column", () => {
|
||||
const game = makeGameView({
|
||||
config: stubConfig({ maxTroops: () => 1_000 }),
|
||||
});
|
||||
const player = makePlayerView({ game });
|
||||
|
||||
for (const column of COLUMN_DEFS) {
|
||||
const value = column.value(player, game);
|
||||
expect(typeof value).toBe("number");
|
||||
expect(typeof column.renderValue(value, game)).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders zero owned percentage when no valid land remains", () => {
|
||||
const game = makeGameView();
|
||||
vi.spyOn(game, "numLandTiles").mockReturnValue(0);
|
||||
vi.spyOn(game, "numTilesWithFallout").mockReturnValue(0);
|
||||
|
||||
expect(columnById("tiles").renderValue(1, game)).toBe("0.0%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { PlayerStats } from "../src/client/hud/layers/PlayerStats";
|
||||
import type { GameView, PlayerView } from "../src/client/view";
|
||||
import { UserSettings } from "../src/core/game/UserSettings";
|
||||
|
||||
function player(id: string, tiles: number): PlayerView {
|
||||
return {
|
||||
id: () => id,
|
||||
displayName: () => id,
|
||||
numTilesOwned: () => tiles,
|
||||
gold: () => BigInt(tiles),
|
||||
troops: () => tiles,
|
||||
totalUnitLevels: () => tiles,
|
||||
isAlive: () => true,
|
||||
isOnSameTeam: () => false,
|
||||
} as unknown as PlayerView;
|
||||
}
|
||||
|
||||
function gameWith(players: PlayerView[], me: PlayerView | null): GameView {
|
||||
return {
|
||||
myPlayer: () => me,
|
||||
playerViews: () => players,
|
||||
config: () => ({ maxTroops: () => 100 }),
|
||||
numLandTiles: () => 100,
|
||||
numTilesWithFallout: () => 0,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
async function mount(game: GameView): Promise<PlayerStats> {
|
||||
const playerStats = new PlayerStats();
|
||||
playerStats.game = game;
|
||||
playerStats.visible = true;
|
||||
document.body.append(playerStats);
|
||||
playerStats.refresh();
|
||||
await playerStats.updateComplete;
|
||||
return playerStats;
|
||||
}
|
||||
|
||||
function scrollRows(el: PlayerStats): string[] {
|
||||
return [...el.querySelectorAll(".stats-table-scroll .stats-table-row")].map(
|
||||
(row) => row.textContent?.trim().split(/\s+/)[0] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
// jsdom reports zero element sizes, so the component falls back to its
|
||||
// defaults: 24px rows, 180px viewport, 4 overscan rows. The expected
|
||||
// window is ceil(180 / 24) + 4 = 12 rows at scrollTop 0.
|
||||
describe("StatsTable virtualization", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
(
|
||||
UserSettings as unknown as { cache: Map<string, string | null> }
|
||||
).cache.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders only a window of rows for large player lists", async () => {
|
||||
const players = Array.from({ length: 300 }, (_, i) =>
|
||||
player(`p${i}`, 1000 - i),
|
||||
);
|
||||
const el = await mount(gameWith(players, null));
|
||||
|
||||
expect(scrollRows(el)).toEqual(
|
||||
Array.from({ length: 12 }, (_, i) => `${i + 1}`),
|
||||
);
|
||||
const bottomSpacer = el.querySelector(
|
||||
".stats-table-scroll .stats-table-spacer:last-child",
|
||||
);
|
||||
expect(bottomSpacer?.getAttribute("style")).toContain(
|
||||
`height: ${(300 - 12) * 24}px`,
|
||||
);
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("reveals later rows with a top spacer when scrolled", async () => {
|
||||
const players = Array.from({ length: 300 }, (_, i) =>
|
||||
player(`p${i}`, 1000 - i),
|
||||
);
|
||||
const el = await mount(gameWith(players, null));
|
||||
|
||||
const scroller = el.querySelector<HTMLElement>(".stats-table-scroll")!;
|
||||
scroller.scrollTop = 240;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await el.updateComplete;
|
||||
|
||||
// first = floor(240/24) - 4 = 6; last = ceil((240+180)/24) + 4 = 22
|
||||
expect(scrollRows(el)).toEqual(
|
||||
Array.from({ length: 16 }, (_, i) => `${i + 7}`),
|
||||
);
|
||||
const topSpacer = scroller.querySelector(".stats-table-spacer");
|
||||
expect(topSpacer?.getAttribute("style")).toContain(`height: ${6 * 24}px`);
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("renders the pinned row even when it is far outside the window", async () => {
|
||||
const players = Array.from({ length: 299 }, (_, i) =>
|
||||
player(`p${i}`, 1000 - i),
|
||||
);
|
||||
const me = player("me", 1);
|
||||
players.push(me);
|
||||
const el = await mount(gameWith(players, me));
|
||||
|
||||
const pinned = el.querySelector(".stats-table-pinned-row");
|
||||
expect(pinned?.textContent).toContain("me");
|
||||
expect(pinned?.textContent?.trim().split(/\s+/)[0]).toBe("300");
|
||||
expect(scrollRows(el)).toEqual(
|
||||
Array.from({ length: 12 }, (_, i) => `${i + 1}`),
|
||||
);
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("renders small lists fully without spacers", async () => {
|
||||
const players = Array.from({ length: 7 }, (_, i) =>
|
||||
player(`p${i}`, 100 - i),
|
||||
);
|
||||
const el = await mount(gameWith(players, null));
|
||||
|
||||
expect(scrollRows(el)).toEqual(["1", "2", "3", "4", "5", "6", "7"]);
|
||||
expect(el.querySelector(".stats-table-spacer")).toBeNull();
|
||||
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ColumnDef } from "../src/client/hud/layers/lib/StatsColumns";
|
||||
import {
|
||||
aggregateTeamValues,
|
||||
TeamStats,
|
||||
} from "../src/client/hud/layers/TeamStats";
|
||||
import type { GameView, PlayerView } from "../src/client/view";
|
||||
import { PlayerType } from "../src/core/game/Game";
|
||||
import { UserSettings } from "../src/core/game/UserSettings";
|
||||
import { playerInfo, setup } from "./util/Setup";
|
||||
|
||||
describe("aggregateTeamValues", () => {
|
||||
it("sums values for alive players only", async () => {
|
||||
const game = await setup("plains", {}, [
|
||||
playerInfo("alive", PlayerType.Human),
|
||||
playerInfo("other-alive", PlayerType.Human),
|
||||
playerInfo("dead", PlayerType.Human),
|
||||
]);
|
||||
const alivePlayer = game.player("alive");
|
||||
const otherAlivePlayer = game.player("other-alive");
|
||||
const deadPlayer = game.player("dead");
|
||||
|
||||
for (let x = 0; x < 12; x++) alivePlayer.conquer(game.ref(x, 0));
|
||||
for (let x = 0; x < 8; x++) otherAlivePlayer.conquer(game.ref(x, 1));
|
||||
alivePlayer.addGold(50n);
|
||||
otherAlivePlayer.addGold(30n);
|
||||
deadPlayer.addGold(100n);
|
||||
|
||||
const selected: ColumnDef[] = [
|
||||
{
|
||||
id: "tiles",
|
||||
labelKey: "leaderboard.owned",
|
||||
valueAlignment: "end",
|
||||
value: (player) => player.numTilesOwned(),
|
||||
renderValue: (value) => `tiles:${value}`,
|
||||
},
|
||||
{
|
||||
id: "gold",
|
||||
labelKey: "leaderboard.gold",
|
||||
valueAlignment: "end",
|
||||
value: (player) => Number(player.gold()),
|
||||
renderValue: (value) => `gold:${value}`,
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
aggregateTeamValues(
|
||||
game.allPlayers() as unknown as PlayerView[],
|
||||
selected,
|
||||
game as unknown as GameView,
|
||||
),
|
||||
),
|
||||
).toEqual({ tiles: 20, gold: 80 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TeamStats", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
(
|
||||
UserSettings as unknown as { cache: Map<string, string | null> }
|
||||
).cache.clear();
|
||||
});
|
||||
|
||||
it("renders team ranks with the shared stats table", async () => {
|
||||
const players = [
|
||||
{
|
||||
id: () => "blue-player",
|
||||
team: () => "Blue",
|
||||
numTilesOwned: () => 10,
|
||||
gold: () => 10n,
|
||||
isAlive: () => true,
|
||||
},
|
||||
{
|
||||
id: () => "red-player",
|
||||
team: () => "Red",
|
||||
numTilesOwned: () => 5,
|
||||
gold: () => 20n,
|
||||
isAlive: () => true,
|
||||
},
|
||||
] as unknown as PlayerView[];
|
||||
const game = {
|
||||
myPlayer: () => players[0],
|
||||
playerViews: () => players,
|
||||
config: () => ({ maxTroops: () => 100 }),
|
||||
numLandTiles: () => 100,
|
||||
numTilesWithFallout: () => 0,
|
||||
} as unknown as GameView;
|
||||
const teamStats = new TeamStats();
|
||||
teamStats.game = game;
|
||||
teamStats.visible = true;
|
||||
document.body.append(teamStats);
|
||||
|
||||
teamStats.refresh();
|
||||
await teamStats.updateComplete;
|
||||
|
||||
const table = teamStats.querySelector(".stats-table");
|
||||
const picker = teamStats.querySelector("column-picker");
|
||||
const rowCells = teamStats.querySelectorAll(
|
||||
'.stats-table-row > [role="cell"]',
|
||||
);
|
||||
expect(teamStats.children).toHaveLength(1);
|
||||
expect(picker?.closest(".stats-table")).toBe(table);
|
||||
expect(table?.querySelector(".stats-table-header")).not.toBeNull();
|
||||
expect(rowCells[0]?.textContent?.trim()).toBe("1");
|
||||
expect(rowCells[1]?.textContent?.trim()).toContain("Blue");
|
||||
|
||||
const headers = teamStats.querySelectorAll(
|
||||
'.stats-table-header > [role="columnheader"]',
|
||||
);
|
||||
(headers[3].querySelector("button") as HTMLButtonElement).click();
|
||||
await teamStats.updateComplete;
|
||||
expect(
|
||||
teamStats.querySelectorAll('.stats-table-row > [role="cell"]')[1]
|
||||
?.textContent,
|
||||
).toContain("Red");
|
||||
|
||||
teamStats.remove();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import { EFFECTS_KEY, UserSettings } from "../src/core/game/UserSettings";
|
||||
import {
|
||||
EFFECTS_KEY,
|
||||
PLAYER_STATS_COLUMNS_KEY,
|
||||
TEAM_STATS_COLUMNS_KEY,
|
||||
UserSettings,
|
||||
} from "../src/core/game/UserSettings";
|
||||
|
||||
describe("UserSettings effect selection", () => {
|
||||
beforeEach(() => {
|
||||
@@ -58,3 +63,75 @@ describe("UserSettings effect selection", () => {
|
||||
expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserSettings stats columns", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
(
|
||||
UserSettings as unknown as { cache: Map<string, string | null> }
|
||||
).cache.clear();
|
||||
});
|
||||
|
||||
it("returns defaults when nothing is stored", () => {
|
||||
expect(new UserSettings().statsColumns("player")).toEqual([
|
||||
"tiles",
|
||||
"gold",
|
||||
"maxtroops",
|
||||
]);
|
||||
expect(new UserSettings().statsColumns("team")).toEqual([
|
||||
"tiles",
|
||||
"gold",
|
||||
"maxtroops",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips a selection in registry order", () => {
|
||||
const s = new UserSettings();
|
||||
// Stored order is check order; getter returns registry (display) order.
|
||||
s.setStatsColumns("player", ["warships", "gold"]);
|
||||
expect(s.statsColumns("player")).toEqual(["gold", "warships"]);
|
||||
});
|
||||
|
||||
it("filters unknown ids", () => {
|
||||
localStorage.setItem(
|
||||
PLAYER_STATS_COLUMNS_KEY,
|
||||
JSON.stringify(["gold", "bogus"]),
|
||||
);
|
||||
expect(new UserSettings().statsColumns("player")).toEqual(["gold"]);
|
||||
});
|
||||
|
||||
it("drops the removed attacks column from persisted selections", () => {
|
||||
localStorage.setItem(
|
||||
PLAYER_STATS_COLUMNS_KEY,
|
||||
JSON.stringify(["attacks", "gold"]),
|
||||
);
|
||||
expect(new UserSettings().statsColumns("player")).toEqual(["gold"]);
|
||||
});
|
||||
|
||||
it("falls back to defaults on corrupt JSON", () => {
|
||||
localStorage.setItem(PLAYER_STATS_COLUMNS_KEY, "not json");
|
||||
expect(new UserSettings().statsColumns("player")).toEqual([
|
||||
"tiles",
|
||||
"gold",
|
||||
"maxtroops",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to defaults when no valid ids remain", () => {
|
||||
localStorage.setItem(PLAYER_STATS_COLUMNS_KEY, JSON.stringify(["bogus"]));
|
||||
expect(new UserSettings().statsColumns("player")).toEqual([
|
||||
"tiles",
|
||||
"gold",
|
||||
"maxtroops",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps player and team selections independent", () => {
|
||||
const s = new UserSettings();
|
||||
s.setStatsColumns("player", ["gold"]);
|
||||
s.setStatsColumns("team", ["warships"]);
|
||||
expect(s.statsColumns("player")).toEqual(["gold"]);
|
||||
expect(s.statsColumns("team")).toEqual(["warships"]);
|
||||
expect(localStorage.getItem(TEAM_STATS_COLUMNS_KEY)).toBe('["warships"]');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user