From da2e0918a44532e4e947807799f73ceba68cfaf3 Mon Sep 17 00:00:00 2001 From: unne27 <59091348+unne27@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:21:50 +0200 Subject: [PATCH] feat/highlight the owner of hovered naval units (#4610) Resolves #4445 ## Description: Changes HoverHighlightController.ts to check for naval units if the mouse is not currently hovering over land. It then highlights the owner of the closest naval unit within 50px of the mouse. The activation radius feels a little to large right now, but it is consistent with the PlayerInfoOverlay functionality. Some input would be appreciated It is also currently disabled by default, not sure if this should be the case. ## 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: unne27 https://github.com/user-attachments/assets/7b8262e5-a097-44c8-b2ae-1ac336466623 image --------- Co-authored-by: unne27 <> --- resources/lang/en.json | 2 + .../controllers/HoverHighlightController.ts | 42 ++++++- .../hud/layers/GraphicsSettingsModal.ts | 35 ++++++ src/client/render/gl/GraphicsOverrides.ts | 1 + src/client/render/gl/RenderOverrides.ts | 3 + src/client/render/gl/RenderSettings.ts | 1 + src/client/render/gl/render-settings.json | 1 + .../HoverHighlightController.test.ts | 110 ++++++++++++++++++ 8 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 tests/client/controllers/HoverHighlightController.test.ts 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); + }); +});