Feature/colorblind mode (#4150)

**Add approved & assigned issue number here:**

Resolves #2549

## Description:

Adds colorblind mode. Similar to dark mode, it exists as a toggle in
settings. When enabled, it swaps the game's theme (which is refactored
to extend from a theme base class) to use more colorblind-friendly
colors and brightness variations. Borders are darkened, and terrarin is
separated by lightness. Friendly/Foe colors and switched to blue/orange
instead of red/green.

The theme refactor supports adding new themes without having to
reimplement the color distribution system. New themes can extend the
BaseTheme and supply the data, such as palettes, team-color variations,
and terrain.

New setting:
<img width="880" height="273" alt="Screenshot 2026-06-04 at 11 30 27 AM"
src="https://github.com/user-attachments/assets/d5d573d5-cc64-4ac1-95c2-00627faf17cc"
/>

New color palette:
<img width="1119" height="757" alt="Screenshot 2026-06-04 at 11 30
59 AM"
src="https://github.com/user-attachments/assets/2bb15bc9-992b-41ae-ab0e-b01fe0c3c6bb"
/>

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

jetaviz
This commit is contained in:
noahschmal
2026-06-11 10:53:03 -07:00
committed by GitHub
parent 7137347b7d
commit 21776e81af
16 changed files with 723 additions and 304 deletions
+228
View File
@@ -0,0 +1,228 @@
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 ---
/** Color pool for human players. */
protected abstract humanPalette(): Colord[];
/** Color pool for bot players. */
protected abstract botPalette(): Colord[];
/** Color pool for nation (FFA AI) players. */
protected abstract nationPalette(): Colord[];
/** Extra colors used once the human pool is exhausted. */
protected abstract fallbackPalette(): Colord[];
/** Per-team color variations; index 0 is the team's base color. */
protected abstract teamColorVariations(team: Team): Colord[];
/** Color for a terrain tile, based on its type and elevation magnitude. */
abstract terrainColor(gm: GameMap, tile: TileRef): Colord;
// --- Allocation dispatch (overridable) ---
/** Base color for a team (the first entry of its variations). */
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),
});
}
/**
* Color for a player's territory: a per-player variation when the player is
* on a team, otherwise a distinct color allocated from the matching pool
* (human / bot / nation).
*/
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 ---
/**
* Derive the light/dark color pair used to render a structure icon over a
* territory, nudging luminance until the two reach a minimum contrast so the
* icon stays legible on any fill.
*/
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;
} else if (loopCount > loopLimit) {
// Increase the light color once the loop limit is reached (probably
// because the dark color is already as dark as it can get).
lightLAB.l = this.clamp(lightLAB.l + luminanceChange);
} else {
// Decrease the dark color first to keep the light color as close
// to the territory color as possible.
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) };
}
/** Perceptual (CIE76 delta-E) distance between two LAB colors. */
private contrast(first: LabaColor, second: LabaColor): number {
return colord(first).delta(colord(second));
}
/** Clamp a number into the inclusive [low, high] range (default 0100). */
private clamp(num: number, low: number = 0, high: number = 100): number {
return Math.min(Math.max(low, num), high);
}
/**
* Border color for a territory. Don't call directly — use PlayerView.
* Themes override this to change how borders relate to the fill.
*/
borderColor(territoryColor: Colord): Colord {
return territoryColor.darken(0.125);
}
/** Light/dark border pair used to render a defended (fortified) border. */
defendedBorderColors(territoryColor: Colord): {
light: Colord;
dark: Colord;
} {
return {
light: territoryColor.darken(0.2),
dark: territoryColor.darken(0.4),
};
}
/** Border color used to highlight the currently focused player. */
focusedBorderColor(): Colord {
return colord("rgb(230,230,230)");
}
/** Player name text color (darker for humans, gray for AI). */
textColor(player: PlayerView): string {
return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D";
}
/** Map background color. */
backgroundColor(): Colord {
return this.background;
}
/** A random color from the fallout palette (for the nuke fallout effect). */
falloutColor(): Colord {
return this.rand.randElement(this.falloutColors);
}
/** Font stack used for in-map text. */
font(): string {
return "Overpass, sans-serif";
}
/** Highlight color for a spawnable tile during the spawn phase. */
spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
}
/** Spawn highlight color for the local player's own tiles. */
spawnHighlightSelfColor(): Colord {
return this._spawnHighlightSelfColor;
}
/** Spawn highlight color for teammates' tiles. */
spawnHighlightTeamColor(): Colord {
return this._spawnHighlightTeamColor;
}
/** Spawn highlight color for enemies' tiles. */
spawnHighlightEnemyColor(): Colord {
return this._spawnHighlightEnemyColor;
}
}
+27 -69
View File
@@ -1,61 +1,35 @@
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)];
}
}
/**
* Return the color assigned to `id`, allocating one on first request. New
* colors are chosen to be as visually distinct as possible from those already
* handed out (falling back to random selection once the pool is large or
* exhausted, for performance). Assignments are stable for the allocator's
* lifetime.
*/
assignColor(id: string): Colord {
if (this.assigned.has(id)) {
return this.assigned.get(id)!;
@@ -76,46 +50,27 @@ export class ColorAllocator {
selectedIndex = rand.nextInt(0, this.availableColors.length);
} else {
const assignedColors = Array.from(this.assigned.values());
selectedIndex =
selectDistinctColorIndex(this.availableColors, assignedColors) ?? 0;
selectedIndex = selectDistinctColorIndex(
this.availableColors,
assignedColors,
);
}
const color = this.availableColors.splice(selectedIndex, 1)[0];
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
// is most different from the assigned colors
/**
* Index of the available color that is most perceptually different from the
* already-assigned colors (the one whose nearest assigned neighbor is farthest
* away, by delta-E 2000). Throws if no colors have been assigned yet.
*/
export function selectDistinctColorIndex(
availableColors: Colord[],
assignedColors: Colord[],
): number | null {
): number {
if (assignedColors.length === 0) {
throw new Error("No assigned colors");
}
@@ -136,16 +91,19 @@ export function selectDistinctColorIndex(
return maxIndex;
}
/** Smallest delta-E 2000 distance from `lab1` to any of the assigned colors. */
function minDeltaE(lab1: Color, assignedLabColors: Color[]) {
return assignedLabColors.reduce((min, assigned) => {
return Math.min(min, deltaE2000(lab1, assigned));
}, Infinity);
}
/** Perceptual distance between two colors using the CIEDE2000 formula. */
function deltaE2000(c1: Color, c2: Color): number {
return c1.deltaE(c2, "2000");
}
/** Convert a colord color to a colorjs.io LAB color for delta-E math. */
function toColor(colord: Colord): Color {
const lab = colord.toLab();
return new Color("lab", [lab.l, lab.a, lab.b]);
+113
View File
@@ -0,0 +1,113 @@
import { Colord, colord } from "colord";
import { ColoredTeams, Team, TerrainType } from "../../core/game/Game";
import { GameMap, TileRef } from "../../core/game/GameMap";
import {
botTeamColors,
cbBlueTeamColors,
cbGreenTeamColors,
cbOrangeTeamColors,
cbPurpleTeamColors,
cbRedTeamColors,
cbTealTeamColors,
cbYellowTeamColors,
colorblindColors,
} from "./Colors";
import { PastelTheme } from "./PastelTheme";
/**
* Colorblind theme — keeps the light terrain but swaps player and team palettes
* for a high-contrast, lightness-varied, colorblind-safe set. Shares all the
* allocation logic from BaseTheme via PastelTheme.
*/
export class ColorblindTheme extends PastelTheme {
/** All player pools share the single CVD-safe, lightness-varied palette. */
protected humanPalette(): Colord[] {
return colorblindColors;
}
protected botPalette(): Colord[] {
return colorblindColors;
}
protected nationPalette(): Colord[] {
return colorblindColors;
}
/** Colorblind-safe per-team variations (blue/orange-anchored Okabe-Ito). */
protected teamColorVariations(team: Team): Colord[] {
switch (team) {
case ColoredTeams.Blue:
return cbBlueTeamColors;
case ColoredTeams.Red:
return cbRedTeamColors;
case ColoredTeams.Teal:
return cbTealTeamColors;
case ColoredTeams.Purple:
return cbPurpleTeamColors;
case ColoredTeams.Yellow:
return cbYellowTeamColors;
case ColoredTeams.Orange:
return cbOrangeTeamColors;
case ColoredTeams.Green:
return cbGreenTeamColors;
case ColoredTeams.Bot:
return botTeamColors;
case ColoredTeams.Humans:
return cbBlueTeamColors;
case ColoredTeams.Nations:
return cbRedTeamColors;
default:
return [this.humanColorAllocator.assignColor(team)];
}
}
/**
* Fill-derived border, darkened *relative* to each fill's own lightness
* rather than by a fixed amount. An absolute darken (e.g. .darken(0.3))
* pushes already-dark fills to near-black while barely touching light ones,
* so borders read inconsistently across nations. Scaling lightness keeps
* every border the same proportion darker than its territory — distinct, but
* still hued and never collapsing to black. Friend/foe tints are mixed on top
* in the border shader.
*/
borderColor(territoryColor: Colord): Colord {
const hsl = territoryColor.toHsl();
return colord({ ...hsl, l: hsl.l * 0.6 });
}
/**
* CVD-tuned terrain: separate elevation bands by *lightness* (the cue all
* colorblindness types keep) rather than the green→brown→gray hue ramp, which
* blurs plains↔hills under red-green CVD. Dark plains → mid hills → bright
* mountains. Water/shore are inherited (blue is already CVD-safe).
*/
terrainColor(gm: GameMap, tile: TileRef): Colord {
const mag = gm.magnitude(tile);
if (gm.isShore(tile)) {
return this.shore;
}
const type = gm.terrainType(tile);
switch (type) {
case TerrainType.Ocean: {
const w = this.water.rgba;
if (gm.isShoreline(tile) && gm.isWater(tile)) {
return this.shorelineWater;
}
return colord({
r: Math.max(w.r - 10 + (11 - Math.min(mag, 10)), 0),
g: Math.max(w.g - 10 + (11 - Math.min(mag, 10)), 0),
b: Math.max(w.b - 10 + (11 - Math.min(mag, 10)), 0),
});
}
case TerrainType.Plains: // dark green, low lightness
return colord({ r: 90, g: 140 - mag, b: 70 });
case TerrainType.Highland: // mid ochre, clearly lighter than plains
return colord({ r: 165 + 2 * mag, g: 145 + 2 * mag, b: 105 + mag });
case TerrainType.Mountain: // near-white, brightest band
return colord({ r: 225 + mag / 2, g: 225 + mag / 2, b: 228 + mag / 2 });
default: {
// Exhaustiveness guard: a new TerrainType is a compile error here.
const _exhaustive: never = type;
return _exhaustive;
}
}
}
}
+35
View File
@@ -23,6 +23,41 @@ export const orangeTeamColors: Colord[] = generateTeamColors(orange);
export const greenTeamColors: Colord[] = generateTeamColors(green);
export const botTeamColors: Colord[] = [botColor];
// High-contrast, lightness-varied palette for colorblind mode. Hue is spread by
// the golden angle and lightness walks across a wide range so colors differ in
// brightness (the cue all colorblindness types retain), not just hue. The
// allocator's greedy max-ΔE pick then keeps neighbors as distinct as possible.
export const colorblindColors: Colord[] = Array.from({ length: 32 }, (_, i) => {
const h = (i * 137.508) % 360;
const l = 35 + ((i * 7) % 50); // 35..84, spread across entries
const c = 78;
return colord({ l, c, h });
});
// Colorblind-safe team base colors (Okabe-Ito), expanded into per-player
// variations the same way the pastel teams are.
export const cbBlueTeamColors: Colord[] = generateTeamColors(
colord("rgb(0,114,178)"),
);
export const cbRedTeamColors: Colord[] = generateTeamColors(
colord("rgb(213,94,0)"), // vermillion
);
export const cbTealTeamColors: Colord[] = generateTeamColors(
colord("rgb(0,158,115)"), // bluish green
);
export const cbPurpleTeamColors: Colord[] = generateTeamColors(
colord("rgb(204,121,167)"), // reddish purple
);
export const cbYellowTeamColors: Colord[] = generateTeamColors(
colord("rgb(240,228,66)"),
);
export const cbOrangeTeamColors: Colord[] = generateTeamColors(
colord("rgb(230,159,0)"),
);
export const cbGreenTeamColors: Colord[] = generateTeamColors(
colord("rgb(86,180,233)"), // sky blue (green is hard for CVD)
);
function generateTeamColors(baseColor: Colord): Colord[] {
const lch = baseColor.toLch();
const colorCount = 64;
+80 -160
View File
@@ -1,148 +1,91 @@
import { Colord, colord, LabaColor } from "colord";
import { PseudoRandom } from "../../core/PseudoRandom";
import { PlayerType, Team, TerrainType } from "../../core/game/Game";
import { Colord, colord } from "colord";
import { ColoredTeams, 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";
import { BaseTheme } from "./BaseTheme";
import {
blueTeamColors,
botColors,
botTeamColors,
fallbackColors,
greenTeamColors,
humanColors,
nationColors,
orangeTeamColors,
purpleTeamColors,
redTeamColors,
tealTeamColors,
yellowTeamColors,
} from "./Colors";
export class PastelTheme implements Theme {
private rand = new PseudoRandom(123);
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
private botColorAllocator = new ColorAllocator(botColors, botColors);
private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors);
private nationColorAllocator = new ColorAllocator(nationColors, nationColors);
/**
* Default light theme — soft pastel player palettes and a naturalistic
* (green → tan → white) terrain ramp. Other themes extend it to reuse the
* shared terrain/water colors while swapping palettes.
*/
export class PastelTheme extends BaseTheme {
protected shore = colord("rgb(204,203,158)");
protected water = colord("rgb(70,132,180)");
protected shorelineWater = colord("rgb(100,143,255)");
private background = colord("rgb(60,60,60)");
private shore = colord("rgb(204,203,158)");
private falloutColors = [
colord("rgb(120,255,71)"), // Original color
colord("rgb(130,255,85)"), // Slightly lighter
colord("rgb(110,245,65)"), // Slightly darker
colord("rgb(125,255,75)"), // Warmer tint
colord("rgb(115,250,68)"), // Cooler tint
];
private water = colord("rgb(70,132,180)");
private shorelineWater = colord("rgb(100,143,255)");
/** 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 */
private _spawnHighlightSelfColor = colord("rgb(255,255,255)");
/** Added non-default spawn highlight colors for teammates, green */
private _spawnHighlightTeamColor = colord("rgb(0,255,0)");
/** Added non-default spawn highlight colors for enemies, red */
private _spawnHighlightEnemyColor = colord("rgb(255,0,0)");
teamColor(team: Team): Colord {
return this.teamColorAllocator.assignTeamColor(team);
protected humanPalette(): Colord[] {
return humanColors;
}
protected botPalette(): Colord[] {
return botColors;
}
protected nationPalette(): Colord[] {
return nationColors;
}
protected fallbackPalette(): Colord[] {
return fallbackColors;
}
territoryColor(player: PlayerView): Colord {
const team = player.team();
if (team !== null) {
return this.teamColorAllocator.assignTeamPlayerColor(team, player.id());
protected teamColorVariations(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.humanColorAllocator.assignColor(team)];
}
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());
}
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";
}
// | Terrain Type | Magnitude | Base Color Logic | Visual Description |
// | :---------------- | :-------- | :---------------------------------------------- | :------------------------------------------------------------------- |
// | **Shore (Land)** | N/A | Fixed: `rgb(204, 203, 158)` | Sandy beige. Overrides other land types if adjacent to water. |
// | **Plains** | 0 - 9 | `rgb(190, 220, 138)` - `rgb(190, 202, 138)` | Light green. Gets slightly darker/less green as magnitude increases. |
// | **Highland** | 10 - 19 | `rgb(220, 203, 158)` - `rgb(238, 221, 176)` | Tan/Beige. Gets lighter as magnitude increases. |
// | **Mountain** | 20 - 30 | `rgb(240, 240, 240)` - `rgb(245, 245, 245)` | Grayscale (White/Grey). Represents snow caps or rocky peaks. |
// | **Water (Shore)** | 0 | Fixed: `rgb(100, 143, 255)` | Light blue near land. |
// | **Water (Deep)** | 1 - 10+ | `rgb(70, 132, 180)` - `rgb(61, 123, 171)` | Darker blue, adjusted slightly by distance to land. |
/**
* Naturalistic terrain ramp by type and elevation magnitude:
*
* | Terrain Type | Magnitude | Base Color Logic | Visual Description |
* | :---------------- | :-------- | :---------------------------------------------- | :------------------------------------------------------------------- |
* | **Shore (Land)** | N/A | Fixed: `rgb(204, 203, 158)` | Sandy beige. Overrides other land types if adjacent to water. |
* | **Plains** | 0 - 9 | `rgb(190, 220, 138)` - `rgb(190, 202, 138)` | Light green. Gets slightly darker/less green as magnitude increases. |
* | **Highland** | 10 - 19 | `rgb(220, 203, 158)` - `rgb(238, 221, 176)` | Tan/Beige. Gets lighter as magnitude increases. |
* | **Mountain** | 20 - 30 | `rgb(240, 240, 240)` - `rgb(245, 245, 245)` | Grayscale (White/Grey). Represents snow caps or rocky peaks. |
* | **Water (Shore)** | 0 | Fixed: `rgb(100, 143, 255)` | Light blue near land. |
* | **Water (Deep)** | 1 - 10+ | `rgb(70, 132, 180)` - `rgb(61, 123, 171)` | Darker blue, adjusted slightly by distance to land. |
*/
terrainColor(gm: GameMap, tile: TileRef): Colord {
const mag = gm.magnitude(tile);
if (gm.isShore(tile)) {
return this.shore;
}
switch (gm.terrainType(tile)) {
const type = gm.terrainType(tile);
switch (type) {
case TerrainType.Ocean: {
const w = this.water.rgba;
if (gm.isShoreline(tile) && gm.isWater(tile)) {
@@ -172,34 +115,11 @@ export class PastelTheme implements Theme {
g: 230 + mag / 2,
b: 230 + mag / 2,
});
default: {
// Exhaustiveness guard: a new TerrainType is a compile error here.
const _exhaustive: never = type;
return _exhaustive;
}
}
}
backgroundColor(): Colord {
return this.background;
}
falloutColor(): Colord {
return this.rand.randElement(this.falloutColors);
}
font(): string {
return "Overpass, sans-serif";
}
spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
}
/** Return spawn highlight color for self */
spawnHighlightSelfColor(): Colord {
return this._spawnHighlightSelfColor;
}
/** Return spawn highlight color for teammates */
spawnHighlightTeamColor(): Colord {
return this._spawnHighlightTeamColor;
}
/** Return spawn highlight color for enemies */
spawnHighlightEnemyColor(): Colord {
return this._spawnHighlightEnemyColor;
}
}
+7 -1
View File
@@ -1,4 +1,5 @@
import { UserSettings } from "../../core/game/UserSettings";
import { ColorblindTheme } from "./ColorblindTheme";
import { PastelTheme } from "./PastelTheme";
import { PastelThemeDark } from "./PastelThemeDark";
import { Theme } from "./Theme";
@@ -12,9 +13,13 @@ class ThemeProvider {
private readonly userSettings = new UserSettings();
private light = new PastelTheme();
private dark = new PastelThemeDark();
private colorblind = new ColorblindTheme();
/** The active theme, selected from the user's dark-mode preference. */
/** The active theme, from colorblind mode, then the dark-mode preference. */
current(): Theme {
if (this.userSettings.graphicsOverrides().accessibility?.colorblind) {
return this.colorblind;
}
return this.userSettings.darkMode() ? this.dark : this.light;
}
@@ -26,6 +31,7 @@ class ThemeProvider {
reset(): void {
this.light = new PastelTheme();
this.dark = new PastelThemeDark();
this.colorblind = new ColorblindTheme();
}
}