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:
151
core/coords.ts
Normal file
151
core/coords.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user