Skip to content
2 changes: 2 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@
"tab_keybinds": "Keybinds",
"dark_mode_label": "Dark Mode",
"dark_mode_desc": "Toggle the site’s appearance between light and dark themes",
"color_blind_label": "Color-blind assist",
"color_blind_desc": "Adds visual aid to make spotting teammates during spawn phase easier",
"emojis_label": "Emojis",
"emojis_desc": "Toggle whether emojis are shown in game",
"alert_frame_label": "Alert Frame",
Expand Down
17 changes: 17 additions & 0 deletions src/client/UserSettingModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,14 @@ export class UserSettingModal extends BaseModal {
console.log("🌙 Dark Mode:", enabled ? "ON" : "OFF");
}

private toggleColorBlind(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;

this.userSettings.set("settings.colorBlind", enabled);
this.requestUpdate();
}

private toggleEmojis(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
Expand Down Expand Up @@ -794,6 +802,15 @@ export class UserSettingModal extends BaseModal {
this.toggleDarkMode(e)}
></setting-toggle>

<!-- 🌈 Color-blind assist -->
<setting-toggle
label="${translateText("user_setting.color_blind_label")}"
description="${translateText("user_setting.color_blind_desc")}"
id="color-blind-toggle"
.checked=${this.userSettings.colorBlind()}
@change=${this.toggleColorBlind}
></setting-toggle>

<!-- 😊 Emojis -->
<setting-toggle
label="${translateText("user_setting.emojis_label")}"
Expand Down
50 changes: 50 additions & 0 deletions src/client/graphics/TeammateGlow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const TWO_PI = Math.PI * 2;

export type TeammateGlowOptions = {
outerRadius: number;
pulsePhase?: number;
};

export function drawTeammateGlow(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
options: TeammateGlowOptions,
): void {
const outerRadius = Math.max(1, options.outerRadius);
const phase = options.pulsePhase ?? 0;

if (!Number.isFinite(x) || !Number.isFinite(y)) return;
if (!Number.isFinite(outerRadius) || outerRadius <= 0) return;

// Pulse between 0.5 and 1.0 opacity (brighter)
const pulse = 0.75 + 0.25 * Math.sin(phase);
const goldAlpha = pulse;
const whiteAlpha = 1 - pulse;

ctx.save();
ctx.translate(x, y);

// White background layer (visible when gold fades)
const whiteGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, outerRadius);
whiteGradient.addColorStop(0, `rgba(255, 255, 255, ${whiteAlpha})`);
whiteGradient.addColorStop(0.6, `rgba(255, 255, 255, ${whiteAlpha * 0.6})`);
whiteGradient.addColorStop(1, "rgba(255, 255, 255, 0)");
ctx.beginPath();
ctx.arc(0, 0, outerRadius, 0, TWO_PI);
ctx.fillStyle = whiteGradient;
ctx.fill();

// Gold overlay layer (pulses in) - brighter colors
const goldGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, outerRadius);
goldGradient.addColorStop(0, `rgba(255, 215, 0, ${goldAlpha})`);
goldGradient.addColorStop(0.4, `rgba(255, 200, 50, ${goldAlpha * 0.85})`);
goldGradient.addColorStop(0.75, `rgba(255, 180, 30, ${goldAlpha * 0.5})`);
goldGradient.addColorStop(1, "rgba(255, 165, 0, 0)");
ctx.beginPath();
ctx.arc(0, 0, outerRadius, 0, TWO_PI);
ctx.fillStyle = goldGradient;
ctx.fill();

ctx.restore();
}
30 changes: 30 additions & 0 deletions src/client/graphics/layers/SettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}

private onToggleColorBlindButtonClick() {
this.userSettings.toggleColorBlind();
this.requestUpdate();
}

private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
this.requestUpdate();
Expand Down Expand Up @@ -338,6 +343,31 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>

<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.onToggleColorBlindButtonClick}"
>
<img
src=${settingsIcon}
alt="colorBlind"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.color_blind_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.color_blind_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.colorBlind()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>

