diff --git a/index.html b/index.html index 51e4d8200..96ecbd3a4 100644 --- a/index.html +++ b/index.html @@ -378,8 +378,6 @@ - - + + upper-limit + + + + + + + + \ No newline at end of file diff --git a/resources/lang/en.json b/resources/lang/en.json index bf2eb1cf6..25c99b363 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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": { diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 186f5ec32..85d94aef6 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -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", diff --git a/src/client/StatsConstants.ts b/src/client/StatsConstants.ts new file mode 100644 index 000000000..15ce8c58c --- /dev/null +++ b/src/client/StatsConstants.ts @@ -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"; diff --git a/src/client/components/StatsTable.ts b/src/client/components/StatsTable.ts new file mode 100644 index 000000000..d2c6bdc32 --- /dev/null +++ b/src/client/components/StatsTable.ts @@ -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; + emphasized?: boolean; + pinned?: boolean; + onClick?: () => void; +} + +interface RenderedStatsRow extends Omit { + 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) { + 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) { + 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` +
+
+ ${row.position} +
+
+ ${row.name} +
+ ${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` +
+ ${cell} +
+ `; + }, + )} + +
+ `; + + return html` +
+
event.preventDefault()} + > +
+
+
+ # +
+
+ ${translateText(this.nameLabelKey)} +
+ ${repeat( + selected, + (column) => column.id, + (column) => { + const label = translateText(column.labelKey); + return html` +
+ +
+ `; + }, + )} +
+ column.id)} + @columns-changed=${this.onColumnsChanged} + > +
+
+ +
+ ${topSpacerPx > 0 + ? html`` + : 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`` + : nothing} +
+ + ${pinnedRow === null ? nothing : renderRow(pinnedRow, "", true)} +
+
+
+ `; + } +} diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index dc1f92b64..6a5c8a472 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -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, diff --git a/src/client/hud/HotbarIcons.ts b/src/client/hud/HotbarIcons.ts new file mode 100644 index 000000000..c8f9bdeca --- /dev/null +++ b/src/client/hud/HotbarIcons.ts @@ -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"); diff --git a/src/client/hud/layers/ColumnPicker.ts b/src/client/hud/layers/ColumnPicker.ts new file mode 100644 index 000000000..1902d1fe7 --- /dev/null +++ b/src/client/hud/layers/ColumnPicker.ts @@ -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) 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("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` +
+ ${this.columns.map((column) => { + const checked = this.selected.includes(column.id); + return html` + + `; + })} +
+ `, + this.portal, + ); + } + + private removePortal() { + this.portal?.remove(); + this.portal = null; + } + + render() { + return html` + + `; + } +} diff --git a/src/client/hud/layers/ControlPanel.ts b/src/client/hud/layers/ControlPanel.ts index 3ba34b2af..2874cf48d 100644 --- a/src/client/hud/layers/ControlPanel.ts +++ b/src/client/hud/layers/ControlPanel.ts @@ -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") diff --git a/src/client/hud/layers/GameLeftSidebar.ts b/src/client/hud/layers/GameLeftSidebar.ts index a96311bfb..86f60e756 100644 --- a/src/client/hud/layers/GameLeftSidebar.ts +++ b/src/client/hud/layers/GameLeftSidebar.ts @@ -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`