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

226
src/map/hex-layer.ts Normal file
View File

@@ -0,0 +1,226 @@
import L from 'leaflet';
import type { AxialCoord, HexTerrain } from '../../core/types.js';
import { axialToPixel, hexVertices, hexHeight, hexWidth, computeHexGeometry } from '../../core/coords.js';
import { getHexesInBounds, type PixelBounds } from '../../core/hex-grid.js';
import { getTerrainType } from '../../core/terrain.js';
import type { HexMap } from '../../core/hex-map.js';
import { connectedEdges } from '../../core/edge-connectivity.js';
export interface HexLayerOptions extends L.GridLayerOptions {
hexSize: number;
hexMap: HexMap;
origin?: { x: number; y: number };
selectedHex?: AxialCoord | null;
showGrid?: boolean;
opacity?: number;
}
/**
* Leaflet GridLayer that renders the hex overlay using Canvas.
*/
export class HexOverlayLayer extends L.GridLayer {
private hexSize: number;
private hexMap: HexMap;
private origin: { x: number; y: number };
private _selectedHex: AxialCoord | null = null;
private _showGrid = true;
private _hexOpacity = 0.7;
constructor(options: HexLayerOptions) {
super(options);
this.hexSize = options.hexSize;
this.hexMap = options.hexMap;
this.origin = options.origin ?? { x: 0, y: 0 };
this._selectedHex = options.selectedHex ?? null;
this._showGrid = options.showGrid ?? true;
this._hexOpacity = options.opacity ?? 0.7;
}
setSelectedHex(coord: AxialCoord | null): void {
this._selectedHex = coord;
this.redraw();
}
setShowGrid(show: boolean): void {
this._showGrid = show;
this.redraw();
}
setHexOpacity(opacity: number): void {
this._hexOpacity = opacity;
this.redraw();
}
createTile(coords: L.Coords): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const tileSize = this.getTileSize();
canvas.width = tileSize.x;
canvas.height = tileSize.y;
const ctx = canvas.getContext('2d')!;
// Convert tile coords to pixel bounds on the source image
const nwPoint = coords.scaleBy(tileSize);
const sePoint = nwPoint.add(tileSize);
// At the current zoom, convert tile pixel coords to source image coords
const zoom = coords.z;
const maxZoom = this._map?.getMaxZoom() ?? 6;
const scale = Math.pow(2, maxZoom - zoom);
const bounds: PixelBounds = {
minX: nwPoint.x * scale,
minY: nwPoint.y * scale,
maxX: sePoint.x * scale,
maxY: sePoint.y * scale,
};
// Find hexes overlapping this tile
const hexCoords = getHexesInBounds(bounds, this.hexSize, this.origin);
// Draw each hex
for (const coord of hexCoords) {
const terrain = this.hexMap.getTerrain(coord);
const pixelCenter = axialToPixel(coord, this.hexSize, this.origin);
// Convert source image pixel coords to tile-local coords
const localX = (pixelCenter.x - bounds.minX) / scale;
const localY = (pixelCenter.y - bounds.minY) / scale;
const localSize = this.hexSize / scale;
this.drawHex(ctx, localX, localY, localSize, terrain, coord);
}
return canvas;
}
private drawHex(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
size: number,
terrain: HexTerrain,
coord: AxialCoord,
): void {
if (size < 2) return; // Too small to render
const vertices = hexVertices(cx, cy, size);
const geom = computeHexGeometry(cx, cy, size);
// Draw hex path
ctx.beginPath();
ctx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < 6; i++) {
ctx.lineTo(vertices[i].x, vertices[i].y);
}
ctx.closePath();
// Fill with base terrain color
const baseType = getTerrainType(terrain.base);
if (baseType) {
ctx.globalAlpha = this._hexOpacity;
ctx.fillStyle = baseType.color;
ctx.fill();
ctx.globalAlpha = 1.0;
}
// Draw linear features
const sortedFeatures = [...terrain.features].sort((a, b) => {
const ta = getTerrainType(a.terrainId);
const tb = getTerrainType(b.terrainId);
return (ta?.zIndex ?? 0) - (tb?.zIndex ?? 0);
});
for (const feature of sortedFeatures) {
const type = getTerrainType(feature.terrainId);
if (!type) continue;
const edges = connectedEdges(feature.edgeMask);
if (edges.length === 0) continue;
ctx.strokeStyle = type.color;
ctx.lineWidth = Math.max(1, size / 8);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.globalAlpha = 0.9;
if (feature.terrainId === 'road') {
ctx.setLineDash([size / 4, size / 6]);
} else {
ctx.setLineDash([]);
}
if (feature.terrainId === 'coastline') {
// Coastline: draw along hex edges
ctx.lineWidth = Math.max(2, size / 5);
for (const edge of edges) {
const v1 = vertices[edge];
const v2 = vertices[(edge + 1) % 6];
ctx.beginPath();
ctx.moveTo(v1.x, v1.y);
ctx.lineTo(v2.x, v2.y);
ctx.stroke();
}
} else if (edges.length === 1) {
// Dead-end: edge midpoint to center
const mp = geom.edgeMidpoints[edges[0]];
ctx.beginPath();
ctx.moveTo(mp.x, mp.y);
ctx.lineTo(cx, cy);
ctx.stroke();
// Terminus dot
ctx.fillStyle = type.color;
ctx.beginPath();
ctx.arc(cx, cy, Math.max(1, size / 10), 0, Math.PI * 2);
ctx.fill();
} else {
// Connect all edges through center with bezier curves
for (const edge of edges) {
const mp = geom.edgeMidpoints[edge];
ctx.beginPath();
ctx.moveTo(mp.x, mp.y);
// Bezier through ~halfway to center for a slight curve
const cpX = (mp.x + cx) / 2;
const cpY = (mp.y + cy) / 2;
ctx.quadraticCurveTo(cpX, cpY, cx, cy);
ctx.stroke();
}
}
ctx.setLineDash([]);
ctx.globalAlpha = 1.0;
}
// Grid outline
if (this._showGrid && size > 4) {
ctx.beginPath();
ctx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < 6; i++) {
ctx.lineTo(vertices[i].x, vertices[i].y);
}
ctx.closePath();
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 0.5;
ctx.stroke();
}
// Selection highlight
if (
this._selectedHex &&
this._selectedHex.q === coord.q &&
this._selectedHex.r === coord.r
) {
ctx.beginPath();
ctx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < 6; i++) {
ctx.lineTo(vertices[i].x, vertices[i].y);
}
ctx.closePath();
ctx.strokeStyle = '#fff';
ctx.lineWidth = Math.max(1, size / 10);
ctx.stroke();
ctx.strokeStyle = '#000';
ctx.lineWidth = Math.max(0.5, size / 20);
ctx.stroke();
}
}
}