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
This commit is contained in:
maxime.io
2025-07-26 01:50:26 +02:00
committed by GitHub
parent 79c638c1ca
commit 71fe6a81a0
4 changed files with 227 additions and 21 deletions
+8
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+37 -5
View File
@@ -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();
+181 -15
View File
@@ -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) {