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