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:
@@ -42,13 +42,14 @@ export class HexMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Add or update a linear feature on a hex */
|
/** 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 terrain = this.getTerrain(coord);
|
||||||
const existing = terrain.features.find(f => f.terrainId === terrainId);
|
const existing = terrain.features.find(f => f.terrainId === terrainId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.edgeMask = edgeMask;
|
existing.edgeMask = edgeMask;
|
||||||
|
if (waterSide !== undefined) existing.waterSide = waterSide;
|
||||||
} else {
|
} else {
|
||||||
terrain.features.push({ terrainId, edgeMask });
|
terrain.features.push({ terrainId, edgeMask, ...(waterSide !== undefined ? { waterSide } : {}) });
|
||||||
}
|
}
|
||||||
// Remove features with empty mask
|
// Remove features with empty mask
|
||||||
terrain.features = terrain.features.filter(f => f.edgeMask !== 0);
|
terrain.features = terrain.features.filter(f => f.edgeMask !== 0);
|
||||||
|
|||||||
@@ -1,84 +1,107 @@
|
|||||||
/**
|
/**
|
||||||
* Canonical tile patterns for linear features.
|
* Canonical tile patterns for linear features.
|
||||||
* Each pattern is defined by a base edge mask and a human-readable name.
|
* Each pattern has a base edge mask and can be rotated in 60° increments.
|
||||||
* Patterns can be rotated by 60° increments to produce all placements.
|
* Coastlines only use 2-edge patterns and have an additional waterSide property.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EdgeMask } from './types.js';
|
import type { EdgeMask } from './types.js';
|
||||||
import { HexEdge } 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 {
|
export interface TilePattern {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
baseMask: EdgeMask;
|
baseMask: EdgeMask;
|
||||||
/** Number of distinct rotations (accounting for symmetry) */
|
/** Max distinct rotations (accounting for symmetry) */
|
||||||
rotations: number;
|
rotations: number;
|
||||||
|
/** Number of connected edges */
|
||||||
|
edgeCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All canonical patterns for linear features */
|
/** General patterns for road/river (all edge counts) */
|
||||||
export const TILE_PATTERNS: TilePattern[] = [
|
export const GENERAL_PATTERNS: TilePattern[] = [
|
||||||
{
|
{
|
||||||
id: 'dead-end',
|
id: 'dead-end',
|
||||||
name: 'Dead End',
|
name: 'Dead End',
|
||||||
baseMask: edgeMask(HexEdge.E),
|
baseMask: edgeMask(HexEdge.E),
|
||||||
rotations: 6,
|
rotations: 6,
|
||||||
|
edgeCount: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'straight',
|
id: 'straight',
|
||||||
name: 'Straight',
|
name: 'Straight',
|
||||||
baseMask: edgeMask(HexEdge.E, HexEdge.W),
|
baseMask: edgeMask(HexEdge.E, HexEdge.W),
|
||||||
rotations: 3, // Symmetric: E-W = same as W-E
|
rotations: 3,
|
||||||
|
edgeCount: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gentle-curve',
|
id: 'gentle-curve',
|
||||||
name: 'Wide Curve',
|
name: 'Wide Curve',
|
||||||
baseMask: edgeMask(HexEdge.E, HexEdge.SW),
|
baseMask: edgeMask(HexEdge.E, HexEdge.SW),
|
||||||
rotations: 6,
|
rotations: 6,
|
||||||
|
edgeCount: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sharp-bend',
|
id: 'sharp-bend',
|
||||||
name: 'Sharp Bend',
|
name: 'Sharp Bend',
|
||||||
baseMask: edgeMask(HexEdge.E, HexEdge.SE),
|
baseMask: edgeMask(HexEdge.E, HexEdge.SE),
|
||||||
rotations: 6,
|
rotations: 6,
|
||||||
|
edgeCount: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'y-junction',
|
id: 'y-junction',
|
||||||
name: 'Y Split',
|
name: 'Y Split',
|
||||||
baseMask: edgeMask(HexEdge.NE, HexEdge.SE, HexEdge.W),
|
baseMask: edgeMask(HexEdge.NE, HexEdge.SE, HexEdge.W),
|
||||||
rotations: 6,
|
rotations: 6,
|
||||||
|
edgeCount: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'y-junction-wide',
|
id: 'y-junction-wide',
|
||||||
name: 'Y Wide',
|
name: 'Y Wide',
|
||||||
baseMask: edgeMask(HexEdge.NE, HexEdge.SW, HexEdge.SE),
|
baseMask: edgeMask(HexEdge.NE, HexEdge.SW, HexEdge.SE),
|
||||||
rotations: 6,
|
rotations: 6,
|
||||||
|
edgeCount: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'crossroads',
|
id: 'crossroads',
|
||||||
name: 'Cross',
|
name: 'Cross',
|
||||||
baseMask: edgeMask(HexEdge.NE, HexEdge.E, HexEdge.SW, HexEdge.W),
|
baseMask: edgeMask(HexEdge.NE, HexEdge.E, HexEdge.SW, HexEdge.W),
|
||||||
rotations: 3,
|
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.
|
* Get the appropriate patterns for a terrain type.
|
||||||
* Returns array of { mask, rotation } where rotation is the number of 60° steps.
|
|
||||||
*/
|
*/
|
||||||
export function getPatternRotations(pattern: TilePattern): Array<{ mask: EdgeMask; rotation: number }> {
|
export function getPatternsForTerrain(terrainId: string): TilePattern[] {
|
||||||
const seen = new Set<EdgeMask>();
|
if (terrainId === 'coastline') return COASTLINE_PATTERNS;
|
||||||
const result: Array<{ mask: EdgeMask; rotation: number }> = [];
|
return GENERAL_PATTERNS;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ export interface TerrainType {
|
|||||||
export interface HexFeature {
|
export interface HexFeature {
|
||||||
terrainId: string;
|
terrainId: string;
|
||||||
edgeMask: EdgeMask;
|
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 */
|
/** Complete terrain state for a single hex */
|
||||||
|
|||||||
22
src/main.ts
22
src/main.ts
@@ -58,9 +58,20 @@ createToolbar(toolbar, (mode) => {
|
|||||||
const hexInspectorUI = createHexInspector(hexInspector, (event: FeatureRotateEvent) => {
|
const hexInspectorUI = createHexInspector(hexInspector, (event: FeatureRotateEvent) => {
|
||||||
if (event.newMask === 0) {
|
if (event.newMask === 0) {
|
||||||
hexMap.removeFeature(event.coord, event.terrainId);
|
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 {
|
} else {
|
||||||
hexMap.setFeature(event.coord, event.terrainId, event.newMask);
|
// Rotate: preserve waterSide
|
||||||
// Re-enforce constraints after rotation
|
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);
|
const actions = enforceEdgeConstraints(hexMap, event.coord, event.terrainId, event.newMask);
|
||||||
applyConstraintActions(hexMap, actions);
|
applyConstraintActions(hexMap, actions);
|
||||||
}
|
}
|
||||||
@@ -153,8 +164,9 @@ function handleFeaturePlacement(event: HexClickEvent) {
|
|||||||
if (!selectedPattern) return;
|
if (!selectedPattern) return;
|
||||||
const coord = event.coord;
|
const coord = event.coord;
|
||||||
|
|
||||||
// Place the selected pattern on this hex
|
// Place the selected pattern on this hex (with waterSide for coastline)
|
||||||
hexMap.setFeature(coord, selectedPattern.terrainId, selectedPattern.mask);
|
const waterSide = selectedPattern.terrainId === 'coastline' ? selectedPattern.waterSide : undefined;
|
||||||
|
hexMap.setFeature(coord, selectedPattern.terrainId, selectedPattern.mask, waterSide);
|
||||||
|
|
||||||
// Enforce edge constraints on neighbors
|
// Enforce edge constraints on neighbors
|
||||||
const actions = enforceEdgeConstraints(hexMap, coord, selectedPattern.terrainId, selectedPattern.mask);
|
const actions = enforceEdgeConstraints(hexMap, coord, selectedPattern.terrainId, selectedPattern.mask);
|
||||||
@@ -187,6 +199,8 @@ document.addEventListener('keydown', (e) => {
|
|||||||
terrainPickerUI.rotateCCW();
|
terrainPickerUI.rotateCCW();
|
||||||
} else if (e.key === 'e' || e.key === 'E') {
|
} else if (e.key === 'e' || e.key === 'E') {
|
||||||
terrainPickerUI.rotateCW();
|
terrainPickerUI.rotateCW();
|
||||||
|
} else if (e.key === 'f' || e.key === 'F') {
|
||||||
|
terrainPickerUI.flipWaterSide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -273,3 +273,24 @@ html, body {
|
|||||||
background: #4a2020;
|
background: #4a2020;
|
||||||
border-color: #6a3030;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,13 +249,14 @@ function drawLinearFeature(
|
|||||||
edges: HexEdge[],
|
edges: HexEdge[],
|
||||||
color: string,
|
color: string,
|
||||||
size: number,
|
size: number,
|
||||||
|
waterSide: 1 | -1 = 1,
|
||||||
): void {
|
): void {
|
||||||
if (edges.length === 0) return;
|
if (edges.length === 0) return;
|
||||||
|
|
||||||
const { cx, cy, edgeMidpoints, vertices } = geom;
|
const { cx, cy, edgeMidpoints, vertices } = geom;
|
||||||
|
|
||||||
if (terrainId === 'coastline') {
|
if (terrainId === 'coastline') {
|
||||||
drawCoastlineFeature(ctx, geom, edges, size);
|
drawCoastlineFeature(ctx, geom, edges, waterSide, size);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,14 +376,15 @@ function drawBezierRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coastline: routes edge-to-edge like road/river, but fills one side with water.
|
* Coastline: routes edge-to-edge, fills one side with water.
|
||||||
* The water side is the side AWAY from the hex center (the "outside" of the curve).
|
* waterSide is stored on the feature: 1 = CW side, -1 = CCW side.
|
||||||
* We determine this using a cross-product test on the bezier midpoint.
|
* This is stable across rotations — no heuristic needed.
|
||||||
*/
|
*/
|
||||||
function drawCoastlineFeature(
|
function drawCoastlineFeature(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
geom: HexGeometry,
|
geom: HexGeometry,
|
||||||
edges: HexEdge[],
|
edges: HexEdge[],
|
||||||
|
waterSide: 1 | -1,
|
||||||
size: number,
|
size: number,
|
||||||
): void {
|
): void {
|
||||||
const { cx, cy, edgeMidpoints, vertices } = geom;
|
const { cx, cy, edgeMidpoints, vertices } = geom;
|
||||||
@@ -397,37 +399,13 @@ function drawCoastlineFeature(
|
|||||||
if (pair.length === 2) {
|
if (pair.length === 2) {
|
||||||
const p1 = edgeMidpoints[pair[0]];
|
const p1 = edgeMidpoints[pair[0]];
|
||||||
const p2 = edgeMidpoints[pair[1]];
|
const p2 = edgeMidpoints[pair[1]];
|
||||||
|
|
||||||
// Control point for the quadratic bezier (through center)
|
|
||||||
const cp = { x: cx, y: cy };
|
const cp = { x: cx, y: cy };
|
||||||
|
|
||||||
// Determine which side of the curve is "away" from center
|
// Fill the water side using the explicit waterSide value
|
||||||
// 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
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(p1.x, p1.y);
|
ctx.moveTo(p1.x, p1.y);
|
||||||
ctx.quadraticCurveTo(cp.x, cp.y, p2.x, p2.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);
|
const waterVerts = getVerticesOnSide(pair[0], pair[1], waterSide, vertices);
|
||||||
for (const v of waterVerts) {
|
for (const v of waterVerts) {
|
||||||
ctx.lineTo(v.x, v.y);
|
ctx.lineTo(v.x, v.y);
|
||||||
@@ -445,15 +423,6 @@ function drawCoastlineFeature(
|
|||||||
ctx.strokeStyle = coastColor;
|
ctx.strokeStyle = coastColor;
|
||||||
ctx.lineWidth = Math.max(1.5, size / 10);
|
ctx.lineWidth = Math.max(1.5, size / 10);
|
||||||
ctx.stroke();
|
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;
|
if (edges.length === 0) continue;
|
||||||
|
|
||||||
ctx.globalAlpha = 0.9;
|
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;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface FeatureRotateEvent {
|
|||||||
coord: AxialCoord;
|
coord: AxialCoord;
|
||||||
terrainId: string;
|
terrainId: string;
|
||||||
newMask: number;
|
newMask: number;
|
||||||
|
/** For coastline: flip water side */
|
||||||
|
flipWater?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHexInspector(
|
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');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'rotate-btn remove-btn';
|
removeBtn.className = 'rotate-btn remove-btn';
|
||||||
removeBtn.textContent = '\u2715'; // ✕
|
removeBtn.textContent = '\u2715'; // ✕
|
||||||
|
|||||||
@@ -93,5 +93,6 @@ export function createTerrainPicker(
|
|||||||
getSelectedArea: () => selectedArea,
|
getSelectedArea: () => selectedArea,
|
||||||
rotateCW() { palette?.rotateCW(); },
|
rotateCW() { palette?.rotateCW(); },
|
||||||
rotateCCW() { palette?.rotateCCW(); },
|
rotateCCW() { palette?.rotateCCW(); },
|
||||||
|
flipWaterSide() { palette?.flipWaterSide(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TerrainType, EdgeMask } from '../../core/types.js';
|
import type { TerrainType, EdgeMask } from '../../core/types.js';
|
||||||
import { getLinearTerrains, getTerrainType } from '../../core/terrain.js';
|
import { getLinearTerrains } from '../../core/terrain.js';
|
||||||
import { TILE_PATTERNS, rotatePattern } from '../../core/tile-patterns.js';
|
import { getPatternsForTerrain, rotatePattern } from '../../core/tile-patterns.js';
|
||||||
import { computeHexGeometry } from '../../core/coords.js';
|
import { computeHexGeometry } from '../../core/coords.js';
|
||||||
import { renderHex } from '../svg/renderer.js';
|
import { renderHex } from '../svg/renderer.js';
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ export interface SelectedPattern {
|
|||||||
mask: EdgeMask;
|
mask: EdgeMask;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
patternId: string;
|
patternId: string;
|
||||||
|
/** For coastline: which side is water (1=CW, -1=CCW) */
|
||||||
|
waterSide: 1 | -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTilePalette(
|
export function createTilePalette(
|
||||||
@@ -18,9 +20,9 @@ export function createTilePalette(
|
|||||||
getSelected: () => SelectedPattern | null;
|
getSelected: () => SelectedPattern | null;
|
||||||
rotateCW: () => void;
|
rotateCW: () => void;
|
||||||
rotateCCW: () => void;
|
rotateCCW: () => void;
|
||||||
|
flipWaterSide: () => void;
|
||||||
} {
|
} {
|
||||||
let selected: SelectedPattern | null = null;
|
let selected: SelectedPattern | null = null;
|
||||||
let currentTerrainId: string | null = null;
|
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -28,6 +30,8 @@ export function createTilePalette(
|
|||||||
const linearTerrains = getLinearTerrains();
|
const linearTerrains = getLinearTerrains();
|
||||||
|
|
||||||
for (const terrain of linearTerrains) {
|
for (const terrain of linearTerrains) {
|
||||||
|
const patterns = getPatternsForTerrain(terrain.id);
|
||||||
|
|
||||||
// Terrain header
|
// Terrain header
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'palette-terrain-header';
|
header.className = 'palette-terrain-header';
|
||||||
@@ -38,7 +42,7 @@ export function createTilePalette(
|
|||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'palette-grid';
|
grid.className = 'palette-grid';
|
||||||
|
|
||||||
for (const pattern of TILE_PATTERNS) {
|
for (const pattern of patterns) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'palette-item';
|
item.className = 'palette-item';
|
||||||
|
|
||||||
@@ -53,10 +57,14 @@ export function createTilePalette(
|
|||||||
canvas.height = previewSize * 2 + 4;
|
canvas.height = previewSize * 2 + 4;
|
||||||
|
|
||||||
const mask = isSelected && selected
|
const mask = isSelected && selected
|
||||||
? rotatePattern(pattern.baseMask, selected.rotation)
|
? selected.mask
|
||||||
: pattern.baseMask;
|
: 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);
|
item.appendChild(canvas);
|
||||||
|
|
||||||
// Label
|
// Label
|
||||||
@@ -67,17 +75,15 @@ export function createTilePalette(
|
|||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
// Deselect
|
|
||||||
selected = null;
|
selected = null;
|
||||||
currentTerrainId = null;
|
|
||||||
} else {
|
} else {
|
||||||
selected = {
|
selected = {
|
||||||
terrainId: terrain.id,
|
terrainId: terrain.id,
|
||||||
mask: pattern.baseMask,
|
mask: pattern.baseMask,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
patternId: pattern.id,
|
patternId: pattern.id,
|
||||||
|
waterSide: 1,
|
||||||
};
|
};
|
||||||
currentTerrainId = terrain.id;
|
|
||||||
}
|
}
|
||||||
onSelect(selected);
|
onSelect(selected);
|
||||||
render();
|
render();
|
||||||
@@ -89,33 +95,53 @@ export function createTilePalette(
|
|||||||
container.appendChild(grid);
|
container.appendChild(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotation hint if something is selected
|
// Controls when something is selected
|
||||||
if (selected) {
|
if (selected) {
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.className = 'palette-controls';
|
||||||
|
|
||||||
const hint = document.createElement('div');
|
const hint = document.createElement('div');
|
||||||
hint.className = 'palette-hint';
|
hint.className = 'palette-hint';
|
||||||
hint.textContent = 'Q/E or scroll to rotate before placing';
|
hint.textContent = 'Q/E or scroll: rotate';
|
||||||
container.appendChild(hint);
|
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() {
|
function rotateCW() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
selected.rotation = (selected.rotation + 1) % 6;
|
selected.rotation = (selected.rotation + 1) % 6;
|
||||||
selected.mask = rotatePattern(
|
selected.mask = rotatePattern(getBaseMask(), selected.rotation);
|
||||||
TILE_PATTERNS.find(p => p.id === selected!.patternId)!.baseMask,
|
|
||||||
selected.rotation,
|
|
||||||
);
|
|
||||||
onSelect(selected);
|
onSelect(selected);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateCCW() {
|
function rotateCCW() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
selected.rotation = (selected.rotation + 5) % 6; // +5 = -1 mod 6
|
selected.rotation = (selected.rotation + 5) % 6;
|
||||||
selected.mask = rotatePattern(
|
selected.mask = rotatePattern(getBaseMask(), selected.rotation);
|
||||||
TILE_PATTERNS.find(p => p.id === selected!.patternId)!.baseMask,
|
onSelect(selected);
|
||||||
selected.rotation,
|
render();
|
||||||
);
|
}
|
||||||
|
|
||||||
|
function flipWaterSide() {
|
||||||
|
if (!selected || selected.terrainId !== 'coastline') return;
|
||||||
|
selected.waterSide = selected.waterSide === 1 ? -1 : 1;
|
||||||
onSelect(selected);
|
onSelect(selected);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -126,6 +152,7 @@ export function createTilePalette(
|
|||||||
getSelected: () => selected,
|
getSelected: () => selected,
|
||||||
rotateCW,
|
rotateCW,
|
||||||
rotateCCW,
|
rotateCCW,
|
||||||
|
flipWaterSide,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +161,7 @@ function renderPreview(
|
|||||||
terrain: TerrainType,
|
terrain: TerrainType,
|
||||||
mask: EdgeMask,
|
mask: EdgeMask,
|
||||||
size: number,
|
size: number,
|
||||||
|
waterSide?: 1 | -1,
|
||||||
): void {
|
): void {
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
const cx = canvas.width / 2;
|
const cx = canvas.width / 2;
|
||||||
@@ -143,7 +171,11 @@ function renderPreview(
|
|||||||
|
|
||||||
renderHex(ctx, geom, {
|
renderHex(ctx, geom, {
|
||||||
base: 'plains',
|
base: 'plains',
|
||||||
features: [{ terrainId: terrain.id, edgeMask: mask }],
|
features: [{
|
||||||
|
terrainId: terrain.id,
|
||||||
|
edgeMask: mask,
|
||||||
|
...(waterSide !== undefined ? { waterSide } : {}),
|
||||||
|
}],
|
||||||
}, {
|
}, {
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
showGrid: true,
|
showGrid: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user