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;