diff --git a/resources/lang/en.json b/resources/lang/en.json index f4165a490..55de8cf91 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -729,5 +729,17 @@ "map": "Map", "difficulty": "Difficulty", "type": "Type" + }, + "player_stats_tree": { + "public": "Public", + "private": "Private", + "singleplayer": "Single Player", + "mode": "Mode", + "stats_wins": "Wins", + "stats_losses": "Losses", + "stats_wlr": "Win:Loss Ratio", + "stats_games_played": "Games Played", + "mode_ffa": "Free-for-All", + "mode_team": "Team" } } diff --git a/src/client/components/baseComponents/stats/PlayerStatsGrid.ts b/src/client/components/baseComponents/stats/PlayerStatsGrid.ts new file mode 100644 index 000000000..5387b6764 --- /dev/null +++ b/src/client/components/baseComponents/stats/PlayerStatsGrid.ts @@ -0,0 +1,54 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("player-stats-grid") +export class PlayerStatsGrid extends LitElement { + static styles = css` + .grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + @media (min-width: 640px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } + } + .stat { + text-align: center; + color: white; + font-size: 1rem; + } + .stat-title { + color: #bbb; + font-size: 0.9rem; + } + .stat-value { + font-size: 1.25rem; + font-weight: bold; + } + `; + + @property({ type: Array }) titles: string[] = []; + @property({ type: Array }) values: Array = []; + + // Currently fixed to display 4 stats (can be changed if needed) + private readonly VISIBLE_STATS_COUNT = 4; + + render() { + return html` +
+ ${Array(this.VISIBLE_STATS_COUNT) + .fill(0) + .map( + (_, i) => html` +
+
${this.values[i] ?? ""}
+
${this.titles[i] ?? ""}
+
+ `, + )} +
+ `; + } +} diff --git a/src/client/components/baseComponents/stats/PlayerStatsTree.ts b/src/client/components/baseComponents/stats/PlayerStatsTree.ts new file mode 100644 index 000000000..4527529a2 --- /dev/null +++ b/src/client/components/baseComponents/stats/PlayerStatsTree.ts @@ -0,0 +1,202 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas"; +import { + Difficulty, + GameMode, + GameType, + isDifficulty, + isGameMode, + isGameType, +} from "../../../../core/game/Game"; +import { PlayerStats } from "../../../../core/StatsSchemas"; +import { renderNumber, translateText } from "../../../Utils"; +import "./PlayerStatsGrid"; +import "./PlayerStatsTable"; + +@customElement("player-stats-tree-view") +export class PlayerStatsTreeView extends LitElement { + @property({ type: Object }) statsTree?: PlayerStatsTree; + @state() selectedType: GameType = GameType.Public; + @state() selectedMode: GameMode = GameMode.FFA; + @state() selectedDifficulty: Difficulty = Difficulty.Medium; + + private get availableTypes(): GameType[] { + if (!this.statsTree) return []; + return Object.keys(this.statsTree).filter(isGameType); + } + + private get availableModes(): GameMode[] { + const typeNode = this.statsTree?.[this.selectedType]; + if (!typeNode) return []; + return Object.keys(typeNode).filter(isGameMode); + } + + private get availableDifficulties(): Difficulty[] { + const typeNode = this.statsTree?.[this.selectedType]; + const modeNode = typeNode?.[this.selectedMode]; + if (!modeNode) return []; + return Object.keys(modeNode).filter(isDifficulty); + } + + private labelForMode(m: GameMode) { + return m === GameMode.FFA + ? translateText("player_stats_tree.mode_ffa") + : translateText("player_stats_tree.mode_team"); + } + + createRenderRoot() { + return this; + } + + private getSelectedLeaf(): PlayerStatsLeaf | null { + const typeNode = this.statsTree?.[this.selectedType]; + if (!typeNode) return null; + const modeNode = typeNode[this.selectedMode]; + if (!modeNode) return null; + const diffNode = modeNode[this.selectedDifficulty]; + if (!diffNode) return null; + return diffNode; + } + + private getDisplayedStats(): PlayerStats | null { + const leaf = this.getSelectedLeaf(); + if (!leaf || !leaf.stats) return null; + return leaf.stats; + } + + private setGameType(t: GameType) { + if (this.selectedType === t) return; + this.selectedType = t; + const modes = this.availableModes; + if (!modes.includes(this.selectedMode)) { + this.selectedMode = modes[0] ?? this.selectedMode; + } + const diffs = this.availableDifficulties; + if (!diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; + } + this.requestUpdate(); + } + + private setMode(m: GameMode) { + if (this.selectedMode === m) return; + this.selectedMode = m; + const diffs = this.availableDifficulties; + if (!diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; + } + this.requestUpdate(); + } + + private setDifficulty(d: Difficulty) { + if (this.selectedDifficulty === d) return; + this.selectedDifficulty = d; + this.requestUpdate(); + } + + render() { + const types = this.availableTypes; + if (types.length && !types.includes(this.selectedType)) { + this.selectedType = types[0]; + } + const modes = this.availableModes; + if (modes.length && !modes.includes(this.selectedMode)) { + this.selectedMode = modes[0]; + } + const diffs = this.availableDifficulties; + if (diffs.length && !diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0]; + } + + const leaf = this.getSelectedLeaf(); + const wlr = leaf + ? leaf.losses === 0n + ? Number(leaf.wins) + : Number(leaf.wins) / Number(leaf.losses) + : 0; + + return html` + +
+ ${types.map( + (t) => html` + + `, + )} +
+ + ${modes.length + ? html`
+ ${modes.map( + (m) => html` + + `, + )} +
` + : html``} + + ${diffs.length + ? html`
+ ${diffs.map( + (d) => + html` `, + )} +
` + : html``} + ${leaf + ? html` +
+ +
+ + ` + : html``} + `; + } +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 2a2432905..03c828fcc 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -11,6 +11,13 @@ import { RailNetwork } from "./RailNetwork"; import { Stats } from "./Stats"; import { UnitPredicate } from "./UnitGrid"; +function isEnumValue>( + enumObj: T, + value: unknown, +): value is T[keyof T] { + return Object.values(enumObj).includes(value as T[keyof T]); +} + export type PlayerID = string; export type Tick = number; export type Gold = bigint; @@ -37,6 +44,8 @@ export enum Difficulty { Hard = "Hard", Impossible = "Impossible", } +export const isDifficulty = (value: unknown): value is Difficulty => + isEnumValue(Difficulty, value); export type Team = string; @@ -134,11 +143,15 @@ export enum GameType { Public = "Public", Private = "Private", } +export const isGameType = (value: unknown): value is GameType => + isEnumValue(GameType, value); export enum GameMode { FFA = "Free For All", Team = "Team", } +export const isGameMode = (value: unknown): value is GameMode => + isEnumValue(GameMode, value); export enum GameMapSize { Compact = "Compact",