Fix coastline: 2-edge only, explicit waterSide, flip control

Coastline analysis and fix:
- Coastlines now only show 2-edge patterns (straight, wide curve,
  sharp bend) — Y-junctions and crossroads removed since a coast
  always divides exactly two sides
- Added waterSide property to HexFeature (1=CW, -1=CCW) — stored
  explicitly instead of computed by broken center-distance heuristic
  that flipped water/land at rotation midpoint
- F key or "Flip water side" button in palette to toggle which side
  is water before placing
- Inspector shows flip button (swap arrows) for placed coastlines
- Rotation preserves waterSide — no more land/water swaps
- Separate pattern lists: GENERAL_PATTERNS for road/river,
  COASTLINE_PATTERNS for coastline (core/tile-patterns.ts)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-04-07 11:17:56 +00:00
parent b6bd66cd9e
commit ce95930b87
9 changed files with 176 additions and 89 deletions

View File

@@ -42,13 +42,14 @@ export class HexMap {
}
/** Add or update a linear feature on a hex */
setFeature(coord: AxialCoord, terrainId: string, edgeMask: EdgeMask): void {
setFeature(coord: AxialCoord, terrainId: string, edgeMask: EdgeMask, waterSide?: 1 | -1): void {
const terrain = this.getTerrain(coord);
const existing = terrain.features.find(f => f.terrainId === terrainId);
if (existing) {
existing.edgeMask = edgeMask;
if (waterSide !== undefined) existing.waterSide = waterSide;
} else {
terrain.features.push({ terrainId, edgeMask });
terrain.features.push({ terrainId, edgeMask, ...(waterSide !== undefined ? { waterSide } : {}) });
}
// Remove features with empty mask
terrain.features = terrain.features.filter(f => f.edgeMask !== 0);

View File

@@ -1,84 +1,107 @@
/**
* Canonical tile patterns for linear features.
* Each pattern is defined by a base edge mask and a human-readable name.
* Patterns can be rotated by 60° increments to produce all placements.
* Each pattern has a base edge mask and can be rotated in 60° increments.
* Coastlines only use 2-edge patterns and have an additional waterSide property.
*/
import type { EdgeMask } from './types.js';
import { HexEdge } from './types.js';
import { edgeMask, rotateMask, edgeCount } from './edge-connectivity.js';
import { edgeMask, rotateMask } from './edge-connectivity.js';
export interface TilePattern {
id: string;
name: string;
baseMask: EdgeMask;
/** Number of distinct rotations (accounting for symmetry) */
/** Max distinct rotations (accounting for symmetry) */
rotations: number;
/** Number of connected edges */
edgeCount: number;
}
/** All canonical patterns for linear features */
export const TILE_PATTERNS: TilePattern[] = [
/** General patterns for road/river (all edge counts) */
export const GENERAL_PATTERNS: TilePattern[] = [
{
id: 'dead-end',
name: 'Dead End',
baseMask: edgeMask(HexEdge.E),
rotations: 6,
edgeCount: 1,
},
{
id: 'straight',
name: 'Straight',
baseMask: edgeMask(HexEdge.E, HexEdge.W),
rotations: 3, // Symmetric: E-W = same as W-E
rotations: 3,
edgeCount: 2,
},
{
id: 'gentle-curve',
name: 'Wide Curve',
baseMask: edgeMask(HexEdge.E, HexEdge.SW),
rotations: 6,
edgeCount: 2,
},
{
id: 'sharp-bend',
name: 'Sharp Bend',
baseMask: edgeMask(HexEdge.E, HexEdge.SE),
rotations: 6,
edgeCount: 2,
},
{
id: 'y-junction',
name: 'Y Split',
baseMask: edgeMask(HexEdge.NE, HexEdge.SE, HexEdge.W),
rotations: 6,
edgeCount: 3,
},
{
id: 'y-junction-wide',
name: 'Y Wide',
baseMask: edgeMask(HexEdge.NE, HexEdge.SW, HexEdge.SE),
rotations: 6,
edgeCount: 3,
},
{
id: 'crossroads',
name: 'Cross',
baseMask: edgeMask(HexEdge.NE, HexEdge.E, HexEdge.SW, HexEdge.W),
rotations: 3,
edgeCount: 4,
},
];
/** Coastline patterns — only 2-edge (a coast is always a line from A to B) */
export const COASTLINE_PATTERNS: TilePattern[] = [
{
id: 'straight',
name: 'Straight',
baseMask: edgeMask(HexEdge.E, HexEdge.W),
rotations: 3,
edgeCount: 2,
},
{
id: 'gentle-curve',
name: 'Wide Curve',
baseMask: edgeMask(HexEdge.E, HexEdge.SW),
rotations: 6,
edgeCount: 2,
},
{
id: 'sharp-bend',
name: 'Sharp Bend',
baseMask: edgeMask(HexEdge.E, HexEdge.SE),
rotations: 6,
edgeCount: 2,
},
];
/**
* Get all unique rotations for a pattern.
* Returns array of { mask, rotation } where rotation is the number of 60° steps.
* Get the appropriate patterns for a terrain type.
*/
export function getPatternRotations(pattern: TilePattern): Array<{ mask: EdgeMask; rotation: number }> {
const seen = new Set<EdgeMask>();
const result: Array<{ mask: EdgeMask; rotation: number }> = [];
for (let r = 0; r < 6; r++) {
const mask = rotateMask(pattern.baseMask, r);
if (!seen.has(mask)) {
seen.add(mask);
result.push({ mask, rotation: r });
}
}
return result;
export function getPatternsForTerrain(terrainId: string): TilePattern[] {
if (terrainId === 'coastline') return COASTLINE_PATTERNS;
return GENERAL_PATTERNS;
}
/**

View File

@@ -69,6 +69,13 @@ export interface TerrainType {
export interface HexFeature {
terrainId: string;
edgeMask: EdgeMask;
/**
* For coastline only: which side of the curve is water.
* 1 = clockwise side (right side looking from first edge to second)
* -1 = counter-clockwise side (left side)
* Undefined for non-coastline features.
*/
waterSide?: 1 | -1;
}
/** Complete terrain state for a single hex */

View File

@@ -58,9 +58,20 @@ createToolbar(toolbar, (mode) => {
const hexInspectorUI = createHexInspector(hexInspector, (event: FeatureRotateEvent) => {
if (event.newMask === 0) {
hexMap.removeFeature(event.coord, event.terrainId);
} else if (event.flipWater && event.terrainId === 'coastline') {
// Flip water side on coastline
const terrain = hexMap.getTerrain(event.coord);
const feature = terrain.features.find(f => f.terrainId === 'coastline');
if (feature) {
const newSide: 1 | -1 = (feature.waterSide ?? 1) === 1 ? -1 : 1;
hexMap.setFeature(event.coord, event.terrainId, event.newMask, newSide);
}
} else {
hexMap.setFeature(event.coord, event.terrainId, event.newMask);
// Re-enforce constraints after rotation
// Rotate: preserve waterSide
const terrain = hexMap.getTerrain(event.coord);
const feature = terrain.features.find(f => f.terrainId === event.terrainId);
const waterSide = feature?.waterSide;
hexMap.setFeature(event.coord, event.terrainId, event.newMask, waterSide);
const actions = enforceEdgeConstraints(hexMap, event.coord, event.terrainId, event.newMask);
applyConstraintActions(hexMap, actions);
}
@@ -153,8 +164,9 @@ function handleFeaturePlacement(event: HexClickEvent) {
if (!selectedPattern) return;
const coord = event.coord;
// Place the selected pattern on this hex
hexMap.setFeature(coord, selectedPattern.terrainId, selectedPattern.mask);
// Place the selected pattern on this hex (with waterSide for coastline)
const waterSide = selectedPattern.terrainId === 'coastline' ? selectedPattern.waterSide : undefined;
hexMap.setFeature(coord, selectedPattern.terrainId, selectedPattern.mask, waterSide);
// Enforce edge constraints on neighbors
const actions = enforceEdgeConstraints(hexMap, coord, selectedPattern.terrainId, selectedPattern.mask);
@@ -187,6 +199,8 @@ document.addEventListener('keydown', (e) => {
terrainPickerUI.rotateCCW();
} else if (e.key === 'e' || e.key === 'E') {
terrainPickerUI.rotateCW();
} else if (e.key === 'f' || e.key === 'F') {
terrainPickerUI.flipWaterSide();
}
});

View File

@@ -273,3 +273,24 @@ html, body {
background: #4a2020;
border-color: #6a3030;
}
.palette-controls {
margin-top: 4px;
}
.palette-flip-btn {
width: 100%;
padding: 4px 8px;
margin-top: 4px;
border: 1px solid #2a2a4a;
border-radius: 4px;
background: #1a1a2e;
color: #ccc;
cursor: pointer;
font-size: 11px;
}
.palette-flip-btn:hover {
background: #2a2a4a;
color: #fff;
}

View File

@@ -249,13 +249,14 @@ function drawLinearFeature(
edges: HexEdge[],
color: string,
size: number,
waterSide: 1 | -1 = 1,
): void {
if (edges.length === 0) return;
const { cx, cy, edgeMidpoints, vertices } = geom;
if (terrainId === 'coastline') {
drawCoastlineFeature(ctx, geom, edges, size);
drawCoastlineFeature(ctx, geom, edges, waterSide, size);
return;
}
@@ -375,14 +376,15 @@ function drawBezierRoute(
}
/**
* Coastline: routes edge-to-edge like road/river, but fills one side with water.
* The water side is the side AWAY from the hex center (the "outside" of the curve).
* We determine this using a cross-product test on the bezier midpoint.
* Coastline: routes edge-to-edge, fills one side with water.
* waterSide is stored on the feature: 1 = CW side, -1 = CCW side.
* This is stable across rotations — no heuristic needed.
*/
function drawCoastlineFeature(
ctx: CanvasRenderingContext2D,
geom: HexGeometry,
edges: HexEdge[],
waterSide: 1 | -1,
size: number,
): void {
const { cx, cy, edgeMidpoints, vertices } = geom;
@@ -397,37 +399,13 @@ function drawCoastlineFeature(
if (pair.length === 2) {
const p1 = edgeMidpoints[pair[0]];
const p2 = edgeMidpoints[pair[1]];
// Control point for the quadratic bezier (through center)
const cp = { x: cx, y: cy };
// Determine which side of the curve is "away" from center
// Sample the bezier midpoint, then offset perpendicular
const bezMidX = 0.25 * p1.x + 0.5 * cp.x + 0.25 * p2.x;
const bezMidY = 0.25 * p1.y + 0.5 * cp.y + 0.25 * p2.y;
// Tangent at midpoint (derivative of quadratic bezier at t=0.5)
const tanX = (p2.x - p1.x);
const tanY = (p2.y - p1.y);
// Perpendicular (rotated 90° CW)
const perpX = tanY;
const perpY = -tanX;
// The "water side" offset: we pick the side that is FARTHER from center
const testX = bezMidX + perpX * 0.1;
const testY = bezMidY + perpY * 0.1;
const distFromCenter = Math.hypot(testX - cx, testY - cy);
const distOther = Math.hypot(bezMidX - perpX * 0.1 - cx, bezMidY - perpY * 0.1 - cy);
const waterSide = distFromCenter > distOther ? 1 : -1;
// Fill the water side: build a path from the curve to the hex boundary on that side
// Fill the water side using the explicit waterSide value
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.quadraticCurveTo(cp.x, cp.y, p2.x, p2.y);
// Walk hex vertices on the water side from p2's edge to p1's edge
const waterVerts = getVerticesOnSide(pair[0], pair[1], waterSide, vertices);
for (const v of waterVerts) {
ctx.lineTo(v.x, v.y);
@@ -445,15 +423,6 @@ function drawCoastlineFeature(
ctx.strokeStyle = coastColor;
ctx.lineWidth = Math.max(1.5, size / 10);
ctx.stroke();
} else {
// Dead-end: just draw as a line to center
const mp = edgeMidpoints[pair[0]];
ctx.beginPath();
ctx.moveTo(mp.x, mp.y);
ctx.lineTo(cx, cy);
ctx.strokeStyle = coastColor;
ctx.lineWidth = Math.max(1.5, size / 10);
ctx.stroke();
}
}
@@ -548,7 +517,7 @@ export function renderHex(
if (edges.length === 0) continue;
ctx.globalAlpha = 0.9;
drawLinearFeature(ctx, geom, feature.terrainId, edges, type.color, size);
drawLinearFeature(ctx, geom, feature.terrainId, edges, type.color, size, feature.waterSide ?? 1);
ctx.globalAlpha = 1;
}

View File

@@ -16,6 +16,8 @@ export interface FeatureRotateEvent {
coord: AxialCoord;
terrainId: string;
newMask: number;
/** For coastline: flip water side */
flipWater?: boolean;
}
export function createHexInspector(
@@ -97,6 +99,23 @@ export function createHexInspector(
});
});
// Flip water side button for coastlines
if (feature.terrainId === 'coastline') {
const flipBtn = document.createElement('button');
flipBtn.className = 'rotate-btn';
flipBtn.textContent = '\u21C4'; // ⇄
flipBtn.title = 'Flip water side';
flipBtn.addEventListener('click', () => {
onRotateFeature({
coord: currentCoord!,
terrainId: feature.terrainId,
newMask: feature.edgeMask,
flipWater: true,
});
});
controls.appendChild(flipBtn);
}
const removeBtn = document.createElement('button');
removeBtn.className = 'rotate-btn remove-btn';
removeBtn.textContent = '\u2715'; // ✕

View File

@@ -93,5 +93,6 @@ export function createTerrainPicker(
getSelectedArea: () => selectedArea,
rotateCW() { palette?.rotateCW(); },
rotateCCW() { palette?.rotateCCW(); },
flipWaterSide() { palette?.flipWaterSide(); },
};
}

View File

@@ -1,6 +1,6 @@
import type { TerrainType, EdgeMask } from '../../core/types.js';
import { getLinearTerrains, getTerrainType } from '../../core/terrain.js';
import { TILE_PATTERNS, rotatePattern } from '../../core/tile-patterns.js';
import { getLinearTerrains } from '../../core/terrain.js';
import { getPatternsForTerrain, rotatePattern } from '../../core/tile-patterns.js';
import { computeHexGeometry } from '../../core/coords.js';
import { renderHex } from '../svg/renderer.js';
@@ -9,6 +9,8 @@ export interface SelectedPattern {
mask: EdgeMask;
rotation: number;
patternId: string;
/** For coastline: which side is water (1=CW, -1=CCW) */
waterSide: 1 | -1;
}
export function createTilePalette(
@@ -18,9 +20,9 @@ export function createTilePalette(
getSelected: () => SelectedPattern | null;
rotateCW: () => void;
rotateCCW: () => void;
flipWaterSide: () => void;
} {
let selected: SelectedPattern | null = null;
let currentTerrainId: string | null = null;
function render() {
container.innerHTML = '';
@@ -28,6 +30,8 @@ export function createTilePalette(
const linearTerrains = getLinearTerrains();
for (const terrain of linearTerrains) {
const patterns = getPatternsForTerrain(terrain.id);
// Terrain header
const header = document.createElement('div');
header.className = 'palette-terrain-header';
@@ -38,7 +42,7 @@ export function createTilePalette(
const grid = document.createElement('div');
grid.className = 'palette-grid';
for (const pattern of TILE_PATTERNS) {
for (const pattern of patterns) {
const item = document.createElement('div');
item.className = 'palette-item';
@@ -53,10 +57,14 @@ export function createTilePalette(
canvas.height = previewSize * 2 + 4;
const mask = isSelected && selected
? rotatePattern(pattern.baseMask, selected.rotation)
? selected.mask
: pattern.baseMask;
const waterSide = isSelected && selected
? selected.waterSide
: 1;
renderPreview(canvas, terrain, mask, previewSize);
renderPreview(canvas, terrain, mask, previewSize,
terrain.id === 'coastline' ? waterSide : undefined);
item.appendChild(canvas);
// Label
@@ -67,17 +75,15 @@ export function createTilePalette(
item.addEventListener('click', () => {
if (isSelected) {
// Deselect
selected = null;
currentTerrainId = null;
} else {
selected = {
terrainId: terrain.id,
mask: pattern.baseMask,
rotation: 0,
patternId: pattern.id,
waterSide: 1,
};
currentTerrainId = terrain.id;
}
onSelect(selected);
render();
@@ -89,33 +95,53 @@ export function createTilePalette(
container.appendChild(grid);
}
// Rotation hint if something is selected
// Controls when something is selected
if (selected) {
const controls = document.createElement('div');
controls.className = 'palette-controls';
const hint = document.createElement('div');
hint.className = 'palette-hint';
hint.textContent = 'Q/E or scroll to rotate before placing';
container.appendChild(hint);
hint.textContent = 'Q/E or scroll: rotate';
controls.appendChild(hint);
if (selected.terrainId === 'coastline') {
const flipBtn = document.createElement('button');
flipBtn.className = 'palette-flip-btn';
flipBtn.textContent = 'Flip water side (F)';
flipBtn.addEventListener('click', () => flipWaterSide());
controls.appendChild(flipBtn);
}
container.appendChild(controls);
}
}
function getBaseMask(): EdgeMask {
if (!selected) return 0;
return getPatternsForTerrain(selected.terrainId)
.find(p => p.id === selected!.patternId)!.baseMask;
}
function rotateCW() {
if (!selected) return;
selected.rotation = (selected.rotation + 1) % 6;
selected.mask = rotatePattern(
TILE_PATTERNS.find(p => p.id === selected!.patternId)!.baseMask,
selected.rotation,
);
selected.mask = rotatePattern(getBaseMask(), selected.rotation);
onSelect(selected);
render();
}
function rotateCCW() {
if (!selected) return;
selected.rotation = (selected.rotation + 5) % 6; // +5 = -1 mod 6
selected.mask = rotatePattern(
TILE_PATTERNS.find(p => p.id === selected!.patternId)!.baseMask,
selected.rotation,
);
selected.rotation = (selected.rotation + 5) % 6;
selected.mask = rotatePattern(getBaseMask(), selected.rotation);
onSelect(selected);
render();
}
function flipWaterSide() {
if (!selected || selected.terrainId !== 'coastline') return;
selected.waterSide = selected.waterSide === 1 ? -1 : 1;
onSelect(selected);
render();
}
@@ -126,6 +152,7 @@ export function createTilePalette(
getSelected: () => selected,
rotateCW,
rotateCCW,
flipWaterSide,
};
}
@@ -134,6 +161,7 @@ function renderPreview(
terrain: TerrainType,
mask: EdgeMask,
size: number,
waterSide?: 1 | -1,
): void {
const ctx = canvas.getContext('2d')!;
const cx = canvas.width / 2;
@@ -143,7 +171,11 @@ function renderPreview(
renderHex(ctx, geom, {
base: 'plains',
features: [{ terrainId: terrain.id, edgeMask: mask }],
features: [{
terrainId: terrain.id,
edgeMask: mask,
...(waterSide !== undefined ? { waterSide } : {}),
}],
}, {
opacity: 0.9,
showGrid: true,