From 72016f3dd4eb7300a1f07b0031220e731885a963 Mon Sep 17 00:00:00 2001 From: tropptr-torrptrop <95888269+tropptr-torrptrop@users.noreply.github.com> Date: Sat, 29 Mar 2025 23:41:28 +0200 Subject: [PATCH] Focused player border highlight (#304) Tried to implement feature to outline player under mouse cursor. Intention is to improve gameplay and provide a way to estimate players territories for strategic planning / avoid attacking wrong players with confusingly similar colors. When you stop cursor at the player - his borders will be drawn in white color. To focus on other player - click or move cursor to other player. To hide outline - click on empty space (water, land). "Focus" UI feature also triggers outline for the target player (easier to see who exactly is that you are looking at). ![1](https://github.com/user-attachments/assets/f7bda876-4b20-456c-9463-d2cfbb53ad5a) ![2](https://github.com/user-attachments/assets/f5cd6d12-fd52-4890-b3ee-caba9ff17753) ![FUN 2025-03-20 23 06 23](https://github.com/user-attachments/assets/f0e6e343-9442-4d89-9b7a-4bd01c6e00d0) ![FUN 2025-03-20 23 07 51](https://github.com/user-attachments/assets/2cc748e1-bc66-4834-82ad-22a4184a3006) Done via getting player under mouse cursor, setting it as global focusedPlayer and painting borders in white color. Tried to maintain minimal changes and utilize existing rendering queue. Also added hover delays to avoid excessive redraws and provide better experience. Redraws only happens when focusedPlayer changes - one time for old focused player to clean outline and one time for new focused player. --- src/client/ClientGameRunner.ts | 73 +++++++++++++++++++- src/client/graphics/TransformHandler.ts | 1 + src/client/graphics/layers/TerritoryLayer.ts | 31 ++++++++- src/core/GameRunner.ts | 10 +++ src/core/configuration/Config.ts | 2 + src/core/configuration/PastelTheme.ts | 7 ++ src/core/configuration/PastelThemeDark.ts | 7 ++ src/core/game/Game.ts | 4 ++ src/core/game/GameView.ts | 13 ++++ src/core/worker/Worker.worker.ts | 20 ++++++ src/core/worker/WorkerClient.ts | 27 ++++++++ src/core/worker/WorkerMessages.ts | 27 ++++++-- 12 files changed, 214 insertions(+), 8 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 3fff5b342..1ac489863 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -3,11 +3,13 @@ import { GameMapType, Difficulty, GameType, + Unit, + UnitType, TeamName, } from "../core/game/Game"; import { EventBus } from "../core/EventBus"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; -import { InputHandler, MouseUpEvent } from "./InputHandler"; +import { InputHandler, MouseUpEvent, MouseMoveEvent } from "./InputHandler"; import { ClientID, GameConfig, @@ -34,12 +36,21 @@ import { WorkerClient } from "../core/worker/WorkerClient"; import { consolex, initRemoteSender } from "../core/Consolex"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; -import { GameView, PlayerView } from "../core/game/GameView"; +import { GameView, PlayerView, UnitView } from "../core/game/GameView"; import { GameUpdateViewData } from "../core/game/GameUpdates"; import { UserSettings } from "../core/game/UserSettings"; import { LocalPersistantStats } from "./LocalPersistantStats"; import { createGameRecord } from "../core/Util"; import { getPersistentIDFromCookie } from "./Main"; +import { TileRef } from "../core/game/GameMap"; + +function distSortUnitWorld(tile: TileRef, game: GameView) { + return (a: Unit | UnitView, b: Unit | UnitView) => { + return ( + game.euclideanDist(tile, a.tile()) - game.euclideanDist(tile, b.tile()) + ); + }; +} export interface LobbyConfig { serverConfig: ServerConfig; @@ -152,6 +163,10 @@ export class ClientGameRunner { private turnsSeen = 0; private hasJoined = false; + private lastMousePosition: { x: number; y: number } | null = null; + private mouseHoverTimer: number | null = null; + private readonly HOVER_DELAY = 200; + constructor( private lobby: LobbyConfig, private eventBus: EventBus, @@ -199,6 +214,7 @@ export class ClientGameRunner { consolex.log("starting client game"); this.isActive = true; this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e)); + this.eventBus.on(MouseMoveEvent, (e) => this.onMouseMove(e)); this.renderer.initialize(); this.input.initialize(); @@ -328,8 +344,61 @@ export class ClientGameRunner { ), ); } + + const owner = this.gameView.owner(tile); + if (owner.isPlayer()) { + this.gameView.setFocusedPlayer(owner as PlayerView); + } else { + this.gameView.setFocusedPlayer(null); + } }); } + + private onMouseMove(event: MouseMoveEvent) { + this.lastMousePosition = { x: event.x, y: event.y }; + this.clearHoverTimer(); + + this.mouseHoverTimer = window.setTimeout(() => { + this.checkTileUnderCursor(); + }, this.HOVER_DELAY); + } + + private clearHoverTimer() { + if (this.mouseHoverTimer !== null) { + clearTimeout(this.mouseHoverTimer); + this.mouseHoverTimer = null; + } + } + + private checkTileUnderCursor() { + if (!this.lastMousePosition || !this.renderer.transformHandler) return; + + const cell = this.renderer.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + + if (!cell || !this.gameView.isValidCoord(cell.x, cell.y)) { + return; + } + + const tile = this.gameView.ref(cell.x, cell.y); + + if (this.gameView.isLand(tile)) { + const owner = this.gameView.owner(tile); + if (owner.isPlayer()) { + this.gameView.setFocusedPlayer(owner as PlayerView); + } + } else { + const units = this.gameView + .units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip) + .filter((u) => this.gameView.euclideanDist(tile, u.tile()) < 50) + .sort(distSortUnitWorld(tile, this.gameView)); + if (units.length > 0) { + this.gameView.setFocusedPlayer(units[0].owner() as PlayerView); + } + } + } } function showErrorModal( diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index 5228ce851..69787595c 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -142,6 +142,7 @@ export class TransformHandler { } onGoToPlayer(event: GoToPlayerEvent) { + this.game.setFocusedPlayer(event.player); this.clearTarget(); this.target = new Cell( event.player.nameLocation().x, diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index b77693e51..fb1fd5c20 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -50,6 +50,8 @@ export class TerritoryLayer implements Layer { private refreshRate = 50; private lastRefresh = 0; + private lastFocusedPlayer: PlayerView | null = null; + constructor( private game: GameView, private eventBus: EventBus, @@ -83,6 +85,17 @@ export class TerritoryLayer implements Layer { } }); + const focusedPlayer = this.game.focusedPlayer(); + if (focusedPlayer !== this.lastFocusedPlayer) { + if (this.lastFocusedPlayer) { + this.enqueuePlayerBorder(this.lastFocusedPlayer); + } + if (focusedPlayer) { + this.enqueuePlayerBorder(focusedPlayer); + } + this.lastFocusedPlayer = focusedPlayer; + } + if (!this.game.inSpawnPhase()) { return; } @@ -239,6 +252,7 @@ export class TerritoryLayer implements Layer { } const owner = this.game.owner(tile) as PlayerView; if (this.game.isBorder(tile)) { + const playerIsFocused = owner && this.game.focusedPlayer() == owner; if ( this.game .nearbyUnits( @@ -248,17 +262,23 @@ export class TerritoryLayer implements Layer { ) .filter((u) => u.unit.owner() == owner).length > 0 ) { + const useDefendedBorderColor = playerIsFocused + ? this.theme.focusedDefendedBorderColor() + : this.theme.defendedBorderColor(owner); this.paintCell( this.game.x(tile), this.game.y(tile), - this.theme.defendedBorderColor(owner), + useDefendedBorderColor, 255, ); } else { + const useBorderColor = playerIsFocused + ? this.theme.focusedBorderColor() + : this.theme.borderColor(owner); this.paintCell( this.game.x(tile), this.game.y(tile), - this.theme.borderColor(owner), + useBorderColor, 255, ); } @@ -294,6 +314,13 @@ export class TerritoryLayer implements Layer { }); } + async enqueuePlayerBorder(player: PlayerView) { + const playerBorderTiles = await player.borderTiles(); + playerBorderTiles.borderTiles.forEach((tile: TileRef) => { + this.enqueueTile(tile); + }); + } + paintHighlightCell(cell: Cell, color: Colord, alpha: number) { this.clearCell(cell); this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString(); diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 469d1c2b4..d02077157 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -15,6 +15,7 @@ import { PlayerActions, PlayerID, PlayerProfile, + PlayerBorderTiles, PlayerType, UnitType, } from "./game/Game"; @@ -180,4 +181,13 @@ export class GameRunner { } return player.playerProfile(); } + public playerBorderTiles(playerID: PlayerID): PlayerBorderTiles { + const player = this.game.player(playerID); + if (!player.isPlayer()) { + throw new Error(`player with id ${playerID} not found`); + } + return { + borderTiles: player.borderTiles(), + } as PlayerBorderTiles; + } } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 75d51f4b6..71756b3aa 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -118,6 +118,8 @@ export interface Theme { specialBuildingColor(playerInfo: PlayerView): Colord; borderColor(playerInfo: PlayerView): Colord; defendedBorderColor(playerInfo: PlayerView): Colord; + focusedBorderColor(): Colord; + focusedDefendedBorderColor(): Colord; terrainColor(gm: GameMap, tile: TileRef): Colord; backgroundColor(): Colord; falloutColor(): Colord; diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts index 4e7e00318..f5ba8907e 100644 --- a/src/core/configuration/PastelTheme.ts +++ b/src/core/configuration/PastelTheme.ts @@ -85,6 +85,13 @@ export const pastelTheme = new (class implements Theme { }); } + focusedBorderColor(): Colord { + return colord({ r: 255, g: 255, b: 255 }); + } + focusedDefendedBorderColor(): Colord { + return colord({ r: 215, g: 215, b: 215 }); + } + terrainColor(gm: GameMap, tile: TileRef): Colord { const mag = gm.magnitude(tile); if (gm.isShore(tile)) { diff --git a/src/core/configuration/PastelThemeDark.ts b/src/core/configuration/PastelThemeDark.ts index 7f15db8c8..e232923b9 100644 --- a/src/core/configuration/PastelThemeDark.ts +++ b/src/core/configuration/PastelThemeDark.ts @@ -85,6 +85,13 @@ export const pastelThemeDark = new (class implements Theme { }); } + focusedBorderColor(): Colord { + return colord({ r: 255, g: 255, b: 255 }); + } + focusedDefendedBorderColor(): Colord { + return colord({ r: 215, g: 215, b: 215 }); + } + terrainColor(gm: GameMap, tile: TileRef): Colord { const mag = gm.magnitude(tile); if (gm.isShore(tile)) { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 583583a83..03c8a2c6c 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -470,6 +470,10 @@ export interface PlayerProfile { alliances: number[]; } +export interface PlayerBorderTiles { + borderTiles: ReadonlySet; +} + export interface PlayerInteraction { sharedBorder: boolean; canSendEmoji: boolean; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index f598d816a..573653a1b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -6,6 +6,7 @@ import { Player, PlayerActions, PlayerProfile, + PlayerBorderTiles, TeamName, } from "./Game"; import { AttackUpdate, PlayerUpdate } from "./GameUpdates"; @@ -133,6 +134,10 @@ export class PlayerView { ); } + async borderTiles(): Promise { + return this.game.worker.playerBorderTiles(this.id()); + } + outgoingAttacks(): AttackUpdate[] { return this.data.outgoingAttacks; } @@ -268,6 +273,7 @@ export class GameView implements GameMap { private updatedTiles: TileRef[] = []; private _myPlayer: PlayerView | null = null; + private _focusedPlayer: PlayerView | null = null; private unitGrid: UnitGrid; @@ -534,4 +540,11 @@ export class GameView implements GameMap { gameID(): GameID { return this._gameID; } + + focusedPlayer(): PlayerView | null { + return this._focusedPlayer; + } + setFocusedPlayer(player: PlayerView | null): void { + this._focusedPlayer = player; + } } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 544d067c4..232f5100f 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -6,6 +6,7 @@ import { InitializedMessage, PlayerActionsResultMessage, PlayerProfileResultMessage, + PlayerBorderTilesResultMessage, } from "./WorkerMessages"; const ctx: Worker = self as any; @@ -101,6 +102,25 @@ ctx.addEventListener("message", async (e: MessageEvent) => { throw error; } break; + case "player_border_tiles": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + + try { + const borderTiles = (await gameRunner).playerBorderTiles( + message.playerID, + ); + sendMessage({ + type: "player_border_tiles_result", + id: message.id, + result: borderTiles, + } as PlayerBorderTilesResultMessage); + } catch (error) { + console.error("Failed to get border tiles:", error); + throw error; + } + break; default: console.warn("Unknown message :", message); } diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 6a739ddac..256ef071d 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -3,6 +3,7 @@ import { PlayerID, PlayerInfo, PlayerProfile, + PlayerBorderTiles, } from "../game/Game"; import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameConfig, GameID, Turn } from "../Schemas"; @@ -132,6 +133,32 @@ export class WorkerClient { }); } + playerBorderTiles(playerID: PlayerID): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error("Worker not initialized")); + return; + } + + const messageId = generateID(); + + this.messageHandlers.set(messageId, (message) => { + if ( + message.type === "player_border_tiles_result" && + message.result !== undefined + ) { + resolve(message.result); + } + }); + + this.worker.postMessage({ + type: "player_border_tiles", + id: messageId, + playerID: playerID, + }); + }); + } + playerInteraction( playerID: PlayerID, x: number, diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 28ce03e35..206bbd265 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -1,6 +1,11 @@ import { GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameConfig, GameID, Turn } from "../Schemas"; -import { PlayerActions, PlayerID, PlayerProfile } from "../game/Game"; +import { + PlayerActions, + PlayerID, + PlayerProfile, + PlayerBorderTiles, +} from "../game/Game"; export type WorkerMessageType = | "heartbeat" @@ -11,7 +16,9 @@ export type WorkerMessageType = | "player_actions" | "player_actions_result" | "player_profile" - | "player_profile_result"; + | "player_profile_result" + | "player_border_tiles" + | "player_border_tiles_result"; // Base interface for all messages interface BaseWorkerMessage { @@ -68,17 +75,29 @@ export interface PlayerProfileResultMessage extends BaseWorkerMessage { result: PlayerProfile; } +export interface PlayerBorderTilesMessage extends BaseWorkerMessage { + type: "player_border_tiles"; + playerID: PlayerID; +} + +export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage { + type: "player_border_tiles_result"; + result: PlayerBorderTiles; +} + // Union types for type safety export type MainThreadMessage = | HeartbeatMessage | InitMessage | TurnMessage | PlayerActionsMessage - | PlayerProfileMessage; + | PlayerProfileMessage + | PlayerBorderTilesMessage; // Message send from worker export type WorkerMessage = | InitializedMessage | GameUpdateMessage | PlayerActionsResultMessage - | PlayerProfileResultMessage; + | PlayerProfileResultMessage + | PlayerBorderTilesResultMessage;