Tile palette with previews, click-to-place, independent rotation

Completely reworked feature placement UX:
- Tile palette in sidebar shows 7 pattern types (dead-end, straight,
  wide curve, sharp bend, Y-split, Y-wide, crossroads) for each
  linear terrain (road, river, coastline) with small hex previews
- Click pattern to select, click hex to place — no more edge-clicking
- Q/E keys or scroll wheel to rotate selected pattern before placing
- Each placed feature has independent rotation controls (arrows) and
  remove button in the hex inspector panel
- Edge constraint enforcement still runs automatically on placement

Also added: core/tile-patterns.ts with canonical pattern definitions
and rotation math (accounting for symmetry).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-04-07 11:09:28 +00:00
parent f144063db9
commit b6bd66cd9e
6 changed files with 562 additions and 94 deletions

View File

@@ -7,11 +7,10 @@ import type { AxialCoord, TerrainType } from '../core/types.js';
import { createSidebar } from './ui/sidebar.js';
import { createToolbar, type ToolMode } from './ui/toolbar.js';
import { createTerrainPicker } from './ui/terrain-picker.js';
import { createHexInspector } from './ui/hex-inspector.js';
import { createHexInspector, type FeatureRotateEvent } from './ui/hex-inspector.js';
import { createMapSettings } from './ui/map-settings.js';
import type { SelectedPattern } from './ui/tile-palette.js';
import {
edgeMask,
toggleEdge,
enforceEdgeConstraints,
applyConstraintActions,
} from '../core/edge-connectivity.js';
@@ -21,9 +20,10 @@ const STORAGE_KEY = 'hexifyer_map';
// --- State ---
const hexMap = new HexMap();
let currentMode: ToolMode = 'select';
let selectedTerrain: TerrainType | null = null;
let selectedAreaTerrain: TerrainType | null = null;
let selectedPattern: SelectedPattern | null = null;
let selectedHex: AxialCoord | null = null;
const hexSize = 48; // Fixed per map — set once
const hexSize = 48;
const origin = { x: 0, y: 0 };
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -44,30 +44,43 @@ hexLayer.addTo(map);
const sidebarEl = document.getElementById('sidebar')!;
const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl);
const terrainPickerUI = createTerrainPicker(terrainPicker, (terrain) => {
selectedTerrain = terrain;
});
const terrainPickerUI = createTerrainPicker(
terrainPicker,
(terrain) => { selectedAreaTerrain = terrain; },
(pattern) => { selectedPattern = pattern; },
);
createToolbar(toolbar, (mode) => {
currentMode = mode;
terrainPickerUI.setMode(mode);
});
const hexInspectorUI = createHexInspector(hexInspector);
const hexInspectorUI = createHexInspector(hexInspector, (event: FeatureRotateEvent) => {
if (event.newMask === 0) {
hexMap.removeFeature(event.coord, event.terrainId);
} else {
hexMap.setFeature(event.coord, event.terrainId, event.newMask);
// Re-enforce constraints after rotation
const actions = enforceEdgeConstraints(hexMap, event.coord, event.terrainId, event.newMask);
applyConstraintActions(hexMap, actions);
}
hexLayer.redraw();
hexInspectorUI.update(event.coord, hexMap.getTerrain(event.coord));
scheduleSave();
});
createMapSettings(settings, { showGrid: true, opacity: 0.7 }, (s) => {
hexLayer.setShowGrid(s.showGrid);
hexLayer.setHexOpacity(s.opacity);
});
// --- Auto-save to localStorage (debounced) ---
// --- Auto-save to localStorage ---
function scheduleSave() {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
if (!hexMap.dirty) return;
try {
const data = hexMap.serialize();
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
localStorage.setItem(STORAGE_KEY, JSON.stringify(hexMap.serialize()));
hexMap.markClean();
} catch (err) {
console.error('[save] localStorage failed:', err);
@@ -77,8 +90,7 @@ function scheduleSave() {
// --- File export/import ---
function exportMap() {
const data = hexMap.serialize();
const json = JSON.stringify(data, null, 2);
const json = JSON.stringify(hexMap.serialize(), null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -118,7 +130,6 @@ function importMap() {
input.click();
}
// Expose to sidebar buttons
(window as any).__hexifyer = { exportMap, importMap };
// --- Interaction handlers ---
@@ -130,29 +141,24 @@ function handleSelect(event: HexClickEvent) {
}
function handlePaint(event: HexClickEvent) {
if (!selectedTerrain) return;
hexMap.setBase(event.coord, selectedTerrain.id);
if (!selectedAreaTerrain) return;
hexMap.setBase(event.coord, selectedAreaTerrain.id);
hexLayer.redraw();
selectedHex = event.coord;
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
scheduleSave();
}
function handleFeature(event: HexClickEvent) {
if (!selectedTerrain) return;
function handleFeaturePlacement(event: HexClickEvent) {
if (!selectedPattern) 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);
// Place the selected pattern on this hex
hexMap.setFeature(coord, selectedPattern.terrainId, selectedPattern.mask);
if (newMask > currentMask) {
const addedEdgeMask = edgeMask(event.edge);
const actions = enforceEdgeConstraints(hexMap, coord, selectedTerrain.id, addedEdgeMask);
applyConstraintActions(hexMap, actions);
}
// Enforce edge constraints on neighbors
const actions = enforceEdgeConstraints(hexMap, coord, selectedPattern.terrainId, selectedPattern.mask);
applyConstraintActions(hexMap, actions);
selectedHex = coord;
hexLayer.setSelectedHex(selectedHex);
@@ -167,13 +173,34 @@ attachHexInteraction(
(event) => {
if (currentMode === 'select') handleSelect(event);
else if (currentMode === 'paint') handlePaint(event);
else if (currentMode === 'feature') handleFeature(event);
else if (currentMode === 'feature') handleFeaturePlacement(event);
},
(event) => {
if (currentMode === 'paint') handlePaint(event);
},
);
// --- Keyboard rotation (Q/E) ---
document.addEventListener('keydown', (e) => {
if (currentMode !== 'feature') return;
if (e.key === 'q' || e.key === 'Q') {
terrainPickerUI.rotateCCW();
} else if (e.key === 'e' || e.key === 'E') {
terrainPickerUI.rotateCW();
}
});
// --- Mouse wheel rotation ---
document.getElementById('map')?.addEventListener('wheel', (e) => {
if (currentMode !== 'feature' || !selectedPattern) return;
e.preventDefault();
if (e.deltaY > 0) {
terrainPickerUI.rotateCW();
} else {
terrainPickerUI.rotateCCW();
}
}, { passive: false });
// --- Load from localStorage ---
try {
const saved = localStorage.getItem(STORAGE_KEY);
@@ -187,7 +214,6 @@ try {
}
hexMap.markClean();
hexLayer.redraw();
console.log(`[init] Loaded ${data.hexes.length} hexes from localStorage`);
}
} catch (err) {
console.warn('[init] Failed to load from localStorage:', err);