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

174
tests/core/coords.test.ts Normal file
View File

@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import {
axialToPixel,
pixelToAxial,
axialRound,
getNeighbor,
getNeighbors,
axialDistance,
coordKey,
parseCoordKey,
hexVertices,
hexEdgeMidpoints,
closestEdge,
} from '@core/coords';
import { HexEdge } from '@core/types';
describe('axialToPixel / pixelToAxial roundtrip', () => {
const size = 32;
it('origin hex maps to origin pixel', () => {
const p = axialToPixel({ q: 0, r: 0 }, size);
expect(p.x).toBeCloseTo(0);
expect(p.y).toBeCloseTo(0);
});
it('roundtrips integer coordinates', () => {
const cases = [
{ q: 0, r: 0 },
{ q: 1, r: 0 },
{ q: 0, r: 1 },
{ q: -2, r: 3 },
{ q: 5, r: -3 },
];
for (const coord of cases) {
const pixel = axialToPixel(coord, size);
const back = pixelToAxial(pixel, size);
expect(back.q).toBe(coord.q);
expect(back.r).toBe(coord.r);
}
});
it('respects origin offset', () => {
const origin = { x: 100, y: 200 };
const coord = { q: 2, r: 1 };
const pixel = axialToPixel(coord, size, origin);
const back = pixelToAxial(pixel, size, origin);
expect(back.q).toBe(coord.q);
expect(back.r).toBe(coord.r);
});
});
describe('axialRound', () => {
it('rounds fractional coords to nearest hex', () => {
const result = axialRound({ q: 0.3, r: 0.1 });
expect(result.q).toBe(0);
expect(result.r).toBe(0);
});
it('handles mid-boundary correctly', () => {
const result = axialRound({ q: 0.7, r: -0.2 });
expect(Number.isInteger(result.q)).toBe(true);
expect(Number.isInteger(result.r)).toBe(true);
// Cube constraint: q + r + s = 0
const s = -result.q - result.r;
expect(Number.isInteger(s)).toBe(true);
});
});
describe('neighbors', () => {
it('returns correct neighbor for each edge', () => {
const origin = { q: 3, r: 4 };
expect(getNeighbor(origin, HexEdge.NE)).toEqual({ q: 4, r: 3 });
expect(getNeighbor(origin, HexEdge.E)).toEqual({ q: 4, r: 4 });
expect(getNeighbor(origin, HexEdge.SE)).toEqual({ q: 3, r: 5 });
expect(getNeighbor(origin, HexEdge.SW)).toEqual({ q: 2, r: 5 });
expect(getNeighbor(origin, HexEdge.W)).toEqual({ q: 2, r: 4 });
expect(getNeighbor(origin, HexEdge.NW)).toEqual({ q: 3, r: 3 });
});
it('getNeighbors returns 6 neighbors', () => {
const neighbors = getNeighbors({ q: 0, r: 0 });
expect(neighbors).toHaveLength(6);
// All should be distance 1
for (const n of neighbors) {
expect(axialDistance({ q: 0, r: 0 }, n)).toBe(1);
}
});
});
describe('axialDistance', () => {
it('same hex is distance 0', () => {
expect(axialDistance({ q: 2, r: 3 }, { q: 2, r: 3 })).toBe(0);
});
it('adjacent hexes are distance 1', () => {
expect(axialDistance({ q: 0, r: 0 }, { q: 1, r: 0 })).toBe(1);
});
it('diagonal distance', () => {
expect(axialDistance({ q: 0, r: 0 }, { q: 3, r: -3 })).toBe(3);
});
});
describe('coordKey / parseCoordKey', () => {
it('roundtrips', () => {
const coord = { q: -5, r: 12 };
expect(parseCoordKey(coordKey(coord))).toEqual(coord);
});
});
describe('hexVertices', () => {
it('produces 6 vertices', () => {
const verts = hexVertices(0, 0, 32);
expect(verts).toHaveLength(6);
});
it('vertices are equidistant from center', () => {
const size = 32;
const verts = hexVertices(10, 20, size);
for (const v of verts) {
const dist = Math.hypot(v.x - 10, v.y - 20);
expect(dist).toBeCloseTo(size, 5);
}
});
});
describe('closestEdge', () => {
// Flat-top hex: vertices at 0,60,120,180,240,300 degrees
// Edge midpoints are between consecutive vertices:
// midpoint 0 (v0-v1) at ~30° → NE direction
// midpoint 1 (v1-v2) at ~90° → top (NW in our enum)
// midpoint 2 (v2-v3) at ~150° → upper-left
// midpoint 3 (v3-v4) at ~210° → lower-left
// midpoint 4 (v4-v5) at ~270° → bottom
// midpoint 5 (v5-v0) at ~330° → lower-right
// The mapping between midpoint index and HexEdge enum depends
// on how we defined the enum. Our vertex-based midpoints go:
// index 0 → NE(30°), 1 → NW(90°)... etc
// So a point at x=30,y=0 (0°) is closest to midpoint 0 (NE) or 5 (SE-ish)
it('point to the upper-right → NE edge', () => {
const center = { x: 0, y: 0 };
// NE midpoint is at ~(-30°) in screen coords = upper-right
const point = { x: 24, y: -14 };
expect(closestEdge(center, 32, point)).toBe(HexEdge.NE);
});
it('point to the right → E edge', () => {
const center = { x: 0, y: 0 };
// E midpoint is at ~30° = right-downish
const point = { x: 28, y: 10 };
expect(closestEdge(center, 32, point)).toBe(HexEdge.E);
});
it('point directly below → SE edge', () => {
const center = { x: 0, y: 0 };
const point = { x: 0, y: 30 };
expect(closestEdge(center, 32, point)).toBe(HexEdge.SE);
});
it('point directly above → NW edge', () => {
const center = { x: 0, y: 0 };
const point = { x: 0, y: -30 };
expect(closestEdge(center, 32, point)).toBe(HexEdge.NW);
});
it('returns a valid edge (0-5)', () => {
const center = { x: 100, y: 100 };
const point = { x: 120, y: 95 };
const edge = closestEdge(center, 32, point);
expect(edge).toBeGreaterThanOrEqual(0);
expect(edge).toBeLessThanOrEqual(5);
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import {
edgeMask,
hasEdge,
toggleEdge,
setEdge,
clearEdge,
edgeCount,
connectedEdges,
rotateMask,
enforceEdgeConstraints,
} from '@core/edge-connectivity';
import { HexEdge } from '@core/types';
import { HexMap } from '@core/hex-map';
describe('edgeMask operations', () => {
it('creates mask from edges', () => {
const mask = edgeMask(HexEdge.NE, HexEdge.SW);
expect(hasEdge(mask, HexEdge.NE)).toBe(true);
expect(hasEdge(mask, HexEdge.SW)).toBe(true);
expect(hasEdge(mask, HexEdge.E)).toBe(false);
expect(edgeCount(mask)).toBe(2);
});
it('toggles edges', () => {
let mask = edgeMask(HexEdge.E);
mask = toggleEdge(mask, HexEdge.E);
expect(hasEdge(mask, HexEdge.E)).toBe(false);
mask = toggleEdge(mask, HexEdge.E);
expect(hasEdge(mask, HexEdge.E)).toBe(true);
});
it('sets and clears edges', () => {
let mask = 0;
mask = setEdge(mask, HexEdge.NW);
expect(hasEdge(mask, HexEdge.NW)).toBe(true);
mask = clearEdge(mask, HexEdge.NW);
expect(hasEdge(mask, HexEdge.NW)).toBe(false);
});
it('connectedEdges returns correct edges', () => {
const mask = edgeMask(HexEdge.NE, HexEdge.SE, HexEdge.W);
const edges = connectedEdges(mask);
expect(edges).toEqual([HexEdge.NE, HexEdge.SE, HexEdge.W]);
});
});
describe('rotateMask', () => {
it('rotating 6 steps returns original', () => {
const mask = edgeMask(HexEdge.NE, HexEdge.E);
expect(rotateMask(mask, 6)).toBe(mask);
});
it('rotates single edge clockwise by 1', () => {
const mask = edgeMask(HexEdge.NE); // bit 0
const rotated = rotateMask(mask, 1);
expect(hasEdge(rotated, HexEdge.E)).toBe(true); // bit 1
expect(edgeCount(rotated)).toBe(1);
});
it('rotates opposite edges correctly', () => {
const mask = edgeMask(HexEdge.NE, HexEdge.SW); // bits 0,3
const rotated = rotateMask(mask, 1);
expect(hasEdge(rotated, HexEdge.E)).toBe(true);
expect(hasEdge(rotated, HexEdge.W)).toBe(true);
expect(edgeCount(rotated)).toBe(2);
});
});
describe('enforceEdgeConstraints', () => {
it('detects missing continuation on neighbor', () => {
const hexMap = new HexMap();
const coord = { q: 0, r: 0 };
const mask = edgeMask(HexEdge.E);
const actions = enforceEdgeConstraints(hexMap, coord, 'road', mask);
expect(actions).toHaveLength(1);
expect(actions[0].coord).toEqual({ q: 1, r: 0 }); // E neighbor
expect(actions[0].edge).toBe(HexEdge.W); // opposite edge
expect(actions[0].terrainId).toBe('road');
});
it('no action needed when neighbor already has feature', () => {
const hexMap = new HexMap();
// Set up neighbor with road on W edge
hexMap.setFeature({ q: 1, r: 0 }, 'road', edgeMask(HexEdge.W));
const actions = enforceEdgeConstraints(
hexMap, { q: 0, r: 0 }, 'road', edgeMask(HexEdge.E),
);
expect(actions).toHaveLength(0);
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { getHexesInBounds, hexAtPixel } from '@core/hex-grid';
describe('getHexesInBounds', () => {
const size = 32;
it('returns hexes covering a small area', () => {
const hexes = getHexesInBounds(
{ minX: 0, minY: 0, maxX: 100, maxY: 100 },
size,
);
expect(hexes.length).toBeGreaterThan(0);
});
it('includes origin hex when bounds contain origin', () => {
const hexes = getHexesInBounds(
{ minX: -10, minY: -10, maxX: 10, maxY: 10 },
size,
);
const hasOrigin = hexes.some(h => h.q === 0 && h.r === 0);
expect(hasOrigin).toBe(true);
});
it('more hexes for larger area', () => {
const small = getHexesInBounds(
{ minX: 0, minY: 0, maxX: 100, maxY: 100 },
size,
);
const large = getHexesInBounds(
{ minX: 0, minY: 0, maxX: 500, maxY: 500 },
size,
);
expect(large.length).toBeGreaterThan(small.length);
});
it('smaller hex size yields more hexes', () => {
const bounds = { minX: 0, minY: 0, maxX: 200, maxY: 200 };
const big = getHexesInBounds(bounds, 64);
const small = getHexesInBounds(bounds, 16);
expect(small.length).toBeGreaterThan(big.length);
});
});
describe('hexAtPixel', () => {
it('origin pixel maps to origin hex', () => {
const coord = hexAtPixel({ x: 0, y: 0 }, 32);
expect(coord.q).toBe(0);
expect(coord.r).toBe(0);
});
});