mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-16 14:02:35 +00:00
Feat: Alphanumeric Coordinate Grid on Alternate View (#2938)
## Description: Adds a coordinate grid to the Alternate View (holding spacebar) using numbers on the X-axis, and letters on the Y-axis. No more "he's attacking you in that—well, the little peninsula thing... next to the island! which island? uhh..." moments when playing with friends. Optimally maps have letters A-J (just like in the Battleships board game) but special maps like Amazon River dynamically resize to only have 2 letters so as to not have too many number columns. This feature overall can be toggled via the settings menu. Also saw it requested on the [official discord](https://discord.com/channels/1359946986937258015/1457037351422263480) a couple times, thought it was a neat idea. ### World Map <img width="3809" height="1824" alt="image" src="https://github.com/user-attachments/assets/dab56879-a34e-48ea-a588-2907d26feb45" /> ### Scales correctly when zoomed in <img width="3798" height="1874" alt="image" src="https://github.com/user-attachments/assets/7e06a47f-d3d9-4f92-8e89-3eaf866e9b25" /> ### Amazon River <img width="3803" height="1595" alt="image" src="https://github.com/user-attachments/assets/4797c576-20b2-4aa8-8b7a-107078ab6308" /> ### Enable/Disable via settings https://github.com/user-attachments/assets/ec9f4e07-70a1-4f9d-b137-c3c3d2a2540c ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: bijx --------- Co-authored-by: iamlewis <lewismmmm@gmail.com>
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import {
|
||||
AlternateViewEvent,
|
||||
ToggleCoordinateGridEvent,
|
||||
} from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const BASE_CELL_COUNT = 10;
|
||||
const MAX_COLUMNS = 50;
|
||||
const MIN_ROWS = 2;
|
||||
const LABEL_PADDING = 8;
|
||||
|
||||
const toAlphaLabel = (index: number): string => {
|
||||
let value = index;
|
||||
let label = "";
|
||||
do {
|
||||
label = String.fromCharCode(65 + (value % 26)) + label;
|
||||
value = Math.floor(value / 26) - 1;
|
||||
} while (value >= 0);
|
||||
return label;
|
||||
};
|
||||
|
||||
const computeGrid = (width: number, height: number) => {
|
||||
// Initial square-ish estimate
|
||||
let cellSize = Math.min(width, height) / BASE_CELL_COUNT;
|
||||
let rows = Math.max(1, Math.round(height / cellSize));
|
||||
let cols = Math.max(1, Math.round(width / cellSize));
|
||||
|
||||
// Cap columns and adjust rows accordingly
|
||||
if (cols > MAX_COLUMNS) {
|
||||
const maxRowsForCols = Math.floor((MAX_COLUMNS * height) / width);
|
||||
rows = Math.max(MIN_ROWS, Math.min(rows, maxRowsForCols));
|
||||
cols = MAX_COLUMNS;
|
||||
}
|
||||
|
||||
cellSize = Math.min(width / cols, height / rows);
|
||||
const fullCols = Math.max(1, Math.floor(width / cellSize));
|
||||
const fullRows = Math.max(1, Math.floor(height / cellSize));
|
||||
|
||||
const remainderX = Math.max(0, width - fullCols * cellSize);
|
||||
const remainderY = Math.max(0, height - fullRows * cellSize);
|
||||
|
||||
const hasExtraCol = remainderX > 0.001;
|
||||
const hasExtraRow = remainderY > 0.001;
|
||||
|
||||
const totalCols = fullCols + (hasExtraCol ? 1 : 0);
|
||||
const totalRows = fullRows + (hasExtraRow ? 1 : 0);
|
||||
|
||||
const lastColWidth = hasExtraCol ? remainderX : cellSize;
|
||||
const lastRowHeight = hasExtraRow ? remainderY : cellSize;
|
||||
|
||||
return {
|
||||
cellSize,
|
||||
rows: totalRows,
|
||||
cols: totalCols,
|
||||
fullCols,
|
||||
fullRows,
|
||||
lastColWidth,
|
||||
lastRowHeight,
|
||||
hasExtraCol,
|
||||
hasExtraRow,
|
||||
gridWidth: width,
|
||||
gridHeight: height,
|
||||
};
|
||||
};
|
||||
|
||||
export class CoordinateGridLayer implements Layer {
|
||||
private isVisible = false;
|
||||
private alternateView = false;
|
||||
private cachedGridCanvas: HTMLCanvasElement | null = null;
|
||||
private cachedGridContext: CanvasRenderingContext2D | null = null;
|
||||
private cachedGridKey = "";
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
this.eventBus.on(ToggleCoordinateGridEvent, (event) => {
|
||||
this.isVisible = event.enabled;
|
||||
});
|
||||
this.eventBus.on(AlternateViewEvent, (event) => {
|
||||
this.alternateView = event.alternateView;
|
||||
});
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
if (!this.isVisible && !this.alternateView) return;
|
||||
|
||||
const width = this.game.width();
|
||||
const height = this.game.height();
|
||||
if (width <= 0 || height <= 0) return;
|
||||
const canvasWidth = context.canvas.width;
|
||||
const canvasHeight = context.canvas.height;
|
||||
|
||||
const cacheKey = this.buildCacheKey(
|
||||
width,
|
||||
height,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
);
|
||||
const cacheContext = this.ensureCacheContext(canvasWidth, canvasHeight);
|
||||
if (cacheContext === null || this.cachedGridCanvas === null) return;
|
||||
|
||||
if (this.cachedGridKey !== cacheKey) {
|
||||
cacheContext.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
this.drawGrid(cacheContext, width, height);
|
||||
this.cachedGridKey = cacheKey;
|
||||
}
|
||||
|
||||
context.drawImage(this.cachedGridCanvas, 0, 0);
|
||||
}
|
||||
|
||||
private ensureCacheContext(
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): CanvasRenderingContext2D | null {
|
||||
this.cachedGridCanvas ??= document.createElement("canvas");
|
||||
|
||||
if (
|
||||
this.cachedGridCanvas.width !== canvasWidth ||
|
||||
this.cachedGridCanvas.height !== canvasHeight
|
||||
) {
|
||||
this.cachedGridCanvas.width = canvasWidth;
|
||||
this.cachedGridCanvas.height = canvasHeight;
|
||||
this.cachedGridContext = null;
|
||||
this.cachedGridKey = "";
|
||||
}
|
||||
|
||||
this.cachedGridContext ??= this.cachedGridCanvas.getContext("2d");
|
||||
|
||||
return this.cachedGridContext;
|
||||
}
|
||||
|
||||
private buildCacheKey(
|
||||
width: number,
|
||||
height: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): string {
|
||||
const topLeft = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, 0),
|
||||
);
|
||||
const bottomRight = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(width, height),
|
||||
);
|
||||
return [
|
||||
width,
|
||||
height,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
this.transformHandler.scale.toFixed(4),
|
||||
topLeft.x.toFixed(2),
|
||||
topLeft.y.toFixed(2),
|
||||
bottomRight.x.toFixed(2),
|
||||
bottomRight.y.toFixed(2),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
private drawGrid(
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const {
|
||||
cellSize,
|
||||
rows,
|
||||
cols,
|
||||
fullCols,
|
||||
fullRows,
|
||||
lastColWidth,
|
||||
lastRowHeight,
|
||||
hasExtraCol,
|
||||
hasExtraRow,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
} = computeGrid(width, height);
|
||||
const cellWidth = cellSize;
|
||||
const cellHeight = cellSize;
|
||||
const canvasWidth = context.canvas.width;
|
||||
const canvasHeight = context.canvas.height;
|
||||
|
||||
const mapTopScreenRaw = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, 0),
|
||||
).y;
|
||||
const mapBottomScreenRaw = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, height),
|
||||
).y;
|
||||
const mapLeftScreenRaw = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, 0),
|
||||
).x;
|
||||
const mapRightScreenRaw = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(width, 0),
|
||||
).x;
|
||||
|
||||
const mapTopScreen = Math.min(mapTopScreenRaw, mapBottomScreenRaw);
|
||||
const mapLeftScreen = Math.min(mapLeftScreenRaw, mapRightScreenRaw);
|
||||
const mapTopWorld = 0;
|
||||
const mapLeftWorld = 0;
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = "rgba(255, 255, 255, 0.35)";
|
||||
context.lineWidth = 1.25;
|
||||
context.beginPath();
|
||||
|
||||
for (let col = 0; col <= fullCols; col++) {
|
||||
const worldX = col * cellWidth + mapLeftWorld;
|
||||
const screenX = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(worldX, mapTopWorld),
|
||||
).x;
|
||||
if (screenX < -1 || screenX > canvasWidth + 1) continue;
|
||||
const screenBottom = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(worldX, gridHeight),
|
||||
).y;
|
||||
context.moveTo(screenX, mapTopScreen);
|
||||
context.lineTo(screenX, screenBottom);
|
||||
}
|
||||
// Final vertical line at map right edge only if grid fits perfectly
|
||||
if (!hasExtraCol) {
|
||||
const mapRightLine = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(gridWidth, mapTopWorld),
|
||||
).x;
|
||||
context.moveTo(mapRightLine, mapTopScreen);
|
||||
context.lineTo(
|
||||
mapRightLine,
|
||||
this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(gridWidth, gridHeight),
|
||||
).y,
|
||||
);
|
||||
}
|
||||
|
||||
for (let row = 0; row <= fullRows; row++) {
|
||||
const worldY = row * cellHeight + mapTopWorld;
|
||||
const screenY = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(mapLeftWorld, worldY),
|
||||
).y;
|
||||
if (screenY < -1 || screenY > canvasHeight + 1) continue;
|
||||
const screenRight = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(gridWidth, worldY),
|
||||
).x;
|
||||
context.moveTo(mapLeftScreen, screenY);
|
||||
context.lineTo(screenRight, screenY);
|
||||
}
|
||||
// Final horizontal line at map bottom edge only if grid fits perfectly
|
||||
if (!hasExtraRow) {
|
||||
const mapBottomLine = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(mapLeftWorld, gridHeight),
|
||||
).y;
|
||||
context.moveTo(mapLeftScreen, mapBottomLine);
|
||||
context.lineTo(
|
||||
this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(gridWidth, gridHeight),
|
||||
).x,
|
||||
mapBottomLine,
|
||||
);
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
|
||||
context.font = "12px monospace";
|
||||
|
||||
const drawLabel = (text: string, x: number, y: number) => {
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "rgba(20, 20, 20, 0.9)";
|
||||
context.fillText(text, x, y);
|
||||
};
|
||||
|
||||
// Render per-cell labels (like A1, B1, etc.) at cell top-left
|
||||
const fontSize = Math.min(
|
||||
16,
|
||||
Math.max(9, 10 + (this.transformHandler.scale - 1) * 1.2),
|
||||
);
|
||||
context.font = `${fontSize}px monospace`;
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const rowLabel = toAlphaLabel(row);
|
||||
const startY = row * cellHeight;
|
||||
const rowHeight = row < fullRows ? cellHeight : lastRowHeight;
|
||||
const centerY = startY + rowHeight / 2;
|
||||
const screenY = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, centerY),
|
||||
).y;
|
||||
if (screenY < -LABEL_PADDING || screenY > canvasHeight + LABEL_PADDING)
|
||||
continue;
|
||||
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const startX = col * cellWidth;
|
||||
const colWidth = col < fullCols ? cellWidth : lastColWidth;
|
||||
const centerX = startX + colWidth / 2;
|
||||
const screenX = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(centerX, centerY),
|
||||
).x;
|
||||
if (screenX < -LABEL_PADDING || screenX > canvasWidth + LABEL_PADDING)
|
||||
continue;
|
||||
|
||||
// Position at cell top-left in screen space
|
||||
const cellTopLeft = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(startX, startY),
|
||||
);
|
||||
drawLabel(
|
||||
`${rowLabel}${col + 1}`,
|
||||
cellTopLeft.x + LABEL_PADDING,
|
||||
cellTopLeft.y + LABEL_PADDING,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user