Phase 2: Terrain textures, bezier routing, drag-paint, HTTPS

- svg/renderer.ts: Full Canvas rendering engine with terrain textures
  (trees for forest, waves for water, peaks for mountains, contour
  lines for hills, hatch for farmland, buildings for settlements)
- Linear features: paired-edge bezier routing (straight-through,
  curves, dead-ends), river wobble, proper coastline along hex edges
- Drag-paint: click-and-drag in Paint mode paints multiple hexes,
  disables map panning during paint gesture
- NGINX reverse proxy + Let's Encrypt cert for hexifyer.davoryn.de
- Refactored hex-layer.ts to delegate rendering to renderer module

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-04-07 10:41:24 +00:00
parent f302932ea8
commit 0e2903b789
6 changed files with 652 additions and 195 deletions

1
public/tiles Symbolic link
View File

@@ -0,0 +1 @@
/var/www/kiepenkerl/tiles

View File

@@ -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,23 +76,24 @@ function rebuildHexLayer(showGrid: boolean, opacity: number) {
reattachInteraction();
}
// --- Hex Interaction ---
let detachInteraction: (() => void) | null = null;
// --- Interaction handlers ---
function reattachInteraction() {
detachInteraction?.();
detachInteraction = attachHexInteraction(map, hexSize, origin, (event) => {
if (currentMode === 'select') {
function handleSelect(event: HexClickEvent) {
selectedHex = event.coord;
hexLayer.setSelectedHex(selectedHex);
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
} else if (currentMode === 'paint' && selectedTerrain) {
}
function handlePaint(event: HexClickEvent) {
if (!selectedTerrain) return;
hexMap.setBase(event.coord, selectedTerrain.id);
selectedHex = event.coord;
hexLayer.setSelectedHex(selectedHex);
hexLayer.redraw();
selectedHex = event.coord;
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
} else if (currentMode === 'feature' && selectedTerrain) {
}
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);
@@ -101,9 +102,7 @@ function reattachInteraction() {
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);
@@ -113,8 +112,26 @@ function reattachInteraction() {
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,
// 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();

View File

@@ -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();
};
}

View File

@@ -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();
}
}
}

521
src/svg/renderer.ts Normal file
View File

@@ -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<string, TextureRenderer> = {
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, [number, number]> = {
[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')}`;
}

View File

@@ -13,6 +13,7 @@ export default defineConfig({
},
server: {
port: 5173,
allowedHosts: ['hexifyer.davoryn.de'],
proxy: {
'/api': {
target: 'http://localhost:3001',