mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-07 19:21:30 +00:00
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:
@@ -810,6 +810,8 @@
|
|||||||
"keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.",
|
"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_label": "Dark Mode",
|
||||||
"dark_mode_desc": "Toggle the site’s appearance between light and dark themes",
|
"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_label": "Emojis",
|
||||||
"emojis_desc": "Toggle whether emojis are shown in game",
|
"emojis_desc": "Toggle whether emojis are shown in game",
|
||||||
"alert_frame_label": "Alert Frame",
|
"alert_frame_label": "Alert Frame",
|
||||||
@@ -946,6 +948,7 @@
|
|||||||
"colored": "Colored",
|
"colored": "Colored",
|
||||||
"black": "Black",
|
"black": "Black",
|
||||||
"section_structure_icons": "Structure Icons",
|
"section_structure_icons": "Structure Icons",
|
||||||
|
"section_accessibility": "Accessibility",
|
||||||
"classic_icons_label": "Classic icons",
|
"classic_icons_label": "Classic icons",
|
||||||
"classic_icons_desc": "Lighter outline with near-black interior",
|
"classic_icons_desc": "Lighter outline with near-black interior",
|
||||||
"section_map": "Map",
|
"section_map": "Map",
|
||||||
|
|||||||
@@ -497,10 +497,20 @@ async function createClientGame(
|
|||||||
applyGraphicsOverrides(live, userSettings.graphicsOverrides());
|
applyGraphicsOverrides(live, userSettings.graphicsOverrides());
|
||||||
applyDarkModeOverride(live, userSettings.darkMode());
|
applyDarkModeOverride(live, userSettings.darkMode());
|
||||||
};
|
};
|
||||||
|
// Re-apply render settings, then re-theme and recolor players, on a
|
||||||
|
// graphics-override change (covers a theme switch such as colorblind mode).
|
||||||
|
const onGraphicsChanged = (): void => {
|
||||||
|
regenerateRenderSettings();
|
||||||
|
// A graphics override can switch the active theme (e.g. colorblind mode),
|
||||||
|
// so re-theme existing players and re-upload the palette to recolor their
|
||||||
|
// territory fills/borders live.
|
||||||
|
gameView.refreshPlayerColors();
|
||||||
|
webglBuilder.refreshPalette(gameView);
|
||||||
|
};
|
||||||
regenerateRenderSettings();
|
regenerateRenderSettings();
|
||||||
globalThis.addEventListener(
|
globalThis.addEventListener(
|
||||||
`${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`,
|
`${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`,
|
||||||
regenerateRenderSettings,
|
onGraphicsChanged,
|
||||||
{ signal: graphicsListenerAbort.signal },
|
{ signal: graphicsListenerAbort.signal },
|
||||||
);
|
);
|
||||||
globalThis.addEventListener(
|
globalThis.addEventListener(
|
||||||
|
|||||||
@@ -206,6 +206,25 @@ export class UserSettingModal extends BaseModal {
|
|||||||
console.log("🌙 Dark Mode:", this.userSettings.darkMode() ? "ON" : "OFF");
|
console.log("🌙 Dark Mode:", this.userSettings.darkMode() ? "ON" : "OFF");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether colorblind mode is currently enabled in the graphics overrides. */
|
||||||
|
private colorblindMode(): boolean {
|
||||||
|
return (
|
||||||
|
this.userSettings.graphicsOverrides().accessibility?.colorblind ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip the colorblind-mode graphics override and persist it. */
|
||||||
|
private toggleColorblindMode() {
|
||||||
|
const overrides = this.userSettings.graphicsOverrides();
|
||||||
|
this.userSettings.setGraphicsOverrides({
|
||||||
|
...overrides,
|
||||||
|
accessibility: {
|
||||||
|
...overrides.accessibility,
|
||||||
|
colorblind: !this.colorblindMode(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private toggleEmojis() {
|
private toggleEmojis() {
|
||||||
this.userSettings.toggleEmojis();
|
this.userSettings.toggleEmojis();
|
||||||
|
|
||||||
@@ -742,6 +761,15 @@ export class UserSettingModal extends BaseModal {
|
|||||||
@change=${this.toggleDarkMode}
|
@change=${this.toggleDarkMode}
|
||||||
></setting-toggle>
|
></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 -->
|
<!-- 😊 Emojis -->
|
||||||
<setting-toggle
|
<setting-toggle
|
||||||
label="${translateText("user_setting.emojis_label")}"
|
label="${translateText("user_setting.emojis_label")}"
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ export class WebGLFrameBuilder {
|
|||||||
this.skinsInitialized = false;
|
this.skinsInitialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-write every player's palette entry from their current (possibly re-themed)
|
||||||
|
* colors and re-upload just the palette texture. Used after a mid-game theme
|
||||||
|
* change (e.g. toggling colorblind mode) so existing territories re-color
|
||||||
|
* without re-syncing players, skins, or spawns.
|
||||||
|
*/
|
||||||
|
refreshPalette(gameView: GameView): void {
|
||||||
|
for (const p of gameView.players()) {
|
||||||
|
this.writePaletteEntry(p.smallID(), p.territoryColor(), p.borderColor());
|
||||||
|
}
|
||||||
|
this.view.updatePalette(this.palette);
|
||||||
|
}
|
||||||
|
|
||||||
update(gameView: GameView): void {
|
update(gameView: GameView): void {
|
||||||
this.syncPlayers(gameView);
|
this.syncPlayers(gameView);
|
||||||
this.syncPlayerSpawns(gameView);
|
this.syncPlayerSpawns(gameView);
|
||||||
|
|||||||
@@ -299,6 +299,18 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Merge a patch into the accessibility graphics overrides and persist it. */
|
||||||
|
private patchAccessibility(
|
||||||
|
patch: Partial<GraphicsOverrides["accessibility"]>,
|
||||||
|
) {
|
||||||
|
const current = this.userSettings.graphicsOverrides();
|
||||||
|
this.userSettings.setGraphicsOverrides({
|
||||||
|
...current,
|
||||||
|
accessibility: { ...current.accessibility, ...patch },
|
||||||
|
});
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
private currentSpecialEffects(): boolean {
|
private currentSpecialEffects(): boolean {
|
||||||
return (
|
return (
|
||||||
this.userSettings.graphicsOverrides().passEnabled?.fx ??
|
this.userSettings.graphicsOverrides().passEnabled?.fx ??
|
||||||
@@ -310,6 +322,18 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
|
|||||||
this.patchPassEnabled({ fx: !this.currentSpecialEffects() });
|
this.patchPassEnabled({ fx: !this.currentSpecialEffects() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether colorblind mode is currently enabled. */
|
||||||
|
private currentColorblind(): boolean {
|
||||||
|
return (
|
||||||
|
this.userSettings.graphicsOverrides().accessibility?.colorblind ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle colorblind-friendly colors. */
|
||||||
|
private onToggleColorblind() {
|
||||||
|
this.patchAccessibility({ colorblind: !this.currentColorblind() });
|
||||||
|
}
|
||||||
|
|
||||||
private onNameScaleChange(event: Event) {
|
private onNameScaleChange(event: Event) {
|
||||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||||
this.patchName({ nameScaleFactor: value });
|
this.patchName({ nameScaleFactor: value });
|
||||||
@@ -356,6 +380,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
|
|||||||
const territoryAlpha = this.currentTerritoryAlpha();
|
const territoryAlpha = this.currentTerritoryAlpha();
|
||||||
const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom();
|
const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom();
|
||||||
const railThickness = this.currentRailThickness();
|
const railThickness = this.currentRailThickness();
|
||||||
|
const colorblind = this.currentColorblind();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
@@ -717,6 +742,31 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
|
||||||
|
>
|
||||||
|
${translateText("graphics_setting.section_accessibility")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||||
|
@click=${this.onToggleColorblind}
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
${translateText("user_setting.colorblind_label")}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400">
|
||||||
|
${translateText("user_setting.colorblind_desc")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400">
|
||||||
|
${colorblind
|
||||||
|
? translateText("user_setting.on")
|
||||||
|
: translateText("user_setting.off")}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="border-t border-slate-600 pt-3 mt-4">
|
<div class="border-t border-slate-600 pt-3 mt-4">
|
||||||
<button
|
<button
|
||||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export const GraphicsOverridesSchema = z
|
|||||||
fx: z.boolean(),
|
fx: z.boolean(),
|
||||||
})
|
})
|
||||||
.partial(),
|
.partial(),
|
||||||
|
accessibility: z
|
||||||
|
.object({
|
||||||
|
colorblind: z.boolean(),
|
||||||
|
})
|
||||||
|
.partial(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import type { RenderSettings } from "./RenderSettings";
|
|||||||
|
|
||||||
const DARK_AMBIENT = 0.35;
|
const DARK_AMBIENT = 0.35;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the user's graphics overrides onto a RenderSettings in place: name
|
||||||
|
* scaling, classic/dark structure and name styling, and the colorblind-safe
|
||||||
|
* affiliation/tint palette.
|
||||||
|
*/
|
||||||
export function applyGraphicsOverrides(
|
export function applyGraphicsOverrides(
|
||||||
settings: RenderSettings,
|
settings: RenderSettings,
|
||||||
overrides: GraphicsOverrides,
|
overrides: GraphicsOverrides,
|
||||||
@@ -67,8 +72,36 @@ export function applyGraphicsOverrides(
|
|||||||
settings.name.outlineG = channel;
|
settings.name.outlineG = channel;
|
||||||
settings.name.outlineB = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply dark-mode lighting (ambient + enabled) onto settings when active. */
|
||||||
export function applyDarkModeOverride(
|
export function applyDarkModeOverride(
|
||||||
settings: RenderSettings,
|
settings: RenderSettings,
|
||||||
isDark: boolean,
|
isDark: boolean,
|
||||||
|
|||||||
@@ -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 0–100). */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +1,35 @@
|
|||||||
import { colord, Colord, extend } from "colord";
|
import { Colord, extend } from "colord";
|
||||||
import labPlugin from "colord/plugins/lab";
|
import labPlugin from "colord/plugins/lab";
|
||||||
import lchPlugin from "colord/plugins/lch";
|
import lchPlugin from "colord/plugins/lch";
|
||||||
import Color from "colorjs.io";
|
import Color from "colorjs.io";
|
||||||
import { ColoredTeams, Team } from "../../core/game/Game";
|
|
||||||
import { PseudoRandom } from "../../core/PseudoRandom";
|
import { PseudoRandom } from "../../core/PseudoRandom";
|
||||||
import { simpleHash } from "../../core/Util";
|
import { simpleHash } from "../../core/Util";
|
||||||
import {
|
|
||||||
blueTeamColors,
|
|
||||||
botTeamColors,
|
|
||||||
greenTeamColors,
|
|
||||||
orangeTeamColors,
|
|
||||||
purpleTeamColors,
|
|
||||||
redTeamColors,
|
|
||||||
tealTeamColors,
|
|
||||||
yellowTeamColors,
|
|
||||||
} from "./Colors";
|
|
||||||
extend([lchPlugin]);
|
extend([lchPlugin]);
|
||||||
extend([labPlugin]);
|
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 {
|
export class ColorAllocator {
|
||||||
private availableColors: Colord[];
|
private availableColors: Colord[];
|
||||||
private fallbackColors: Colord[];
|
private fallbackColors: Colord[];
|
||||||
private assigned = new Map<string, Colord>();
|
private assigned = new Map<string, Colord>();
|
||||||
private teamPlayerColors = new Map<string, Colord>();
|
|
||||||
|
|
||||||
constructor(colors: Colord[], fallback: Colord[]) {
|
constructor(colors: Colord[], fallback: Colord[]) {
|
||||||
this.availableColors = [...colors];
|
this.availableColors = [...colors];
|
||||||
this.fallbackColors = [...colors, ...fallback];
|
this.fallbackColors = [...colors, ...fallback];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTeamColorVariations(team: Team): Colord[] {
|
/**
|
||||||
switch (team) {
|
* Return the color assigned to `id`, allocating one on first request. New
|
||||||
case ColoredTeams.Blue:
|
* colors are chosen to be as visually distinct as possible from those already
|
||||||
return blueTeamColors;
|
* handed out (falling back to random selection once the pool is large or
|
||||||
case ColoredTeams.Red:
|
* exhausted, for performance). Assignments are stable for the allocator's
|
||||||
return redTeamColors;
|
* lifetime.
|
||||||
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 {
|
assignColor(id: string): Colord {
|
||||||
if (this.assigned.has(id)) {
|
if (this.assigned.has(id)) {
|
||||||
return this.assigned.get(id)!;
|
return this.assigned.get(id)!;
|
||||||
@@ -76,46 +50,27 @@ export class ColorAllocator {
|
|||||||
selectedIndex = rand.nextInt(0, this.availableColors.length);
|
selectedIndex = rand.nextInt(0, this.availableColors.length);
|
||||||
} else {
|
} else {
|
||||||
const assignedColors = Array.from(this.assigned.values());
|
const assignedColors = Array.from(this.assigned.values());
|
||||||
selectedIndex =
|
selectedIndex = selectDistinctColorIndex(
|
||||||
selectDistinctColorIndex(this.availableColors, assignedColors) ?? 0;
|
this.availableColors,
|
||||||
|
assignedColors,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = this.availableColors.splice(selectedIndex, 1)[0];
|
const color = this.availableColors.splice(selectedIndex, 1)[0];
|
||||||
this.assigned.set(id, color);
|
this.assigned.set(id, color);
|
||||||
return 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(
|
export function selectDistinctColorIndex(
|
||||||
availableColors: Colord[],
|
availableColors: Colord[],
|
||||||
assignedColors: Colord[],
|
assignedColors: Colord[],
|
||||||
): number | null {
|
): number {
|
||||||
if (assignedColors.length === 0) {
|
if (assignedColors.length === 0) {
|
||||||
throw new Error("No assigned colors");
|
throw new Error("No assigned colors");
|
||||||
}
|
}
|
||||||
@@ -136,16 +91,19 @@ export function selectDistinctColorIndex(
|
|||||||
return maxIndex;
|
return maxIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Smallest delta-E 2000 distance from `lab1` to any of the assigned colors. */
|
||||||
function minDeltaE(lab1: Color, assignedLabColors: Color[]) {
|
function minDeltaE(lab1: Color, assignedLabColors: Color[]) {
|
||||||
return assignedLabColors.reduce((min, assigned) => {
|
return assignedLabColors.reduce((min, assigned) => {
|
||||||
return Math.min(min, deltaE2000(lab1, assigned));
|
return Math.min(min, deltaE2000(lab1, assigned));
|
||||||
}, Infinity);
|
}, Infinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Perceptual distance between two colors using the CIEDE2000 formula. */
|
||||||
function deltaE2000(c1: Color, c2: Color): number {
|
function deltaE2000(c1: Color, c2: Color): number {
|
||||||
return c1.deltaE(c2, "2000");
|
return c1.deltaE(c2, "2000");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert a colord color to a colorjs.io LAB color for delta-E math. */
|
||||||
function toColor(colord: Colord): Color {
|
function toColor(colord: Colord): Color {
|
||||||
const lab = colord.toLab();
|
const lab = colord.toLab();
|
||||||
return new Color("lab", [lab.l, lab.a, lab.b]);
|
return new Color("lab", [lab.l, lab.a, lab.b]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,41 @@ export const orangeTeamColors: Colord[] = generateTeamColors(orange);
|
|||||||
export const greenTeamColors: Colord[] = generateTeamColors(green);
|
export const greenTeamColors: Colord[] = generateTeamColors(green);
|
||||||
export const botTeamColors: Colord[] = [botColor];
|
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[] {
|
function generateTeamColors(baseColor: Colord): Colord[] {
|
||||||
const lch = baseColor.toLch();
|
const lch = baseColor.toLch();
|
||||||
const colorCount = 64;
|
const colorCount = 64;
|
||||||
|
|||||||
+80
-160
@@ -1,148 +1,91 @@
|
|||||||
import { Colord, colord, LabaColor } from "colord";
|
import { Colord, colord } from "colord";
|
||||||
import { PseudoRandom } from "../../core/PseudoRandom";
|
import { ColoredTeams, Team, TerrainType } from "../../core/game/Game";
|
||||||
import { PlayerType, Team, TerrainType } from "../../core/game/Game";
|
|
||||||
import { GameMap, TileRef } from "../../core/game/GameMap";
|
import { GameMap, TileRef } from "../../core/game/GameMap";
|
||||||
import { PlayerView } from "../../core/game/GameView";
|
import { BaseTheme } from "./BaseTheme";
|
||||||
import { ColorAllocator } from "./ColorAllocator";
|
import {
|
||||||
import { botColors, fallbackColors, humanColors, nationColors } from "./Colors";
|
blueTeamColors,
|
||||||
import { Theme } from "./Theme";
|
botColors,
|
||||||
|
botTeamColors,
|
||||||
|
fallbackColors,
|
||||||
|
greenTeamColors,
|
||||||
|
humanColors,
|
||||||
|
nationColors,
|
||||||
|
orangeTeamColors,
|
||||||
|
purpleTeamColors,
|
||||||
|
redTeamColors,
|
||||||
|
tealTeamColors,
|
||||||
|
yellowTeamColors,
|
||||||
|
} from "./Colors";
|
||||||
|
|
||||||
export class PastelTheme implements Theme {
|
/**
|
||||||
private rand = new PseudoRandom(123);
|
* Default light theme — soft pastel player palettes and a naturalistic
|
||||||
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
|
* (green → tan → white) terrain ramp. Other themes extend it to reuse the
|
||||||
private botColorAllocator = new ColorAllocator(botColors, botColors);
|
* shared terrain/water colors while swapping palettes.
|
||||||
private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors);
|
*/
|
||||||
private nationColorAllocator = new ColorAllocator(nationColors, nationColors);
|
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)");
|
protected humanPalette(): Colord[] {
|
||||||
private shore = colord("rgb(204,203,158)");
|
return humanColors;
|
||||||
private falloutColors = [
|
}
|
||||||
colord("rgb(120,255,71)"), // Original color
|
protected botPalette(): Colord[] {
|
||||||
colord("rgb(130,255,85)"), // Slightly lighter
|
return botColors;
|
||||||
colord("rgb(110,245,65)"), // Slightly darker
|
}
|
||||||
colord("rgb(125,255,75)"), // Warmer tint
|
protected nationPalette(): Colord[] {
|
||||||
colord("rgb(115,250,68)"), // Cooler tint
|
return nationColors;
|
||||||
];
|
}
|
||||||
private water = colord("rgb(70,132,180)");
|
protected fallbackPalette(): Colord[] {
|
||||||
private shorelineWater = colord("rgb(100,143,255)");
|
return fallbackColors;
|
||||||
|
|
||||||
/** 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
territoryColor(player: PlayerView): Colord {
|
protected teamColorVariations(team: Team): Colord[] {
|
||||||
const team = player.team();
|
switch (team) {
|
||||||
if (team !== null) {
|
case ColoredTeams.Blue:
|
||||||
return this.teamColorAllocator.assignTeamPlayerColor(team, player.id());
|
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.
|
* Naturalistic terrain ramp by type and elevation magnitude:
|
||||||
const lightLAB = territoryColor.alpha(150 / 255).toLab();
|
*
|
||||||
// Get "border color" from territory color & convert to LAB color space
|
* | Terrain Type | Magnitude | Base Color Logic | Visual Description |
|
||||||
const darkLAB = this.borderColor(territoryColor).toLab();
|
* | :---------------- | :-------- | :---------------------------------------------- | :------------------------------------------------------------------- |
|
||||||
// Calculate the contrast of the two provided colors
|
* | **Shore (Land)** | N/A | Fixed: `rgb(204, 203, 158)` | Sandy beige. Overrides other land types if adjacent to water. |
|
||||||
let contrast = this.contrast(lightLAB, darkLAB);
|
* | **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. |
|
||||||
// Don't want excessive contrast, so incrementally increase contrast within a loop.
|
* | **Mountain** | 20 - 30 | `rgb(240, 240, 240)` - `rgb(245, 245, 245)` | Grayscale (White/Grey). Represents snow caps or rocky peaks. |
|
||||||
// Define target values, looping limits, and loop counter
|
* | **Water (Shore)** | 0 | Fixed: `rgb(100, 143, 255)` | Light blue near land. |
|
||||||
const loopLimit = 10; // Switch from darkening border to lightening fill if loopLimit is reached
|
* | **Water (Deep)** | 1 - 10+ | `rgb(70, 132, 180)` - `rgb(61, 123, 171)` | Darker blue, adjusted slightly by distance to land. |
|
||||||
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. |
|
|
||||||
terrainColor(gm: GameMap, tile: TileRef): Colord {
|
terrainColor(gm: GameMap, tile: TileRef): Colord {
|
||||||
const mag = gm.magnitude(tile);
|
const mag = gm.magnitude(tile);
|
||||||
if (gm.isShore(tile)) {
|
if (gm.isShore(tile)) {
|
||||||
return this.shore;
|
return this.shore;
|
||||||
}
|
}
|
||||||
switch (gm.terrainType(tile)) {
|
const type = gm.terrainType(tile);
|
||||||
|
switch (type) {
|
||||||
case TerrainType.Ocean: {
|
case TerrainType.Ocean: {
|
||||||
const w = this.water.rgba;
|
const w = this.water.rgba;
|
||||||
if (gm.isShoreline(tile) && gm.isWater(tile)) {
|
if (gm.isShoreline(tile) && gm.isWater(tile)) {
|
||||||
@@ -172,34 +115,11 @@ export class PastelTheme implements Theme {
|
|||||||
g: 230 + mag / 2,
|
g: 230 + mag / 2,
|
||||||
b: 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { UserSettings } from "../../core/game/UserSettings";
|
import { UserSettings } from "../../core/game/UserSettings";
|
||||||
|
import { ColorblindTheme } from "./ColorblindTheme";
|
||||||
import { PastelTheme } from "./PastelTheme";
|
import { PastelTheme } from "./PastelTheme";
|
||||||
import { PastelThemeDark } from "./PastelThemeDark";
|
import { PastelThemeDark } from "./PastelThemeDark";
|
||||||
import { Theme } from "./Theme";
|
import { Theme } from "./Theme";
|
||||||
@@ -12,9 +13,13 @@ class ThemeProvider {
|
|||||||
private readonly userSettings = new UserSettings();
|
private readonly userSettings = new UserSettings();
|
||||||
private light = new PastelTheme();
|
private light = new PastelTheme();
|
||||||
private dark = new PastelThemeDark();
|
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 {
|
current(): Theme {
|
||||||
|
if (this.userSettings.graphicsOverrides().accessibility?.colorblind) {
|
||||||
|
return this.colorblind;
|
||||||
|
}
|
||||||
return this.userSettings.darkMode() ? this.dark : this.light;
|
return this.userSettings.darkMode() ? this.dark : this.light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +31,7 @@ class ThemeProvider {
|
|||||||
reset(): void {
|
reset(): void {
|
||||||
this.light = new PastelTheme();
|
this.light = new PastelTheme();
|
||||||
this.dark = new PastelThemeDark();
|
this.dark = new PastelThemeDark();
|
||||||
|
this.colorblind = new ColorblindTheme();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -872,6 +872,17 @@ export class GameView implements GameMap {
|
|||||||
return Array.from(this._players.values());
|
return Array.from(this._players.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute every player's theme-derived colors. Call when the active theme
|
||||||
|
* changes mid-game (e.g. toggling colorblind mode) so existing territories
|
||||||
|
* re-color; the renderer palette must be refreshed afterwards.
|
||||||
|
*/
|
||||||
|
refreshPlayerColors(): void {
|
||||||
|
for (const p of this._players.values()) {
|
||||||
|
p.refreshColors();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
playerBySmallID(id: number): PlayerView | TerraNullius {
|
playerBySmallID(id: number): PlayerView | TerraNullius {
|
||||||
if (id === 0) {
|
if (id === 0) {
|
||||||
return new TerraNulliusImpl();
|
return new TerraNulliusImpl();
|
||||||
|
|||||||
@@ -105,19 +105,20 @@ export class PlayerView {
|
|||||||
/** Static header data — set once at construction, never mutated. */
|
/** Static header data — set once at construction, never mutated. */
|
||||||
public static: PlayerStatic;
|
public static: PlayerStatic;
|
||||||
|
|
||||||
private _territoryColor: Colord;
|
// Assigned via computeColors() in the constructor; re-assignable on theme change.
|
||||||
private _borderColor: Colord;
|
private _territoryColor!: Colord;
|
||||||
private _railColor: Colord;
|
private _borderColor!: Colord;
|
||||||
|
private _railColor!: Colord;
|
||||||
// Update here to include structure light and dark colors
|
// Update here to include structure light and dark colors
|
||||||
private _structureColors: { light: Colord; dark: Colord };
|
private _structureColors!: { light: Colord; dark: Colord };
|
||||||
|
|
||||||
// Pre-computed border color variants
|
// Pre-computed border color variants
|
||||||
private _borderColorNeutral: Colord;
|
private _borderColorNeutral!: Colord;
|
||||||
private _borderColorFriendly: Colord;
|
private _borderColorFriendly!: Colord;
|
||||||
private _borderColorEmbargo: Colord;
|
private _borderColorEmbargo!: Colord;
|
||||||
private _borderColorDefendedNeutral: { light: Colord; dark: Colord };
|
private _borderColorDefendedNeutral!: { light: Colord; dark: Colord };
|
||||||
private _borderColorDefendedFriendly: { light: Colord; dark: Colord };
|
private _borderColorDefendedFriendly!: { light: Colord; dark: Colord };
|
||||||
private _borderColorDefendedEmbargo: { light: Colord; dark: Colord };
|
private _borderColorDefendedEmbargo!: { light: Colord; dark: Colord };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private game: GameView,
|
private game: GameView,
|
||||||
@@ -135,6 +136,23 @@ export class PlayerView {
|
|||||||
this.anonymousName = createRandomName(data.name!, data.playerType!);
|
this.anonymousName = createRandomName(data.name!, data.playerType!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.computeColors();
|
||||||
|
|
||||||
|
const pattern = userSettings.territoryPatterns()
|
||||||
|
? this.cosmetics.pattern
|
||||||
|
: undefined;
|
||||||
|
this.decoder =
|
||||||
|
pattern === undefined
|
||||||
|
? undefined
|
||||||
|
: new PatternDecoder(pattern, base64url.decode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute every theme-derived color (fill, border, structure, and the
|
||||||
|
* neutral/friendly/embargo border variants) from the active theme. Re-callable
|
||||||
|
* so a mid-game theme change — e.g. toggling colorblind mode — can refresh them.
|
||||||
|
*/
|
||||||
|
private computeColors(): void {
|
||||||
const theme = themeProvider.current();
|
const theme = themeProvider.current();
|
||||||
|
|
||||||
const defaultTerritoryColor = theme.territoryColor(this);
|
const defaultTerritoryColor = theme.territoryColor(this);
|
||||||
@@ -164,7 +182,7 @@ export class PlayerView {
|
|||||||
this._structureColors = theme.structureColors(this._territoryColor);
|
this._structureColors = theme.structureColors(this._territoryColor);
|
||||||
|
|
||||||
const maybeFocusedBorderColor =
|
const maybeFocusedBorderColor =
|
||||||
this.game.myClientID() === data.clientID
|
this.game.myClientID() === this.static.clientID
|
||||||
? theme.focusedBorderColor()
|
? theme.focusedBorderColor()
|
||||||
: defaultBorderColor;
|
: defaultBorderColor;
|
||||||
|
|
||||||
@@ -230,11 +248,11 @@ export class PlayerView {
|
|||||||
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
|
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
|
||||||
this._borderColorEmbargo,
|
this._borderColorEmbargo,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.decoder =
|
/** Recompute colors after the active theme changes (e.g. colorblind toggle). */
|
||||||
pattern === undefined
|
refreshColors(): void {
|
||||||
? undefined
|
this.computeColors();
|
||||||
: new PatternDecoder(pattern, base64url.decode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+46
-58
@@ -3,6 +3,7 @@ import {
|
|||||||
ColorAllocator,
|
ColorAllocator,
|
||||||
selectDistinctColorIndex,
|
selectDistinctColorIndex,
|
||||||
} from "../src/client/theme/ColorAllocator";
|
} from "../src/client/theme/ColorAllocator";
|
||||||
|
import { ColorblindTheme } from "../src/client/theme/ColorblindTheme";
|
||||||
import {
|
import {
|
||||||
blue,
|
blue,
|
||||||
botColor,
|
botColor,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
teal,
|
teal,
|
||||||
yellow,
|
yellow,
|
||||||
} from "../src/client/theme/Colors";
|
} from "../src/client/theme/Colors";
|
||||||
|
import { PastelTheme } from "../src/client/theme/PastelTheme";
|
||||||
import { ColoredTeams } from "../src/core/game/Game";
|
import { ColoredTeams } from "../src/core/game/Game";
|
||||||
|
|
||||||
const mockColors: Colord[] = [
|
const mockColors: Colord[] = [
|
||||||
@@ -80,71 +82,58 @@ describe("ColorAllocator", () => {
|
|||||||
expect(c1.isEqual(c1Again)).toBe(true);
|
expect(c1.isEqual(c1Again)).toBe(true);
|
||||||
expect(c2.isEqual(c2Again)).toBe(true);
|
expect(c2.isEqual(c2Again)).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("assignTeamColor returns the base color from the team", () => {
|
describe("PastelTheme team colors", () => {
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Blue)).toEqual(blue);
|
test("teamColor returns the base color from the team", () => {
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Red)).toEqual(red);
|
const theme = new PastelTheme();
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Teal)).toEqual(teal);
|
expect(theme.teamColor(ColoredTeams.Blue)).toEqual(blue);
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Purple)).toEqual(purple);
|
expect(theme.teamColor(ColoredTeams.Red)).toEqual(red);
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Yellow)).toEqual(yellow);
|
expect(theme.teamColor(ColoredTeams.Teal)).toEqual(teal);
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Orange)).toEqual(orange);
|
expect(theme.teamColor(ColoredTeams.Purple)).toEqual(purple);
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Green)).toEqual(green);
|
expect(theme.teamColor(ColoredTeams.Yellow)).toEqual(yellow);
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Bot)).toEqual(botColor);
|
expect(theme.teamColor(ColoredTeams.Orange)).toEqual(orange);
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Humans)).toEqual(blue);
|
expect(theme.teamColor(ColoredTeams.Green)).toEqual(green);
|
||||||
expect(allocator.assignTeamColor(ColoredTeams.Nations)).toEqual(red);
|
expect(theme.teamColor(ColoredTeams.Bot)).toEqual(botColor);
|
||||||
|
expect(theme.teamColor(ColoredTeams.Humans)).toEqual(blue);
|
||||||
|
expect(theme.teamColor(ColoredTeams.Nations)).toEqual(red);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assignTeamPlayerColor always returns the same color for the same playerID", () => {
|
test("teamColorForPlayer is stable for the same playerID", () => {
|
||||||
const playerId = "player123";
|
const theme = new PastelTheme();
|
||||||
|
const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player123");
|
||||||
const blueColor1 = allocator.assignTeamPlayerColor(
|
const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player123");
|
||||||
ColoredTeams.Blue,
|
expect(a.isEqual(b)).toBe(true);
|
||||||
playerId,
|
|
||||||
);
|
|
||||||
const blueColor2 = allocator.assignTeamPlayerColor(
|
|
||||||
ColoredTeams.Blue,
|
|
||||||
playerId,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(blueColor1.isEqual(blueColor2)).toBe(true);
|
|
||||||
|
|
||||||
const redColor1 = allocator.assignTeamPlayerColor(
|
|
||||||
ColoredTeams.Red,
|
|
||||||
playerId,
|
|
||||||
);
|
|
||||||
const redColor2 = allocator.assignTeamPlayerColor(
|
|
||||||
ColoredTeams.Red,
|
|
||||||
playerId,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(redColor1.isEqual(redColor2)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assignTeamPlayerColor returns a different color when the playerID is different", () => {
|
test("teamColorForPlayer differs for different playerIDs", () => {
|
||||||
const playerIdOne = "player1";
|
const theme = new PastelTheme();
|
||||||
const playerIdTwo = "player2";
|
const a = theme.teamColorForPlayer(ColoredTeams.Blue, "player1");
|
||||||
|
const b = theme.teamColorForPlayer(ColoredTeams.Blue, "player2");
|
||||||
|
expect(a.isEqual(b)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const blueColorPlayerOne = allocator.assignTeamPlayerColor(
|
describe("ColorblindTheme", () => {
|
||||||
|
test("applies a palette distinct from PastelTheme", () => {
|
||||||
|
const pastel = new PastelTheme();
|
||||||
|
const colorblind = new ColorblindTheme();
|
||||||
|
|
||||||
|
// At least one team's base color should differ — the colorblind theme
|
||||||
|
// swaps the team palettes for CVD-safe (Okabe-Ito) colors.
|
||||||
|
const teams = [
|
||||||
ColoredTeams.Blue,
|
ColoredTeams.Blue,
|
||||||
playerIdOne,
|
|
||||||
);
|
|
||||||
const blueColorPlayerTwo = allocator.assignTeamPlayerColor(
|
|
||||||
ColoredTeams.Blue,
|
|
||||||
playerIdTwo,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(blueColorPlayerOne.isEqual(blueColorPlayerTwo)).toBe(false);
|
|
||||||
|
|
||||||
const redColorPlayerOne = allocator.assignTeamPlayerColor(
|
|
||||||
ColoredTeams.Red,
|
ColoredTeams.Red,
|
||||||
playerIdOne,
|
ColoredTeams.Teal,
|
||||||
|
ColoredTeams.Purple,
|
||||||
|
ColoredTeams.Yellow,
|
||||||
|
ColoredTeams.Orange,
|
||||||
|
ColoredTeams.Green,
|
||||||
|
];
|
||||||
|
const anyDifferent = teams.some(
|
||||||
|
(team) => !pastel.teamColor(team).isEqual(colorblind.teamColor(team)),
|
||||||
);
|
);
|
||||||
const redColorPlayerTwo = allocator.assignTeamPlayerColor(
|
expect(anyDifferent).toBe(true);
|
||||||
ColoredTeams.Red,
|
|
||||||
playerIdTwo,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(redColorPlayerOne.isEqual(redColorPlayerTwo)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,8 +147,7 @@ describe("selectDistinctColor", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const result = selectDistinctColorIndex(availableColors, assignedColors);
|
const result = selectDistinctColorIndex(availableColors, assignedColors);
|
||||||
expect(result).not.toBeNull();
|
const rgb = availableColors[result].toRgb();
|
||||||
const rgb = availableColors[result!].toRgb();
|
|
||||||
expect([
|
expect([
|
||||||
{ r: 0, g: 255, b: 0, a: 1 },
|
{ r: 0, g: 255, b: 0, a: 1 },
|
||||||
{ r: 0, g: 0, b: 255, a: 1 },
|
{ r: 0, g: 0, b: 255, a: 1 },
|
||||||
|
|||||||
Reference in New Issue
Block a user