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
public/tiles
Symbolic link
1
public/tiles
Symbolic link
@@ -0,0 +1 @@
|
||||
/var/www/kiepenkerl/tiles
|
||||
87
src/main.ts
87
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();
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
521
src/svg/renderer.ts
Normal 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')}`;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
allowedHosts: ['hexifyer.davoryn.de'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
|
||||
Reference in New Issue
Block a user