Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,9 @@
"tab_basic": "Basic Settings",
"tab_keybinds": "Keybinds",
"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",
"accessibility_patterns_label": "Accessibility Patterns",
"accessibility_patterns_desc": "Overlay unique patterns on team territories to distinguish them without relying on color",
"emojis_label": "Emojis",
"emojis_desc": "Toggle whether emojis are shown in game",
"alert_frame_label": "Alert Frame",
Expand Down
19 changes: 19 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 toggleAccessibilityPatterns(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;

this.userSettings.set("settings.accessibilityPatterns", 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,17 @@ export class UserSettingModal extends BaseModal {
this.toggleDarkMode(e)}
></setting-toggle>

<!-- ♿ Accessibility Patterns -->
<setting-toggle
label="${translateText("user_setting.accessibility_patterns_label")}"
description="${translateText(
"user_setting.accessibility_patterns_desc",
)}"
id="accessibility-patterns-toggle"
.checked=${this.userSettings.accessibilityPatterns()}
@change=${this.toggleAccessibilityPatterns}
></setting-toggle>

<!-- 😊 Emojis -->
<setting-toggle
label="${translateText("user_setting.emojis_label")}"
Expand Down
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 onToggleAccessibilityPatternsButtonClick() {
this.userSettings.toggleAccessibilityPatterns();
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.onToggleAccessibilityPatternsButtonClick}"
>
<img
src=${settingsIcon}
alt="accessibilityPatterns"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.accessibility_patterns_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.accessibility_patterns_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.accessibilityPatterns()
? 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
85 changes: 80 additions & 5 deletions src/client/graphics/layers/TerritoryLayer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import {
applyPatternDarken,
getPatternByIndex,
getTeamPattern,
PatternLUT,
PatternType,
} from "../../../core/configuration/Patterns";
import { EventBus } from "../../../core/EventBus";
import {
Cell,
Expand Down Expand Up @@ -32,6 +39,7 @@ export class TerritoryLayer implements Layer {
private borderAnimTime = 0;

private cachedTerritoryPatternsEnabled: boolean | undefined;
private patternLUT = new PatternLUT();

private tileToRenderQueue: PriorityQueue<{
tile: TileRef;
Expand Down Expand Up @@ -428,8 +436,13 @@ export class TerritoryLayer implements Layer {
this.highlightCanvas.width = this.game.width();
this.highlightCanvas.height = this.game.height();

const accessibilityEnabled = this.userSettings.accessibilityPatterns();
const accessibilityOpacity = accessibilityEnabled
? this.userSettings.accessibilityPatternOpacity()
: 0;

this.game.forEachTile((t) => {
this.paintTerritory(t);
this.paintTerritory(t, false, accessibilityEnabled, accessibilityOpacity);
});
}

Expand Down Expand Up @@ -515,6 +528,11 @@ export class TerritoryLayer implements Layer {
}

renderTerritory() {
const accessibilityEnabled = this.userSettings.accessibilityPatterns();
const accessibilityOpacity = accessibilityEnabled
? this.userSettings.accessibilityPatternOpacity()
: 0;

let numToRender = Math.floor(this.tileToRenderQueue.size() / 10);
if (numToRender === 0 || this.game.inSpawnPhase()) {
numToRender = this.tileToRenderQueue.size();
Expand All @@ -529,14 +547,29 @@ export class TerritoryLayer implements Layer {
}

const tile = entry.tile;
this.paintTerritory(tile);
this.paintTerritory(
tile,
false,
accessibilityEnabled,
accessibilityOpacity,
);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
this.paintTerritory(
neighbor,
true,
accessibilityEnabled,
accessibilityOpacity,
);
}
}
}

paintTerritory(tile: TileRef, isBorder: boolean = false) {
paintTerritory(
tile: TileRef,
isBorder: boolean = false,
accessibilityEnabled?: boolean,
accessibilityOpacity?: number,
) {
if (isBorder && !this.game.hasOwner(tile)) {
return;
}
Expand Down Expand Up @@ -586,7 +619,34 @@ export class TerritoryLayer implements Layer {
// Alternative view only shows borders.
this.clearAlternativeTile(tile);

this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
const baseColor = owner.territoryColor(tile);
if (accessibilityEnabled) {
const team = owner.team();
let patternType =
team !== null ? getTeamPattern(team) : PatternType.None;
if (patternType === PatternType.None) {
patternType = getPatternByIndex(owner.smallID());
}
if (
this.patternLUT.isPattern(
this.game.x(tile),
this.game.y(tile),
patternType,
)
) {
const [pr, pg, pb] = applyPatternDarken(
baseColor.rgba.r,
baseColor.rgba.g,
baseColor.rgba.b,
accessibilityOpacity!,
);
this.paintTileRaw(this.imageData, tile, pr, pg, pb, 150);
} else {
this.paintTile(this.imageData, tile, baseColor, 150);
}
} else {
this.paintTile(this.imageData, tile, baseColor, 150);
}
}
}

Expand Down Expand Up @@ -620,6 +680,21 @@ export class TerritoryLayer implements Layer {
imageData.data[offset + 3] = alpha;
}

paintTileRaw(
imageData: ImageData,
tile: TileRef,
r: number,
g: number,
b: number,
alpha: number,
) {
const offset = tile * 4;
imageData.data[offset] = r;
imageData.data[offset + 1] = g;
imageData.data[offset + 2] = b;
imageData.data[offset + 3] = alpha;
}

clearTile(tile: TileRef) {
const offset = tile * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
Expand Down
118 changes: 118 additions & 0 deletions src/core/configuration/Patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Team } from "../game/Game";

export enum PatternType {
DiagonalRight = "diagonal_right",
Horizontal = "horizontal",
Vertical = "vertical",
Crosshatch = "crosshatch",
Dots = "dots",
DiagonalLeft = "diagonal_left",
Grid = "grid",
None = "none",
}

const TEAM_PATTERN_MAP: Record<string, PatternType> = {
Red: PatternType.DiagonalRight,
Blue: PatternType.Horizontal,
Teal: PatternType.Vertical,
Purple: PatternType.Crosshatch,
Yellow: PatternType.Dots,
Orange: PatternType.DiagonalLeft,
Green: PatternType.Grid,
Bot: PatternType.None,
Humans: PatternType.DiagonalRight,
Nations: PatternType.Horizontal,
};

export function getTeamPattern(team: Team | null): PatternType {
if (team === null) return PatternType.None;
return TEAM_PATTERN_MAP[team] ?? PatternType.None;
}
Comment on lines +14 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine GameImpl team assignment logic
fd GameImpl.ts --type f -x cat -n {} | head -200

Repository: openfrontio/OpenFrontIO

Length of output: 7460


🏁 Script executed:

# Check TerritoryLayer pattern selection
fd TerritoryLayer.ts --type f -x cat -n {} | head -700

Repository: openfrontio/OpenFrontIO

Length of output: 25857


🏁 Script executed:

# Verify the Team type definition
rg -n "type Team|enum Team|interface Team" --type=ts -A5

Repository: openfrontio/OpenFrontIO

Length of output: 1588


Teams with generic names (8+ player count) lose accessibility patterns.

When a game has 8 or more teams, GameImpl.ts assigns team names like "Team 1", "Team 2", etc. These generic names are not in TEAM_PATTERN_MAP, so getTeamPattern() returns PatternType.None.

In TerritoryLayer.ts (lines 599–603), the code calls getTeamPattern(team) when team !== null, but then only applies a pattern if the result is not None. This means players on "Team 1", "Team 2", etc. get no accessibility pattern at all—defeating the purpose of the feature for that game mode.

Fix by falling back to getPatternByIndex() when the team pattern lookup fails:

Suggested change in TerritoryLayer.ts
 const team = owner.team();
-const patternType =
-  team !== null
-    ? getTeamPattern(team)
-    : getPatternByIndex(owner.smallID());
+let patternType =
+  team !== null ? getTeamPattern(team) : PatternType.None;
+if (patternType === PatternType.None) {
+  patternType = getPatternByIndex(owner.smallID());
+}
🤖 Prompt for AI Agents
In `@src/core/configuration/Patterns.ts` around lines 14 - 30, getTeamPattern
currently returns PatternType.None for generic team names ("Team 1", "Team 2",
etc.) because TEAM_PATTERN_MAP has only named teams; update the code so when a
lookup returns None you fall back to the index-based pattern generator: in the
call site in TerritoryLayer (where getTeamPattern(team) is used) detect
PatternType.None and instead call getPatternByIndex(team.index or equivalent) to
obtain a pattern, or alternatively change getTeamPattern to accept an optional
index and return getPatternByIndex(index) when TEAM_PATTERN_MAP[teamName] is
undefined; reference TEAM_PATTERN_MAP, getTeamPattern, getPatternByIndex and the
TerritoryLayer usage to locate and implement the fallback.


const PATTERN_CYCLE: PatternType[] = [
PatternType.DiagonalRight,
PatternType.Horizontal,
PatternType.Vertical,
PatternType.Crosshatch,
PatternType.Dots,
PatternType.DiagonalLeft,
PatternType.Grid,
];

export function getPatternByIndex(index: number): PatternType {
const len = PATTERN_CYCLE.length;
return PATTERN_CYCLE[((index % len) + len) % len];
}

export function isPatternPixel(
x: number,
y: number,
type: PatternType,
): boolean {
switch (type) {
case PatternType.DiagonalRight:
return ((x + y) & 3) === 0;
case PatternType.Horizontal:
return (y & 3) === 0;
case PatternType.Vertical:
return (x & 3) === 0;
case PatternType.Crosshatch:
return ((x + y) & 3) === 0 || ((x - y + 256) & 3) === 0;
case PatternType.Dots:
return (x & 3) === 1 && (y & 3) === 1;
case PatternType.DiagonalLeft:
return ((x - y + 256) & 3) === 0;
case PatternType.Grid:
return (x & 3) === 0 || (y & 3) === 0;
case PatternType.None:
return false;
}
}

const LUT_SIZE = 16;

export class PatternLUT {
private tables: Map<PatternType, Uint8Array> = new Map();

constructor() {
for (const type of Object.values(PatternType)) {
if (type === PatternType.None) continue;
const lut = new Uint8Array(LUT_SIZE * LUT_SIZE);
for (let y = 0; y < LUT_SIZE; y++) {
for (let x = 0; x < LUT_SIZE; x++) {
lut[y * LUT_SIZE + x] = isPatternPixel(x, y, type) ? 1 : 0;
}
}
this.tables.set(type, lut);
}
}

isPattern(x: number, y: number, type: PatternType): boolean {
if (type === PatternType.None) return false;
const lut = this.tables.get(type);
if (!lut) return false;
return lut[(y & 15) * LUT_SIZE + (x & 15)] === 1;
}
}

export function applyPatternDarken(
r: number,
g: number,
b: number,
opacity: number,
): [number, number, number] {
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
if (luminance > 160) {
const factor = 1.0 - opacity;
return [
Math.round(r * factor),
Math.round(g * factor),
Math.round(b * factor),
];
}
return [
Math.min(255, Math.round(r + (255 - r) * opacity)),
Math.min(255, Math.round(g + (255 - g) * opacity)),
Math.min(255, Math.round(b + (255 - b) * opacity)),
];
}
19 changes: 19 additions & 0 deletions src/core/game/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ export class UserSettings {
return this.get("settings.territoryPatterns", true);
}

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

accessibilityPatternOpacity(): number {
return this.getFloat("settings.accessibilityPatternOpacity", 0.2);
}

setAccessibilityPatternOpacity(value: number): void {
this.setFloat(
"settings.accessibilityPatternOpacity",
Math.max(0, Math.min(0.5, value)),
);
}

toggleAccessibilityPatterns() {
this.set("settings.accessibilityPatterns", !this.accessibilityPatterns());
}

cursorCostLabel() {
const legacy = this.get("settings.ghostPricePill", true);
return this.get("settings.cursorCostLabel", legacy);
Expand Down
Loading