// Hex-grid maths shared by the server simulation and the browser view. // // The map is a flat-top hexagonal grid stored in "offset" coordinates // (x = column, y = row) with odd columns pushed down by half a hex, matching // the source art layout (tile_size 63x56, vertical offset axis). Everything // that walks the grid -- the generator, pathfinding, territory, fog and the // renderer -- goes through this module so they all agree on neighbourhoods. export const HEX_W = 63; export const HEX_H = 56; // Horizontal distance between adjacent columns of a flat-top hex grid. export const COL_STEP = HEX_W * 0.75; // Cube directions for a flat-top axial grid. const CUBE_DIRECTIONS = [ [1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1], ]; export function key(x, y) { return x + "," + y; } export function parseKey(k) { const i = k.indexOf(","); return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; } // Parity of a (possibly negative) integer: 1 for odd, 0 for even. export function parity(x) { return x & 1; } export function offsetToCube(x, y) { const q = x; const r = y - Math.floor((x - parity(x)) / 2); return { x: q, y: r, z: -q - r }; } export function cubeToOffset(q, r) { return { x: q, y: r + Math.floor((q - parity(q)) / 2) }; } export function cubeDistance(a, b) { return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z)); } // Pixel centre of a tile, in world space, with (0,0) at the origin. export function mapToLocal(x, y) { return { x: COL_STEP * x, y: HEX_H * (y + 0.5 * parity(x)) }; } // The tile whose centre is nearest a world pixel point: the inverse of // `mapToLocal`. Three candidate columns are tested (the point can sit just past // a column boundary); the winning cell is folded back into the map when the // world wraps. export function localToMap(point, topology = null) { const column = Math.round(point.x / COL_STEP); let best = null; let bestDistance = Infinity; for (let x = column - 1; x <= column + 1; x++) { const originY = HEX_H * 0.5 * parity(x); const y = Math.round((point.y - originY) / HEX_H); const center = mapToLocal(x, y); const distance = (center.x - point.x) ** 2 + (center.y - point.y) ** 2; if (distance < bestDistance) { bestDistance = distance; best = { x, y }; } } return topology ? topology.wrapCoords(best.x, best.y) : best; } // The copy of a world pixel point nearest `reference`, a whole number of map // periods away. A flat map returns the point unchanged. Mirrors the browser's // `_nearestCopy`, so a flight across the seam is drawn where the server flies. export function nearestCopyPoint(point, reference, topology) { const period = topology && topology.periodPixels ? topology.periodPixels() : 0; if (!period) return { x: point.x, y: point.y }; const k = Math.round((reference.x - point.x) / period); return { x: point.x + k * period, y: point.y }; } // A straight flight: the world-pixel points from `fromPoint` to the nearest // wrapped copy of the tile `toCoords`. `fromPoint` is already a pixel, so a // plane mid-flight can be redirected without snapping back to a tile centre. export function flightLine(fromPoint, toCoords, topology) { const to = mapToLocal(toCoords.x, toCoords.y); return [ { x: fromPoint.x, y: fromPoint.y }, nearestCopyPoint(to, fromPoint, topology), ]; } // Straight-line distance between two tile centres in tile units, taking the // shortest wrapped route and scaling by the longest hex step. This is the // aircraft mile: fuel and mission reach are both measured along it. export function flightDistanceTiles(from, to, topology, maxStepLength) { const delta = topology.pixelDelta(from, to); return Math.hypot(delta.x, delta.y) / (maxStepLength || 1); } export class MapTopology { constructor(size, cylindrical) { this.width = size.x; this.height = size.y; this.originX = -Math.floor(size.x / 2); this.originY = -Math.floor(size.y / 2); this.cylindrical = !!cylindrical; } isCylindrical() { return this.cylindrical; } wrapX(value) { if (!this.cylindrical || this.width <= 0) return value; const rel = ((value - this.originX) % this.width + this.width) % this.width; return rel + this.originX; } wrapCoords(x, y) { return { x: this.wrapX(x), y }; } // Shortest signed offset from `from` to `to`, folding the wrapped axis. wrappedDelta(from, to) { let dx = to.x - from.x; const dy = to.y - from.y; if (this.cylindrical && this.width > 0) { dx = ((dx + this.width / 2) % this.width + this.width) % this.width - this.width / 2; } return { x: dx, y: dy }; } // Hex distance using the shortest wrapped offset. tileDistance(from, to) { const delta = this.wrappedDelta(from, to); const a = offsetToCube(from.x, from.y); const b = offsetToCube(from.x + delta.x, from.y + delta.y); return cubeDistance(a, b); } // The six neighbours of a cell, folded back into the map when wrapping. neighbours(x, y) { const cube = offsetToCube(x, y); const result = []; for (const [dq, dr] of CUBE_DIRECTIONS) { const offset = cubeToOffset(cube.x + dq, cube.y + dr); const wrapped = this.wrapCoords(offset.x, offset.y); result.push(wrapped); } return result; } // Pixel vector from one tile to the nearest copy of another, following the // shortest wrapped route. pixelDelta(from, to) { const delta = this.wrappedDelta(from, to); const a = mapToLocal(from.x, from.y); const b = mapToLocal(from.x + delta.x, from.y + delta.y); return { x: b.x - a.x, y: b.y - a.y }; } // Horizontal pixel period: distance between a tile and its wrapped copy. periodPixels() { if (!this.cylindrical || this.width <= 0) return 0; return COL_STEP * this.width; } }