mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 07:28:09 +00:00
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).   ## 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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user