diff --git a/resources/lang/en.json b/resources/lang/en.json
index ec19fd069..9ab07da4d 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -636,6 +636,8 @@
"name_cull_desc": "Hide names smaller than this size",
"name_cull_label": "Minimum name size",
"name_scale_label": "Name Scale",
+ "naval_hover_highlight_desc": "Highlight the owner of naval units when hovering over them.",
+ "naval_hover_highlight_label": "Naval Hover Highlight",
"nuke_color_desc": "Color of the fallout tint left on territory after a nuke.",
"nuke_color_label": "Nuke fallout color",
"ocean_color_desc": "Base color of ocean.",
diff --git a/src/client/controllers/HoverHighlightController.ts b/src/client/controllers/HoverHighlightController.ts
index e33a68f6a..85ca09632 100644
--- a/src/client/controllers/HoverHighlightController.ts
+++ b/src/client/controllers/HoverHighlightController.ts
@@ -9,12 +9,13 @@
*/
import { EventBus } from "../../core/EventBus";
+import { UnitType } from "../../core/game/Game";
import { Controller } from "../Controller";
import { MouseMoveEvent } from "../InputHandler";
import { MapRenderer } from "../render/gl";
import { OWNER_MASK } from "../render/gl/utils/TileCodec";
import { TransformHandler } from "../TransformHandler";
-import { GameView } from "../view";
+import { GameView, UnitView } from "../view";
export class HoverHighlightController implements Controller {
private lastOwnerID = 0;
@@ -30,16 +31,47 @@ export class HoverHighlightController implements Controller {
this.eventBus.on(MouseMoveEvent, (e) => this.onMouseMove(e));
}
+ private navalHighlightEnabled(): boolean {
+ return this.view.getSettings().mapOverlay.navalHighlight;
+ }
+
private onMouseMove(e: MouseMoveEvent): void {
const world = this.transformHandler.screenToWorldCoordinatesFloat(e.x, e.y);
this.view.setMouseWorldPos(world.x, world.y);
const cell = this.transformHandler.screenToWorldCoordinates(e.x, e.y);
- let ownerID = 0;
- if (this.game.isValidCoord(cell.x, cell.y)) {
- const ref = this.game.ref(cell.x, cell.y);
- ownerID = this.game.tileState(ref) & OWNER_MASK;
+ if (!this.game.isValidCoord(cell.x, cell.y)) {
+ if (this.lastOwnerID !== 0) {
+ this.lastOwnerID = 0;
+ this.view.setHighlightOwner(0);
+ }
+ return;
}
+ let ownerID = 0;
+
+ const ref = this.game.ref(cell.x, cell.y);
+ if (this.game.isLand(ref)) {
+ ownerID = this.game.tileState(ref) & OWNER_MASK;
+ } else if (this.navalHighlightEnabled()) {
+ // Avoid square root for performance; 50 tile radius = 2500 tiles²
+ let closestUnit: UnitView | null = null;
+ let closestDistSquared = 2500;
+ for (const u of this.game.units(
+ UnitType.Warship,
+ UnitType.TradeShip,
+ UnitType.TransportShip,
+ )) {
+ const distSquared = this.game.euclideanDistSquared(ref, u.tile());
+ if (distSquared < closestDistSquared) {
+ closestDistSquared = distSquared;
+ closestUnit = u;
+ }
+ }
+ if (closestUnit !== null) {
+ ownerID = closestUnit.owner().smallID();
+ }
+ }
+
if (ownerID === this.lastOwnerID) return;
this.lastOwnerID = ownerID;
this.view.setHighlightOwner(ownerID);
diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts
index ffea83f46..1a7225239 100644
--- a/src/client/hud/layers/GraphicsSettingsModal.ts
+++ b/src/client/hud/layers/GraphicsSettingsModal.ts
@@ -297,6 +297,17 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.requestUpdate();
}
+ private currentNavalHighlight(): boolean {
+ return (
+ this.userSettings.graphicsOverrides().mapOverlay?.navalHighlight ??
+ renderDefaults.mapOverlay.navalHighlight
+ );
+ }
+
+ private onToggleNavalHighlight() {
+ this.patchMapOverlay({ navalHighlight: !this.currentNavalHighlight() });
+ }
+
private currentHighlightFill(): number {
return (
this.userSettings.graphicsOverrides().mapOverlay?.highlightFillBrighten ??
@@ -692,6 +703,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const classicIcons = this.currentClassicIcons();
const classicNumbers = this.currentClassicNumbers();
const showDots = this.currentShowDots();
+ const navalHighlight = this.currentNavalHighlight();
const highlightFill = this.currentHighlightFill();
const highlightBrighten = this.currentHighlightBrighten();
const highlightThicken = this.currentHighlightThicken();
@@ -1040,6 +1052,29 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
${translateText("graphics_setting.section_map")}
+
+
diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts
index aa12474a1..d17aeeca6 100644
--- a/src/client/render/gl/GraphicsOverrides.ts
+++ b/src/client/render/gl/GraphicsOverrides.ts
@@ -24,6 +24,7 @@ export const GraphicsOverridesSchema = z
.partial(),
mapOverlay: z
.object({
+ navalHighlight: z.boolean(),
highlightFillBrighten: z.number(),
highlightBrighten: z.number(),
highlightThicken: z.number(),
diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts
index b772d3541..d46c6b6dc 100644
--- a/src/client/render/gl/RenderOverrides.ts
+++ b/src/client/render/gl/RenderOverrides.ts
@@ -47,6 +47,9 @@ export function applyGraphicsOverrides(
// triggers — structures stay as full icons at every zoom level.
settings.structure.dotsZoomThreshold = 0;
}
+ if (overrides.mapOverlay?.navalHighlight !== undefined) {
+ settings.mapOverlay.navalHighlight = overrides.mapOverlay.navalHighlight;
+ }
if (overrides.mapOverlay?.highlightFillBrighten !== undefined) {
settings.mapOverlay.highlightFillBrighten =
overrides.mapOverlay.highlightFillBrighten;
diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts
index d993d9ec5..900cb772a 100644
--- a/src/client/render/gl/RenderSettings.ts
+++ b/src/client/render/gl/RenderSettings.ts
@@ -137,6 +137,7 @@ export interface RenderSettings {
staleNukeR: number;
staleNukeG: number;
staleNukeB: number;
+ navalHighlight: boolean;
highlightBrighten: number;
highlightFillBrighten: number;
highlightThicken: number;
diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json
index c9cdf58c0..bfd350e5c 100644
--- a/src/client/render/gl/render-settings.json
+++ b/src/client/render/gl/render-settings.json
@@ -72,6 +72,7 @@
"lightRadiusMultiplier": 1
},
"mapOverlay": {
+ "navalHighlight": false,
"trailAlpha": 0.588,
"spiralResolutionScale": 0.25,
"defenseCheckerDarken": 0.7,
diff --git a/tests/client/controllers/HoverHighlightController.test.ts b/tests/client/controllers/HoverHighlightController.test.ts
new file mode 100644
index 000000000..4fe0b5895
--- /dev/null
+++ b/tests/client/controllers/HoverHighlightController.test.ts
@@ -0,0 +1,110 @@
+import { PlayerInfo, PlayerType, UnitType } from "src/core/game/Game";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { HoverHighlightController } from "../../../src/client/controllers/HoverHighlightController";
+import { MouseMoveEvent } from "../../../src/client/InputHandler";
+import { setup } from "../../util/Setup";
+
+describe("HoverHighlightController", () => {
+ let game: any;
+ let eventBus: any;
+ let transformHandler: any;
+ let view: any;
+
+ beforeEach(async () => {
+ game = await setup(
+ "giantworldmap",
+ { infiniteGold: true, instantBuild: true },
+ [new PlayerInfo("player1", PlayerType.Human, null, "player1_id")],
+ );
+
+ eventBus = { on: vi.fn() };
+ transformHandler = {
+ screenToWorldCoordinatesFloat: vi
+ .fn()
+ .mockReturnValue({ x: 100.5, y: 200.5 }),
+ screenToWorldCoordinates: vi
+ .fn()
+ .mockImplementation((x, y) => ({ x, y })),
+ };
+ view = {
+ setMouseWorldPos: vi.fn(),
+ setHighlightOwner: vi.fn(),
+ };
+ });
+
+ it("sets highlight owner for land tiles and updates mouse world pos", () => {
+ const player1 = game.player("player1_id");
+ const tile = game.ref(200, 200);
+ expect(game.isLand(tile)).toBe(true); // Make sure we are testing on land
+ player1.conquer(tile);
+ const ui = new HoverHighlightController(
+ game,
+ eventBus,
+ transformHandler,
+ view,
+ );
+ ui.init();
+
+ expect(eventBus.on).toHaveBeenCalledWith(
+ MouseMoveEvent,
+ expect.any(Function),
+ );
+ const handler = (eventBus.on as any).mock.calls[0][1];
+
+ handler(new MouseMoveEvent(200, 200));
+
+ expect(transformHandler.screenToWorldCoordinatesFloat).toHaveBeenCalledWith(
+ 200,
+ 200,
+ );
+ expect(view.setMouseWorldPos).toHaveBeenCalledWith(100.5, 200.5);
+ expect(view.setHighlightOwner).toHaveBeenCalledWith(player1.smallID());
+ });
+
+ it("uses naval hover highlighting when tile is not land", () => {
+ const waterTile = game.ref(50, 100);
+ expect(game.isWater(waterTile)).toBe(true); // Make sure we are testing on water
+
+ const unit = game
+ .player("player1_id")
+ .buildUnit(UnitType.Warship, waterTile, { patrolTile: waterTile });
+
+ const ui = new HoverHighlightController(
+ game,
+ eventBus,
+ transformHandler,
+ view,
+ );
+ // enable naval hover behavior
+ ui["navalHighlightEnabled"] = () => true;
+
+ ui.init();
+ const handler = (eventBus.on as any).mock.calls[0][1];
+ handler(new MouseMoveEvent(50, 101));
+
+ expect(view.setHighlightOwner).toHaveBeenCalledWith(unit.owner().smallID());
+ });
+
+ it("clears hover highlight when naval hover finds no nearby units", () => {
+ const waterTile = game.ref(50, 100);
+ expect(game.isWater(waterTile)).toBe(true); // Make sure we are testing on water
+ const unit = game
+ .player("player1_id")
+ .buildUnit(UnitType.Warship, waterTile, { patrolTile: waterTile });
+
+ const ui = new HoverHighlightController(
+ game,
+ eventBus,
+ transformHandler,
+ view,
+ );
+ // enable naval hover behavior
+ ui["navalHighlightEnabled"] = () => true;
+ ui["lastOwnerID"] = unit.owner().smallID() + 1; // set to a different owner ID to ensure it updates
+
+ ui.init();
+ const handler = (eventBus.on as any).mock.calls[0][1];
+ handler(new MouseMoveEvent(200, 100)); // >50 tiles from unit
+ expect(view.setHighlightOwner).toHaveBeenCalledWith(0);
+ });
+});