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

129
core/hex-map.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { AxialCoord, HexTerrain, HexFeature, EdgeMask } from './types.js';
import { coordKey, parseCoordKey } from './coords.js';
import { DEFAULT_BASE_TERRAIN } from './terrain.js';
/** Serialized hex map format (for save/load) */
export interface SerializedHexMap {
hexes: Array<{
q: number;
r: number;
base: string;
features: HexFeature[];
}>;
}
/**
* In-memory hex map state.
* Sparse storage: only hexes that differ from default are stored.
*/
export class HexMap {
private data = new Map<string, HexTerrain>();
private _dirty = false;
/** Get terrain for a hex. Returns default if not explicitly set. */
getTerrain(coord: AxialCoord): HexTerrain {
const key = coordKey(coord);
const existing = this.data.get(key);
if (existing) return existing;
return { base: DEFAULT_BASE_TERRAIN, features: [] };
}
/** Set terrain for a hex */
setTerrain(coord: AxialCoord, terrain: HexTerrain): void {
this.data.set(coordKey(coord), terrain);
this._dirty = true;
}
/** Set only the base terrain, preserving features */
setBase(coord: AxialCoord, base: string): void {
const terrain = this.getTerrain(coord);
terrain.base = base;
this.setTerrain(coord, terrain);
}
/** Add or update a linear feature on a hex */
setFeature(coord: AxialCoord, terrainId: string, edgeMask: EdgeMask): void {
const terrain = this.getTerrain(coord);
const existing = terrain.features.find(f => f.terrainId === terrainId);
if (existing) {
existing.edgeMask = edgeMask;
} else {
terrain.features.push({ terrainId, edgeMask });
}
// Remove features with empty mask
terrain.features = terrain.features.filter(f => f.edgeMask !== 0);
this.setTerrain(coord, terrain);
}
/** Remove a feature entirely from a hex */
removeFeature(coord: AxialCoord, terrainId: string): void {
const terrain = this.getTerrain(coord);
terrain.features = terrain.features.filter(f => f.terrainId !== terrainId);
this.setTerrain(coord, terrain);
}
/** Reset a hex to default state */
clearHex(coord: AxialCoord): void {
this.data.delete(coordKey(coord));
this._dirty = true;
}
/** Check if a hex has been explicitly set */
hasHex(coord: AxialCoord): boolean {
return this.data.has(coordKey(coord));
}
/** Get all explicitly set hexes */
getAllHexes(): Array<{ coord: AxialCoord; terrain: HexTerrain }> {
const result: Array<{ coord: AxialCoord; terrain: HexTerrain }> = [];
for (const [key, terrain] of this.data) {
result.push({ coord: parseCoordKey(key), terrain });
}
return result;
}
/** Number of explicitly set hexes */
get size(): number {
return this.data.size;
}
/** Whether the map has unsaved changes */
get dirty(): boolean {
return this._dirty;
}
markClean(): void {
this._dirty = false;
}
/** Serialize for persistence */
serialize(): SerializedHexMap {
return {
hexes: this.getAllHexes().map(({ coord, terrain }) => ({
q: coord.q,
r: coord.r,
base: terrain.base,
features: terrain.features,
})),
};
}
/** Load from serialized data */
static deserialize(data: SerializedHexMap): HexMap {
const map = new HexMap();
for (const hex of data.hexes) {
map.setTerrain({ q: hex.q, r: hex.r }, {
base: hex.base,
features: hex.features,
});
}
map._dirty = false;
return map;
}
/** Clear all data */
clear(): void {
this.data.clear();
this._dirty = true;
}
}