From dea04615d1032727e112e6cd2ee2e10cac6fccba Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 16 May 2026 19:58:59 -0700 Subject: [PATCH 1/4] feat: Add custom territory colors and alliance stats to leaderboard --- src/client/UserSettingModal.ts | 41 +++++ .../baseComponents/setting/SettingColor.ts | 53 +++++++ src/client/graphics/layers/Leaderboard.ts | 145 +++++++++++------- src/core/game/GameView.ts | 36 +++-- src/core/game/UserSettings.ts | 24 +++ 5 files changed, 234 insertions(+), 65 deletions(-) create mode 100644 src/client/components/baseComponents/setting/SettingColor.ts diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index e36444ee79..eb8f1d9167 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -8,6 +8,7 @@ import "./components/baseComponents/setting/SettingNumber"; import "./components/baseComponents/setting/SettingSelect"; import "./components/baseComponents/setting/SettingSlider"; import "./components/baseComponents/setting/SettingToggle"; +import "./components/baseComponents/setting/SettingColor"; import { BaseModal } from "./components/BaseModal"; import { modalHeader } from "./components/ui/ModalHeader"; import { Platform } from "./Platform"; @@ -308,6 +309,19 @@ export class UserSettingModal extends BaseModal { ); } + private toggleCustomColorsEnabled() { + this.userSettings.toggleCustomColorsEnabled(); + this.requestUpdate(); + } + + private handleCustomPrimaryColor(e: CustomEvent<{ value: string }>) { + this.userSettings.setCustomPrimaryColor(e.detail.value); + } + + private handleCustomSecondaryColor(e: CustomEvent<{ value: string }>) { + this.userSettings.setCustomSecondaryColor(e.detail.value); + } + private toggleGoToPlayer() { this.userSettings.toggleGoToPlayer(); @@ -841,6 +855,33 @@ export class UserSettingModal extends BaseModal { @change=${this.toggleTerritoryPatterns} > + + + + ${this.userSettings.customColorsEnabled() + ? html` + + + + ` + : ""} + +
+
+ ${this.label} +
+
+ ${this.description} +
+
+ +
+
${this.value}
+ +
+ + `; + } +} diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 3100e88d94..3cc36e3b69 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -14,6 +14,8 @@ interface Entry { score: string; gold: string; maxTroops: string; + betrayals: string; + alliances: string; isMyPlayer: boolean; isOnSameTeam: boolean; player: PlayerView; @@ -30,7 +32,10 @@ export class Leaderboard extends LitElement implements Layer { private showTopFive = true; @state() - private _sortKey: "tiles" | "gold" | "maxtroops" = "tiles"; + private _viewState: "default" | "alliances" = "default"; + + @state() + private _sortKey: "tiles" | "gold" | "maxtroops" | "betrayals" | "alliances" = "tiles"; @state() private _sortOrder: "asc" | "desc" = "desc"; @@ -57,7 +62,7 @@ export class Leaderboard extends LitElement implements Layer { this.updateLeaderboard(); } - private setSort(key: "tiles" | "gold" | "maxtroops") { + private setSort(key: "tiles" | "gold" | "maxtroops" | "betrayals" | "alliances") { if (this._sortKey === key) { this._sortOrder = this._sortOrder === "asc" ? "desc" : "asc"; } else { @@ -87,6 +92,12 @@ export class Leaderboard extends LitElement implements Layer { case "maxtroops": sorted = sorted.sort((a, b) => compare(maxTroops(a), maxTroops(b))); break; + case "betrayals": + sorted = sorted.sort((a, b) => compare(a.betrayals(), b.betrayals())); + break; + case "alliances": + sorted = sorted.sort((a, b) => compare(a.alliances().length, b.alliances().length)); + break; default: sorted = sorted.sort((a, b) => compare(a.numTilesOwned(), b.numTilesOwned()), @@ -102,7 +113,7 @@ export class Leaderboard extends LitElement implements Layer { : alivePlayers; this.players = playersToShow.map((player, index) => { - const maxTroops = this.game!.config().maxTroops(player); + const pMaxTroops = this.game!.config().maxTroops(player); return { name: player.displayName(), position: index + 1, @@ -110,7 +121,9 @@ export class Leaderboard extends LitElement implements Layer { player.numTilesOwned() / numTilesWithoutFallout, ), gold: renderNumber(player.gold()), - maxTroops: renderTroops(maxTroops), + maxTroops: renderTroops(pMaxTroops), + betrayals: player.betrayals().toString(), + alliances: player.alliances().length.toString(), isMyPlayer: player === myPlayer, isOnSameTeam: myPlayer !== null && @@ -142,6 +155,8 @@ export class Leaderboard extends LitElement implements Layer { ), gold: renderNumber(myPlayer.gold()), maxTroops: renderTroops(myPlayerMaxTroops), + betrayals: myPlayer.betrayals().toString(), + alliances: myPlayer.alliances().length.toString(), isMyPlayer: true, isOnSameTeam: true, player: myPlayer, @@ -199,28 +214,38 @@ export class Leaderboard extends LitElement implements Layer { : "⬇️" : ""} -
this.setSort("gold")} - > - ${translateText("leaderboard.gold")} - ${this._sortKey === "gold" - ? this._sortOrder === "asc" - ? "⬆️" - : "⬇️" - : ""} -
-
this.setSort("maxtroops")} - > - ${translateText("leaderboard.maxtroops")} - ${this._sortKey === "maxtroops" - ? this._sortOrder === "asc" - ? "⬆️" - : "⬇️" - : ""} -
+ + ${this._viewState === "default" ? html` +
this.setSort("gold")} + > + ${translateText("leaderboard.gold")} + ${this._sortKey === "gold" ? (this._sortOrder === "asc" ? "⬆️" : "⬇️") : ""} +
+
this.setSort("maxtroops")} + > + ${translateText("leaderboard.maxtroops")} + ${this._sortKey === "maxtroops" ? (this._sortOrder === "asc" ? "⬆️" : "⬇️") : ""} +
+ ` : html` +
this.setSort("betrayals")} + > + Betrayals + ${this._sortKey === "betrayals" ? (this._sortOrder === "asc" ? "⬆️" : "⬇️") : ""} +
+
this.setSort("alliances")} + > + Alliances + ${this._sortKey === "alliances" ? (this._sortOrder === "asc" ? "⬆️" : "⬇️") : ""} +
+ `} ${repeat( @@ -257,39 +282,53 @@ export class Leaderboard extends LitElement implements Layer { > ${player.score} -
- ${player.gold} -
-
- ${player.maxTroops} -
+ + ${this._viewState === "default" ? html` +
+ ${player.gold} +
+
+ ${player.maxTroops} +
+ ` : html` +
+ ${player.betrayals} +
+
+ ${player.alliances} +
+ `} `, )} - +
+ + +
`; } } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 44bc83a855..840ee84687 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -265,28 +265,37 @@ export class PlayerView { } satisfies ColorPalette; } + const isMyPlayer = this.game.myClientID() === this.data.clientID; + if (this.team() === null) { - this._territoryColor = colord( - this.cosmetics.color?.color ?? - pattern?.colorPalette?.primaryColor ?? - defaultTerritoryColor.toHex(), - ); + if (isMyPlayer && userSettings.customColorsEnabled()) { + this._territoryColor = colord(userSettings.customPrimaryColor()); + } else { + this._territoryColor = colord( + this.cosmetics.color?.color ?? + pattern?.colorPalette?.primaryColor ?? + defaultTerritoryColor.toHex(), + ); + } } else { this._territoryColor = defaultTerritoryColor; } this._structureColors = theme.structureColors(this._territoryColor); - const maybeFocusedBorderColor = - this.game.myClientID() === this.data.clientID + const maybeFocusedBorderColor = isMyPlayer ? theme.focusedBorderColor() : defaultBorderColor; - this._borderColor = new Colord( - pattern?.colorPalette?.secondaryColor ?? - this.cosmetics.color?.color ?? - maybeFocusedBorderColor.toHex(), - ); + if (isMyPlayer && userSettings.customColorsEnabled()) { + this._borderColor = new Colord(userSettings.customSecondaryColor()); + } else { + this._borderColor = new Colord( + pattern?.colorPalette?.secondaryColor ?? + this.cosmetics.color?.color ?? + maybeFocusedBorderColor.toHex(), + ); + } // Pre-compute all border color variants once const baseRgb = this._borderColor.toRgb(); @@ -620,6 +629,9 @@ export class PlayerView { isTraitor(): boolean { return this.data.isTraitor; } + betrayals(): number { + return this.data.betrayals; + } getTraitorRemainingTicks(): number { return Math.max(0, this.data.traitorRemainingTicks ?? 0); } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 9faff1a0c6..9467a56cba 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -237,6 +237,30 @@ export class UserSettings { this.setBool(DARK_MODE_KEY, !this.darkMode()); } + customColorsEnabled() { + return this.getBool("settings.customColorsEnabled", false); + } + + toggleCustomColorsEnabled() { + this.setBool("settings.customColorsEnabled", !this.customColorsEnabled()); + } + + customPrimaryColor(): string { + return this.getString("settings.customPrimaryColor", "#2196f3"); + } + + setCustomPrimaryColor(color: string): void { + this.setString("settings.customPrimaryColor", color); + } + + customSecondaryColor(): string { + return this.getString("settings.customSecondaryColor", "#1565c0"); + } + + setCustomSecondaryColor(color: string): void { + this.setString("settings.customSecondaryColor", color); + } + // For development only. Used for testing patterns, set in the console manually. getDevOnlyPattern(): PlayerPattern | undefined { const data = localStorage.getItem("dev-pattern") ?? undefined; From afe29282fc50792cf669e898dfb5f7aa64c11a38 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 16 May 2026 20:37:56 -0700 Subject: [PATCH 2/4] style: move custom color picker to main menu --- src/client/UserSettingModal.ts | 41 --------------------------- src/client/components/PlayPage.ts | 47 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index eb8f1d9167..e36444ee79 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -8,7 +8,6 @@ import "./components/baseComponents/setting/SettingNumber"; import "./components/baseComponents/setting/SettingSelect"; import "./components/baseComponents/setting/SettingSlider"; import "./components/baseComponents/setting/SettingToggle"; -import "./components/baseComponents/setting/SettingColor"; import { BaseModal } from "./components/BaseModal"; import { modalHeader } from "./components/ui/ModalHeader"; import { Platform } from "./Platform"; @@ -309,19 +308,6 @@ export class UserSettingModal extends BaseModal { ); } - private toggleCustomColorsEnabled() { - this.userSettings.toggleCustomColorsEnabled(); - this.requestUpdate(); - } - - private handleCustomPrimaryColor(e: CustomEvent<{ value: string }>) { - this.userSettings.setCustomPrimaryColor(e.detail.value); - } - - private handleCustomSecondaryColor(e: CustomEvent<{ value: string }>) { - this.userSettings.setCustomSecondaryColor(e.detail.value); - } - private toggleGoToPlayer() { this.userSettings.toggleGoToPlayer(); @@ -855,33 +841,6 @@ export class UserSettingModal extends BaseModal { @change=${this.toggleTerritoryPatterns} >
- - - - ${this.userSettings.customColorsEnabled() - ? html` - - - - ` - : ""} - ) { + this.userSettings.setCustomPrimaryColor(e.detail.value); + } + + private handleCustomSecondaryColor(e: CustomEvent<{ value: string }>) { + this.userSettings.setCustomSecondaryColor(e.detail.value); + } + createRenderRoot() { return this; } @@ -112,6 +130,35 @@ export class PlayPage extends LitElement { class="flex-1 h-full" > + + +
+ + + ${this.userSettings.customColorsEnabled() + ? html` + + + + ` + : ""} +
From 491b5dd45511a7be03ff3ed88f663370b8842366 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 16 May 2026 20:51:16 -0700 Subject: [PATCH 3/4] feat: replace color toggle with color-input button next to skin --- src/client/components/PlayPage.ts | 60 +++++++------------------------ src/core/game/GameView.ts | 20 +++++++---- src/core/game/UserSettings.ts | 18 ++++++++-- 3 files changed, 40 insertions(+), 58 deletions(-) diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index 3143404093..7c682b0172 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -1,28 +1,11 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { assetUrl } from "../../core/AssetUrls"; -import { UserSettings } from "../../core/game/UserSettings"; -import "./baseComponents/setting/SettingToggle"; -import "./baseComponents/setting/SettingColor"; +import "../ColorInput"; import "./NewsBox"; @customElement("play-page") export class PlayPage extends LitElement { - private userSettings = new UserSettings(); - - private toggleCustomColorsEnabled() { - this.userSettings.toggleCustomColorsEnabled(); - this.requestUpdate(); - } - - private handleCustomPrimaryColor(e: CustomEvent<{ value: string }>) { - this.userSettings.setCustomPrimaryColor(e.detail.value); - } - - private handleCustomSecondaryColor(e: CustomEvent<{ value: string }>) { - this.userSettings.setCustomSecondaryColor(e.detail.value); - } - createRenderRoot() { return this; } @@ -109,6 +92,11 @@ export class PlayPage extends LitElement { adaptive-size class="shrink-0 lg:hidden" > + + - + - - -
- - - ${this.userSettings.customColorsEnabled() - ? html` - - - - ` - : ""} -
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 840ee84687..5c673cd7f1 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -266,19 +266,25 @@ export class PlayerView { } const isMyPlayer = this.game.myClientID() === this.data.clientID; + const colorMode = isMyPlayer ? userSettings.colorMode() : "random"; - if (this.team() === null) { - if (isMyPlayer && userSettings.customColorsEnabled()) { - this._territoryColor = colord(userSettings.customPrimaryColor()); - } else { + if (colorMode === "custom") { + // Always use user's chosen color, even if in a team + this._territoryColor = colord(userSettings.customPrimaryColor()); + } else if (colorMode === "team") { + // Force team color (default game behavior, team always wins) + this._territoryColor = defaultTerritoryColor; + } else { + // "random" — normal game logic + if (this.team() === null) { this._territoryColor = colord( this.cosmetics.color?.color ?? pattern?.colorPalette?.primaryColor ?? defaultTerritoryColor.toHex(), ); + } else { + this._territoryColor = defaultTerritoryColor; } - } else { - this._territoryColor = defaultTerritoryColor; } this._structureColors = theme.structureColors(this._territoryColor); @@ -287,7 +293,7 @@ export class PlayerView { ? theme.focusedBorderColor() : defaultBorderColor; - if (isMyPlayer && userSettings.customColorsEnabled()) { + if (colorMode === "custom") { this._borderColor = new Colord(userSettings.customSecondaryColor()); } else { this._borderColor = new Colord( diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 9467a56cba..13b69eac73 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -237,12 +237,24 @@ export class UserSettings { this.setBool(DARK_MODE_KEY, !this.darkMode()); } - customColorsEnabled() { - return this.getBool("settings.customColorsEnabled", false); + // Color mode: "random" = game default, "custom" = user picks, "team" = always use team color + colorMode(): "random" | "custom" | "team" { + const val = this.getString("settings.colorMode", "random"); + if (val === "custom" || val === "team") return val; + return "random"; + } + + setColorMode(mode: "random" | "custom" | "team"): void { + this.setString("settings.colorMode", mode); + } + + // Keep legacy helpers so GameView still compiles + customColorsEnabled(): boolean { + return this.colorMode() === "custom"; } toggleCustomColorsEnabled() { - this.setBool("settings.customColorsEnabled", !this.customColorsEnabled()); + this.setColorMode(this.colorMode() === "custom" ? "random" : "custom"); } customPrimaryColor(): string { From 67b4fcb79c7126def9718a8373b28dc424d5ec69 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 16 May 2026 21:02:08 -0700 Subject: [PATCH 4/4] feat: color button next to skin, team game color fix --- src/client/ColorInput.ts | 108 ++++++++++++++++++++++++++++++++++++++ src/core/game/GameView.ts | 10 ++-- 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 src/client/ColorInput.ts diff --git a/src/client/ColorInput.ts b/src/client/ColorInput.ts new file mode 100644 index 0000000000..77663500b3 --- /dev/null +++ b/src/client/ColorInput.ts @@ -0,0 +1,108 @@ +import { LitElement, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { UserSettings } from "../core/game/UserSettings"; + +export const COLOR_MODE_CHANGED_EVENT = "color-mode-changed"; + +/** + * A small button that sits next to the Skin/Pattern button on the main menu. + * Clicking it cycles through color modes: Random → Custom → Team. + * In "custom" mode a native color picker is also shown. + */ +@customElement("color-input") +export class ColorInput extends LitElement { + @state() private mode: "random" | "custom" | "team" = "random"; + @state() private primaryColor = "#2196f3"; + + private userSettings = new UserSettings(); + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + this.mode = this.userSettings.colorMode(); + this.primaryColor = this.userSettings.customPrimaryColor(); + } + + private cycleMode() { + const next: Record = { + random: "custom", + custom: "team", + team: "random", + }; + const newMode = next[this.mode]; + this.mode = newMode; + this.userSettings.setColorMode(newMode); + window.dispatchEvent(new CustomEvent(COLOR_MODE_CHANGED_EVENT)); + } + + private onColorChange(e: Event) { + const color = (e.target as HTMLInputElement).value; + this.primaryColor = color; + this.userSettings.setCustomPrimaryColor(color); + // Use a slightly darker shade as secondary color + this.userSettings.setCustomSecondaryColor(color); + window.dispatchEvent(new CustomEvent(COLOR_MODE_CHANGED_EVENT)); + } + + private modeLabel(): string { + if (this.mode === "custom") return "Custom"; + if (this.mode === "team") return "Team"; + return "Random"; + } + + private modeIcon(): string { + if (this.mode === "custom") return "🎨"; + if (this.mode === "team") return "🤝"; + return "🎲"; + } + + render() { + return html` +
+ + + + + ${this.mode === "custom" + ? html` + + ` + : ""} +
+ `; + } +} diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 5c673cd7f1..cbfd4efcf0 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -268,11 +268,11 @@ export class PlayerView { const isMyPlayer = this.game.myClientID() === this.data.clientID; const colorMode = isMyPlayer ? userSettings.colorMode() : "random"; - if (colorMode === "custom") { - // Always use user's chosen color, even if in a team + if (colorMode === "custom" && this.team() === null) { + // Custom color only in solo/FFA — team games always use assigned team color this._territoryColor = colord(userSettings.customPrimaryColor()); - } else if (colorMode === "team") { - // Force team color (default game behavior, team always wins) + } else if (colorMode === "team" || this.team() !== null) { + // Force team color (default game behavior) this._territoryColor = defaultTerritoryColor; } else { // "random" — normal game logic @@ -293,7 +293,7 @@ export class PlayerView { ? theme.focusedBorderColor() : defaultBorderColor; - if (colorMode === "custom") { + if (colorMode === "custom" && this.team() === null) { this._borderColor = new Colord(userSettings.customSecondaryColor()); } else { this._borderColor = new Colord(