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

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