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:
bijx
2026-02-28 23:28:47 -05:00
committed by GitHub
parent ebe1f76bbf
commit 8754f5291f
7 changed files with 365 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" fill="none" stroke="white" stroke-width="2" />
<line x1="9" y1="3" x2="9" y2="21" stroke="white" stroke-width="2" />
<line x1="15" y1="3" x2="15" y2="21" stroke="white" stroke-width="2" />
<line x1="3" y1="9" x2="21" y2="9" stroke="white" stroke-width="2" />
<line x1="3" y1="15" x2="21" y2="15" stroke="white" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 450 B

+3
View File
@@ -89,6 +89,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",
@@ -514,6 +515,8 @@
"attack_ratio_desc": "What percentage of your troops to send in an attack (1100%)",
"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",
+9
View File
@@ -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")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.coordinateGrid)}
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_coordinate_grid")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.swapDirection)}
+14
View File
@@ -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<string>();
private keybinds: Record<string, string> = {};
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());
+11
View File
@@ -22,6 +22,7 @@ const isMac =
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
@@ -491,6 +492,16 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="coordinateGrid"
label=${translateText("user_setting.coordinate_grid_label")}
description=${translateText("user_setting.coordinate_grid_desc")}
defaultKey=${DefaultKeybinds.coordinateGrid}
.value=${this.getKeyValue("coordinateGrid")}
.display=${this.getKeyChar("coordinateGrid")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
+2
View File
@@ -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),
@@ -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();
}
}