// 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)) }; } 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; } }