diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index dbcd718b2..0ac688062 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -75,6 +75,7 @@ import { } from "./render/gl"; import { ALL_UNIT_TYPES, UnitState } from "./render/types"; import { SoundManager } from "./sound/SoundManager"; +import { themeProvider } from "./theme/ThemeProvider"; export interface LobbyConfig { cosmetics: PlayerCosmeticRefs; @@ -110,6 +111,7 @@ export function joinLobby( console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`); const userSettings: UserSettings = new UserSettings(); + themeProvider.reset(); // fresh colour allocators for this game startGame(lobbyConfig.gameID, lobbyConfig.gameStartInfo?.config ?? {}); const transport = new Transport(lobbyConfig, eventBus); diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 231fef396..d4f4510e1 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -1,7 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; -import { PastelTheme } from "../../core/configuration/PastelTheme"; import { ColoredTeams, Duos, @@ -17,6 +16,8 @@ import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { UserSettings } from "../../core/game/UserSettings"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; import { createRandomName, formatPlayerDisplayName } from "../../core/Util"; +import { Theme } from "../theme/Theme"; +import { themeProvider } from "../theme/ThemeProvider"; import { getTranslatedPlayerTeamLabel, translateText } from "../Utils"; export interface TeamPreviewData { @@ -37,7 +38,9 @@ export class LobbyTeamView extends LitElement { @property({ type: Number }) nationCount: number = 0; @property({ type: Boolean }) isPublicGame: boolean = false; - private theme: PastelTheme = new PastelTheme(); + private get theme(): Theme { + return themeProvider.current(); + } @state() private showTeamColors: boolean = false; private userSettings: UserSettings = new UserSettings(); diff --git a/src/client/hud/SpriteLoader.ts b/src/client/hud/SpriteLoader.ts index b9d1a95bc..8361bd0ac 100644 --- a/src/client/hud/SpriteLoader.ts +++ b/src/client/hud/SpriteLoader.ts @@ -1,8 +1,8 @@ import { Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; import { assetUrl } from "../../core/AssetUrls"; import { TrainType, UnitType } from "../../core/game/Game"; import { UnitView } from "../../core/game/GameView"; +import { Theme } from "../theme/Theme"; const atomBombSprite = assetUrl("sprites/atombomb.png"); const hydrogenBombSprite = assetUrl("sprites/hydrogenbomb.png"); const mirvSprite = assetUrl("sprites/mirv2.png"); diff --git a/src/client/hud/layers/AttacksDisplay.ts b/src/client/hud/layers/AttacksDisplay.ts index 13afd3678..04f736d83 100644 --- a/src/client/hud/layers/AttacksDisplay.ts +++ b/src/client/hud/layers/AttacksDisplay.ts @@ -10,6 +10,7 @@ import { } from "../../../core/game/GameUpdates"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { Controller } from "../../Controller"; +import { themeProvider } from "../../theme/ThemeProvider"; import { GoToPlayerEvent, GoToPositionEvent, @@ -166,7 +167,7 @@ export class AttacksDisplay extends LitElement implements Controller { const cached = this.spriteDataURLCache.get(key); if (cached) return cached; try { - const canvas = getColoredSprite(unit, this.game.config().theme()); + const canvas = getColoredSprite(unit, themeProvider.current()); const dataURL = canvas.toDataURL(); this.spriteDataURLCache.set(key, dataURL); return dataURL; diff --git a/src/client/hud/layers/GameLeftSidebar.ts b/src/client/hud/layers/GameLeftSidebar.ts index a0039e327..65a13ab4e 100644 --- a/src/client/hud/layers/GameLeftSidebar.ts +++ b/src/client/hud/layers/GameLeftSidebar.ts @@ -7,6 +7,7 @@ import { GameMode, Team } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { Controller } from "../../Controller"; import { Platform } from "../../Platform"; +import { themeProvider } from "../../theme/ThemeProvider"; import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; import { SpawnBarVisibleEvent } from "./SpawnTimer"; @@ -66,10 +67,7 @@ export class GameLeftSidebar extends LitElement implements Controller { if (!this.playerTeam && this.game.myPlayer()?.team()) { this.playerTeam = this.game.myPlayer()!.team(); if (this.playerTeam) { - this.playerColor = this.game - .config() - .theme() - .teamColor(this.playerTeam); + this.playerColor = themeProvider.current().teamColor(this.playerTeam); this.requestUpdate(); } } diff --git a/src/client/hud/layers/PlayerInfoOverlay.ts b/src/client/hud/layers/PlayerInfoOverlay.ts index fc2d1e1c5..44497143f 100644 --- a/src/client/hud/layers/PlayerInfoOverlay.ts +++ b/src/client/hud/layers/PlayerInfoOverlay.ts @@ -18,6 +18,7 @@ import { MouseMoveEvent, TouchEvent, } from "../../InputHandler"; +import { themeProvider } from "../../theme/ThemeProvider"; import { TransformHandler } from "../../TransformHandler"; import { getTranslatedPlayerTeamLabel, @@ -360,9 +361,8 @@ export class PlayerInfoOverlay extends LitElement implements Controller { > [${playerTeam} 0) { for (const [team, count] of teamTiles) { diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 362c0fd00..7ea731b8b 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -79,6 +79,27 @@ export interface RenderSettings { defensePostRange: number; embargoTintRatio: number; friendlyTintRatio: number; + embargoTintR: number; + embargoTintG: number; + embargoTintB: number; + friendlyTintR: number; + friendlyTintG: number; + friendlyTintB: number; + }; + /** Alt-view affiliation colors (0–1 RGB). */ + affiliation: { + selfR: number; + selfG: number; + selfB: number; + allyR: number; + allyG: number; + allyB: number; + neutralR: number; + neutralG: number; + neutralB: number; + enemyR: number; + enemyG: number; + enemyB: number; }; railroad: { railMinZoom: number; diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index e442be642..360b85337 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -478,7 +478,7 @@ export class GPURenderer { this.sceneTarget = { fbo: sceneFbo, tex: sceneTex, w: 1, h: 1 }; // --- Alt-view passes --- - this.affiliationPalette = new AffiliationPalette(gl); + this.affiliationPalette = new AffiliationPalette(gl, this.settings); const affTex = this.affiliationPalette.getTexture(); this.borderStampPass.setAffiliationTex(affTex); this.unitPass.setAffiliationTex(affTex); diff --git a/src/client/render/gl/passes/BorderStampPass.ts b/src/client/render/gl/passes/BorderStampPass.ts index ba125526b..c3925e00f 100644 --- a/src/client/render/gl/passes/BorderStampPass.ts +++ b/src/client/render/gl/passes/BorderStampPass.ts @@ -27,6 +27,8 @@ export class BorderStampPass { private uDefenseCheckerDarken: WebGLUniformLocation; private uEmbargoTintRatio: WebGLUniformLocation; private uFriendlyTintRatio: WebGLUniformLocation; + private uEmbargoTint: WebGLUniformLocation; + private uFriendlyTint: WebGLUniformLocation; private uAltView: WebGLUniformLocation; private vao: WebGLVertexArrayObject; @@ -79,6 +81,8 @@ export class BorderStampPass { this.program, "uFriendlyTintRatio", )!; + this.uEmbargoTint = gl.getUniformLocation(this.program, "uEmbargoTint")!; + this.uFriendlyTint = gl.getUniformLocation(this.program, "uFriendlyTint")!; this.uAltView = gl.getUniformLocation(this.program, "uAltView")!; gl.useProgram(this.program); @@ -109,6 +113,18 @@ export class BorderStampPass { gl.uniform1f(this.uDefenseCheckerDarken, mo.defenseCheckerDarken); gl.uniform1f(this.uEmbargoTintRatio, mo.embargoTintRatio); gl.uniform1f(this.uFriendlyTintRatio, mo.friendlyTintRatio); + gl.uniform3f( + this.uEmbargoTint, + mo.embargoTintR, + mo.embargoTintG, + mo.embargoTintB, + ); + gl.uniform3f( + this.uFriendlyTint, + mo.friendlyTintR, + mo.friendlyTintG, + mo.friendlyTintB, + ); gl.uniform1i(this.uAltView, this.altView ? 1 : 0); gl.activeTexture(gl.TEXTURE0); diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index 79abd17b7..06d17c894 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -75,7 +75,27 @@ "highlightThicken": 2, "defensePostRange": 30, "embargoTintRatio": 0.35, - "friendlyTintRatio": 0.35 + "friendlyTintRatio": 0.35, + "embargoTintR": 1, + "embargoTintG": 0, + "embargoTintB": 0, + "friendlyTintR": 0, + "friendlyTintG": 1, + "friendlyTintB": 0 + }, + "affiliation": { + "selfR": 0, + "selfG": 1, + "selfB": 0, + "allyR": 1, + "allyG": 1, + "allyB": 0, + "neutralR": 0.502, + "neutralG": 0.502, + "neutralB": 0.502, + "enemyR": 1, + "enemyG": 0, + "enemyB": 0 }, "railroad": { "railMinZoom": 4, diff --git a/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl b/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl index 94fe780c6..62873a97d 100644 --- a/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl +++ b/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl @@ -12,6 +12,8 @@ uniform float uHighlightBrighten; uniform float uDefenseCheckerDarken; uniform float uEmbargoTintRatio; uniform float uFriendlyTintRatio; +uniform vec3 uEmbargoTint; +uniform vec3 uFriendlyTint; in vec2 vWorldPos; out vec4 fragColor; @@ -46,9 +48,9 @@ void main() { } // Relationship tint (applied BEFORE defense checkerboard, matching game) if (relation > 0.75) { - bc = mix(bc, vec3(1.0, 0.0, 0.0), uEmbargoTintRatio); + bc = mix(bc, uEmbargoTint, uEmbargoTintRatio); } else if (relation > 0.25) { - bc = mix(bc, vec3(0.0, 1.0, 0.0), uFriendlyTintRatio); + bc = mix(bc, uFriendlyTint, uFriendlyTintRatio); } // Defense bonus: checkerboard darken (applied AFTER tint, matching game) if (defense) { diff --git a/src/client/render/gl/utils/Affiliation.ts b/src/client/render/gl/utils/Affiliation.ts index 750456774..069f30bb1 100644 --- a/src/client/render/gl/utils/Affiliation.ts +++ b/src/client/render/gl/utils/Affiliation.ts @@ -8,6 +8,7 @@ * Rebuilt when localPlayerID or relationship data changes. */ +import type { RenderSettings } from "../RenderSettings"; import { getPaletteSize } from "./ColorUtils"; import { createTexture2D } from "./GlUtils"; @@ -16,20 +17,6 @@ const RELATION_NEUTRAL = 0; const RELATION_FRIENDLY = 1; const RELATION_EMBARGO = 2; -// Affiliation RGB values (upstream PastelTheme) -const SELF_R = 0, - SELF_G = 255, - SELF_B = 0; -const ALLY_R = 255, - ALLY_G = 255, - ALLY_B = 0; -const NEUTRAL_R = 128, - NEUTRAL_G = 128, - NEUTRAL_B = 128; -const ENEMY_R = 255, - ENEMY_G = 0, - ENEMY_B = 0; - const TEX_W = getPaletteSize(); // 4096 — covers full 12-bit smallID range const TEX_H = 2; @@ -44,7 +31,10 @@ export class AffiliationPalette { private relationData: Uint8Array | null = null; private relationSize = 0; - constructor(gl: WebGL2RenderingContext) { + constructor( + gl: WebGL2RenderingContext, + private settings: RenderSettings, + ) { this.gl = gl; this.rebuild(); // initialize to spectator-mode defaults (gray borders, red units) this.tex = createTexture2D(gl, { @@ -100,6 +90,22 @@ export class AffiliationPalette { const rel = this.relationData; const rs = this.relationSize; + // Affiliation RGB values (0–1) from render-settings, expanded to 0–255. + const a = this.settings.affiliation; + const to255 = (v: number) => Math.round(v * 255); + const SELF_R = to255(a.selfR), + SELF_G = to255(a.selfG), + SELF_B = to255(a.selfB); + const ALLY_R = to255(a.allyR), + ALLY_G = to255(a.allyG), + ALLY_B = to255(a.allyB); + const NEUTRAL_R = to255(a.neutralR), + NEUTRAL_G = to255(a.neutralG), + NEUTRAL_B = to255(a.neutralB); + const ENEMY_R = to255(a.enemyR), + ENEMY_G = to255(a.enemyG), + ENEMY_B = to255(a.enemyB); + for (let owner = 0; owner < TEX_W; owner++) { // Determine relationship let relation = RELATION_NEUTRAL; diff --git a/src/core/configuration/ColorAllocator.ts b/src/client/theme/ColorAllocator.ts similarity index 96% rename from src/core/configuration/ColorAllocator.ts rename to src/client/theme/ColorAllocator.ts index bd997d55f..317b00559 100644 --- a/src/core/configuration/ColorAllocator.ts +++ b/src/client/theme/ColorAllocator.ts @@ -2,9 +2,9 @@ import { colord, Colord, extend } from "colord"; import labPlugin from "colord/plugins/lab"; import lchPlugin from "colord/plugins/lch"; import Color from "colorjs.io"; -import { ColoredTeams, Team } from "../game/Game"; -import { PseudoRandom } from "../PseudoRandom"; -import { simpleHash } from "../Util"; +import { ColoredTeams, Team } from "../../core/game/Game"; +import { PseudoRandom } from "../../core/PseudoRandom"; +import { simpleHash } from "../../core/Util"; import { blueTeamColors, botTeamColors, diff --git a/src/core/configuration/Colors.ts b/src/client/theme/Colors.ts similarity index 100% rename from src/core/configuration/Colors.ts rename to src/client/theme/Colors.ts diff --git a/src/core/configuration/PastelTheme.ts b/src/client/theme/PastelTheme.ts similarity index 90% rename from src/core/configuration/PastelTheme.ts rename to src/client/theme/PastelTheme.ts index a4be6a1c7..b11e5d01e 100644 --- a/src/core/configuration/PastelTheme.ts +++ b/src/client/theme/PastelTheme.ts @@ -1,8 +1,8 @@ import { Colord, colord, LabaColor } from "colord"; -import { PseudoRandom } from "../PseudoRandom"; -import { PlayerType, Team, TerrainType } from "../game/Game"; -import { GameMap, TileRef } from "../game/GameMap"; -import { PlayerView } from "../game/GameView"; +import { PseudoRandom } from "../../core/PseudoRandom"; +import { PlayerType, Team, TerrainType } from "../../core/game/Game"; +import { GameMap, TileRef } from "../../core/game/GameMap"; +import { PlayerView } from "../../core/game/GameView"; import { ColorAllocator } from "./ColorAllocator"; import { botColors, fallbackColors, humanColors, nationColors } from "./Colors"; import { Theme } from "./Theme"; @@ -26,15 +26,6 @@ export class PastelTheme implements Theme { private water = colord("rgb(70,132,180)"); private shorelineWater = colord("rgb(100,143,255)"); - /** Alternate View colors for self, green */ - private _selfColor = colord("rgb(0,255,0)"); - /** Alternate View colors for allies, yellow */ - private _allyColor = colord("rgb(255,255,0)"); - /** Alternate View colors for neutral, gray */ - private _neutralColor = colord("rgb(128,128,128)"); - /** Alternate View colors for enemies, red */ - private _enemyColor = colord("rgb(255,0,0)"); - /** Default spawn highlight colors for other players in FFA, yellow */ private _spawnHighlightColor = colord("rgb(255,213,79)"); /** Added non-default spawn highlight colors for self, full white */ @@ -197,19 +188,6 @@ export class PastelTheme implements Theme { return "Overpass, sans-serif"; } - selfColor(): Colord { - return this._selfColor; - } - allyColor(): Colord { - return this._allyColor; - } - neutralColor(): Colord { - return this._neutralColor; - } - enemyColor(): Colord { - return this._enemyColor; - } - spawnHighlightColor(): Colord { return this._spawnHighlightColor; } diff --git a/src/core/configuration/PastelThemeDark.ts b/src/client/theme/PastelThemeDark.ts similarity index 95% rename from src/core/configuration/PastelThemeDark.ts rename to src/client/theme/PastelThemeDark.ts index 2cff80685..7ffde5735 100644 --- a/src/core/configuration/PastelThemeDark.ts +++ b/src/client/theme/PastelThemeDark.ts @@ -1,6 +1,6 @@ import { Colord, colord } from "colord"; -import { TerrainType } from "../game/Game"; -import { GameMap, TileRef } from "../game/GameMap"; +import { TerrainType } from "../../core/game/Game"; +import { GameMap, TileRef } from "../../core/game/GameMap"; import { PastelTheme } from "./PastelTheme"; export class PastelThemeDark extends PastelTheme { diff --git a/src/core/configuration/Theme.ts b/src/client/theme/Theme.ts similarity index 76% rename from src/core/configuration/Theme.ts rename to src/client/theme/Theme.ts index 788c41c73..6ae1fb3d8 100644 --- a/src/core/configuration/Theme.ts +++ b/src/client/theme/Theme.ts @@ -1,7 +1,7 @@ import { Colord } from "colord"; -import { Team } from "../game/Game"; -import { GameMap, TileRef } from "../game/GameMap"; -import { PlayerView } from "../game/GameView"; +import { Team } from "../../core/game/Game"; +import { GameMap, TileRef } from "../../core/game/GameMap"; +import { PlayerView } from "../../core/game/GameView"; export interface Theme { teamColor(team: Team): Colord; @@ -19,11 +19,6 @@ export interface Theme { falloutColor(): Colord; font(): string; textColor(playerInfo: PlayerView): string; - // unit color for alternate view - selfColor(): Colord; - allyColor(): Colord; - neutralColor(): Colord; - enemyColor(): Colord; spawnHighlightColor(): Colord; spawnHighlightSelfColor(): Colord; spawnHighlightTeamColor(): Colord; diff --git a/src/client/theme/ThemeProvider.ts b/src/client/theme/ThemeProvider.ts new file mode 100644 index 000000000..307ddde6a --- /dev/null +++ b/src/client/theme/ThemeProvider.ts @@ -0,0 +1,32 @@ +import { UserSettings } from "../../core/game/UserSettings"; +import { PastelTheme } from "./PastelTheme"; +import { PastelThemeDark } from "./PastelThemeDark"; +import { Theme } from "./Theme"; + +/** + * Client-side source of truth for the active theme. Themes were moved out of + * `src/core` (the simulation never reads colors); this singleton replaces the + * old `Config.theme()` accessor. + */ +class ThemeProvider { + private readonly userSettings = new UserSettings(); + private light = new PastelTheme(); + private dark = new PastelThemeDark(); + + /** The active theme, selected from the user's dark-mode preference. */ + current(): Theme { + return this.userSettings.darkMode() ? this.dark : this.light; + } + + /** + * Recreate the themes so their colour allocators start empty. Call once per + * game — matches the previous per-`Config` theme lifecycle and prevents + * colour-pool depletion across games in a single session. + */ + reset(): void { + this.light = new PastelTheme(); + this.dark = new PastelThemeDark(); + } +} + +export const themeProvider = new ThemeProvider(); diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index f4e488dfc..456582098 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -29,6 +29,7 @@ import { } from "../../core/game/GameUpdates"; import { UserSettings } from "../../core/game/UserSettings"; import { PlayerState, PlayerStatic, PlayerTypeEnum } from "../render/types"; +import { themeProvider } from "../theme/ThemeProvider"; import { GameView } from "./GameView"; import { UnitView } from "./UnitView"; @@ -133,7 +134,7 @@ export class PlayerView { this.anonymousName = createRandomName(data.name!, data.playerType!); } - const theme = this.game.config().theme(); + const theme = themeProvider.current(); const defaultTerritoryColor = theme.territoryColor(this); const defaultBorderColor = theme.borderColor(defaultTerritoryColor); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 4b478d6f3..1cb6940f1 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -21,9 +21,6 @@ import { UserSettings } from "../game/UserSettings"; import { GameConfig, TeamCountConfig } from "../Schemas"; import { NukeType } from "../StatsSchemas"; import { assertNever, sigmoid, toInt, within } from "../Util"; -import { PastelTheme } from "./PastelTheme"; -import { PastelThemeDark } from "./PastelThemeDark"; -import { Theme } from "./Theme"; declare global { interface Window { @@ -84,8 +81,6 @@ export const JwksSchema = z.object({ export const SAM_CONSTRUCTION_TICKS = 30 * 10; export class Config { - private pastelTheme: PastelTheme = new PastelTheme(); - private pastelThemeDark: PastelThemeDark = new PastelThemeDark(); private unitInfoCache = new Map(); constructor( private _gameConfig: GameConfig, @@ -562,11 +557,6 @@ export class Config { numBots(): number { return this.bots(); } - theme(): Theme { - return this.userSettings()?.darkMode() - ? this.pastelThemeDark - : this.pastelTheme; - } attackLogic( gm: Game, diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index e1178c9a2..1177575f5 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -2,7 +2,7 @@ import { colord, Colord } from "colord"; import { ColorAllocator, selectDistinctColorIndex, -} from "../src/core/configuration/ColorAllocator"; +} from "../src/client/theme/ColorAllocator"; import { blue, botColor, @@ -12,7 +12,7 @@ import { red, teal, yellow, -} from "../src/core/configuration/Colors"; +} from "../src/client/theme/Colors"; import { ColoredTeams } from "../src/core/game/Game"; const mockColors: Colord[] = [ diff --git a/tests/util/viewStubs.ts b/tests/util/viewStubs.ts index d02bccced..6ad5503e5 100644 --- a/tests/util/viewStubs.ts +++ b/tests/util/viewStubs.ts @@ -7,10 +7,10 @@ */ import { colord } from "colord"; +import { Theme } from "../../src/client/theme/Theme"; import { GameView } from "../../src/client/view/GameView"; import { PlayerView } from "../../src/client/view/PlayerView"; import { Config } from "../../src/core/configuration/Config"; -import { Theme } from "../../src/core/configuration/Theme"; import { NameViewData, PlayerType, @@ -45,10 +45,6 @@ export function stubTheme(): Theme { falloutColor: () => white, font: () => "Arial", textColor: () => "#000000", - selfColor: () => white, - allyColor: () => white, - neutralColor: () => grey, - enemyColor: () => grey, spawnHighlightColor: () => white, spawnHighlightSelfColor: () => white, spawnHighlightTeamColor: () => white,