<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.onToggleSpecialEffectsButtonClick}"
Expand Down
81 changes: 30 additions & 51 deletions src/client/graphics/layers/TerritoryLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { EventBus } from "../../../core/EventBus";
import {
Cell,
ColoredTeams,
GameMode,
PlayerType,
Team,
UnitType,
Expand All @@ -20,10 +21,13 @@ import {
MouseOverEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { drawTeammateGlow } from "../TeammateGlow";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";

export class TerritoryLayer implements Layer {
private static readonly SPAWN_HIGHLIGHT_RADIUS = 9;
private static readonly PULSE_SPEED = 0.2;
private userSettings: UserSettings;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
Expand Down Expand Up @@ -183,6 +187,7 @@ export class TerritoryLayer implements Layer {

const focusedPlayer = this.game.focusedPlayer();
const teamColors = Object.values(ColoredTeams);
const myPlayer = this.game.myPlayer();
for (const human of humans) {
if (human === focusedPlayer) {
continue;
Expand All @@ -196,7 +201,6 @@ export class TerritoryLayer implements Layer {
continue;
}
let color = this.theme.spawnHighlightColor();
const myPlayer = this.game.myPlayer();
if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) {
// In FFA games (when team === null), use default yellow spawn highlight color
color = this.theme.spawnHighlightColor();
Expand All @@ -217,12 +221,14 @@ export class TerritoryLayer implements Layer {

for (const tile of this.game.bfs(
centerTile,
euclDistFN(centerTile, 9, true),
euclDistFN(centerTile, TerritoryLayer.SPAWN_HIGHLIGHT_RADIUS, true),
)) {
if (!this.game.hasOwner(tile)) {
if (this.game.ownerID(tile) === 0) {
this.paintHighlightTile(tile, color, 255);
}
}

this.maybeDrawTeammateGlow(human);
}
}

Expand Down Expand Up @@ -264,61 +270,34 @@ export class TerritoryLayer implements Layer {
teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games.
);

// Draw breathing rings for teammates in team games (helps colorblind players identify teammates)
this.drawTeammateHighlights(minRad, maxRad, radius);
this.maybeDrawTeammateGlow(focusedPlayer);
}

private drawTeammateHighlights(
minRad: number,
maxRad: number,
radius: number,
) {
private maybeDrawTeammateGlow(player: PlayerView): void {
const myPlayer = this.game.myPlayer();
if (myPlayer === null || myPlayer.team() === null) {
if (
!this.userSettings.colorBlind() ||
this.game.config().gameConfig().gameMode !== GameMode.Team ||
myPlayer === null ||
player.smallID() === myPlayer.smallID() ||
!myPlayer.isOnSameTeam(player)
) {
return;
}

const teammates = this.game
.playerViews()
.filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p));

// Smaller radius for teammates (more subtle than self highlight)
const teammateMinRad = 5;
const teammateMaxRad = 14;
const teammateRadius =
teammateMinRad +
(teammateMaxRad - teammateMinRad) *
((radius - minRad) / (maxRad - minRad));

const teamColors = Object.values(ColoredTeams);
for (const teammate of teammates) {
const center = teammate.nameLocation();
if (!center) {
continue;
}

const team = teammate.team();
let baseColor: Colord;
let breathingColor: Colord;

if (team !== null && teamColors.includes(team)) {
baseColor = this.theme.teamColor(team).alpha(0.5);
breathingColor = this.theme.teamColor(team).alpha(0.5);
} else {
baseColor = this.theme.spawnHighlightTeamColor();
breathingColor = this.theme.spawnHighlightTeamColor();
}

this.drawBreathingRing(
center.x,
center.y,
teammateMinRad,
teammateMaxRad,
teammateRadius,
baseColor,
breathingColor,
);
const spawnTile = player.spawnTile();
if (spawnTile === undefined) {
return;
}
drawTeammateGlow(
this.highlightContext,
this.game.x(spawnTile),
this.game.y(spawnTile),
{
outerRadius: TerritoryLayer.SPAWN_HIGHLIGHT_RADIUS - 1,
pulsePhase: this.game.ticks() * TerritoryLayer.PULSE_SPEED,
},
);
}

init() {
Expand Down
1 change: 1 addition & 0 deletions src/core/game/GameUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export interface PlayerUpdate {
outgoingAllianceRequests: PlayerID[];
alliances: AllianceView[];
hasSpawned: boolean;
spawnTile?: TileRef;
betrayals: number;
lastDeleteUnitTick: Tick;
isLobbyCreator: boolean;
Expand Down
3 changes: 3 additions & 0 deletions src/core/game/GameView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,9 @@ export class PlayerView {
hasSpawned(): boolean {
return this.data.hasSpawned;
}
spawnTile(): TileRef | undefined {
return this.data.spawnTile;
}
isDisconnected(): boolean {
return this.data.isDisconnected;
}
Expand Down
1 change: 1 addition & 0 deletions src/core/game/PlayerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export class PlayerImpl implements Player {
}) satisfies AllianceView,
),
hasSpawned: this.hasSpawned(),
spawnTile: this.spawnTile(),
betrayals: this._betrayalCount,
lastDeleteUnitTick: this.lastDeleteUnitTick,
isLobbyCreator: this.isLobbyCreator(),
Expand Down
8 changes: 8 additions & 0 deletions src/core/game/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export class UserSettings {
return this.get("settings.territoryPatterns", true);
}

colorBlind() {
return this.get("settings.colorBlind", false);
}

cursorCostLabel() {
const legacy = this.get("settings.ghostPricePill", true);
return this.get("settings.cursorCostLabel", legacy);
Expand Down Expand Up @@ -128,6 +132,10 @@ export class UserSettings {
this.set("settings.territoryPatterns", !this.territoryPatterns());
}

toggleColorBlind() {
this.set("settings.colorBlind", !this.colorBlind());
}

toggleDarkMode() {
this.set("settings.darkMode", !this.darkMode());
if (this.darkMode()) {
Expand Down
Loading