Phase 1: Core hex engine, Leaflet overlay, terrain painting UI

- core/: Pure TS hex engine (axial coords, hex grid, terrain types,
  edge connectivity with constraint solver, HexMap state model)
- src/map/: Leaflet L.CRS.Simple map init, Canvas-based hex overlay
  layer (L.GridLayer), click/edge interaction detection
- src/ui/: Sidebar with toolbar (Select/Paint/Feature modes),
  terrain picker, hex inspector, map settings (hex size, grid, opacity)
- pipeline/: Tile pyramid generator (sharp, from source image)
- tests/: 32 passing tests for coords, hex-grid, edge-connectivity
- Uses Kiepenkerl tiles (symlinked) for development

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-04-07 10:32:52 +00:00
parent 5a19864fb5
commit f302932ea8
20 changed files with 1942 additions and 0 deletions

151
core/coords.ts Normal file
View File

@@ -0,0 +1,151 @@
import type { AxialCoord, PixelCoord, HexGeometry } from './types.js';
import { HexEdge, EDGE_DIRECTIONS, ALL_EDGES } from './types.js';
const SQRT3 = Math.sqrt(3);
/** Convert axial coords to pixel center (flat-top hex) */
export function axialToPixel(coord: AxialCoord, size: number, origin: PixelCoord = { x: 0, y: 0 }): PixelCoord {
return {
x: origin.x + size * (3 / 2) * coord.q,
y: origin.y + size * (SQRT3 / 2 * coord.q + SQRT3 * coord.r),
};
}
/** Convert pixel position to fractional axial coords (flat-top hex) */
export function pixelToAxial(pixel: PixelCoord, size: number, origin: PixelCoord = { x: 0, y: 0 }): AxialCoord {
const px = pixel.x - origin.x;
const py = pixel.y - origin.y;
const q = (2 / 3) * px / size;
const r = (-1 / 3 * px + SQRT3 / 3 * py) / size;
return axialRound({ q, r });
}
/** Round fractional axial coords to nearest hex */
export function axialRound(coord: AxialCoord): AxialCoord {
const s = -coord.q - coord.r;
let rq = Math.round(coord.q);
let rr = Math.round(coord.r);
const rs = Math.round(s);
const qDiff = Math.abs(rq - coord.q);
const rDiff = Math.abs(rr - coord.r);
const sDiff = Math.abs(rs - s);
if (qDiff > rDiff && qDiff > sDiff) {
rq = -rr - rs;
} else if (rDiff > sDiff) {
rr = -rq - rs;
}
return { q: rq, r: rr };
}
/** Get the neighbor of a hex in a given direction */
export function getNeighbor(coord: AxialCoord, edge: HexEdge): AxialCoord {
const dir = EDGE_DIRECTIONS[edge];
return { q: coord.q + dir.q, r: coord.r + dir.r };
}
/** Get all 6 neighbors */
export function getNeighbors(coord: AxialCoord): AxialCoord[] {
return ALL_EDGES.map(edge => getNeighbor(coord, edge));
}
/** Axial distance between two hexes */
export function axialDistance(a: AxialCoord, b: AxialCoord): number {
const dq = a.q - b.q;
const dr = a.r - b.r;
return (Math.abs(dq) + Math.abs(dq + dr) + Math.abs(dr)) / 2;
}
/** Create a unique string key for a hex coordinate */
export function coordKey(coord: AxialCoord): string {
return `${coord.q},${coord.r}`;
}
/** Parse a coordinate key back to AxialCoord */
export function parseCoordKey(key: string): AxialCoord {
const [q, r] = key.split(',').map(Number);
return { q, r };
}
/** Compute the 6 vertices of a flat-top hex */
export function hexVertices(cx: number, cy: number, size: number): PixelCoord[] {
const vertices: PixelCoord[] = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 180) * (60 * i);
vertices.push({
x: cx + size * Math.cos(angle),
y: cy + size * Math.sin(angle),
});
}
return vertices;
}
/**
* Compute the 6 edge midpoints, indexed by HexEdge enum.
*
* Flat-top vertices are at 0°,60°,120°,180°,240°,300° (screen coords, y+ down).
* Raw midpoints between consecutive vertices sit at 30°,90°,150°,210°,270°,330°.
* EDGE_DIRECTIONS point at -30°(NE),30°(E),90°(SE),150°(SW),210°(W),270°(NW).
* So raw midpoint index i maps to HexEdge (i+1)%6.
* We reorder so result[HexEdge] gives the correct midpoint.
*/
export function hexEdgeMidpoints(vertices: PixelCoord[]): PixelCoord[] {
// Raw midpoints between consecutive vertex pairs
const raw: PixelCoord[] = [];
for (let i = 0; i < 6; i++) {
const next = (i + 1) % 6;
raw.push({
x: (vertices[i].x + vertices[next].x) / 2,
y: (vertices[i].y + vertices[next].y) / 2,
});
}
// Reorder: HexEdge j = raw[(j + 5) % 6]
// NE(0)=raw[5], E(1)=raw[0], SE(2)=raw[1], SW(3)=raw[2], W(4)=raw[3], NW(5)=raw[4]
const reordered: PixelCoord[] = [];
for (let j = 0; j < 6; j++) {
reordered.push(raw[(j + 5) % 6]);
}
return reordered;
}
/** Compute full hex geometry */
export function computeHexGeometry(cx: number, cy: number, size: number): HexGeometry {
const vertices = hexVertices(cx, cy, size);
const edgeMidpoints = hexEdgeMidpoints(vertices);
return { cx, cy, size, vertices, edgeMidpoints };
}
/** Width of a flat-top hex (vertex to vertex, horizontal) */
export function hexWidth(size: number): number {
return size * 2;
}
/** Height of a flat-top hex (flat edge to flat edge, vertical) */
export function hexHeight(size: number): number {
return size * SQRT3;
}
/**
* Determine which edge of a hex is closest to a pixel point.
* Useful for click-on-edge detection.
*/
export function closestEdge(hexCenter: PixelCoord, size: number, point: PixelCoord): HexEdge {
const vertices = hexVertices(hexCenter.x, hexCenter.y, size);
const midpoints = hexEdgeMidpoints(vertices);
let minDist = Infinity;
let closest = HexEdge.NE;
for (let i = 0; i < 6; i++) {
const mp = midpoints[i];
const dist = Math.hypot(point.x - mp.x, point.y - mp.y);
if (dist < minDist) {
minDist = dist;
closest = i as HexEdge;
}
}
return closest;
}