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[];
|
||||
}
|
||||
Reference in New Issue
Block a user