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.coordinateGrid)} + + + ${translateText("help_modal.action_coordinate_grid")} + + ${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(); + } +}