diff --git a/core/coords.ts b/core/coords.ts new file mode 100644 index 0000000..4243f06 --- /dev/null +++ b/core/coords.ts @@ -0,0 +1,151 @@ +import type { AxialCoord, PixelCoord, HexGeometry } from './types.js'; +import { HexEdge, EDGE_DIRECTIONS, ALL_EDGES } from './types.js'; + +const SQRT3 = Math.sqrt(3); + +/** Convert axial coords to pixel center (flat-top hex) */ +export function axialToPixel(coord: AxialCoord, size: number, origin: PixelCoord = { x: 0, y: 0 }): PixelCoord { + return { + x: origin.x + size * (3 / 2) * coord.q, + y: origin.y + size * (SQRT3 / 2 * coord.q + SQRT3 * coord.r), + }; +} + +/** Convert pixel position to fractional axial coords (flat-top hex) */ +export function pixelToAxial(pixel: PixelCoord, size: number, origin: PixelCoord = { x: 0, y: 0 }): AxialCoord { + const px = pixel.x - origin.x; + const py = pixel.y - origin.y; + const q = (2 / 3) * px / size; + const r = (-1 / 3 * px + SQRT3 / 3 * py) / size; + return axialRound({ q, r }); +} + +/** Round fractional axial coords to nearest hex */ +export function axialRound(coord: AxialCoord): AxialCoord { + const s = -coord.q - coord.r; + let rq = Math.round(coord.q); + let rr = Math.round(coord.r); + const rs = Math.round(s); + + const qDiff = Math.abs(rq - coord.q); + const rDiff = Math.abs(rr - coord.r); + const sDiff = Math.abs(rs - s); + + if (qDiff > rDiff && qDiff > sDiff) { + rq = -rr - rs; + } else if (rDiff > sDiff) { + rr = -rq - rs; + } + + return { q: rq, r: rr }; +} + +/** Get the neighbor of a hex in a given direction */ +export function getNeighbor(coord: AxialCoord, edge: HexEdge): AxialCoord { + const dir = EDGE_DIRECTIONS[edge]; + return { q: coord.q + dir.q, r: coord.r + dir.r }; +} + +/** Get all 6 neighbors */ +export function getNeighbors(coord: AxialCoord): AxialCoord[] { + return ALL_EDGES.map(edge => getNeighbor(coord, edge)); +} + +/** Axial distance between two hexes */ +export function axialDistance(a: AxialCoord, b: AxialCoord): number { + const dq = a.q - b.q; + const dr = a.r - b.r; + return (Math.abs(dq) + Math.abs(dq + dr) + Math.abs(dr)) / 2; +} + +/** Create a unique string key for a hex coordinate */ +export function coordKey(coord: AxialCoord): string { + return `${coord.q},${coord.r}`; +} + +/** Parse a coordinate key back to AxialCoord */ +export function parseCoordKey(key: string): AxialCoord { + const [q, r] = key.split(',').map(Number); + return { q, r }; +} + +/** Compute the 6 vertices of a flat-top hex */ +export function hexVertices(cx: number, cy: number, size: number): PixelCoord[] { + const vertices: PixelCoord[] = []; + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 180) * (60 * i); + vertices.push({ + x: cx + size * Math.cos(angle), + y: cy + size * Math.sin(angle), + }); + } + return vertices; +} + +/** + * Compute the 6 edge midpoints, indexed by HexEdge enum. + * + * Flat-top vertices are at 0°,60°,120°,180°,240°,300° (screen coords, y+ down). + * Raw midpoints between consecutive vertices sit at 30°,90°,150°,210°,270°,330°. + * EDGE_DIRECTIONS point at -30°(NE),30°(E),90°(SE),150°(SW),210°(W),270°(NW). + * So raw midpoint index i maps to HexEdge (i+1)%6. + * We reorder so result[HexEdge] gives the correct midpoint. + */ +export function hexEdgeMidpoints(vertices: PixelCoord[]): PixelCoord[] { + // Raw midpoints between consecutive vertex pairs + const raw: PixelCoord[] = []; + for (let i = 0; i < 6; i++) { + const next = (i + 1) % 6; + raw.push({ + x: (vertices[i].x + vertices[next].x) / 2, + y: (vertices[i].y + vertices[next].y) / 2, + }); + } + // Reorder: HexEdge j = raw[(j + 5) % 6] + // NE(0)=raw[5], E(1)=raw[0], SE(2)=raw[1], SW(3)=raw[2], W(4)=raw[3], NW(5)=raw[4] + const reordered: PixelCoord[] = []; + for (let j = 0; j < 6; j++) { + reordered.push(raw[(j + 5) % 6]); + } + return reordered; +} + +/** Compute full hex geometry */ +export function computeHexGeometry(cx: number, cy: number, size: number): HexGeometry { + const vertices = hexVertices(cx, cy, size); + const edgeMidpoints = hexEdgeMidpoints(vertices); + return { cx, cy, size, vertices, edgeMidpoints }; +} + +/** Width of a flat-top hex (vertex to vertex, horizontal) */ +export function hexWidth(size: number): number { + return size * 2; +} + +/** Height of a flat-top hex (flat edge to flat edge, vertical) */ +export function hexHeight(size: number): number { + return size * SQRT3; +} + +/** + * Determine which edge of a hex is closest to a pixel point. + * Useful for click-on-edge detection. + */ +export function closestEdge(hexCenter: PixelCoord, size: number, point: PixelCoord): HexEdge { + const vertices = hexVertices(hexCenter.x, hexCenter.y, size); + const midpoints = hexEdgeMidpoints(vertices); + + let minDist = Infinity; + let closest = HexEdge.NE; + + for (let i = 0; i < 6; i++) { + const mp = midpoints[i]; + const dist = Math.hypot(point.x - mp.x, point.y - mp.y); + if (dist < minDist) { + minDist = dist; + closest = i as HexEdge; + } + } + + return closest; +} diff --git a/core/edge-connectivity.ts b/core/edge-connectivity.ts new file mode 100644 index 0000000..45c27fd --- /dev/null +++ b/core/edge-connectivity.ts @@ -0,0 +1,118 @@ +import type { AxialCoord, EdgeMask, HexFeature } from './types.js'; +import { HexEdge, OPPOSITE_EDGE, ALL_EDGES } from './types.js'; +import { getNeighbor } from './coords.js'; +import type { HexMap } from './hex-map.js'; + +/** Create an edge mask from a list of edges */ +export function edgeMask(...edges: HexEdge[]): EdgeMask { + let mask = 0; + for (const e of edges) mask |= (1 << e); + return mask; +} + +/** Check if an edge is set in a mask */ +export function hasEdge(mask: EdgeMask, edge: HexEdge): boolean { + return (mask & (1 << edge)) !== 0; +} + +/** Toggle an edge in a mask */ +export function toggleEdge(mask: EdgeMask, edge: HexEdge): EdgeMask { + return mask ^ (1 << edge); +} + +/** Set an edge in a mask */ +export function setEdge(mask: EdgeMask, edge: HexEdge): EdgeMask { + return mask | (1 << edge); +} + +/** Clear an edge from a mask */ +export function clearEdge(mask: EdgeMask, edge: HexEdge): EdgeMask { + return mask & ~(1 << edge); +} + +/** Count active edges */ +export function edgeCount(mask: EdgeMask): number { + let n = 0; + for (let i = 0; i < 6; i++) { + if (mask & (1 << i)) n++; + } + return n; +} + +/** Get list of active edges */ +export function connectedEdges(mask: EdgeMask): HexEdge[] { + const result: HexEdge[] = []; + for (let i = 0; i < 6; i++) { + if (mask & (1 << i)) result.push(i as HexEdge); + } + return result; +} + +/** Rotate a mask clockwise by N steps (each step = 60 degrees) */ +export function rotateMask(mask: EdgeMask, steps: number): EdgeMask { + const s = ((steps % 6) + 6) % 6; + // Rotate the 6 low bits + return ((mask << s) | (mask >> (6 - s))) & 0x3f; +} + +export interface ConstraintAction { + coord: AxialCoord; + terrainId: string; + edge: HexEdge; + action: 'add_dead_end'; +} + +/** + * After a feature edge is set on a hex, check if the neighbor + * needs a corresponding entry. Returns actions to apply. + */ +export function enforceEdgeConstraints( + hexMap: HexMap, + coord: AxialCoord, + terrainId: string, + mask: EdgeMask, +): ConstraintAction[] { + const actions: ConstraintAction[] = []; + + for (const edge of ALL_EDGES) { + if (!hasEdge(mask, edge)) continue; + + const neighbor = getNeighbor(coord, edge); + const oppositeEdge = OPPOSITE_EDGE[edge]; + const neighborTerrain = hexMap.getTerrain(neighbor); + const neighborFeature = neighborTerrain.features.find(f => f.terrainId === terrainId); + + if (!neighborFeature || !hasEdge(neighborFeature.edgeMask, oppositeEdge)) { + actions.push({ + coord: neighbor, + terrainId, + edge: oppositeEdge, + action: 'add_dead_end', + }); + } + } + + return actions; +} + +/** + * Apply constraint actions to the hex map. + * Adds dead-end features on neighbor hexes. + */ +export function applyConstraintActions(hexMap: HexMap, actions: ConstraintAction[]): void { + for (const action of actions) { + const terrain = hexMap.getTerrain(action.coord); + const existing = terrain.features.find(f => f.terrainId === action.terrainId); + + if (existing) { + existing.edgeMask = setEdge(existing.edgeMask, action.edge); + } else { + terrain.features.push({ + terrainId: action.terrainId, + edgeMask: edgeMask(action.edge), + }); + } + + hexMap.setTerrain(action.coord, terrain); + } +} diff --git a/core/hex-grid.ts b/core/hex-grid.ts new file mode 100644 index 0000000..ee0abcb --- /dev/null +++ b/core/hex-grid.ts @@ -0,0 +1,94 @@ +import type { AxialCoord, PixelCoord } from './types.js'; +import { axialToPixel, pixelToAxial, hexWidth, hexHeight } from './coords.js'; + +/** Rectangular pixel bounds */ +export interface PixelBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** + * Iterate all hex coordinates that overlap a rectangular pixel region. + * Returns axial coords for every hex whose center falls within + * the bounds (with one hex margin to avoid edge clipping). + */ +export function getHexesInBounds( + bounds: PixelBounds, + size: number, + origin: PixelCoord = { x: 0, y: 0 }, +): AxialCoord[] { + const w = hexWidth(size); + const h = hexHeight(size); + const colStep = w * 3 / 4; // horizontal distance between hex centers + const rowStep = h; // vertical distance between hex centers + + // Expand bounds by one hex to catch partial overlaps + const expandedBounds: PixelBounds = { + minX: bounds.minX - w, + minY: bounds.minY - h, + maxX: bounds.maxX + w, + maxY: bounds.maxY + h, + }; + + // Find the approximate q range + const qMin = Math.floor((expandedBounds.minX - origin.x) / colStep) - 1; + const qMax = Math.ceil((expandedBounds.maxX - origin.x) / colStep) + 1; + + const result: AxialCoord[] = []; + + for (let q = qMin; q <= qMax; q++) { + // For this q column, find the r range + const colCenterX = origin.x + size * (3 / 2) * q; + const colOffsetY = origin.y + size * (Math.sqrt(3) / 2) * q; + + const rMin = Math.floor((expandedBounds.minY - colOffsetY) / rowStep) - 1; + const rMax = Math.ceil((expandedBounds.maxY - colOffsetY) / rowStep) + 1; + + for (let r = rMin; r <= rMax; r++) { + const pixel = axialToPixel({ q, r }, size, origin); + // Check if hex center is within the expanded bounds + if ( + pixel.x >= expandedBounds.minX && pixel.x <= expandedBounds.maxX && + pixel.y >= expandedBounds.minY && pixel.y <= expandedBounds.maxY + ) { + result.push({ q, r }); + } + } + } + + return result; +} + +/** + * Get the bounding box (in pixels) of the entire hex grid + * that covers a given image size. + */ +export function gridBoundsForImage( + imageWidth: number, + imageHeight: number, + size: number, + origin: PixelCoord = { x: 0, y: 0 }, +): { coords: AxialCoord[]; bounds: PixelBounds } { + const bounds: PixelBounds = { + minX: 0, + minY: 0, + maxX: imageWidth, + maxY: imageHeight, + }; + + const coords = getHexesInBounds(bounds, size, origin); + return { coords, bounds }; +} + +/** + * Find the hex coordinate at a given pixel position. + */ +export function hexAtPixel( + pixel: PixelCoord, + size: number, + origin: PixelCoord = { x: 0, y: 0 }, +): AxialCoord { + return pixelToAxial(pixel, size, origin); +} diff --git a/core/hex-map.ts b/core/hex-map.ts new file mode 100644 index 0000000..4f97c56 --- /dev/null +++ b/core/hex-map.ts @@ -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(); + 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; + } +} diff --git a/core/terrain.ts b/core/terrain.ts new file mode 100644 index 0000000..7916b79 --- /dev/null +++ b/core/terrain.ts @@ -0,0 +1,36 @@ +import type { TerrainType } from './types.js'; + +export const TERRAIN_TYPES: TerrainType[] = [ + // Area fills (drawn first, below linear features) + { id: 'ocean', name: 'Ocean/Sea', category: 'area', color: '#2a5574', zIndex: 0 }, + { id: 'lake', name: 'Lake', category: 'area', color: '#4a90c4', zIndex: 1 }, + { id: 'plains', name: 'Plains', category: 'area', color: '#c4b060', zIndex: 2 }, + { id: 'farmland', name: 'Farmland', category: 'area', color: '#a4c639', zIndex: 3 }, + { id: 'forest', name: 'Forest', category: 'area', color: '#2d6a2d', zIndex: 4 }, + { id: 'hills', name: 'Hills', category: 'area', color: '#8a7a5a', zIndex: 5 }, + { id: 'mountains', name: 'Mountains', category: 'area', color: '#6a6a6a', zIndex: 6 }, + { id: 'settlement', name: 'Settlement', category: 'area', color: '#8b4513', zIndex: 7 }, + // Linear features (drawn on top, have edge connectivity) + { id: 'river', name: 'River', category: 'linear', color: '#2a7fff', zIndex: 10 }, + { id: 'road', name: 'Road', category: 'linear', color: '#a0522d', zIndex: 11 }, + { id: 'coastline', name: 'Coastline', category: 'linear', color: '#1a4a6a', zIndex: 12 }, +]; + +const terrainMap = new Map( + TERRAIN_TYPES.map(t => [t.id, t]), +); + +export function getTerrainType(id: string): TerrainType | undefined { + return terrainMap.get(id); +} + +export function getAreaTerrains(): TerrainType[] { + return TERRAIN_TYPES.filter(t => t.category === 'area'); +} + +export function getLinearTerrains(): TerrainType[] { + return TERRAIN_TYPES.filter(t => t.category === 'linear'); +} + +/** Default terrain for unpainted hexes */ +export const DEFAULT_BASE_TERRAIN = 'plains'; diff --git a/core/types.ts b/core/types.ts new file mode 100644 index 0000000..6ef4ba5 --- /dev/null +++ b/core/types.ts @@ -0,0 +1,87 @@ +/** Axial hex coordinate */ +export interface AxialCoord { + q: number; + r: number; +} + +/** Pixel position */ +export interface PixelCoord { + x: number; + y: number; +} + +/** 6 edges of a flat-top hex, clockwise from top-right */ +export enum HexEdge { + NE = 0, + E = 1, + SE = 2, + SW = 3, + W = 4, + NW = 5, +} + +/** Opposite edge mapping — the edge of the neighbor that faces this edge */ +export const OPPOSITE_EDGE: Record = { + [HexEdge.NE]: HexEdge.SW, + [HexEdge.E]: HexEdge.W, + [HexEdge.SE]: HexEdge.NW, + [HexEdge.SW]: HexEdge.NE, + [HexEdge.W]: HexEdge.E, + [HexEdge.NW]: HexEdge.SE, +}; + +/** Axial direction offsets per edge (flat-top) */ +export const EDGE_DIRECTIONS: Record = { + [HexEdge.NE]: { q: 1, r: -1 }, + [HexEdge.E]: { q: 1, r: 0 }, + [HexEdge.SE]: { q: 0, r: 1 }, + [HexEdge.SW]: { q: -1, r: 1 }, + [HexEdge.W]: { q: -1, r: 0 }, + [HexEdge.NW]: { q: 0, r: -1 }, +}; + +/** All 6 edges in order */ +export const ALL_EDGES: HexEdge[] = [ + HexEdge.NE, + HexEdge.E, + HexEdge.SE, + HexEdge.SW, + HexEdge.W, + HexEdge.NW, +]; + +/** 6-bit bitmask indicating which edges a feature crosses */ +export type EdgeMask = number; + +/** Terrain category */ +export type TerrainCategory = 'area' | 'linear'; + +/** Terrain type definition */ +export interface TerrainType { + id: string; + name: string; + category: TerrainCategory; + color: string; + zIndex: number; +} + +/** A linear feature on a hex with edge connectivity */ +export interface HexFeature { + terrainId: string; + edgeMask: EdgeMask; +} + +/** Complete terrain state for a single hex */ +export interface HexTerrain { + base: string; + features: HexFeature[]; +} + +/** Hex geometry for rendering */ +export interface HexGeometry { + cx: number; + cy: number; + size: number; + vertices: PixelCoord[]; + edgeMidpoints: PixelCoord[]; +} diff --git a/pipeline/generate-tiles.ts b/pipeline/generate-tiles.ts new file mode 100644 index 0000000..d0653f2 --- /dev/null +++ b/pipeline/generate-tiles.ts @@ -0,0 +1,102 @@ +/** + * Generate Leaflet tile pyramid from a source image. + * + * Usage: npx tsx pipeline/generate-tiles.ts + * + * Output: tiles/{z}/{x}/{y}.jpg (also copied to public/tiles/ for dev server) + */ + +import sharp from 'sharp'; +import { mkdirSync, existsSync, cpSync } from 'fs'; +import { join, resolve } from 'path'; + +const TILE_SIZE = 256; +const ROOT = resolve(import.meta.dirname, '..'); + +async function generateTiles(sourcePath: string) { + if (!existsSync(sourcePath)) { + console.error(`Source image not found: ${sourcePath}`); + process.exit(1); + } + + const tilesDir = join(ROOT, 'tiles'); + const publicTilesDir = join(ROOT, 'public', 'tiles'); + + console.log(`Reading source image: ${sourcePath}`); + const metadata = await sharp(sourcePath).metadata(); + const width = metadata.width!; + const height = metadata.height!; + console.log(`Image size: ${width}x${height}`); + + const maxDim = Math.max(width, height); + const maxZoom = Math.ceil(Math.log2(maxDim / TILE_SIZE)); + console.log(`Max zoom: ${maxZoom} (grid: ${Math.pow(2, maxZoom) * TILE_SIZE}px)`); + + let totalTiles = 0; + const startTime = Date.now(); + + for (let z = 0; z <= maxZoom; z++) { + const scale = Math.pow(2, z) / Math.pow(2, maxZoom); + const scaledW = Math.ceil(width * scale); + const scaledH = Math.ceil(height * scale); + const tilesX = Math.ceil(scaledW / TILE_SIZE); + const tilesY = Math.ceil(scaledH / TILE_SIZE); + const levelTiles = tilesX * tilesY; + + console.log(`Zoom ${z}: ${scaledW}x${scaledH} -> ${tilesX}x${tilesY} = ${levelTiles} tiles`); + + const buffer = await sharp(sourcePath) + .resize(scaledW, scaledH, { fit: 'fill', kernel: 'lanczos3' }) + .raw() + .toBuffer(); + + let done = 0; + for (let x = 0; x < tilesX; x++) { + for (let y = 0; y < tilesY; y++) { + const tileDir = join(tilesDir, `${z}`, `${x}`); + mkdirSync(tileDir, { recursive: true }); + + const left = x * TILE_SIZE; + const top = y * TILE_SIZE; + const tileW = Math.min(TILE_SIZE, scaledW - left); + const tileH = Math.min(TILE_SIZE, scaledH - top); + + await sharp(buffer, { + raw: { width: scaledW, height: scaledH, channels: 3 }, + }) + .extract({ left, top, width: tileW, height: tileH }) + .extend({ + right: TILE_SIZE - tileW, + bottom: TILE_SIZE - tileH, + background: { r: 42, g: 85, b: 116, alpha: 1 }, + }) + .jpeg({ quality: 85 }) + .toFile(join(tileDir, `${y}.jpg`)); + + totalTiles++; + done++; + } + } + process.stdout.write(` -> ${done} tiles written\n`); + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`\nGenerated ${totalTiles} tiles in ${tilesDir} (${elapsed}s)`); + + mkdirSync(join(ROOT, 'public'), { recursive: true }); + cpSync(tilesDir, publicTilesDir, { recursive: true }); + console.log(`Copied to ${publicTilesDir}`); + + console.log(`\nMap config: imageSize: [${width}, ${height}], maxZoom: ${maxZoom}`); +} + +const source = process.argv[2]; +if (!source) { + console.error('Usage: npx tsx pipeline/generate-tiles.ts '); + process.exit(1); +} + +generateTiles(source).catch(err => { + console.error('Tile generation failed:', err); + process.exit(1); +}); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..87ecdd8 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,120 @@ +import './style/main.css'; +import { initMap } from './map/map-init.js'; +import { HexOverlayLayer } from './map/hex-layer.js'; +import { attachHexInteraction } from './map/hex-interaction.js'; +import { HexMap } from '../core/hex-map.js'; +import type { AxialCoord, TerrainType } from '../core/types.js'; +import { createSidebar } from './ui/sidebar.js'; +import { createToolbar, type ToolMode } from './ui/toolbar.js'; +import { createTerrainPicker } from './ui/terrain-picker.js'; +import { createHexInspector } from './ui/hex-inspector.js'; +import { createMapSettings } from './ui/map-settings.js'; +import { + edgeMask, + toggleEdge, + enforceEdgeConstraints, + applyConstraintActions, +} from '../core/edge-connectivity.js'; + +// --- State --- +const hexMap = new HexMap(); +let currentMode: ToolMode = 'select'; +let selectedTerrain: TerrainType | null = null; +let selectedHex: AxialCoord | null = null; +let hexSize = 48; +const origin = { x: 0, y: 0 }; + +// --- Init Map --- +const map = initMap('map'); + +// --- Hex Layer --- +let hexLayer = new HexOverlayLayer({ + hexSize, + hexMap, + origin, + showGrid: true, + opacity: 0.7, +}); +hexLayer.addTo(map); + +// --- Sidebar --- +const sidebarEl = document.getElementById('sidebar')!; +const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl); + +const toolbarUI = createToolbar(toolbar, (mode) => { + currentMode = mode; + terrainPickerUI.setMode(mode); +}); + +const terrainPickerUI = createTerrainPicker(terrainPicker, (terrain) => { + selectedTerrain = terrain; +}); + +const hexInspectorUI = createHexInspector(hexInspector); + +createMapSettings(settings, { hexSize, showGrid: true, opacity: 0.7 }, (s) => { + if (s.hexSize !== hexSize) { + hexSize = s.hexSize; + rebuildHexLayer(s.showGrid, s.opacity); + } else { + hexLayer.setShowGrid(s.showGrid); + hexLayer.setHexOpacity(s.opacity); + } +}); + +// --- Rebuild hex layer (when hex size changes) --- +function rebuildHexLayer(showGrid: boolean, opacity: number) { + map.removeLayer(hexLayer); + hexLayer = new HexOverlayLayer({ + hexSize, + hexMap, + origin, + showGrid, + opacity, + }); + hexLayer.addTo(map); + reattachInteraction(); +} + +// --- Hex Interaction --- +let detachInteraction: (() => void) | null = null; + +function reattachInteraction() { + detachInteraction?.(); + detachInteraction = attachHexInteraction(map, hexSize, origin, (event) => { + if (currentMode === 'select') { + selectedHex = event.coord; + hexLayer.setSelectedHex(selectedHex); + hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex)); + } else if (currentMode === 'paint' && selectedTerrain) { + hexMap.setBase(event.coord, selectedTerrain.id); + selectedHex = event.coord; + hexLayer.setSelectedHex(selectedHex); + hexLayer.redraw(); + hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex)); + } else if (currentMode === 'feature' && selectedTerrain) { + const coord = event.coord; + const terrain = hexMap.getTerrain(coord); + const existing = terrain.features.find(f => f.terrainId === selectedTerrain!.id); + const currentMask = existing?.edgeMask ?? 0; + const newMask = toggleEdge(currentMask, event.edge); + + hexMap.setFeature(coord, selectedTerrain.id, newMask); + + // Enforce edge constraints + if (newMask > currentMask) { + // An edge was added — ensure neighbor has continuation + const addedEdgeMask = edgeMask(event.edge); + const actions = enforceEdgeConstraints(hexMap, coord, selectedTerrain.id, addedEdgeMask); + applyConstraintActions(hexMap, actions); + } + + selectedHex = coord; + hexLayer.setSelectedHex(selectedHex); + hexLayer.redraw(); + hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex)); + } + }); +} + +reattachInteraction(); diff --git a/src/map/hex-interaction.ts b/src/map/hex-interaction.ts new file mode 100644 index 0000000..18af0ac --- /dev/null +++ b/src/map/hex-interaction.ts @@ -0,0 +1,47 @@ +import L from 'leaflet'; +import type { AxialCoord } from '../../core/types.js'; +import { HexEdge } from '../../core/types.js'; +import { pixelToAxial, closestEdge, axialToPixel } from '../../core/coords.js'; +import { toPixel } from './map-init.js'; + +export interface HexClickEvent { + coord: AxialCoord; + edge: HexEdge; + latlng: L.LatLng; + pixelOnImage: [number, number]; +} + +export type HexClickHandler = (event: HexClickEvent) => void; + +/** + * Attach hex click detection to a Leaflet map. + * Translates map clicks to hex coordinates + closest edge. + */ +export function attachHexInteraction( + map: L.Map, + hexSize: number, + origin: { x: number; y: number }, + handler: HexClickHandler, +): () => void { + const onClick = (e: L.LeafletMouseEvent) => { + const pixel = toPixel(map, e.latlng); + const pixelCoord = { x: pixel[0], y: pixel[1] }; + const coord = pixelToAxial(pixelCoord, hexSize, origin); + const hexCenter = axialToPixel(coord, hexSize, origin); + const edge = closestEdge(hexCenter, hexSize, pixelCoord); + + handler({ + coord, + edge, + latlng: e.latlng, + pixelOnImage: pixel, + }); + }; + + map.on('click', onClick); + + // Return cleanup function + return () => { + map.off('click', onClick); + }; +} diff --git a/src/map/hex-layer.ts b/src/map/hex-layer.ts new file mode 100644 index 0000000..596d4e9 --- /dev/null +++ b/src/map/hex-layer.ts @@ -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(); + } + } +} diff --git a/src/map/map-init.ts b/src/map/map-init.ts new file mode 100644 index 0000000..6a37cbb --- /dev/null +++ b/src/map/map-init.ts @@ -0,0 +1,63 @@ +import L from 'leaflet'; + +export interface MapConfig { + tileUrl: string; + imageSize: [number, number]; + minZoom: number; + maxZoom: number; + initialZoom: number; + initialCenter: [number, number]; +} + +const DEFAULT_CONFIG: MapConfig = { + tileUrl: '/tiles/{z}/{x}/{y}.jpg', + imageSize: [8000, 12000], + minZoom: 0, + maxZoom: 6, + initialZoom: 2, + initialCenter: [4000, 5000], +}; + +export function initMap( + container: string | HTMLElement, + config: Partial = {}, +): L.Map { + const cfg = { ...DEFAULT_CONFIG, ...config }; + + const map = L.map(container, { + crs: L.CRS.Simple, + minZoom: cfg.minZoom, + maxZoom: cfg.maxZoom, + zoomSnap: 1, + zoomDelta: 1, + attributionControl: false, + }); + + const southWest = map.unproject([0, cfg.imageSize[1]], cfg.maxZoom); + const northEast = map.unproject([cfg.imageSize[0], 0], cfg.maxZoom); + const bounds = new L.LatLngBounds(southWest, northEast); + + L.tileLayer(cfg.tileUrl, { + minZoom: cfg.minZoom, + maxZoom: cfg.maxZoom, + bounds, + noWrap: true, + }).addTo(map); + + const center = map.unproject(cfg.initialCenter, cfg.maxZoom); + map.setView(center, cfg.initialZoom); + map.setMaxBounds(bounds.pad(0.1)); + + return map; +} + +/** Convert pixel coordinates on the source image to Leaflet LatLng */ +export function toLatLng(map: L.Map, pixel: [number, number]): L.LatLng { + return map.unproject(pixel, map.getMaxZoom()); +} + +/** Convert Leaflet LatLng to pixel coordinates on the source image */ +export function toPixel(map: L.Map, latlng: L.LatLng): [number, number] { + const point = map.project(latlng, map.getMaxZoom()); + return [point.x, point.y]; +} diff --git a/src/style/main.css b/src/style/main.css new file mode 100644 index 0000000..4121ea9 --- /dev/null +++ b/src/style/main.css @@ -0,0 +1,170 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + color: #e0e0e0; + background: #1a1a2e; +} + +#app { + display: flex; + height: 100vh; +} + +#map { + flex: 1; + background: #16213e; +} + +/* Sidebar */ +#sidebar { + width: 280px; + background: #16213e; + border-right: 1px solid #2a2a4a; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar-section { + padding: 12px; + border-bottom: 1px solid #2a2a4a; +} + +.sidebar-section h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #888; + margin-bottom: 8px; +} + +/* Toolbar */ +.toolbar { + display: flex; + gap: 4px; +} + +.toolbar button { + flex: 1; + padding: 6px 10px; + border: 1px solid #2a2a4a; + border-radius: 4px; + background: #1a1a2e; + color: #ccc; + cursor: pointer; + font-size: 12px; + transition: all 0.15s; +} + +.toolbar button:hover { + background: #2a2a4a; + color: #fff; +} + +.toolbar button.active { + background: #3a3a6a; + color: #fff; + border-color: #5a5a9a; +} + +/* Terrain Picker */ +.terrain-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; +} + +.terrain-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border: 1px solid #2a2a4a; + border-radius: 4px; + background: #1a1a2e; + color: #ccc; + cursor: pointer; + font-size: 11px; + transition: all 0.15s; +} + +.terrain-btn:hover { + background: #2a2a4a; + color: #fff; +} + +.terrain-btn.selected { + border-color: #fff; + color: #fff; + background: #2a2a4a; +} + +.terrain-swatch { + width: 14px; + height: 14px; + border-radius: 3px; + flex-shrink: 0; +} + +/* Hex Inspector */ +.hex-info { + font-size: 12px; +} + +.hex-info .coord { + color: #888; + font-family: monospace; +} + +.hex-info .terrain-label { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 3px; + background: #2a2a4a; + margin-top: 4px; +} + +/* Settings */ +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.setting-row label { + font-size: 12px; + color: #aaa; +} + +.setting-row input[type="range"] { + width: 100px; +} + +.setting-row input[type="number"] { + width: 60px; + padding: 2px 4px; + background: #1a1a2e; + border: 1px solid #2a2a4a; + border-radius: 3px; + color: #e0e0e0; + font-size: 12px; +} + +/* Feature toggles (linear terrains shown separately) */ +.terrain-section-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #666; + margin: 8px 0 4px 0; +} diff --git a/src/ui/hex-inspector.ts b/src/ui/hex-inspector.ts new file mode 100644 index 0000000..5b51b1a --- /dev/null +++ b/src/ui/hex-inspector.ts @@ -0,0 +1,52 @@ +import type { AxialCoord, HexTerrain } from '../../core/types.js'; +import { getTerrainType } from '../../core/terrain.js'; +import { connectedEdges } from '../../core/edge-connectivity.js'; +import { HexEdge } from '../../core/types.js'; + +const EDGE_NAMES: Record = { + [HexEdge.NE]: 'NE', + [HexEdge.E]: 'E', + [HexEdge.SE]: 'SE', + [HexEdge.SW]: 'SW', + [HexEdge.W]: 'W', + [HexEdge.NW]: 'NW', +}; + +export function createHexInspector(container: HTMLElement): { + update: (coord: AxialCoord | null, terrain: HexTerrain | null) => void; +} { + function update(coord: AxialCoord | null, terrain: HexTerrain | null) { + if (!coord || !terrain) { + container.innerHTML = '
No hex selected
'; + return; + } + + const baseType = getTerrainType(terrain.base); + let html = '
'; + html += `
q: ${coord.q}, r: ${coord.r}
`; + html += `
`; + html += ``; + html += `${baseType?.name ?? terrain.base}`; + html += `
`; + + if (terrain.features.length > 0) { + html += '
Features:
'; + for (const feature of terrain.features) { + const type = getTerrainType(feature.terrainId); + const edges = connectedEdges(feature.edgeMask) + .map(e => EDGE_NAMES[e]) + .join(', '); + html += `
`; + html += ``; + html += `${type?.name ?? feature.terrainId}: ${edges}`; + html += `
`; + } + } + + html += '
'; + container.innerHTML = html; + } + + update(null, null); + return { update }; +} diff --git a/src/ui/map-settings.ts b/src/ui/map-settings.ts new file mode 100644 index 0000000..a279a10 --- /dev/null +++ b/src/ui/map-settings.ts @@ -0,0 +1,67 @@ +export interface MapSettings { + hexSize: number; + showGrid: boolean; + opacity: number; +} + +export function createMapSettings( + container: HTMLElement, + initial: MapSettings, + onChange: (settings: MapSettings) => void, +): { getSettings: () => MapSettings } { + const settings = { ...initial }; + + function render() { + container.innerHTML = ''; + + // Hex size + const sizeRow = document.createElement('div'); + sizeRow.className = 'setting-row'; + sizeRow.innerHTML = ``; + const sizeInput = document.createElement('input'); + sizeInput.type = 'number'; + sizeInput.min = '8'; + sizeInput.max = '256'; + sizeInput.value = String(settings.hexSize); + sizeInput.addEventListener('change', () => { + settings.hexSize = Math.max(8, Math.min(256, Number(sizeInput.value))); + onChange(settings); + }); + sizeRow.appendChild(sizeInput); + container.appendChild(sizeRow); + + // Show grid + const gridRow = document.createElement('div'); + gridRow.className = 'setting-row'; + gridRow.innerHTML = ``; + const gridCheck = document.createElement('input'); + gridCheck.type = 'checkbox'; + gridCheck.checked = settings.showGrid; + gridCheck.addEventListener('change', () => { + settings.showGrid = gridCheck.checked; + onChange(settings); + }); + gridRow.appendChild(gridCheck); + container.appendChild(gridRow); + + // Opacity + const opacityRow = document.createElement('div'); + opacityRow.className = 'setting-row'; + opacityRow.innerHTML = ``; + const opacityInput = document.createElement('input'); + opacityInput.type = 'range'; + opacityInput.min = '0'; + opacityInput.max = '100'; + opacityInput.value = String(Math.round(settings.opacity * 100)); + opacityInput.addEventListener('input', () => { + settings.opacity = Number(opacityInput.value) / 100; + onChange(settings); + }); + opacityRow.appendChild(opacityInput); + container.appendChild(opacityRow); + } + + render(); + + return { getSettings: () => ({ ...settings }) }; +} diff --git a/src/ui/sidebar.ts b/src/ui/sidebar.ts new file mode 100644 index 0000000..3875d75 --- /dev/null +++ b/src/ui/sidebar.ts @@ -0,0 +1,43 @@ +export function createSidebar(container: HTMLElement): { + toolbar: HTMLElement; + terrainPicker: HTMLElement; + hexInspector: HTMLElement; + settings: HTMLElement; +} { + container.innerHTML = ''; + + const toolbarSection = document.createElement('div'); + toolbarSection.className = 'sidebar-section'; + toolbarSection.innerHTML = '

Tools

'; + const toolbar = document.createElement('div'); + toolbar.id = 'toolbar'; + toolbarSection.appendChild(toolbar); + + const terrainSection = document.createElement('div'); + terrainSection.className = 'sidebar-section'; + terrainSection.innerHTML = '

Terrain

'; + const terrainPicker = document.createElement('div'); + terrainPicker.id = 'terrain-picker'; + terrainSection.appendChild(terrainPicker); + + const inspectorSection = document.createElement('div'); + inspectorSection.className = 'sidebar-section'; + inspectorSection.innerHTML = '

Selected Hex

'; + const hexInspector = document.createElement('div'); + hexInspector.id = 'hex-inspector'; + inspectorSection.appendChild(hexInspector); + + const settingsSection = document.createElement('div'); + settingsSection.className = 'sidebar-section'; + settingsSection.innerHTML = '

Settings

'; + const settings = document.createElement('div'); + settings.id = 'settings'; + settingsSection.appendChild(settings); + + container.appendChild(toolbarSection); + container.appendChild(terrainSection); + container.appendChild(inspectorSection); + container.appendChild(settingsSection); + + return { toolbar, terrainPicker, hexInspector, settings }; +} diff --git a/src/ui/terrain-picker.ts b/src/ui/terrain-picker.ts new file mode 100644 index 0000000..a54a578 --- /dev/null +++ b/src/ui/terrain-picker.ts @@ -0,0 +1,80 @@ +import type { TerrainType } from '../../core/types.js'; +import { getAreaTerrains, getLinearTerrains } from '../../core/terrain.js'; +import type { ToolMode } from './toolbar.js'; + +export function createTerrainPicker( + container: HTMLElement, + onChange: (terrain: TerrainType) => void, +): { + setMode: (mode: ToolMode) => void; + getSelected: () => TerrainType | null; +} { + let selected: TerrainType | null = null; + let currentMode: ToolMode = 'select'; + + function render() { + container.innerHTML = ''; + + if (currentMode === 'select') { + container.innerHTML = '
Click a hex to inspect it
'; + return; + } + + const terrains = currentMode === 'paint' ? getAreaTerrains() : getLinearTerrains(); + const label = currentMode === 'paint' ? 'Area Terrain' : 'Linear Features'; + + const sectionLabel = document.createElement('div'); + sectionLabel.className = 'terrain-section-label'; + sectionLabel.textContent = label; + container.appendChild(sectionLabel); + + const grid = document.createElement('div'); + grid.className = 'terrain-grid'; + + for (const terrain of terrains) { + const btn = document.createElement('button'); + btn.className = 'terrain-btn'; + if (selected?.id === terrain.id) btn.classList.add('selected'); + + const swatch = document.createElement('span'); + swatch.className = 'terrain-swatch'; + swatch.style.backgroundColor = terrain.color; + + const name = document.createElement('span'); + name.textContent = terrain.name; + + btn.appendChild(swatch); + btn.appendChild(name); + + btn.addEventListener('click', () => { + selected = terrain; + onChange(terrain); + render(); + }); + + grid.appendChild(btn); + } + + container.appendChild(grid); + + // Auto-select first if nothing selected + if (!selected || !terrains.find(t => t.id === selected!.id)) { + selected = terrains[0] ?? null; + if (selected) onChange(selected); + // Re-render to show selection + const firstBtn = grid.querySelector('.terrain-btn'); + firstBtn?.classList.add('selected'); + } + } + + render(); + + return { + setMode(mode: ToolMode) { + currentMode = mode; + selected = null; + render(); + }, + getSelected: () => selected, + }; +} diff --git a/src/ui/toolbar.ts b/src/ui/toolbar.ts new file mode 100644 index 0000000..f1f2003 --- /dev/null +++ b/src/ui/toolbar.ts @@ -0,0 +1,40 @@ +export type ToolMode = 'select' | 'paint' | 'feature'; + +export function createToolbar( + container: HTMLElement, + onChange: (mode: ToolMode) => void, +): { setMode: (mode: ToolMode) => void } { + let currentMode: ToolMode = 'select'; + + const div = document.createElement('div'); + div.className = 'toolbar'; + + const modes: { mode: ToolMode; label: string }[] = [ + { mode: 'select', label: 'Select' }, + { mode: 'paint', label: 'Paint' }, + { mode: 'feature', label: 'Feature' }, + ]; + + const buttons: Map = new Map(); + + for (const { mode, label } of modes) { + const btn = document.createElement('button'); + btn.textContent = label; + btn.addEventListener('click', () => setMode(mode)); + buttons.set(mode, btn); + div.appendChild(btn); + } + + function setMode(mode: ToolMode) { + currentMode = mode; + for (const [m, btn] of buttons) { + btn.classList.toggle('active', m === currentMode); + } + onChange(currentMode); + } + + container.appendChild(div); + setMode('select'); + + return { setMode }; +} diff --git a/tests/core/coords.test.ts b/tests/core/coords.test.ts new file mode 100644 index 0000000..101cf60 --- /dev/null +++ b/tests/core/coords.test.ts @@ -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); + }); +}); diff --git a/tests/core/edge-connectivity.test.ts b/tests/core/edge-connectivity.test.ts new file mode 100644 index 0000000..8b9e0c5 --- /dev/null +++ b/tests/core/edge-connectivity.test.ts @@ -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); + }); +}); diff --git a/tests/core/hex-grid.test.ts b/tests/core/hex-grid.test.ts new file mode 100644 index 0000000..63a4e75 --- /dev/null +++ b/tests/core/hex-grid.test.ts @@ -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); + }); +});