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

118
core/edge-connectivity.ts Normal file
View File

@@ -0,0 +1,118 @@
import type { AxialCoord, EdgeMask, HexFeature } from './types.js';
import { HexEdge, OPPOSITE_EDGE, ALL_EDGES } from './types.js';
import { getNeighbor } from './coords.js';
import type { HexMap } from './hex-map.js';
/** Create an edge mask from a list of edges */
export function edgeMask(...edges: HexEdge[]): EdgeMask {
let mask = 0;
for (const e of edges) mask |= (1 << e);
return mask;
}
/** Check if an edge is set in a mask */
export function hasEdge(mask: EdgeMask, edge: HexEdge): boolean {
return (mask & (1 << edge)) !== 0;
}
/** Toggle an edge in a mask */
export function toggleEdge(mask: EdgeMask, edge: HexEdge): EdgeMask {
return mask ^ (1 << edge);
}
/** Set an edge in a mask */
export function setEdge(mask: EdgeMask, edge: HexEdge): EdgeMask {
return mask | (1 << edge);
}
/** Clear an edge from a mask */
export function clearEdge(mask: EdgeMask, edge: HexEdge): EdgeMask {
return mask & ~(1 << edge);
}
/** Count active edges */
export function edgeCount(mask: EdgeMask): number {
let n = 0;
for (let i = 0; i < 6; i++) {
if (mask & (1 << i)) n++;
}
return n;
}
/** Get list of active edges */
export function connectedEdges(mask: EdgeMask): HexEdge[] {
const result: HexEdge[] = [];
for (let i = 0; i < 6; i++) {
if (mask & (1 << i)) result.push(i as HexEdge);
}
return result;
}
/** Rotate a mask clockwise by N steps (each step = 60 degrees) */
export function rotateMask(mask: EdgeMask, steps: number): EdgeMask {
const s = ((steps % 6) + 6) % 6;
// Rotate the 6 low bits
return ((mask << s) | (mask >> (6 - s))) & 0x3f;
}
export interface ConstraintAction {
coord: AxialCoord;
terrainId: string;
edge: HexEdge;
action: 'add_dead_end';
}
/**
* After a feature edge is set on a hex, check if the neighbor
* needs a corresponding entry. Returns actions to apply.
*/
export function enforceEdgeConstraints(
hexMap: HexMap,
coord: AxialCoord,
terrainId: string,
mask: EdgeMask,
): ConstraintAction[] {
const actions: ConstraintAction[] = [];
for (const edge of ALL_EDGES) {
if (!hasEdge(mask, edge)) continue;
const neighbor = getNeighbor(coord, edge);
const oppositeEdge = OPPOSITE_EDGE[edge];
const neighborTerrain = hexMap.getTerrain(neighbor);
const neighborFeature = neighborTerrain.features.find(f => f.terrainId === terrainId);
if (!neighborFeature || !hasEdge(neighborFeature.edgeMask, oppositeEdge)) {
actions.push({
coord: neighbor,
terrainId,
edge: oppositeEdge,
action: 'add_dead_end',
});
}
}
return actions;
}
/**
* Apply constraint actions to the hex map.
* Adds dead-end features on neighbor hexes.
*/
export function applyConstraintActions(hexMap: HexMap, actions: ConstraintAction[]): void {
for (const action of actions) {
const terrain = hexMap.getTerrain(action.coord);
const existing = terrain.features.find(f => f.terrainId === action.terrainId);
if (existing) {
existing.edgeMask = setEdge(existing.edgeMask, action.edge);
} else {
terrain.features.push({
terrainId: action.terrainId,
edgeMask: edgeMask(action.edge),
});
}
hexMap.setTerrain(action.coord, terrain);
}
}