Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,8 @@
"keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.",
"dark_mode_label": "Dark Mode",
"dark_mode_desc": "Toggle the site’s appearance between light and dark themes",
"colorblind_label": "Colorblind Mode",
"colorblind_desc": "Use colorblind-friendly territory and border colors",
"emojis_label": "Emojis",
"emojis_desc": "Toggle whether emojis are shown in game",
"alert_frame_label": "Alert Frame",
Expand Down
26 changes: 26 additions & 0 deletions src/client/UserSettingModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,23 @@ export class UserSettingModal extends BaseModal {
console.log("🌙 Dark Mode:", this.userSettings.darkMode() ? "ON" : "OFF");
}

private colorblindMode(): boolean {
return (
this.userSettings.graphicsOverrides().accessibility?.colorblind ?? false
);
}

private toggleColorblindMode() {
const overrides = this.userSettings.graphicsOverrides();
this.userSettings.setGraphicsOverrides({
...overrides,
accessibility: {
...overrides.accessibility,
colorblind: !this.colorblindMode(),
},
});
}

private toggleEmojis() {
this.userSettings.toggleEmojis();

Expand Down Expand Up @@ -751,6 +768,15 @@ export class UserSettingModal extends BaseModal {
@change=${this.toggleDarkMode}
></setting-toggle>

<!-- 🎨 Colorblind Mode -->
<setting-toggle
label="${translateText("user_setting.colorblind_label")}"
description="${translateText("user_setting.colorblind_desc")}"
id="colorblind-toggle"
.checked=${this.colorblindMode()}
@change=${this.toggleColorblindMode}
></setting-toggle>

<!-- 😊 Emojis -->
<setting-toggle
label="${translateText("user_setting.emojis_label")}"
Expand Down
5 changes: 5 additions & 0 deletions src/client/render/gl/GraphicsOverrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export const GraphicsOverridesSchema = z
classicIcons: z.boolean(),
})
.partial(),
accessibility: z
.object({
colorblind: z.boolean(),
})
.partial(),
})
.partial();

Expand Down
27 changes: 27 additions & 0 deletions src/client/render/gl/RenderOverrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,33 @@ export function applyGraphicsOverrides(
settings.name.outlineG = channel;
settings.name.outlineB = channel;
}
if (overrides.accessibility?.colorblind === true) {
// Swap the red/green friend-foe encoding (the most common confusion axis)
// for a colorblind-safe blue/orange pairing (Okabe-Ito).
// Alt-view affiliation borders: self/ally in the blue family, enemy orange.
settings.affiliation.selfR = 0;
settings.affiliation.selfG = 0.447;
settings.affiliation.selfB = 0.698;
settings.affiliation.allyR = 0.337;
settings.affiliation.allyG = 0.706;
settings.affiliation.allyB = 0.914;
settings.affiliation.enemyR = 0.835;
settings.affiliation.enemyG = 0.369;
settings.affiliation.enemyB = 0;
// Normal-view relationship border tints: friendly blue, enemy orange,
// applied strongly so the cue doesn't rely on subtle hue.
settings.mapOverlay.friendlyTintR = 0;
settings.mapOverlay.friendlyTintG = 0.447;
settings.mapOverlay.friendlyTintB = 0.698;
settings.mapOverlay.embargoTintR = 0.835;
settings.mapOverlay.embargoTintG = 0.369;
settings.mapOverlay.embargoTintB = 0;
// Strong ratio so the friend/foe tint dominates the darkened territory
// border — neutral keeps its (darkened) fill hue, ally reads blue, enemy
// reads orange.
settings.mapOverlay.friendlyTintRatio = 0.85;
settings.mapOverlay.embargoTintRatio = 0.85;
}
}

