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:
174
tests/core/coords.test.ts
Normal file
174
tests/core/coords.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
93
tests/core/edge-connectivity.test.ts
Normal file
93
tests/core/edge-connectivity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
tests/core/hex-grid.test.ts
Normal file
50
tests/core/hex-grid.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user