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:
129
core/hex-map.ts
Normal file
129
core/hex-map.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user