mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +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,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 |
@@ -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 (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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user