diff --git a/core/hex-map.ts b/core/hex-map.ts index 4f97c56..ad2aa0b 100644 --- a/core/hex-map.ts +++ b/core/hex-map.ts @@ -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); diff --git a/core/tile-patterns.ts b/core/tile-patterns.ts index ebf7edb..05f080f 100644 --- a/core/tile-patterns.ts +++ b/core/tile-patterns.ts @@ -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(); - 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; } /** diff --git a/core/types.ts b/core/types.ts index 6ef4ba5..8d6456c 100644 --- a/core/types.ts +++ b/core/types.ts @@ -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 */ diff --git a/src/main.ts b/src/main.ts index 791516c..bac9997 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); } }); diff --git a/src/style/main.css b/src/style/main.css index b0da0dc..6bc6747 100644 --- a/src/style/main.css +++ b/src/style/main.css @@ -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; +} diff --git a/src/svg/renderer.ts b/src/svg/renderer.ts index 0b6e115..48f9399 100644 --- a/src/svg/renderer.ts +++ b/src/svg/renderer.ts @@ -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; } diff --git a/src/ui/hex-inspector.ts b/src/ui/hex-inspector.ts index 9789f7d..90da393 100644 --- a/src/ui/hex-inspector.ts +++ b/src/ui/hex-inspector.ts @@ -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'; // ✕ diff --git a/src/ui/terrain-picker.ts b/src/ui/terrain-picker.ts index 7f22bd2..58acc08 100644 --- a/src/ui/terrain-picker.ts +++ b/src/ui/terrain-picker.ts @@ -93,5 +93,6 @@ export function createTerrainPicker( getSelectedArea: () => selectedArea, rotateCW() { palette?.rotateCW(); }, rotateCCW() { palette?.rotateCCW(); }, + flipWaterSide() { palette?.flipWaterSide(); }, }; } diff --git a/src/ui/tile-palette.ts b/src/ui/tile-palette.ts index 0e44b2a..ffd9e9c 100644 --- a/src/ui/tile-palette.ts +++ b/src/ui/tile-palette.ts @@ -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,