diff --git a/resources/images/GridIconWhite.svg b/resources/images/GridIconWhite.svg new file mode 100644 index 000000000..62f5d1592 --- /dev/null +++ b/resources/images/GridIconWhite.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 1fef3cfd8..6f6a6e2ac 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", @@ -515,6 +516,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 95ba88880..21fba5424 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 27eb82ede..1d8a21d77 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 73c0a14e6..7a6025a1c 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -22,6 +22,7 @@ const isMac = const DefaultKeybinds: Record = { toggleView: "Space", + coordinateGrid: "KeyM", buildCity: "Digit1", buildFactory: "Digit2", buildPort: "Digit3", @@ -491,6 +492,16 @@ export class UserSettingModal extends BaseModal { @change=${this.handleKeybindChange} > + +

diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 98708b651..8956214d6 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 000000000..64c211750 --- /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(); + } +} diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index a35434b90..f44762416 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -11,6 +11,7 @@ import { Quads, RankedType, Trios, + mapCategories, } from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; import { GameConfig, PublicGameType, TeamCountConfig } from "../core/Schemas"; @@ -18,6 +19,7 @@ import { logger } from "./Logger"; import { getMapLandTiles } from "./MapLandTiles"; const log = logger.child({}); +const ARCADE_MAPS = new Set(mapCategories.arcade); // How many times each map should appear in the playlist. // Note: The Partial should eventually be removed for better type safety. @@ -87,6 +89,16 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ { config: HumansVsNations, weight: 20 }, ]; +type ModifierKey = "isRandomSpawn" | "isCompact" | "isCrowded" | "startingGold"; + +// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection. +const SPECIAL_MODIFIER_POOL: ModifierKey[] = [ + ...Array(4).fill("isRandomSpawn"), + ...Array(7).fill("isCompact"), + ...Array(1).fill("isCrowded"), + ...Array(6).fill("startingGold"), +]; + export class MapPlaylist { private playlists: Record = { ffa: [], @@ -94,8 +106,6 @@ export class MapPlaylist { team: [], }; - constructor() {} - public async gameConfig(type: PublicGameType): Promise { if (type === "special") { return this.getSpecialConfig(); @@ -175,27 +185,76 @@ export class MapPlaylist { } satisfies GameConfig; } - private getSpecialConfig(): GameConfig { - // TODO: create better special configs. + private async getSpecialConfig(): Promise { + const mode = Math.random() < 0.5 ? GameMode.FFA : GameMode.Team; const map = this.getNextMap("special"); + const playerTeams = + mode === GameMode.Team ? this.getTeamCount() : undefined; + const supportsCompact = + mode !== GameMode.Team || (await this.supportsCompactMapForTeams(map)); + const excludedModifiers: ModifierKey[] = []; + if (!supportsCompact) { + excludedModifiers.push("isCompact"); + } + if ( + playerTeams === Duos || + playerTeams === Trios || + playerTeams === Quads + ) { + excludedModifiers.push("isRandomSpawn"); + } + + let { isCrowded, startingGold, isCompact, isRandomSpawn } = + this.getRandomSpecialGameModifiers(excludedModifiers); + + let crowdedMaxPlayers: number | undefined; + if (isCrowded) { + crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact); + if (crowdedMaxPlayers !== undefined) { + crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams); + } else { + // Map doesn't support crowded. Drop it and pick one replacement only + // if it was the sole modifier, so the lobby always has at least one. + isCrowded = false; + if (!isRandomSpawn && !isCompact && startingGold === undefined) { + excludedModifiers.push("isCrowded"); + ({ isRandomSpawn, isCompact, startingGold } = + this.getRandomSpecialGameModifiers(excludedModifiers, 1)); + } + } + } + + const maxPlayers = Math.max( + 2, + crowdedMaxPlayers ?? + (await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)), + ); + return { - donateGold: true, - donateTroops: true, + donateGold: mode === GameMode.Team, + donateTroops: mode === GameMode.Team, gameMap: map, - maxPlayers: 2, + maxPlayers, gameType: GameType.Public, - gameMapSize: GameMapSize.Normal, - difficulty: Difficulty.Easy, - rankedType: RankedType.OneVOne, + gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, + publicGameModifiers: { + isCompact, + isRandomSpawn, + isCrowded, + startingGold, + }, + startingGold, + difficulty: Difficulty.Medium, infiniteGold: false, infiniteTroops: false, + maxTimerValue: undefined, instantBuild: false, - randomSpawn: false, - disableNations: true, - gameMode: GameMode.Team, - playerTeams: HumansVsNations, - bots: 100, - spawnImmunityDuration: 5 * 10, + randomSpawn: isRandomSpawn, + disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations, + gameMode: mode, + playerTeams, + bots: isCompact ? 100 : 400, + spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10, disabledUnits: [], } satisfies GameConfig; } @@ -234,21 +293,21 @@ export class MapPlaylist { private getNextMap(type: PublicGameType): GameMapType { const playlist = this.playlists[type]; if (playlist.length === 0) { - playlist.push(...this.generateNewPlaylist()); + playlist.push(...this.generateNewPlaylist(type)); } return playlist.shift()!; } - private generateNewPlaylist(): GameMapType[] { - const maps = this.buildMapsList(); + private generateNewPlaylist(type: PublicGameType): GameMapType[] { + const maps = this.buildMapsList(type); const rand = new PseudoRandom(Date.now()); - const shuffledSource = rand.shuffleArray([...maps]); const playlist: GameMapType[] = []; const numAttempts = 10000; for (let attempt = 0; attempt < numAttempts; attempt++) { playlist.length = 0; - const source = [...shuffledSource]; + // Re-shuffle every attempt so retries can explore different orderings. + const source = rand.shuffleArray([...maps]); let success = true; while (source.length > 0) { @@ -288,11 +347,15 @@ export class MapPlaylist { return false; } - private buildMapsList(): GameMapType[] { + private buildMapsList(type: PublicGameType): GameMapType[] { const maps: GameMapType[] = []; (Object.keys(GameMapType) as GameMapName[]).forEach((key) => { + const map = GameMapType[key]; + if (type !== "special" && ARCADE_MAPS.has(map)) { + return; + } for (let i = 0; i < (frequency[key] ?? 0); i++) { - maps.push(GameMapType[key]); + maps.push(map); } }); return maps; @@ -321,6 +384,41 @@ export class MapPlaylist { }; } + private getRandomSpecialGameModifiers( + excludedModifiers: ModifierKey[] = [], + count?: number, + ): PublicGameModifiers { + // Roll how many modifiers to pick: 30% → 1, 40% → 2, 20% → 3, 10% → 4 + const modifierCountRoll = Math.floor(Math.random() * 10) + 1; + const k = + count ?? + (modifierCountRoll <= 3 + ? 1 + : modifierCountRoll <= 7 + ? 2 + : modifierCountRoll <= 9 + ? 3 + : 4); + + // Shuffle the pool, then pick the first k unique modifier keys. + const pool = SPECIAL_MODIFIER_POOL.filter( + (key) => !excludedModifiers.includes(key), + ).sort(() => Math.random() - 0.5); + + const selected = new Set(); + for (const key of pool) { + if (selected.size >= k) break; + selected.add(key); + } + + return { + isRandomSpawn: selected.has("isRandomSpawn"), + isCompact: selected.has("isCompact"), + isCrowded: selected.has("isCrowded"), + startingGold: selected.has("startingGold") ? 5_000_000 : undefined, + }; + } + // Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games // (not enough players after 75% player reduction for compact maps) private async supportsCompactMapForTeams(map: GameMapType): Promise {