Files
Battle-for-Tismo/shared/hex.js
T
2026-09-16 19:59:18 +02:00

126 lines
3.6 KiB
JavaScript

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