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:
88
src/main.ts
88
src/main.ts
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user