diff --git a/resources/lang/en.json b/resources/lang/en.json index b4352e8c9..20fa90e52 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -546,10 +546,8 @@ "game_info_modal": { "all_gold": "All gold", "atoms": "Atoms", - "bombs": "Bombs", "conquered": "Conquered", "conquest_gold": "Conquered player gold", - "conquests": "Conquests", "duration": "Duration", "economy": "Economy", "hydros": "Hydros", @@ -560,14 +558,15 @@ "no_winner": "This game ended with no winner (or a Nation won)", "num_of_conquests_bots": "Tribe kills", "num_of_conquests_humans": "Player kills", + "num_of_conquests_nations": "Nation kills", "pirate": "Pirate", "players": "Players", "retry": "Retry", "stolen_gold": "Stolen with warships", "survival_time": "Survival time", "total_gold": "Total", - "trade": "Trade", "train_trade": "Train", + "unknown_game_type": "Unknown game type", "war": "War" }, "game_list": { diff --git a/src/client/GameStatsModal.ts b/src/client/GameStatsModal.ts index f3bdaeb6e..6a6a578c0 100644 --- a/src/client/GameStatsModal.ts +++ b/src/client/GameStatsModal.ts @@ -2,6 +2,7 @@ import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import "./components/baseComponents/stats/GameInfoView"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import { modalHeader } from "./components/ui/ModalHeader"; import { translateText } from "./Utils"; @@ -12,20 +13,33 @@ export class GameStatsModal extends BaseModal { @state() private gameId: string | null = null; private openedFrom: "account" | "clan" | null = null; + protected modalConfig() { + return { maxWidth: "960px" }; + } + protected renderHeaderSlot() { return modalHeader({ title: translateText("game_list.stats"), onBack: () => this.back(), ariaLabel: translateText("common.back"), + rightContent: this.gameId + ? html` + + ` + : undefined, }); } protected renderBody() { return html` -
-
- -
+
+
`; } diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index f5426c796..4d0832691 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -236,7 +236,6 @@ export class LangSelector extends LitElement { "game-stats-modal", "game-info-view", "ranking-controls", - "ranking-header", "leaderboard-modal", "flag-input-modal", "flag-input", diff --git a/src/client/components/baseComponents/ranking/GameInfoRanking.ts b/src/client/components/baseComponents/ranking/GameInfoRanking.ts index e3be802a7..4642ace8c 100644 --- a/src/client/components/baseComponents/ranking/GameInfoRanking.ts +++ b/src/client/components/baseComponents/ranking/GameInfoRanking.ts @@ -12,6 +12,7 @@ import { export enum RankType { ConquestHumans = "ConquestHumans", + ConquestNations = "ConquestNations", ConquestBots = "ConquestBots", Atoms = "Atoms", Hydros = "Hydros", @@ -24,6 +25,21 @@ export enum RankType { Lifetime = "Lifetime", } +export const RANK_TYPE_LABEL_KEYS: Record = { + [RankType.Lifetime]: "game_info_modal.survival_time", + [RankType.ConquestHumans]: "game_info_modal.num_of_conquests_humans", + [RankType.ConquestNations]: "game_info_modal.num_of_conquests_nations", + [RankType.ConquestBots]: "game_info_modal.num_of_conquests_bots", + [RankType.Atoms]: "game_info_modal.atoms", + [RankType.Hydros]: "game_info_modal.hydros", + [RankType.MIRV]: "game_info_modal.mirv", + [RankType.TotalGold]: "game_info_modal.all_gold", + [RankType.StolenGold]: "game_info_modal.stolen_gold", + [RankType.NavalTrade]: "game_info_modal.naval_trade", + [RankType.TrainTrade]: "game_info_modal.train_trade", + [RankType.ConqueredGold]: "game_info_modal.conquest_gold", +}; + export interface PlayerInfo { id: string; username: string; @@ -84,7 +100,10 @@ export class Ranking { clanTag: player.clanTag, conquests, flag: player.cosmetics?.flag ?? undefined, - killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined, + killedAt: + stats.killedAt === undefined || stats.killedAt === null + ? undefined + : Number(stats.killedAt), gold, atoms: Number(stats.bombs?.abomb?.[0]) || 0, hydros: Number(stats.bombs?.hbomb?.[0]) || 0, @@ -119,17 +138,16 @@ export class Ranking { private getScore(player: PlayerInfo, type: RankType): number { switch (type) { case RankType.Lifetime: - if (player.killedAt) { + if (player.killedAt !== undefined) { return (player.killedAt / Math.max(this.duration, 1)) * 10; } return 100; case RankType.ConquestHumans: return Number(player.conquests[PLAYER_INDEX_HUMAN] ?? 0n); + case RankType.ConquestNations: + return Number(player.conquests[PLAYER_INDEX_NATION] ?? 0n); case RankType.ConquestBots: - return ( - Number(player.conquests[PLAYER_INDEX_BOT] ?? 0n) + - Number(player.conquests[PLAYER_INDEX_NATION] ?? 0n) - ); + return Number(player.conquests[PLAYER_INDEX_BOT] ?? 0n); case RankType.Atoms: return player.atoms; case RankType.Hydros: diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts index d5ae22364..e46d84bda 100644 --- a/src/client/components/baseComponents/ranking/PlayerRow.ts +++ b/src/client/components/baseComponents/ranking/PlayerRow.ts @@ -1,13 +1,15 @@ -import { LitElement, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; import { - GOLD_INDEX_TRADE, - GOLD_INDEX_TRAIN_OTHER, - GOLD_INDEX_TRAIN_SELF, -} from "src/core/StatsSchemas"; + LitElement, + html, + type PropertyValues, + type TemplateResult, +} from "lit"; +import { customElement, property } from "lit/decorators.js"; import { assetUrl } from "../../../../core/AssetUrls"; -import { renderNumber } from "../../../Utils"; -import { PlayerInfo, RankType } from "./GameInfoRanking"; +import { renderNumber, translateText } from "../../../Utils"; +import { PlayerInfo, RANK_TYPE_LABEL_KEYS, RankType } from "./GameInfoRanking"; + +const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg"); @customElement("player-row") export class PlayerRow extends LitElement { @@ -18,6 +20,8 @@ export class PlayerRow extends LitElement { @property({ type: Number }) score = 0; @property({ type: Boolean }) currentPlayer = false; + private failedFlag: string | null = null; + createRenderRoot() { return this; } @@ -25,39 +29,125 @@ export class PlayerRow extends LitElement { render() { if (!this.player) return html``; const { player } = this; - const visibleBorder = player.winner || this.currentPlayer; return html`
  • -
    - ${this.rank} + ${player.winner + ? html`
    ` + : ""} + ${this.renderRank()} ${this.renderIdentity()} +
    + ${this.renderPlayerInfo()}
    - ${this.renderPlayerInfo()}
  • `; } + protected willUpdate(changed: PropertyValues): void { + if (changed.has("player")) { + const previous = changed.get("player") as PlayerInfo | undefined; + if (previous?.flag !== this.player?.flag) this.failedFlag = null; + } + } + + private renderRank(): TemplateResult { + const rankClass = + { + 1: "border-yellow-400/20 bg-yellow-400/10 text-yellow-300", + 2: "border-slate-300/15 bg-slate-300/10 text-slate-300", + 3: "border-amber-600/20 bg-amber-600/10 text-amber-500", + }[this.rank] ?? "border-white/[0.06] bg-white/[0.035] text-white/35"; + return html` +
    + ${this.rank} +
    + `; + } + private renderPlayerIcon() { return html` - ${this.renderIcon()} ${this.player.winner ? this.renderCrownIcon() : ""} +
    + ${this.renderIcon()} + ${this.player.winner + ? this.renderCrownIcon() + : this.player.killedAt !== undefined + ? this.renderEliminatedIcon() + : ""} +
    `; } private renderCrownIcon() { return html` - + + + + `; + } + + private renderEliminatedIcon(): TemplateResult { + return html` + + + + `; + } + + private renderIdentity(): TemplateResult { + return html` +
    + ${this.renderPlayerIcon()} +
    + ${this.player.clanTag + ? html`
    + ${this.player.clanTag} +
    ` + : ""} +
    + ${this.player.username} +
    +
    +
    `; } @@ -65,6 +155,7 @@ export class PlayerRow extends LitElement { switch (this.rankType) { case RankType.Lifetime: case RankType.ConquestHumans: + case RankType.ConquestNations: case RankType.ConquestBots: return this.renderScoreAsBar(); case RankType.Atoms: @@ -84,32 +175,42 @@ export class PlayerRow extends LitElement { } private renderScoreAsBar() { + const isLifetime = this.rankType === RankType.Lifetime; + const formattedScore = `${Number(this.score).toFixed(0)}${ + isLifetime ? "%" : "" + }`; return html` -
    - ${this.renderPlayerIcon()} -
    - ${this.renderPlayerName()} ${this.renderScoreBar()} -
    -
    -
    +
    + ${this.renderScoreBar(formattedScore)}
    `; } - private renderScoreBar() { + private renderScoreBar(formattedScore: string) { const bestScore = Math.max(this.bestScore, 1); + const currentScore = Math.max(this.score, 0); + const accessibleMax = Math.max(bestScore, currentScore); const width = Math.min(Math.max((this.score / bestScore) * 100, 0), 100); return html` -
    -
    - +
    +
    @@ -117,159 +218,90 @@ export class PlayerRow extends LitElement { `; } - private renderMultiScoreType(value: number, highlight: boolean) { - return html` -
    - ${renderNumber(value)} -
    - `; - } - - private renderAllBombs() { - return html` -
    - ${this.renderMultiScoreType( - this.player.atoms, - this.rankType === RankType.Atoms, - )} - / - ${this.renderMultiScoreType( - this.player.hydros, - this.rankType === RankType.Hydros, - )} - / - ${this.renderMultiScoreType( - this.player.mirv, - this.rankType === RankType.MIRV, - )} -
    - `; - } - - private renderAllTrades() { - const navalTrade = this.player.gold[GOLD_INDEX_TRADE] ?? 0n; - const ownTrainTrade = this.player.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n; - const otherTrainTrade = this.player.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n; - return html` -
    - ${this.renderMultiScoreType( - Number(ownTrainTrade + otherTrainTrade), - this.rankType === RankType.TrainTrade, - )} - / - ${this.renderMultiScoreType( - Number(navalTrade), - this.rankType === RankType.NavalTrade, - )} -
    - `; - } - private renderBombScore() { - return html` -
    - ${this.renderPlayerIcon()} -
    - ${this.renderPlayerName()} ${this.renderAllBombs()} -
    -
    - `; + return this.renderValueScore(false); } private renderGoldScore() { - return html` -
    - ${this.renderPlayerIcon()} -
    ${this.renderPlayerName()}
    -
    - -
    -
    - ${renderNumber(this.score)} -
    - -
    - `; + return this.renderValueScore(true); } private renderTradeScore() { - return html` -
    -
    - ${this.renderPlayerIcon()} -
    - ${this.renderPlayerName()} -
    -
    - -
    -
    - ${this.renderAllTrades()} -
    - -
    -
    - `; + return this.renderValueScore(true); } - private renderPlayerName() { - return html` -
    - ${this.player.clanTag ? this.renderTag(this.player.clanTag) : ""} -
    - ${this.player.username} -
    -
    - `; - } - - private renderTag(tag: string) { + private renderValueScore(showCoin: boolean) { + const formattedScore = renderNumber(this.score); return html`
    - ${tag} + ${showCoin ? this.renderCoinIcon() : ""} +
    ${formattedScore}
    `; } + private scoreLabel(): string { + return `${this.player.username}: ${translateText( + RANK_TYPE_LABEL_KEYS[this.rankType], + )}`; + } + private renderIcon() { - if (this.player.killedAt) { - return html`
    - 💀 -
    `; - } else if (this.player.flag) { + const flagUrl = this.getFlagUrl(); + if (flagUrl) { return html` this.handleFlagError()} + class="size-10 rounded-xl border border-white/10 bg-white/[0.055] object-contain p-1" />`; } return html` `; } + + private getFlagUrl(): string | null { + if (!this.player.flag || this.failedFlag === this.player.flag) return null; + try { + return assetUrl(this.player.flag); + } catch { + return null; + } + } + + private handleFlagError(): void { + this.failedFlag = this.player.flag ?? null; + this.requestUpdate(); + } + + private initials(): string { + const normalized = this.player.username.trim(); + return normalized.length > 0 ? normalized.slice(0, 2).toUpperCase() : "?"; + } + + private renderCoinIcon(): TemplateResult { + return html``; + } } diff --git a/src/client/components/baseComponents/ranking/RankingControls.ts b/src/client/components/baseComponents/ranking/RankingControls.ts index 13cd7f33a..1fdfd54c1 100644 --- a/src/client/components/baseComponents/ranking/RankingControls.ts +++ b/src/client/components/baseComponents/ranking/RankingControls.ts @@ -1,34 +1,42 @@ -import { LitElement, html } from "lit"; +import { LitElement, html, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { translateText } from "../../../Utils"; import { RankType } from "./GameInfoRanking"; -const economyRankings = new Set([ - RankType.TotalGold, - RankType.StolenGold, - RankType.ConqueredGold, - RankType.NavalTrade, - RankType.TrainTrade, -]); -const warRankings = new Set([ - RankType.ConquestHumans, - RankType.ConquestBots, - RankType.Atoms, - RankType.Hydros, - RankType.MIRV, -]); -const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]); -const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]); -const conquestRankings = new Set([ - RankType.ConquestHumans, - RankType.ConquestBots, -]); +type Metric = { type: RankType; label: string }; -const isEconomyRanking = (t: RankType) => economyRankings.has(t); -const isTradeRanking = (t: RankType) => tradeRankings.has(t); -const isBombRanking = (t: RankType) => bombRankings.has(t); -const isWarRanking = (t: RankType) => warRankings.has(t); -const isConquestRanking = (t: RankType) => conquestRankings.has(t); +const warMetrics: readonly Metric[] = [ + { + type: RankType.ConquestHumans, + label: "game_info_modal.num_of_conquests_humans", + }, + { + type: RankType.ConquestNations, + label: "game_info_modal.num_of_conquests_nations", + }, + { + type: RankType.ConquestBots, + label: "game_info_modal.num_of_conquests_bots", + }, + { type: RankType.Atoms, label: "game_info_modal.atoms" }, + { type: RankType.Hydros, label: "game_info_modal.hydros" }, + { type: RankType.MIRV, label: "game_info_modal.mirv" }, +]; + +const economyMetrics: readonly Metric[] = [ + { type: RankType.TotalGold, label: "game_info_modal.total_gold" }, + { type: RankType.ConqueredGold, label: "game_info_modal.conquered" }, + { type: RankType.StolenGold, label: "game_info_modal.pirate" }, + { type: RankType.TrainTrade, label: "game_info_modal.train_trade" }, + { type: RankType.NavalTrade, label: "game_info_modal.naval_trade" }, +]; + +const includesMetric = (metrics: readonly Metric[], type: RankType) => + metrics.some((metric) => metric.type === type); + +const isEconomyRanking = (type: RankType) => + includesMetric(economyMetrics, type); +const isWarRanking = (type: RankType) => includesMetric(warMetrics, type); @customElement("ranking-controls") export class RankingControls extends LitElement { @@ -40,98 +48,159 @@ export class RankingControls extends LitElement { private renderMainButtons() { return html` -
    +
    ${this.renderButton( RankType.Lifetime, this.rankType === RankType.Lifetime, "game_info_modal.duration", + this.renderClockIcon(), )} ${this.renderButton( RankType.ConquestHumans, isWarRanking(this.rankType), "game_info_modal.war", + this.renderWarIcon(), )} ${this.renderButton( RankType.TotalGold, isEconomyRanking(this.rankType), "game_info_modal.economy", + this.renderEconomyIcon(), )}
    `; } - private renderButton(type: RankType, active: boolean, label: string) { + private renderButton( + type: RankType, + active: boolean, + label: string, + icon: TemplateResult, + ) { return html` `; } - private renderWarSubranking() { - if (!isWarRanking(this.rankType)) return ""; - + private renderMetricSelector(metrics: readonly Metric[], ariaLabel: string) { + const hasSixMetrics = metrics.length === 6; return html` -
    - ${this.renderSubButton( - RankType.MIRV, - isBombRanking(this.rankType), - "game_info_modal.bombs", - )} - ${this.renderSubButton( - RankType.ConquestHumans, - isConquestRanking(this.rankType), - "game_info_modal.conquests", +
    + ${metrics.map((metric, index) => + this.renderMetricButton( + metric.type, + metric.label, + hasSixMetrics + ? "col-span-2" + : index < 2 + ? "col-span-3" + : "col-span-2", + ), )}
    `; } - private renderEconomySubranking() { - if (!isEconomyRanking(this.rankType)) return ""; - - const econButtons = [ - [RankType.StolenGold, "game_info_modal.pirate"], - [RankType.ConqueredGold, "game_info_modal.conquered"], - [RankType.TotalGold, "game_info_modal.total_gold"], - ]; - - return html` -
    - ${this.renderSubButton( - RankType.NavalTrade, - isTradeRanking(this.rankType), - "game_info_modal.trade", - )} - ${econButtons.map(([type, label]) => - this.renderSubButton(type as RankType, this.rankType === type, label), - )} -
    - `; - } - - private renderSubButton(type: RankType, active: boolean, label: string) { + private renderMetricButton( + type: RankType, + label: string, + mobileSpan: string, + ) { + const active = this.rankType === type; return html` `; } + private renderClockIcon(): TemplateResult { + return html` + + + `; + } + + private renderWarIcon(): TemplateResult { + return html` + + `; + } + + private renderEconomyIcon(): TemplateResult { + return html` + + + `; + } + render() { return html` - ${this.renderMainButtons()} ${this.renderWarSubranking()} - ${this.renderEconomySubranking()} + ${this.renderMainButtons()} + ${isWarRanking(this.rankType) + ? this.renderMetricSelector( + warMetrics, + translateText("game_info_modal.war"), + ) + : isEconomyRanking(this.rankType) + ? this.renderMetricSelector( + economyMetrics, + translateText("game_info_modal.economy"), + ) + : ""} `; } diff --git a/src/client/components/baseComponents/ranking/RankingHeader.ts b/src/client/components/baseComponents/ranking/RankingHeader.ts deleted file mode 100644 index cd1270d15..000000000 --- a/src/client/components/baseComponents/ranking/RankingHeader.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { LitElement, html, nothing } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { translateText } from "../../../Utils"; -import { RankType } from "./GameInfoRanking"; - -@customElement("ranking-header") -export class RankingHeader extends LitElement { - @property({ type: String }) rankType = RankType.Lifetime; - - private onSort(type: RankType) { - this.dispatchEvent(new CustomEvent("sort", { detail: type })); - } - - render() { - return html` -
  • - ${this.renderHeaderContent()} -
  • - `; - } - - private renderHeaderContent() { - switch (this.rankType) { - case RankType.Lifetime: - return html`
    - ${translateText("game_info_modal.survival_time")} -
    `; - case RankType.ConquestHumans: - case RankType.ConquestBots: - return html` -
    - ${this.renderMultipleChoiceHeaderButton( - translateText("game_info_modal.num_of_conquests_humans"), - RankType.ConquestHumans, - )} - / - ${this.renderMultipleChoiceHeaderButton( - translateText("game_info_modal.num_of_conquests_bots"), - RankType.ConquestBots, - )} -
    - `; - case RankType.Atoms: - case RankType.Hydros: - case RankType.MIRV: - return html` -
    - ${this.renderMultipleChoiceHeaderButton( - translateText("game_info_modal.atoms"), - RankType.Atoms, - )} - / - ${this.renderMultipleChoiceHeaderButton( - translateText("game_info_modal.hydros"), - RankType.Hydros, - )} - / - ${this.renderMultipleChoiceHeaderButton( - translateText("game_info_modal.mirv"), - RankType.MIRV, - )} -
    - `; - case RankType.TotalGold: - return html`
    - ${translateText("game_info_modal.all_gold")} -
    `; - case RankType.NavalTrade: - case RankType.TrainTrade: - return html` -
    - ${this.renderMultipleChoiceHeaderButton( - translateText("game_info_modal.train_trade"), - RankType.TrainTrade, - )} - / - ${this.renderMultipleChoiceHeaderButton( - translateText("game_info_modal.naval_trade"), - RankType.NavalTrade, - )} -
    - `; - case RankType.ConqueredGold: - return html`
    - ${translateText("game_info_modal.conquest_gold")} -
    `; - case RankType.StolenGold: - return html`
    - ${translateText("game_info_modal.stolen_gold")} -
    `; - default: - console.warn("Unhandled RankType", this.rankType); - return null; - } - } - - private renderMultipleChoiceHeaderButton(label: string, type: RankType) { - return html` - - `; - } - - createRenderRoot() { - return this; - } -} diff --git a/src/client/components/baseComponents/stats/GameInfoView.ts b/src/client/components/baseComponents/stats/GameInfoView.ts index 59144b3d8..eeedabf7d 100644 --- a/src/client/components/baseComponents/stats/GameInfoView.ts +++ b/src/client/components/baseComponents/stats/GameInfoView.ts @@ -1,14 +1,29 @@ -import { html, LitElement, type PropertyValues } from "lit"; +import { + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { type GameEndInfo } from "../../../../core/Schemas"; -import { GameMode, type GameMapType } from "../../../../core/game/Game"; +import { + GameMode, + GameType, + type GameMapType, +} from "../../../../core/game/Game"; import { fetchGameById } from "../../../Api"; import { terrainMapFileLoader } from "../../../TerrainMapFileLoader"; import { getMapName, renderDuration, translateText } from "../../../Utils"; -import { Ranking, RankType, type PlayerInfo } from "../ranking/GameInfoRanking"; +import { renderLoadingSpinner } from "../../BaseModal"; +import { + RANK_TYPE_LABEL_KEYS, + Ranking, + RankType, + type PlayerInfo, +} from "../ranking/GameInfoRanking"; import "../ranking/PlayerRow"; import "../ranking/RankingControls"; -import "../ranking/RankingHeader"; +import { formatAbsoluteTime } from "./GameHistoryDates"; /** * Game-stats content for the Account > Games > Stats drill-down. @@ -54,7 +69,7 @@ export class GameInfoView extends LitElement { render() { if (!this.gameId) return html``; return html` -
    +
    ${this.isLoadingGame ? this.renderLoadingAnimation() : this.loadFailed @@ -67,31 +82,65 @@ export class GameInfoView extends LitElement { private renderRanking() { if (this.rankedPlayers.length === 0) { return html` -
    -

    ${translateText("game_info_modal.no_winner")}

    + ${this.renderGameInfo()} +
    + +

    + ${translateText("game_info_modal.no_winner")} +

    `; } return html` ${this.renderGameInfo()} - - ${this.renderSummaryTable()} +
    + + ${this.renderSummaryTable()} +
    `; } private renderError() { return html`