diff --git a/public/tiles b/public/tiles new file mode 120000 index 0000000..7032c81 --- /dev/null +++ b/public/tiles @@ -0,0 +1 @@ +/var/www/kiepenkerl/tiles \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 87ecdd8..a03bbef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ 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 { attachHexInteraction, type HexClickEvent } 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'; @@ -76,45 +76,62 @@ function rebuildHexLayer(showGrid: boolean, opacity: number) { reattachInteraction(); } +// --- Interaction handlers --- + +function handleSelect(event: HexClickEvent) { + selectedHex = event.coord; + hexLayer.setSelectedHex(selectedHex); + hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex)); +} + +function handlePaint(event: HexClickEvent) { + if (!selectedTerrain) return; + hexMap.setBase(event.coord, selectedTerrain.id); + hexLayer.redraw(); + selectedHex = event.coord; + hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex)); +} + +function handleFeature(event: HexClickEvent) { + if (!selectedTerrain) return; + 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); + + if (newMask > currentMask) { + 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)); +} + // --- 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)); - } - }); + detachInteraction = attachHexInteraction( + map, hexSize, origin, + // Click handler + (event) => { + if (currentMode === 'select') handleSelect(event); + else if (currentMode === 'paint') handlePaint(event); + else if (currentMode === 'feature') handleFeature(event); + }, + // Drag-paint handler (only active in paint mode) + (event) => { + if (currentMode === 'paint') handlePaint(event); + }, + ); } reattachInteraction(); diff --git a/src/map/hex-interaction.ts b/src/map/hex-interaction.ts index 18af0ac..269a4d0 100644 --- a/src/map/hex-interaction.ts +++ b/src/map/hex-interaction.ts @@ -1,7 +1,7 @@ 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 { pixelToAxial, closestEdge, axialToPixel, coordKey } from '../../core/coords.js'; import { toPixel } from './map-init.js'; export interface HexClickEvent { @@ -13,35 +13,79 @@ export interface HexClickEvent { export type HexClickHandler = (event: HexClickEvent) => void; +function buildEvent(map: L.Map, latlng: L.LatLng, hexSize: number, origin: { x: number; y: number }): HexClickEvent { + const pixel = toPixel(map, 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); + return { coord, edge, latlng, pixelOnImage: pixel }; +} + /** - * Attach hex click detection to a Leaflet map. - * Translates map clicks to hex coordinates + closest edge. + * Attach hex interaction to a Leaflet map. + * Supports both click and drag-paint (mousedown + mousemove). */ export function attachHexInteraction( map: L.Map, hexSize: number, origin: { x: number; y: number }, - handler: HexClickHandler, + onClick: HexClickHandler, + onDragPaint?: 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); + let dragging = false; + let lastDragKey = ''; - handler({ - coord, - edge, - latlng: e.latlng, - pixelOnImage: pixel, - }); + const handleClick = (e: L.LeafletMouseEvent) => { + if (dragging) return; // Don't fire click at end of drag + onClick(buildEvent(map, e.latlng, hexSize, origin)); }; - map.on('click', onClick); + const handleMouseDown = (e: L.LeafletMouseEvent) => { + if (!onDragPaint) return; + dragging = true; + lastDragKey = ''; + + // Disable map dragging while painting + map.dragging.disable(); + + const event = buildEvent(map, e.latlng, hexSize, origin); + lastDragKey = coordKey(event.coord); + onDragPaint(event); + }; + + const handleMouseMove = (e: L.LeafletMouseEvent) => { + if (!dragging || !onDragPaint) return; + + const event = buildEvent(map, e.latlng, hexSize, origin); + const key = coordKey(event.coord); + + // Only fire if we moved to a new hex + if (key !== lastDragKey) { + lastDragKey = key; + onDragPaint(event); + } + }; + + const handleMouseUp = () => { + if (dragging) { + dragging = false; + map.dragging.enable(); + } + }; + + map.on('click', handleClick); + map.on('mousedown', handleMouseDown); + map.on('mousemove', handleMouseMove); + map.on('mouseup', handleMouseUp); + document.addEventListener('mouseup', handleMouseUp); - // Return cleanup function return () => { - map.off('click', onClick); + map.off('click', handleClick); + map.off('mousedown', handleMouseDown); + map.off('mousemove', handleMouseMove); + map.off('mouseup', handleMouseUp); + document.removeEventListener('mouseup', handleMouseUp); + map.dragging.enable(); }; } diff --git a/src/map/hex-layer.ts b/src/map/hex-layer.ts index 596d4e9..fb5ff0e 100644 --- a/src/map/hex-layer.ts +++ b/src/map/hex-layer.ts @@ -1,10 +1,9 @@ import L from 'leaflet'; -import type { AxialCoord, HexTerrain } from '../../core/types.js'; -import { axialToPixel, hexVertices, hexHeight, hexWidth, computeHexGeometry } from '../../core/coords.js'; +import type { AxialCoord } from '../../core/types.js'; +import { axialToPixel, 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'; +import { renderHex } from '../svg/renderer.js'; export interface HexLayerOptions extends L.GridLayerOptions { hexSize: number; @@ -17,6 +16,7 @@ export interface HexLayerOptions extends L.GridLayerOptions { /** * Leaflet GridLayer that renders the hex overlay using Canvas. + * Delegates all hex rendering to svg/renderer.ts. */ export class HexOverlayLayer extends L.GridLayer { private hexSize: number; @@ -58,11 +58,9 @@ export class HexOverlayLayer extends L.GridLayer { 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); @@ -74,153 +72,28 @@ export class HexOverlayLayer extends L.GridLayer { 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); + const geom = computeHexGeometry(localX, localY, localSize); + const isSelected = this._selectedHex !== null && + this._selectedHex.q === coord.q && + this._selectedHex.r === coord.r; + + renderHex(ctx, geom, terrain, { + opacity: this._hexOpacity, + showGrid: this._showGrid, + selected: isSelected, + }); } return canvas; } - - private drawHex( - ctx: CanvasRenderingContext2D, - cx: number, - cy: number, - size: number, - terrain: HexTerrain, - coord: AxialCoord, - ): void { - if (size < 2) return; // Too small to render - - const vertices = hexVertices(cx, cy, size); - const geom = computeHexGeometry(cx, cy, size); - - // Draw hex path - ctx.beginPath(); - ctx.moveTo(vertices[0].x, vertices[0].y); - for (let i = 1; i < 6; i++) { - ctx.lineTo(vertices[i].x, vertices[i].y); - } - ctx.closePath(); - - // Fill with base terrain color - const baseType = getTerrainType(terrain.base); - if (baseType) { - ctx.globalAlpha = this._hexOpacity; - ctx.fillStyle = baseType.color; - ctx.fill(); - ctx.globalAlpha = 1.0; - } - - // Draw linear features - const sortedFeatures = [...terrain.features].sort((a, b) => { - const ta = getTerrainType(a.terrainId); - const tb = getTerrainType(b.terrainId); - return (ta?.zIndex ?? 0) - (tb?.zIndex ?? 0); - }); - - for (const feature of sortedFeatures) { - const type = getTerrainType(feature.terrainId); - if (!type) continue; - - const edges = connectedEdges(feature.edgeMask); - if (edges.length === 0) continue; - - ctx.strokeStyle = type.color; - ctx.lineWidth = Math.max(1, size / 8); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.globalAlpha = 0.9; - - if (feature.terrainId === 'road') { - ctx.setLineDash([size / 4, size / 6]); - } else { - ctx.setLineDash([]); - } - - if (feature.terrainId === 'coastline') { - // Coastline: draw along hex edges - ctx.lineWidth = Math.max(2, size / 5); - for (const edge of edges) { - const v1 = vertices[edge]; - const v2 = vertices[(edge + 1) % 6]; - ctx.beginPath(); - ctx.moveTo(v1.x, v1.y); - ctx.lineTo(v2.x, v2.y); - ctx.stroke(); - } - } else if (edges.length === 1) { - // Dead-end: edge midpoint to center - const mp = geom.edgeMidpoints[edges[0]]; - ctx.beginPath(); - ctx.moveTo(mp.x, mp.y); - ctx.lineTo(cx, cy); - ctx.stroke(); - // Terminus dot - ctx.fillStyle = type.color; - ctx.beginPath(); - ctx.arc(cx, cy, Math.max(1, size / 10), 0, Math.PI * 2); - ctx.fill(); - } else { - // Connect all edges through center with bezier curves - for (const edge of edges) { - const mp = geom.edgeMidpoints[edge]; - ctx.beginPath(); - ctx.moveTo(mp.x, mp.y); - // Bezier through ~halfway to center for a slight curve - const cpX = (mp.x + cx) / 2; - const cpY = (mp.y + cy) / 2; - ctx.quadraticCurveTo(cpX, cpY, cx, cy); - ctx.stroke(); - } - } - - ctx.setLineDash([]); - ctx.globalAlpha = 1.0; - } - - // Grid outline - if (this._showGrid && size > 4) { - ctx.beginPath(); - ctx.moveTo(vertices[0].x, vertices[0].y); - for (let i = 1; i < 6; i++) { - ctx.lineTo(vertices[i].x, vertices[i].y); - } - ctx.closePath(); - ctx.strokeStyle = 'rgba(0,0,0,0.25)'; - ctx.lineWidth = 0.5; - ctx.stroke(); - } - - // Selection highlight - if ( - this._selectedHex && - this._selectedHex.q === coord.q && - this._selectedHex.r === coord.r - ) { - ctx.beginPath(); - ctx.moveTo(vertices[0].x, vertices[0].y); - for (let i = 1; i < 6; i++) { - ctx.lineTo(vertices[i].x, vertices[i].y); - } - ctx.closePath(); - ctx.strokeStyle = '#fff'; - ctx.lineWidth = Math.max(1, size / 10); - ctx.stroke(); - ctx.strokeStyle = '#000'; - ctx.lineWidth = Math.max(0.5, size / 20); - ctx.stroke(); - } - } } diff --git a/src/svg/renderer.ts b/src/svg/renderer.ts new file mode 100644 index 0000000..ff647e0 --- /dev/null +++ b/src/svg/renderer.ts @@ -0,0 +1,521 @@ +/** + * Canvas-based hex renderer with terrain textures and linear feature routing. + * + * This module handles all visual rendering of hex tiles: + * - Area fill + texture patterns (trees, waves, peaks, etc.) + * - Linear feature bezier curves (roads, rivers, coastlines) + * - Grid outlines and selection highlights + */ + +import type { HexTerrain, HexGeometry, PixelCoord } from '../../core/types.js'; +import { HexEdge } from '../../core/types.js'; +import { getTerrainType } from '../../core/terrain.js'; +import { connectedEdges } from '../../core/edge-connectivity.js'; + +// --- Area texture renderers --- + +type TextureRenderer = ( + ctx: CanvasRenderingContext2D, + geom: HexGeometry, + color: string, +) => void; + +function drawTreeSymbols(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void { + const { cx, cy, size } = geom; + const s = size * 0.18; + const spacing = size * 0.42; + + // Place trees in a rough grid clipped to the hex + ctx.save(); + clipToHex(ctx, geom); + + const offsets = [ + { x: 0, y: 0 }, + { x: -spacing, y: -spacing * 0.7 }, + { x: spacing, y: -spacing * 0.7 }, + { x: -spacing * 0.5, y: spacing * 0.6 }, + { x: spacing * 0.5, y: spacing * 0.6 }, + { x: 0, y: -spacing * 1.2 }, + { x: -spacing, y: spacing * 0.2 }, + { x: spacing, y: spacing * 0.2 }, + ]; + + for (const off of offsets) { + const tx = cx + off.x; + const ty = cy + off.y; + // Simple triangle tree + ctx.beginPath(); + ctx.moveTo(tx, ty - s * 1.3); + ctx.lineTo(tx - s, ty + s * 0.4); + ctx.lineTo(tx + s, ty + s * 0.4); + ctx.closePath(); + ctx.fillStyle = lighten(color, 15); + ctx.fill(); + // Trunk + ctx.fillStyle = '#4a3520'; + ctx.fillRect(tx - s * 0.15, ty + s * 0.4, s * 0.3, s * 0.5); + } + + ctx.restore(); +} + +function drawWavePattern(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void { + const { cx, cy, size } = geom; + ctx.save(); + clipToHex(ctx, geom); + + const lineColor = lighten(color, 20); + ctx.strokeStyle = lineColor; + ctx.lineWidth = Math.max(0.5, size * 0.02); + ctx.globalAlpha = 0.5; + + const spacing = size * 0.3; + const amplitude = size * 0.06; + const waveLen = size * 0.25; + + for (let row = -3; row <= 3; row++) { + const baseY = cy + row * spacing; + ctx.beginPath(); + for (let dx = -size * 1.2; dx <= size * 1.2; dx += 2) { + const x = cx + dx; + const y = baseY + Math.sin((dx / waveLen) * Math.PI * 2) * amplitude; + if (dx === -size * 1.2) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + ctx.globalAlpha = 1; + ctx.restore(); +} + +function drawMountainPeaks(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void { + const { cx, cy, size } = geom; + ctx.save(); + clipToHex(ctx, geom); + + const peakColor = lighten(color, 25); + const shadowColor = darken(color, 15); + const s = size * 0.25; + + const peaks = [ + { x: cx, y: cy - size * 0.1, scale: 1.2 }, + { x: cx - size * 0.35, y: cy + size * 0.15, scale: 0.8 }, + { x: cx + size * 0.35, y: cy + size * 0.2, scale: 0.9 }, + ]; + + for (const peak of peaks) { + const ps = s * peak.scale; + // Shadow side + ctx.beginPath(); + ctx.moveTo(peak.x, peak.y - ps * 1.2); + ctx.lineTo(peak.x + ps * 0.9, peak.y + ps * 0.5); + ctx.lineTo(peak.x, peak.y + ps * 0.5); + ctx.closePath(); + ctx.fillStyle = shadowColor; + ctx.fill(); + // Light side + ctx.beginPath(); + ctx.moveTo(peak.x, peak.y - ps * 1.2); + ctx.lineTo(peak.x - ps * 0.9, peak.y + ps * 0.5); + ctx.lineTo(peak.x, peak.y + ps * 0.5); + ctx.closePath(); + ctx.fillStyle = peakColor; + ctx.fill(); + // Snow cap + ctx.beginPath(); + ctx.moveTo(peak.x, peak.y - ps * 1.2); + ctx.lineTo(peak.x - ps * 0.25, peak.y - ps * 0.6); + ctx.lineTo(peak.x + ps * 0.25, peak.y - ps * 0.6); + ctx.closePath(); + ctx.fillStyle = '#e8e8e8'; + ctx.fill(); + } + + ctx.restore(); +} + +function drawHillContours(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void { + const { cx, cy, size } = geom; + ctx.save(); + clipToHex(ctx, geom); + + ctx.strokeStyle = darken(color, 15); + ctx.lineWidth = Math.max(0.5, size * 0.025); + ctx.globalAlpha = 0.6; + + // Concentric arcs suggesting rounded hills + for (let i = 0; i < 3; i++) { + const r = size * (0.2 + i * 0.2); + const offy = -size * 0.1; + ctx.beginPath(); + ctx.arc(cx, cy + offy, r, Math.PI * 1.15, Math.PI * 1.85); + ctx.stroke(); + } + + // Second hill + for (let i = 0; i < 2; i++) { + const r = size * (0.15 + i * 0.18); + ctx.beginPath(); + ctx.arc(cx + size * 0.3, cy + size * 0.2, r, Math.PI * 1.1, Math.PI * 1.9); + ctx.stroke(); + } + + ctx.globalAlpha = 1; + ctx.restore(); +} + +function drawFarmlandHatch(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void { + const { cx, cy, size } = geom; + ctx.save(); + clipToHex(ctx, geom); + + ctx.strokeStyle = darken(color, 20); + ctx.lineWidth = Math.max(0.3, size * 0.015); + ctx.globalAlpha = 0.4; + + const spacing = size * 0.2; + // Parallel lines (field rows) + for (let i = -6; i <= 6; i++) { + const y = cy + i * spacing; + ctx.beginPath(); + ctx.moveTo(cx - size * 1.2, y); + ctx.lineTo(cx + size * 1.2, y); + ctx.stroke(); + } + + ctx.globalAlpha = 1; + ctx.restore(); +} + +function drawSettlementSymbol(ctx: CanvasRenderingContext2D, geom: HexGeometry, color: string): void { + const { cx, cy, size } = geom; + ctx.save(); + clipToHex(ctx, geom); + + const s = size * 0.15; + const buildingColor = lighten(color, 20); + const roofColor = darken(color, 10); + + // Central building + ctx.fillStyle = buildingColor; + ctx.fillRect(cx - s * 1.2, cy - s * 0.3, s * 2.4, s * 1.5); + // Roof + ctx.beginPath(); + ctx.moveTo(cx - s * 1.5, cy - s * 0.3); + ctx.lineTo(cx, cy - s * 1.5); + ctx.lineTo(cx + s * 1.5, cy - s * 0.3); + ctx.closePath(); + ctx.fillStyle = roofColor; + ctx.fill(); + + // Side buildings + for (const dx of [-size * 0.3, size * 0.3]) { + ctx.fillStyle = buildingColor; + ctx.fillRect(cx + dx - s * 0.6, cy + s * 0.3, s * 1.2, s * 1.0); + ctx.beginPath(); + ctx.moveTo(cx + dx - s * 0.8, cy + s * 0.3); + ctx.lineTo(cx + dx, cy - s * 0.3); + ctx.lineTo(cx + dx + s * 0.8, cy + s * 0.3); + ctx.closePath(); + ctx.fillStyle = roofColor; + ctx.fill(); + } + + ctx.restore(); +} + +const TEXTURE_RENDERERS: Record = { + forest: drawTreeSymbols, + ocean: drawWavePattern, + lake: drawWavePattern, + mountains: drawMountainPeaks, + hills: drawHillContours, + farmland: drawFarmlandHatch, + settlement: drawSettlementSymbol, +}; + +// --- Linear feature rendering --- + +/** + * Route linear features through the hex with paired bezier curves. + * Edges are paired: if 2 edges, one curve from A to B through center. + * If odd number, last edge is a dead-end to center. + */ +function drawLinearFeature( + ctx: CanvasRenderingContext2D, + geom: HexGeometry, + terrainId: string, + edges: HexEdge[], + color: string, + size: number, +): void { + if (edges.length === 0) return; + + const { cx, cy, edgeMidpoints, vertices } = geom; + + if (terrainId === 'coastline') { + drawCoastline(ctx, vertices, edges, color, size); + return; + } + + const isRoad = terrainId === 'road'; + const isRiver = terrainId === 'river'; + + ctx.strokeStyle = color; + ctx.lineWidth = isRiver ? Math.max(2, size / 6) : Math.max(1, size / 8); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + if (isRoad) { + ctx.setLineDash([size / 4, size / 6]); + } else { + ctx.setLineDash([]); + } + + // Pair edges for routing + const pairs = pairEdges(edges); + + for (const pair of pairs) { + if (pair.length === 2) { + // Two edges: smooth bezier through center + const p1 = edgeMidpoints[pair[0]]; + const p2 = edgeMidpoints[pair[1]]; + drawBezierRoute(ctx, p1, p2, cx, cy, size, isRiver); + } else { + // Dead-end: edge midpoint to center + const mp = edgeMidpoints[pair[0]]; + ctx.beginPath(); + ctx.moveTo(mp.x, mp.y); + ctx.lineTo(cx, cy); + ctx.stroke(); + // Terminus dot + ctx.setLineDash([]); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(cx, cy, Math.max(1.5, size / 10), 0, Math.PI * 2); + ctx.fill(); + if (isRoad) ctx.setLineDash([size / 4, size / 6]); + } + } + + ctx.setLineDash([]); +} + +/** + * Pair edges into routes. Try to pair opposite or near-opposite edges first. + * Returns array of pairs (2-element arrays) and singletons (dead-ends). + */ +function pairEdges(edges: HexEdge[]): HexEdge[][] { + if (edges.length === 0) return []; + if (edges.length === 1) return [[edges[0]]]; + + const remaining = new Set(edges); + const pairs: HexEdge[][] = []; + + // Try to pair opposite edges first (straight-through) + const opposites: [HexEdge, HexEdge][] = [ + [HexEdge.NE, HexEdge.SW], + [HexEdge.E, HexEdge.W], + [HexEdge.SE, HexEdge.NW], + ]; + + for (const [a, b] of opposites) { + if (remaining.has(a) && remaining.has(b)) { + pairs.push([a, b]); + remaining.delete(a); + remaining.delete(b); + } + } + + // Pair remaining edges by proximity (adjacent pairs make tight bends) + const rest = Array.from(remaining); + while (rest.length >= 2) { + pairs.push([rest.shift()!, rest.shift()!]); + } + if (rest.length === 1) { + pairs.push([rest[0]]); + } + + return pairs; +} + +function drawBezierRoute( + ctx: CanvasRenderingContext2D, + p1: PixelCoord, + p2: PixelCoord, + cx: number, + cy: number, + size: number, + wobble: boolean, +): void { + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + + if (wobble) { + // River: slight sinusoidal wobble via cubic bezier + const mx = (p1.x + p2.x) / 2; + const my = (p1.y + p2.y) / 2; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const perpX = -dy * 0.15; + const perpY = dx * 0.15; + + ctx.bezierCurveTo( + (p1.x + cx) / 2 + perpX, (p1.y + cy) / 2 + perpY, + (p2.x + cx) / 2 - perpX, (p2.y + cy) / 2 - perpY, + p2.x, p2.y, + ); + } else { + // Standard: quadratic through center + ctx.quadraticCurveTo(cx, cy, p2.x, p2.y); + } + + ctx.stroke(); +} + +function drawCoastline( + ctx: CanvasRenderingContext2D, + vertices: PixelCoord[], + edges: HexEdge[], + color: string, + size: number, +): void { + ctx.strokeStyle = color; + ctx.lineWidth = Math.max(2, size / 5); + ctx.lineCap = 'round'; + ctx.setLineDash([]); + + // Coastline runs along hex edges between vertices + // Edge i runs from vertex i to vertex (i+1)%6 + // But our edges are reordered — we need the vertex indices for each HexEdge + // HexEdge NE(0)=vertex pair [5,0], E(1)=[0,1], SE(2)=[1,2], + // SW(3)=[2,3], W(4)=[3,4], NW(5)=[4,5] + const edgeToVertices: Record = { + [HexEdge.NE]: [5, 0], + [HexEdge.E]: [0, 1], + [HexEdge.SE]: [1, 2], + [HexEdge.SW]: [2, 3], + [HexEdge.W]: [3, 4], + [HexEdge.NW]: [4, 5], + }; + + for (const edge of edges) { + const [vi1, vi2] = edgeToVertices[edge]; + const v1 = vertices[vi1]; + const v2 = vertices[vi2]; + ctx.beginPath(); + ctx.moveTo(v1.x, v1.y); + ctx.lineTo(v2.x, v2.y); + ctx.stroke(); + } +} + +// --- Main render function --- + +export function renderHex( + ctx: CanvasRenderingContext2D, + geom: HexGeometry, + terrain: HexTerrain, + options: { + opacity: number; + showGrid: boolean; + selected: boolean; + }, +): void { + const { size } = geom; + if (size < 2) return; + + // 1. Area fill + const baseType = getTerrainType(terrain.base); + if (baseType) { + clipToHex(ctx, geom); + ctx.globalAlpha = options.opacity; + ctx.fillStyle = baseType.color; + ctx.fill(); + ctx.globalAlpha = 1; + + // 2. Area texture pattern + const textureRenderer = TEXTURE_RENDERERS[terrain.base]; + if (textureRenderer && size > 10) { + ctx.globalAlpha = options.opacity; + textureRenderer(ctx, geom, baseType.color); + ctx.globalAlpha = 1; + } + } + + // 3. Linear features (sorted by zIndex) + 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.globalAlpha = 0.9; + drawLinearFeature(ctx, geom, feature.terrainId, edges, type.color, size); + ctx.globalAlpha = 1; + } + + // 4. Grid outline + if (options.showGrid && size > 4) { + drawHexOutline(ctx, geom, 'rgba(0,0,0,0.25)', 0.5); + } + + // 5. Selection highlight + if (options.selected) { + drawHexOutline(ctx, geom, '#fff', Math.max(1, size / 10)); + drawHexOutline(ctx, geom, '#000', Math.max(0.5, size / 20)); + } +} + +// --- Helpers --- + +function clipToHex(ctx: CanvasRenderingContext2D, geom: HexGeometry): void { + ctx.beginPath(); + const { vertices } = geom; + 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(); +} + +function drawHexOutline( + ctx: CanvasRenderingContext2D, + geom: HexGeometry, + color: string, + lineWidth: number, +): void { + const { vertices } = geom; + 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 = color; + ctx.lineWidth = lineWidth; + ctx.stroke(); +} + +function lighten(hex: string, percent: number): string { + return adjustColor(hex, percent); +} + +function darken(hex: string, percent: number): string { + return adjustColor(hex, -percent); +} + +function adjustColor(hex: string, percent: number): string { + const num = parseInt(hex.replace('#', ''), 16); + const r = Math.min(255, Math.max(0, ((num >> 16) & 0xff) + Math.round(2.55 * percent))); + const g = Math.min(255, Math.max(0, ((num >> 8) & 0xff) + Math.round(2.55 * percent))); + const b = Math.min(255, Math.max(0, (num & 0xff) + Math.round(2.55 * percent))); + return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`; +} diff --git a/vite.config.ts b/vite.config.ts index e298bce..4d98045 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ }, server: { port: 5173, + allowedHosts: ['hexifyer.davoryn.de'], proxy: { '/api': { target: 'http://localhost:3001',