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.
This commit is contained in:
tropptr-torrptrop
2025-03-29 23:41:28 +02:00
committed by GitHub
parent 849d612314
commit 72016f3dd4
12 changed files with 214 additions and 8 deletions
+71 -2
View File
@@ -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(
+1
View File
@@ -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,
+29 -2
View File
@@ -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();
+10
View File
@@ -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;
}
}
+2
View File
@@ -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;
+7
View File
@@ -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)) {
@@ -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)) {
+4
View File
@@ -470,6 +470,10 @@ export interface PlayerProfile {
alliances: number[];
}
export interface PlayerBorderTiles {
borderTiles: ReadonlySet<TileRef>;
}
export interface PlayerInteraction {
sharedBorder: boolean;
canSendEmoji: boolean;
+13
View File
@@ -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<PlayerBorderTiles> {
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;
}
}
+20
View File
@@ -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<MainThreadMessage>) => {
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);
}
+27
View File
@@ -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<PlayerBorderTiles> {
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,
+23 -4
View File
@@ -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;