export function applyDarkModeOverride(
Expand Down
199 changes: 199 additions & 0 deletions src/client/theme/BaseTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { Colord, colord, LabaColor } from "colord";
import { PlayerType, Team } from "../../core/game/Game";
import { GameMap, TileRef } from "../../core/game/GameMap";
import { PlayerView } from "../../core/game/GameView";
import { PseudoRandom } from "../../core/PseudoRandom";
import { simpleHash } from "../../core/Util";
import { ColorAllocator } from "./ColorAllocator";
import { Theme } from "./Theme";

/**
* Shared theme machinery. Owns the per-pool color allocators and the
* territory/team color dispatch (the greedy allocation), plus the color math
* every theme shares. Concrete themes supply only the color *data* by
* implementing the abstract hooks (palettes, team-color variations, terrain).
* A theme may also override the dispatch methods for fully custom allocation.
*/
export abstract class BaseTheme implements Theme {
private rand = new PseudoRandom(123);
protected humanColorAllocator: ColorAllocator;
protected botColorAllocator: ColorAllocator;
protected nationColorAllocator: ColorAllocator;
private teamPlayerColors = new Map<string, Colord>();

// Shared "default theme" colors. Override the fields in a subclass to differ.
protected background = colord("rgb(60,60,60)");
protected falloutColors = [
colord("rgb(120,255,71)"),
colord("rgb(130,255,85)"),
colord("rgb(110,245,65)"),
colord("rgb(125,255,75)"),
colord("rgb(115,250,68)"),
];
protected _spawnHighlightColor = colord("rgb(255,213,79)");
protected _spawnHighlightSelfColor = colord("rgb(255,255,255)");
protected _spawnHighlightTeamColor = colord("rgb(0,255,0)");
protected _spawnHighlightEnemyColor = colord("rgb(255,0,0)");

constructor() {
this.humanColorAllocator = new ColorAllocator(
this.humanPalette(),
this.fallbackPalette(),
);
this.botColorAllocator = new ColorAllocator(
this.botPalette(),
this.botPalette(),
);
this.nationColorAllocator = new ColorAllocator(
this.nationPalette(),
this.nationPalette(),
);
}

// --- Color data: concrete themes provide these ---
protected abstract humanPalette(): Colord[];
protected abstract botPalette(): Colord[];
protected abstract nationPalette(): Colord[];
protected abstract fallbackPalette(): Colord[];
/** Per-team color variations; index 0 is the team's base color. */
protected abstract teamColorVariations(team: Team): Colord[];
abstract terrainColor(gm: GameMap, tile: TileRef): Colord;

// --- Allocation dispatch (overridable) ---
teamColor(team: Team): Colord {
const rgb = this.teamColorVariations(team)[0].toRgb();
return colord({
r: Math.round(rgb.r),
g: Math.round(rgb.g),
b: Math.round(rgb.b),
});
}

territoryColor(player: PlayerView): Colord {
const team = player.team();
if (team !== null) {
return this.teamColorForPlayer(team, player.id());
}
if (player.type() === PlayerType.Human) {
return this.humanColorAllocator.assignColor(player.id());
}
if (player.type() === PlayerType.Bot) {
return this.botColorAllocator.assignColor(player.id());
}
return this.nationColorAllocator.assignColor(player.id());
}

/** Stable per-player variation within a team's color set. */
teamColorForPlayer(team: Team, playerId: string): Colord {
const cached = this.teamPlayerColors.get(playerId);
if (cached !== undefined) {
return cached;
}
const colors = this.teamColorVariations(team);
const color = colors[simpleHash(playerId) % colors.length];
this.teamPlayerColors.set(playerId, color);
return color;
}

// --- Shared color math ---
structureColors(territoryColor: Colord): { light: Colord; dark: Colord } {
// Convert territory color to LAB color space. Territory color is rendered in game with alpha = 150/255, use that here.
const lightLAB = territoryColor.alpha(150 / 255).toLab();
// Get "border color" from territory color & convert to LAB color space
const darkLAB = this.borderColor(territoryColor).toLab();
// Calculate the contrast of the two provided colors
let contrast = this.contrast(lightLAB, darkLAB);

// Don't want excessive contrast, so incrementally increase contrast within a loop.
// Define target values, looping limits, and loop counter
const loopLimit = 10; // Switch from darkening border to lightening fill if loopLimit is reached
const maxIterations = 50; // maximum number of loops allowed, throw error above this limit
const contrastTarget = 0.5;
let loopCount = 0;

// Adjust luminance by 5 in each iteration. This is a balance between speed and not overdoing contrast changes.
const luminanceChange = 5;

while (contrast < contrastTarget) {
if (loopCount > maxIterations) {
// Prevent runaway loops
console.warn(`Infinite loop detected during structure color calculation.
Light color: ${colord(lightLAB).toRgbString()},
Dark color: ${colord(darkLAB).toRgbString()},
Contrast: ${contrast}`);
break;

// Increase the light color if the "loop limit" has been reach
// (probably due to the dark color already being as dark as it can be)
} else if (loopCount > loopLimit) {
lightLAB.l = this.clamp(lightLAB.l + luminanceChange);

// Decrease the dark color first to keep the light color as close
// to the territory color as possible
} else {
darkLAB.l = this.clamp(darkLAB.l - luminanceChange);
}

// re-calculate contrast and increment loop counter
contrast = this.contrast(lightLAB, darkLAB);
loopCount++;
}
return { light: colord(lightLAB), dark: colord(darkLAB) };
}

private contrast(first: LabaColor, second: LabaColor): number {
return colord(first).delta(colord(second));
}

private clamp(num: number, low: number = 0, high: number = 100): number {
return Math.min(Math.max(low, num), high);
}

// Don't call directly, use PlayerView
borderColor(territoryColor: Colord): Colord {
return territoryColor.darken(0.125);
}

defendedBorderColors(territoryColor: Colord): {
light: Colord;
dark: Colord;
} {
return {
light: territoryColor.darken(0.2),
dark: territoryColor.darken(0.4),
};
}

focusedBorderColor(): Colord {
return colord("rgb(230,230,230)");
}

textColor(player: PlayerView): string {
return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D";
}

backgroundColor(): Colord {
return this.background;
}

falloutColor(): Colord {
return this.rand.randElement(this.falloutColors);
}

font(): string {
return "Overpass, sans-serif";
}

spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
}
spawnHighlightSelfColor(): Colord {
return this._spawnHighlightSelfColor;
}
spawnHighlightTeamColor(): Colord {
return this._spawnHighlightTeamColor;
}
spawnHighlightEnemyColor(): Colord {
return this._spawnHighlightEnemyColor;
}
}
71 changes: 7 additions & 64 deletions src/client/theme/ColorAllocator.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,28 @@
import { colord, Colord, extend } from "colord";
import { 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 "../../core/game/Game";
import { PseudoRandom } from "../../core/PseudoRandom";
import { simpleHash } from "../../core/Util";
import {
blueTeamColors,
botTeamColors,
greenTeamColors,
orangeTeamColors,
purpleTeamColors,
redTeamColors,
tealTeamColors,
yellowTeamColors,
} from "./Colors";
extend([lchPlugin]);
extend([labPlugin]);

/**
* Assigns a stable, visually distinct color to each id from a pool, falling
* back to a larger list once the pool is exhausted. Theme-agnostic: it knows
* nothing about teams or palettes — a theme supplies the pool and owns any
* team-color logic.
*/
export class ColorAllocator {
private availableColors: Colord[];
private fallbackColors: Colord[];
private assigned = new Map<string, Colord>();
private teamPlayerColors = new Map<string, Colord>();

constructor(colors: Colord[], fallback: Colord[]) {
this.availableColors = [...colors];
this.fallbackColors = [...colors, ...fallback];
}

private getTeamColorVariations(team: Team): Colord[] {
switch (team) {
case ColoredTeams.Blue:
return blueTeamColors;
case ColoredTeams.Red:
return redTeamColors;
case ColoredTeams.Teal:
return tealTeamColors;
case ColoredTeams.Purple:
return purpleTeamColors;
case ColoredTeams.Yellow:
return yellowTeamColors;
case ColoredTeams.Orange:
return orangeTeamColors;
case ColoredTeams.Green:
return greenTeamColors;
case ColoredTeams.Bot:
return botTeamColors;
case ColoredTeams.Humans:
return blueTeamColors;
case ColoredTeams.Nations:
return redTeamColors;
default:
return [this.assignColor(team)];
}
}

assignColor(id: string): Colord {
if (this.assigned.has(id)) {
return this.assigned.get(id)!;
Expand Down Expand Up @@ -84,30 +51,6 @@ export class ColorAllocator {
this.assigned.set(id, color);
return color;
}

assignTeamColor(team: Team): Colord {
const teamColors = this.getTeamColorVariations(team);
const rgb = teamColors[0].toRgb();
rgb.r = Math.round(rgb.r);
rgb.g = Math.round(rgb.g);
rgb.b = Math.round(rgb.b);
return colord(rgb);
}

assignTeamPlayerColor(team: Team, playerId: string): Colord {
if (this.teamPlayerColors.has(playerId)) {
return this.teamPlayerColors.get(playerId)!;
}

const teamColors = this.getTeamColorVariations(team);
const hashValue = simpleHash(playerId);
const colorIndex = hashValue % teamColors.length;
const color = teamColors[colorIndex];

this.teamPlayerColors.set(playerId, color);

return color;
}
}

// Select a distinct color index from the available colors that
Expand Down
Loading
Loading