mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:10:42 +00:00
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).     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:
committed by
GitHub
parent
849d612314
commit
72016f3dd4
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -470,6 +470,10 @@ export interface PlayerProfile {
|
||||
alliances: number[];
|
||||
}
|
||||
|
||||
export interface PlayerBorderTiles {
|
||||
borderTiles: ReadonlySet<TileRef>;
|
||||
}
|
||||
|
||||
export interface PlayerInteraction {
|
||||
sharedBorder: boolean;
|
||||
canSendEmoji: boolean;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user