From 71fe6a81a0d966f25347d3d33369c36cc9151b5c Mon Sep 17 00:00:00 2001 From: "maxime.io" Date: Sat, 26 Jul 2025 01:50:26 +0200 Subject: [PATCH] Improve the alternate view by adding geopolitical colors for territories and hover-based highlighting (#1320) ## Description: Improve existing alternate view (space bar). It now displays territories in different colours based on their status (enemy, allied, or your own). Hovering over a territory highlights it. I tested several approaches, and this one delivers excellent performance (on mobile too). ![VERT_OpenFront (ALPHA) (6)](https://github.com/user-attachments/assets/40eeb4cd-e1dc-4daf-9165-b41f8693494c) ![image](https://github.com/user-attachments/assets/53a24369-698d-4213-9b4c-cb736feee22d) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Related issue: https://github.com/openfrontio/OpenFrontIO/issues/900 https://github.com/openfrontio/OpenFrontIO/issues/484 ## Please put your Discord username so you can be contacted if a bug or regression is found: devalnor --- src/client/InputHandler.ts | 8 + src/client/graphics/GameRenderer.ts | 2 +- src/client/graphics/layers/NameLayer.ts | 42 +++- src/client/graphics/layers/TerritoryLayer.ts | 196 +++++++++++++++++-- 4 files changed, 227 insertions(+), 21 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 4646734b0..3a835e3a5 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -11,6 +11,13 @@ export class MouseUpEvent implements GameEvent { ) {} } +export class MouseOverEvent implements GameEvent { + constructor( + public readonly x: number, + public readonly y: number, + ) {} +} + /** * Event emitted when a unit is selected or deselected */ @@ -377,6 +384,7 @@ export class InputHandler { this.pointers.set(event.pointerId, event); if (!this.pointerDown) { + this.eventBus.emit(new MouseOverEvent(event.clientX, event.clientY)); return; } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 16d8e0285..4615b2e04 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -231,7 +231,7 @@ export function createRenderer( new UnitLayer(game, eventBus, transformHandler), new FxLayer(game), new UILayer(game, eventBus, transformHandler), - new NameLayer(game, transformHandler), + new NameLayer(game, transformHandler, eventBus), eventsDisplay, chatDisplay, buildMenu, diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 6b7af3197..2a1863c12 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -11,11 +11,13 @@ import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg"; import targetIcon from "../../../../resources/images/TargetIcon.svg"; import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { renderPlayerFlag } from "../../../core/CustomFlag"; +import { EventBus } from "../../../core/EventBus"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; +import { AlternateViewEvent } from "../../InputHandler"; import { createCanvas, renderNumber, renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; @@ -57,10 +59,12 @@ export class NameLayer implements Layer { private firstPlace: PlayerView | null = null; private theme: Theme = this.game.config().theme(); private userSettings: UserSettings = new UserSettings(); + private isVisible: boolean = true; constructor( private game: GameView, private transformHandler: TransformHandler, + private eventBus: EventBus, ) { this.traitorIconImage = new Image(); this.traitorIconImage.src = traitorIcon; @@ -113,6 +117,34 @@ export class NameLayer implements Layer { this.container.style.pointerEvents = "none"; this.container.style.zIndex = "2"; document.body.appendChild(this.container); + + this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e)); + } + + private onAlternateViewChange(event: AlternateViewEvent) { + this.isVisible = !event.alternateView; + // Update visibility of all name elements immediately + for (const render of this.renders) { + this.updateElementVisibility(render); + } + } + + private updateElementVisibility(render: RenderInfo) { + if (!render.player.nameLocation() || !render.player.isAlive()) { + return; + } + + const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size)); + const size = this.transformHandler.scale * baseSize; + const isOnScreen = render.location + ? this.transformHandler.isOnScreen(render.location) + : false; + + if (!this.isVisible || size < 7 || !isOnScreen) { + render.element.style.display = "none"; + } else { + render.element.style.display = "flex"; + } } public tick() { @@ -290,13 +322,13 @@ export class NameLayer implements Layer { render.fontSize = Math.max(4, Math.floor(baseSize * 0.4)); render.fontColor = this.theme.textColor(render.player); - // Screen space calculations - const size = this.transformHandler.scale * baseSize; - if (size < 7 || !this.transformHandler.isOnScreen(render.location)) { - render.element.style.display = "none"; + // Update element visibility (handles Ctrl key, size, and screen position) + this.updateElementVisibility(render); + + // If element is hidden, don't continue with rendering + if (render.element.style.display === "none") { return; } - render.element.style.display = "flex"; // Throttle updates const now = Date.now(); diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 59cc8a79e..e104559d2 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -11,6 +11,7 @@ import { PseudoRandom } from "../../../core/PseudoRandom"; import { AlternateViewEvent, DragEvent, + MouseOverEvent, RefreshGraphicsEvent, } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; @@ -21,6 +22,7 @@ export class TerritoryLayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; private imageData: ImageData; + private alternativeImageData: ImageData; private cachedTerritoryPatternsEnabled: boolean | undefined; @@ -37,9 +39,12 @@ export class TerritoryLayer implements Layer { private highlightCanvas: HTMLCanvasElement; private highlightContext: CanvasRenderingContext2D; + private highlightedTerritory: PlayerView | null = null; + private alternativeView = false; private lastDragTime = 0; private nodrawDragDuration = 200; + private lastMousePosition: { x: number; y: number } | null = null; private refreshRate = 10; //refresh every 10ms private lastRefresh = 0; @@ -94,6 +99,36 @@ export class TerritoryLayer implements Layer { } }); + // Detect alliance mutations + const myPlayer = this.game.myPlayer(); + if (myPlayer) { + updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => { + const territory = this.game.playerBySmallID(update.betrayedID); + console.log("betrayedID", update.betrayedID); + console.log("territory", territory); + if (territory && territory instanceof PlayerView) { + this.redrawTerritory(territory); + } + }); + + updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => { + if ( + update.accepted && + (update.request.requestorID === myPlayer.smallID() || + update.request.recipientID === myPlayer.smallID()) + ) { + const territoryId = + update.request.requestorID === myPlayer.smallID() + ? update.request.recipientID + : update.request.requestorID; + const territory = this.game.playerBySmallID(territoryId); + if (territory && territory instanceof PlayerView) { + this.redrawTerritory(territory); + } + } + }); + } + const focusedPlayer = this.game.focusedPlayer(); if (focusedPlayer !== this.lastFocusedPlayer) { if (this.lastFocusedPlayer) { @@ -152,6 +187,7 @@ export class TerritoryLayer implements Layer { } init() { + this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); this.eventBus.on(AlternateViewEvent, (e) => { this.alternativeView = e.alternateView; }); @@ -162,6 +198,62 @@ export class TerritoryLayer implements Layer { this.redraw(); } + onMouseOver(event: MouseOverEvent) { + this.lastMousePosition = { x: event.x, y: event.y }; + this.updateHighlightedTerritory(); + } + + private updateHighlightedTerritory() { + if (!this.alternativeView) { + return; + } + + if (!this.lastMousePosition) { + return; + } + + const cell = this.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + if (!this.game.isValidCoord(cell.x, cell.y)) { + return; + } + + const previousTerritory = this.highlightedTerritory; + const territory = this.getTerritoryAtCell(cell); + + if (territory) { + this.highlightedTerritory = territory; + } else { + this.highlightedTerritory = null; + } + + if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { + const territories: PlayerView[] = []; + if (previousTerritory) { + territories.push(previousTerritory); + } + if (this.highlightedTerritory) { + territories.push(this.highlightedTerritory); + } + this.redrawTerritory(territories); + } + } + + private getTerritoryAtCell(cell: { x: number; y: number }) { + const tile = this.game.ref(cell.x, cell.y); + if (!tile) { + return null; + } + // If the tile has no owner, it is either a fallout tile or a terra nullius tile. + if (!this.game.hasOwner(tile)) { + return null; + } + const owner = this.game.owner(tile); + return owner instanceof PlayerView ? owner : null; + } + redraw() { console.log("redrew territory layer"); this.canvas = document.createElement("canvas"); @@ -177,8 +269,19 @@ export class TerritoryLayer implements Layer { this.canvas.width, this.canvas.height, ); + this.alternativeImageData = this.context.getImageData( + 0, + 0, + this.canvas.width, + this.canvas.height, + ); this.initImageData(); - this.context.putImageData(this.imageData, 0, 0); + + this.context.putImageData( + this.alternativeView ? this.alternativeImageData : this.imageData, + 0, + 0, + ); // Add a second canvas for highlights this.highlightCanvas = document.createElement("canvas"); @@ -195,12 +298,25 @@ export class TerritoryLayer implements Layer { }); } + redrawTerritory(territory: PlayerView | PlayerView[]) { + const territories = Array.isArray(territory) ? territory : [territory]; + const territorySet = new Set(territories); + + this.game.forEachTile((t) => { + const owner = this.game.owner(t) as PlayerView; + if (territorySet.has(owner)) { + this.paintTerritory(t); + } + }); + } + initImageData() { this.game.forEachTile((tile) => { const cell = new Cell(this.game.x(tile), this.game.y(tile)); const index = cell.y * this.game.width() + cell.x; const offset = index * 4; this.imageData.data[offset + 3] = 0; + this.alternativeImageData.data[offset + 3] = 0; }); } @@ -223,12 +339,17 @@ export class TerritoryLayer implements Layer { const h = vy1 - vy0 + 1; if (w > 0 && h > 0) { - this.context.putImageData(this.imageData, 0, 0, vx0, vy0, w, h); + this.context.putImageData( + this.alternativeView ? this.alternativeImageData : this.imageData, + 0, + 0, + vx0, + vy0, + w, + h, + ); } } - if (this.alternativeView) { - return; - } context.drawImage( this.canvas, @@ -274,17 +395,38 @@ export class TerritoryLayer implements Layer { if (isBorder && !this.game.hasOwner(tile)) { return; } + if (!this.game.hasOwner(tile)) { if (this.game.hasFallout(tile)) { - this.paintTile(tile, this.theme.falloutColor(), 150); + this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); + this.paintTile( + this.alternativeImageData, + tile, + this.theme.falloutColor(), + 150, + ); return; } this.clearTile(tile); return; } const owner = this.game.owner(tile) as PlayerView; + const isHighlighted = + this.highlightedTerritory && + this.highlightedTerritory.id() === owner.id(); + const myPlayer = this.game.myPlayer(); + if (this.game.isBorder(tile)) { const playerIsFocused = owner && this.game.focusedPlayer() === owner; + if (myPlayer) { + let alternativeColor = owner.isFriendly(myPlayer) + ? this.theme.allyColor() + : this.theme.enemyColor(); + if (owner.smallID() === myPlayer.smallID()) { + alternativeColor = this.theme.selfColor(); + } + this.paintTile(this.alternativeImageData, tile, alternativeColor, 255); + } if ( this.game.hasUnitNearby( tile, @@ -299,18 +441,41 @@ export class TerritoryLayer implements Layer { const lightTile = (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); const borderColor = lightTile ? borderColors.light : borderColors.dark; - this.paintTile(tile, borderColor, 255); + this.paintTile(this.imageData, tile, borderColor, 255); } else { const useBorderColor = playerIsFocused ? this.theme.focusedBorderColor() : this.theme.borderColor(owner); - this.paintTile(tile, useBorderColor, 255); + this.paintTile(this.imageData, tile, useBorderColor, 255); } } else { const pattern = owner.cosmetics.pattern; const patternsEnabled = this.cachedTerritoryPatternsEnabled ?? false; + + if (myPlayer) { + let alternativeColor = owner.isFriendly(myPlayer) + ? this.theme.allyColor() + : this.theme.enemyColor(); + // If the current player is the owner + if (owner.smallID() === myPlayer.smallID()) { + alternativeColor = this.theme.selfColor(); + } + // If the tile is on a ally territory, use the ally color + this.paintTile( + this.alternativeImageData, + tile, + alternativeColor, + isHighlighted ? 150 : 60, + ); + } + if (pattern === undefined || patternsEnabled === false) { - this.paintTile(tile, this.theme.territoryColor(owner), 150); + this.paintTile( + this.imageData, + tile, + this.theme.territoryColor(owner), + 150, + ); } else { const x = this.game.x(tile); const y = this.game.y(tile); @@ -320,22 +485,23 @@ export class TerritoryLayer implements Layer { const color = decoder?.isSet(x, y) ? baseColor.darken(0.125) : baseColor; - this.paintTile(tile, color, 150); + this.paintTile(this.imageData, tile, color, 150); } } } - paintTile(tile: TileRef, color: Colord, alpha: number) { + paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) { const offset = tile * 4; - this.imageData.data[offset] = color.rgba.r; - this.imageData.data[offset + 1] = color.rgba.g; - this.imageData.data[offset + 2] = color.rgba.b; - this.imageData.data[offset + 3] = alpha; + imageData.data[offset] = color.rgba.r; + imageData.data[offset + 1] = color.rgba.g; + imageData.data[offset + 2] = color.rgba.b; + imageData.data[offset + 3] = alpha; } clearTile(tile: TileRef) { const offset = tile * 4; this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) + this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) } enqueueTile(tile: TileRef) {