diff --git a/resources/images/GridIconWhite.svg b/resources/images/GridIconWhite.svg
new file mode 100644
index 0000000000..62f5d15927
--- /dev/null
+++ b/resources/images/GridIconWhite.svg
@@ -0,0 +1,7 @@
+
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 83160eb119..b10bf7d214 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -90,6 +90,7 @@
"table_key": "Key",
"table_action": "Action",
"action_alt_view": "Alternate view (terrain/countries)",
+ "action_coordinate_grid": "Toggle coordinate grid overlay",
"action_attack_altclick": "Attack (when left click is set to open menu)",
"action_build": "Open build menu",
"action_emote": "Open emote menu",
@@ -512,6 +513,8 @@
"attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)",
"territory_patterns_label": "🏳️ Territory Skins",
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
+ "coordinate_grid_label": "Coordinate Grid",
+ "coordinate_grid_desc": "Toggle the alphanumeric grid overlay",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"easter_writing_speed_label": "Writing Speed Multiplier",
diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts
index 95ba888807..21fba54248 100644
--- a/src/client/HelpModal.ts
+++ b/src/client/HelpModal.ts
@@ -42,6 +42,7 @@ export class HelpModal extends BaseModal {
const isMac = /Mac/.test(navigator.userAgent);
return {
toggleView: "Space",
+ coordinateGrid: "KeyM",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
@@ -265,6 +266,14 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.action_alt_view")}
+
${this.renderKey(keybinds.swapDirection)}
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 45d1188a3b..49051086fc 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -129,6 +129,10 @@ export class AutoUpgradeEvent implements GameEvent {
) {}
}
+export class ToggleCoordinateGridEvent implements GameEvent {
+ constructor(public readonly enabled: boolean) {}
+}
+
export class TickMetricsEvent implements GameEvent {
constructor(
public readonly tickExecutionDuration?: number,
@@ -154,6 +158,7 @@ export class InputHandler {
private moveInterval: NodeJS.Timeout | null = null;
private activeKeys = new Set();
private keybinds: Record = {};
+ private coordinateGridEnabled = false;
private readonly PAN_SPEED = 5;
private readonly ZOOM_SPEED = 10;
@@ -201,6 +206,7 @@ export class InputHandler {
this.keybinds = {
toggleView: "Space",
+ coordinateGrid: "KeyM",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
@@ -316,6 +322,14 @@ export class InputHandler {
}
}
+ if (e.code === this.keybinds.coordinateGrid && !e.repeat) {
+ e.preventDefault();
+ this.coordinateGridEnabled = !this.coordinateGridEnabled;
+ this.eventBus.emit(
+ new ToggleCoordinateGridEvent(this.coordinateGridEnabled),
+ );
+ }
+
if (e.code === "Escape") {
e.preventDefault();
this.eventBus.emit(new CloseViewEvent());
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index a9eb3bc666..36a9b165b7 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -21,6 +21,7 @@ const isMac =
const DefaultKeybinds: Record = {
toggleView: "Space",
+ coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
@@ -473,6 +474,16 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
>
+
+
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index d1e1911628..6a2bea3062 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -12,6 +12,7 @@ import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
+import { CoordinateGridLayer } from "./layers/CoordinateGridLayer";
import { DynamicUILayer } from "./layers/DynamicUILayer";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
@@ -282,6 +283,7 @@ export function createRenderer(
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game, eventBus, transformHandler, uiState),
+ new CoordinateGridLayer(game, eventBus, transformHandler),
structureLayer,
samRadiusLayer,
new UnitLayer(game, eventBus, transformHandler),
diff --git a/src/client/graphics/layers/CoordinateGridLayer.ts b/src/client/graphics/layers/CoordinateGridLayer.ts
new file mode 100644
index 0000000000..64c2117500
--- /dev/null
+++ b/src/client/graphics/layers/CoordinateGridLayer.ts
@@ -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();
+ }
+}
|