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); + }); +});