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