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:
151
core/coords.ts
Normal file
151
core/coords.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
118
core/edge-connectivity.ts
Normal file
118
core/edge-connectivity.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
core/hex-grid.ts
Normal file
94
core/hex-grid.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
core/terrain.ts
Normal file
36
core/terrain.ts
Normal file
@@ -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<string, TerrainType>(
|
||||||
|
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';
|
||||||
87
core/types.ts
Normal file
87
core/types.ts
Normal file
@@ -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, HexEdge> = {
|
||||||
|
[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, AxialCoord> = {
|
||||||
|
[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[];
|
||||||
|
}
|
||||||
102
pipeline/generate-tiles.ts
Normal file
102
pipeline/generate-tiles.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Generate Leaflet tile pyramid from a source image.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx pipeline/generate-tiles.ts <path-to-image>
|
||||||
|
*
|
||||||
|
* 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 <path-to-image>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateTiles(source).catch(err => {
|
||||||
|
console.error('Tile generation failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
120
src/main.ts
Normal file
120
src/main.ts
Normal file
@@ -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();
|
||||||
47
src/map/hex-interaction.ts
Normal file
47
src/map/hex-interaction.ts
Normal file
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
226
src/map/hex-layer.ts
Normal file
226
src/map/hex-layer.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/map/map-init.ts
Normal file
63
src/map/map-init.ts
Normal file
@@ -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<MapConfig> = {},
|
||||||
|
): 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];
|
||||||
|
}
|
||||||
170
src/style/main.css
Normal file
170
src/style/main.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
52
src/ui/hex-inspector.ts
Normal file
52
src/ui/hex-inspector.ts
Normal file
@@ -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, string> = {
|
||||||
|
[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 = '<div style="color:#666;font-size:12px">No hex selected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseType = getTerrainType(terrain.base);
|
||||||
|
let html = '<div class="hex-info">';
|
||||||
|
html += `<div class="coord">q: ${coord.q}, r: ${coord.r}</div>`;
|
||||||
|
html += `<div class="terrain-label">`;
|
||||||
|
html += `<span class="terrain-swatch" style="background:${baseType?.color ?? '#666'};display:inline-block;width:10px;height:10px;border-radius:2px"></span>`;
|
||||||
|
html += `${baseType?.name ?? terrain.base}`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
if (terrain.features.length > 0) {
|
||||||
|
html += '<div style="margin-top:6px;font-size:11px;color:#888">Features:</div>';
|
||||||
|
for (const feature of terrain.features) {
|
||||||
|
const type = getTerrainType(feature.terrainId);
|
||||||
|
const edges = connectedEdges(feature.edgeMask)
|
||||||
|
.map(e => EDGE_NAMES[e])
|
||||||
|
.join(', ');
|
||||||
|
html += `<div class="terrain-label" style="margin-top:2px">`;
|
||||||
|
html += `<span class="terrain-swatch" style="background:${type?.color ?? '#666'};display:inline-block;width:10px;height:10px;border-radius:2px"></span>`;
|
||||||
|
html += `${type?.name ?? feature.terrainId}: ${edges}`;
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(null, null);
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
67
src/ui/map-settings.ts
Normal file
67
src/ui/map-settings.ts
Normal file
@@ -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 = `<label>Hex size (px)</label>`;
|
||||||
|
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 = `<label>Show grid</label>`;
|
||||||
|
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 = `<label>Opacity</label>`;
|
||||||
|
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 }) };
|
||||||
|
}
|
||||||
43
src/ui/sidebar.ts
Normal file
43
src/ui/sidebar.ts
Normal file
@@ -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 = '<h3>Tools</h3>';
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.id = 'toolbar';
|
||||||
|
toolbarSection.appendChild(toolbar);
|
||||||
|
|
||||||
|
const terrainSection = document.createElement('div');
|
||||||
|
terrainSection.className = 'sidebar-section';
|
||||||
|
terrainSection.innerHTML = '<h3>Terrain</h3>';
|
||||||
|
const terrainPicker = document.createElement('div');
|
||||||
|
terrainPicker.id = 'terrain-picker';
|
||||||
|
terrainSection.appendChild(terrainPicker);
|
||||||
|
|
||||||
|
const inspectorSection = document.createElement('div');
|
||||||
|
inspectorSection.className = 'sidebar-section';
|
||||||
|
inspectorSection.innerHTML = '<h3>Selected Hex</h3>';
|
||||||
|
const hexInspector = document.createElement('div');
|
||||||
|
hexInspector.id = 'hex-inspector';
|
||||||
|
inspectorSection.appendChild(hexInspector);
|
||||||
|
|
||||||
|
const settingsSection = document.createElement('div');
|
||||||
|
settingsSection.className = 'sidebar-section';
|
||||||
|
settingsSection.innerHTML = '<h3>Settings</h3>';
|
||||||
|
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 };
|
||||||
|
}
|
||||||
80
src/ui/terrain-picker.ts
Normal file
80
src/ui/terrain-picker.ts
Normal file
@@ -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 = '<div style="color:#666;font-size:12px">Click a hex to inspect it</div>';
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
src/ui/toolbar.ts
Normal file
40
src/ui/toolbar.ts
Normal file
@@ -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<ToolMode, HTMLButtonElement> = 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 };
|
||||||
|
}
|
||||||
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