From d5ee5d7fe2ee19c372152085c6dcde076643c6fb Mon Sep 17 00:00:00 2001 From: Steven Hooker Date: Fri, 17 Apr 2026 10:01:45 +0200 Subject: [PATCH 1/3] feat: add territory hover highlight setting Add an opt-in setting to highlight the territory of the player under the cursor, making it easier to identify ownership in large games where colors repeat or look similar. Three modes: Always, On Key Press (default H, rebindable), and Never. When active, hovered player's territory renders at alpha 230 vs the normal 150, giving an immediate visual distinction. Closes #1307 Relates to #3125, #2549 Co-Authored-By: Claude Opus 4.6 --- resources/lang/en.json | 8 +++ src/client/InputHandler.ts | 24 +++++++ src/client/UserSettingModal.ts | 54 ++++++++++++++ src/client/graphics/layers/TerritoryLayer.ts | 76 ++++++++++++++++---- src/core/game/UserSettings.ts | 12 ++++ 5 files changed, 161 insertions(+), 13 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 1d7b1625b0..dc31a82332 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -570,6 +570,11 @@ "attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)", "territory_patterns_label": "🏳️ Territory Skins", "territory_patterns_desc": "Choose whether to display territory skin designs in game", + "territory_highlight_label": "Territory Highlight", + "territory_highlight_desc": "Highlight the territory of the player under your cursor", + "territory_highlight_always": "Always", + "territory_highlight_on_key": "On Key Press (default H, customizable via key settings)", + "territory_highlight_never": "Never", "coordinate_grid_label": "Coordinate Grid", "coordinate_grid_desc": "Toggle the alphanumeric grid overlay", "attacking_troops_overlay_label": "Attacking Troops Overlay", @@ -584,6 +589,9 @@ "view_options": "View Options", "toggle_view": "Toggle View", "toggle_view_desc": "Alternate view (terrain/countries)", + "highlight_territory": "Highlight Territory", + "highlight_territory_desc": "Hold to highlight the hovered player's territory", + "highlight_territory_disabled": "Enable \"On Key Press\" mode in basic settings to configure this keybind", "build_controls": "Build Controls", "build_city": "Build City", "build_city_desc": "Build a City under your cursor.", diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 1782d43d25..5944b5b79e 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -144,6 +144,10 @@ export class ToggleCoordinateGridEvent implements GameEvent { constructor(public readonly enabled: boolean) {} } +export class TerritoryHighlightKeyEvent implements GameEvent { + constructor(public readonly active: boolean) {} +} + export class TickMetricsEvent implements GameEvent { constructor( public readonly tickExecutionDuration?: number, @@ -165,6 +169,7 @@ export class InputHandler { private pointerDown: boolean = false; private alternateView = false; + private highlightKeyHeld = false; private moveInterval: NodeJS.Timeout | null = null; private activeKeys = new Set(); @@ -214,6 +219,10 @@ export class InputHandler { this.alternateView = false; this.eventBus.emit(new AlternateViewEvent(false)); } + if (this.highlightKeyHeld) { + this.highlightKeyHeld = false; + this.eventBus.emit(new TerritoryHighlightKeyEvent(false)); + } this.pointerDown = false; this.pointers.clear(); }); @@ -289,6 +298,15 @@ export class InputHandler { } } + if ( + this.keybindMatchesEvent(e, this.keybinds.highlightTerritory) && + !this.highlightKeyHeld + ) { + e.preventDefault(); + this.highlightKeyHeld = true; + this.eventBus.emit(new TerritoryHighlightKeyEvent(true)); + } + if ( this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) && !e.repeat @@ -384,6 +402,12 @@ export class InputHandler { this.eventBus.emit(new AlternateViewEvent(false)); } + if (this.keybindMatchesEvent(e, this.keybinds.highlightTerritory)) { + e.preventDefault(); + this.highlightKeyHeld = false; + this.eventBus.emit(new TerritoryHighlightKeyEvent(false)); + } + const resetKey = this.keybinds.resetGfx ?? "KeyR"; if (e.code === resetKey && this.isAltKeyHeld(e)) { e.preventDefault(); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 77a0f33d12..9cb8aa697b 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -309,6 +309,13 @@ export class UserSettingModal extends BaseModal { ); } + private changeTerritoryHighlight(e: CustomEvent<{ value: number | string }>) { + const value = e.detail?.value; + if (typeof value !== "string") return; + this.userSettings.setTerritoryHighlight(value); + this.requestUpdate(); + } + private togglePerformanceOverlay() { this.userSettings.togglePerformanceOverlay(); } @@ -429,6 +436,31 @@ export class UserSettingModal extends BaseModal { @change=${this.handleKeybindChange} > + ${this.userSettings.territoryHighlight() === "onKeyPress" + ? html`` + : html`
+
+ +
+ ${translateText("user_setting.highlight_territory_disabled")} +
+
+
`} +

@@ -833,6 +865,28 @@ export class UserSettingModal extends BaseModal { @change=${this.toggleTerritoryPatterns} > + + + { this.alternativeView = e.alternateView; }); + this.eventBus.on(TerritoryHighlightKeyEvent, (e) => { + this.highlightKeyHeld = e.active; + this.updateHighlightedTerritory(); + }); this.eventBus.on(DragEvent, (e) => { // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. // this.lastDragTime = Date.now(); }); + + globalThis.addEventListener?.( + `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_HIGHLIGHT_KEY}`, + ((e: CustomEvent) => { + const value = e.detail; + if (value === "always" || value === "onKeyPress") { + this.highlightMode = value; + } else { + this.highlightMode = "never"; + } + this.updateHighlightedTerritory(); + }) as EventListener, + ); + this.redraw(); } @@ -342,7 +369,17 @@ export class TerritoryLayer implements Layer { } private updateHighlightedTerritory() { - if (!this.alternativeView) { + const shouldHighlight = + this.highlightMode === "always" || + (this.highlightMode === "onKeyPress" && this.highlightKeyHeld) || + this.alternativeView; + + if (!shouldHighlight) { + if (this.highlightedTerritory) { + const prev = this.highlightedTerritory; + this.highlightedTerritory = null; + this.updateHighlightAlpha(prev, null); + } return; } @@ -368,17 +405,32 @@ export class TerritoryLayer implements Layer { } if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - const territories: PlayerView[] = []; - if (previousTerritory) { - territories.push(previousTerritory); - } - if (this.highlightedTerritory) { - territories.push(this.highlightedTerritory); - } - this.redrawBorder(...territories); + this.updateHighlightAlpha(previousTerritory, this.highlightedTerritory); } } + private updateHighlightAlpha( + oldPlayer: PlayerView | null, + newPlayer: PlayerView | null, + ) { + const data = this.imageData.data; + const oldID = oldPlayer?.id(); + const newID = newPlayer?.id(); + this.game.forEachTile((tile) => { + const offset = tile * 4; + const alpha = data[offset + 3]; + // Only update non-border territory fill tiles (alpha 150 or 230) + if (alpha !== 150 && alpha !== 230) return; + if (!this.game.hasOwner(tile)) return; + const ownerID = (this.game.owner(tile) as PlayerView).id(); + if (newID !== undefined && ownerID === newID) { + data[offset + 3] = 230; + } else if (alpha === 230 && oldID !== undefined && ownerID === oldID) { + data[offset + 3] = 150; + } + }); + } + private getTerritoryAtCell(cell: { x: number; y: number }) { const tile = this.game.ref(cell.x, cell.y); if (!tile) { @@ -559,15 +611,12 @@ export class TerritoryLayer implements Layer { return; } const owner = this.game.owner(tile) as PlayerView; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const isHighlighted = this.highlightedTerritory && this.highlightedTerritory.id() === owner.id(); const myPlayer = this.game.myPlayer(); if (this.game.isBorder(tile)) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const playerIsFocused = owner && this.game.focusedPlayer() === owner; if (myPlayer) { const alternativeColor = this.alternateViewColor(owner); this.paintTile(this.alternativeImageData, tile, alternativeColor, 255); @@ -589,7 +638,8 @@ export class TerritoryLayer implements Layer { // Alternative view only shows borders. this.clearAlternativeTile(tile); - this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150); + const alpha = isHighlighted ? 230 : 150; + this.paintTile(this.imageData, tile, owner.territoryColor(tile), alpha); } } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 158d108942..5780ff6824 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -34,6 +34,7 @@ export function getDefaultKeybinds(isMac: boolean): Record { pauseGame: "KeyP", gameSpeedUp: "Period", gameSpeedDown: "Comma", + highlightTerritory: "KeyH", }; } @@ -43,6 +44,7 @@ export const FLAG_KEY = "flag"; export const COLOR_KEY = "settings.territoryColor"; export const DARK_MODE_KEY = "settings.darkMode"; export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay"; +export const TERRITORY_HIGHLIGHT_KEY = "settings.territoryHighlight"; export const KEYBINDS_KEY = "settings.keybinds"; export class UserSettings { @@ -221,6 +223,16 @@ export class UserSettings { this.setBool("settings.territoryPatterns", !this.territoryPatterns()); } + territoryHighlight(): "always" | "onKeyPress" | "never" { + const value = this.getString(TERRITORY_HIGHLIGHT_KEY, "never"); + if (value === "always" || value === "onKeyPress") return value; + return "never"; + } + + setTerritoryHighlight(value: string) { + this.setString(TERRITORY_HIGHLIGHT_KEY, value); + } + toggleDarkMode() { this.setBool(DARK_MODE_KEY, !this.darkMode()); } From 0f2808d52a8e76318bf10767fd564aeddc8a69d6 Mon Sep 17 00:00:00 2001 From: Steven Hooker Date: Fri, 17 Apr 2026 10:29:53 +0200 Subject: [PATCH 2/3] refactor: address CodeRabbit review feedback - Add updateHighlightedTerritory() call in AlternateViewEvent handler so highlight refreshes immediately on spacebar toggle - Extract TerritoryHighlightMode type alias to eliminate duplicated union literals across UserSettings and TerritoryLayer - Tighten setTerritoryHighlight setter and changeTerritoryHighlight handler to only accept valid mode values - Store settings listener as instance field for future cleanup Co-Authored-By: Claude Opus 4.6 --- src/client/UserSettingModal.ts | 3 ++- src/client/graphics/layers/TerritoryLayer.ts | 23 +++++++++++--------- src/core/game/UserSettings.ts | 5 +++-- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 9cb8aa697b..9f566a391b 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -311,7 +311,8 @@ export class UserSettingModal extends BaseModal { private changeTerritoryHighlight(e: CustomEvent<{ value: number | string }>) { const value = e.detail?.value; - if (typeof value !== "string") return; + if (value !== "never" && value !== "always" && value !== "onKeyPress") + return; this.userSettings.setTerritoryHighlight(value); this.requestUpdate(); } diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index b7aae14e00..91975c3f8a 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -14,6 +14,7 @@ import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { TERRITORY_HIGHLIGHT_KEY, + TerritoryHighlightMode, USER_SETTINGS_CHANGED_EVENT, UserSettings, } from "../../../core/game/UserSettings"; @@ -53,8 +54,17 @@ export class TerritoryLayer implements Layer { private highlightedTerritory: PlayerView | null = null; private alternativeView = false; - private highlightMode: "always" | "onKeyPress" | "never"; + private highlightMode: TerritoryHighlightMode; private highlightKeyHeld = false; + private onHighlightSettingChanged = ((e: CustomEvent) => { + const value = e.detail; + if (value === "always" || value === "onKeyPress") { + this.highlightMode = value; + } else { + this.highlightMode = "never"; + } + this.updateHighlightedTerritory(); + }) as EventListener; private lastDragTime = 0; private nodrawDragDuration = 200; private lastMousePosition: { x: number; y: number } | null = null; @@ -337,6 +347,7 @@ export class TerritoryLayer implements Layer { this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; + this.updateHighlightedTerritory(); }); this.eventBus.on(TerritoryHighlightKeyEvent, (e) => { this.highlightKeyHeld = e.active; @@ -349,15 +360,7 @@ export class TerritoryLayer implements Layer { globalThis.addEventListener?.( `${USER_SETTINGS_CHANGED_EVENT}:${TERRITORY_HIGHLIGHT_KEY}`, - ((e: CustomEvent) => { - const value = e.detail; - if (value === "always" || value === "onKeyPress") { - this.highlightMode = value; - } else { - this.highlightMode = "never"; - } - this.updateHighlightedTerritory(); - }) as EventListener, + this.onHighlightSettingChanged, ); this.redraw(); diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 5780ff6824..ff72e4138f 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -44,6 +44,7 @@ export const FLAG_KEY = "flag"; export const COLOR_KEY = "settings.territoryColor"; export const DARK_MODE_KEY = "settings.darkMode"; export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay"; +export type TerritoryHighlightMode = "always" | "onKeyPress" | "never"; export const TERRITORY_HIGHLIGHT_KEY = "settings.territoryHighlight"; export const KEYBINDS_KEY = "settings.keybinds"; @@ -223,13 +224,13 @@ export class UserSettings { this.setBool("settings.territoryPatterns", !this.territoryPatterns()); } - territoryHighlight(): "always" | "onKeyPress" | "never" { + territoryHighlight(): TerritoryHighlightMode { const value = this.getString(TERRITORY_HIGHLIGHT_KEY, "never"); if (value === "always" || value === "onKeyPress") return value; return "never"; } - setTerritoryHighlight(value: string) { + setTerritoryHighlight(value: TerritoryHighlightMode) { this.setString(TERRITORY_HIGHLIGHT_KEY, value); } From 6b19661c9351722e37559355f623c67e21f56646 Mon Sep 17 00:00:00 2001 From: Steven Hooker Date: Fri, 17 Apr 2026 11:13:04 +0200 Subject: [PATCH 3/3] perf: use smallID integer comparison in highlight alpha scan Replace expensive owner(tile).id() string lookups with cheap ownerID(tile) integer comparison in updateHighlightAlpha and paintTerritory. ownerID is a direct Uint16Array read + bitmask vs owner() which does a Map lookup + PlayerView creation. Co-Authored-By: Claude Opus 4.6 --- src/client/graphics/layers/TerritoryLayer.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 91975c3f8a..5626d5e4b2 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -417,18 +417,17 @@ export class TerritoryLayer implements Layer { newPlayer: PlayerView | null, ) { const data = this.imageData.data; - const oldID = oldPlayer?.id(); - const newID = newPlayer?.id(); + const oldSmallID = oldPlayer?.smallID() ?? -1; + const newSmallID = newPlayer?.smallID() ?? -1; this.game.forEachTile((tile) => { const offset = tile * 4; const alpha = data[offset + 3]; // Only update non-border territory fill tiles (alpha 150 or 230) if (alpha !== 150 && alpha !== 230) return; - if (!this.game.hasOwner(tile)) return; - const ownerID = (this.game.owner(tile) as PlayerView).id(); - if (newID !== undefined && ownerID === newID) { + const tileOwner = this.game.ownerID(tile); + if (tileOwner === newSmallID) { data[offset + 3] = 230; - } else if (alpha === 230 && oldID !== undefined && ownerID === oldID) { + } else if (alpha === 230 && tileOwner === oldSmallID) { data[offset + 3] = 150; } }); @@ -616,7 +615,7 @@ export class TerritoryLayer implements Layer { const owner = this.game.owner(tile) as PlayerView; const isHighlighted = this.highlightedTerritory && - this.highlightedTerritory.id() === owner.id(); + this.highlightedTerritory.smallID() === owner.smallID(); const myPlayer = this.game.myPlayer(); if (this.game.isBorder(tile)) {