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>
221 lines
6.4 KiB
TypeScript
221 lines
6.4 KiB
TypeScript
import './style/main.css';
|
|
import { initMap } from './map/map-init.js';
|
|
import { createHexLayer } from './map/hex-layer.js';
|
|
import { attachHexInteraction, type HexClickEvent } from './map/hex-interaction.js';
|
|
import { HexMap } from '../core/hex-map.js';
|
|
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, type FeatureRotateEvent } from './ui/hex-inspector.js';
|
|
import { createMapSettings } from './ui/map-settings.js';
|
|
import type { SelectedPattern } from './ui/tile-palette.js';
|
|
import {
|
|
enforceEdgeConstraints,
|
|
applyConstraintActions,
|
|
} from '../core/edge-connectivity.js';
|
|
|
|
const STORAGE_KEY = 'hexifyer_map';
|
|
|
|
// --- State ---
|
|
const hexMap = new HexMap();
|
|
let currentMode: ToolMode = 'select';
|
|
let selectedAreaTerrain: TerrainType | null = null;
|
|
let selectedPattern: SelectedPattern | null = null;
|
|
let selectedHex: AxialCoord | null = null;
|
|
const hexSize = 48;
|
|
const origin = { x: 0, y: 0 };
|
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// --- Init Map ---
|
|
const map = initMap('map');
|
|
|
|
// --- Hex Layer ---
|
|
const hexLayer = createHexLayer({
|
|
hexSize,
|
|
hexMap,
|
|
origin,
|
|
showGrid: true,
|
|
opacity: 0.7,
|
|
});
|
|
hexLayer.addTo(map);
|
|
|
|
// --- Sidebar ---
|
|
const sidebarEl = document.getElementById('sidebar')!;
|
|
const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl);
|
|
|
|
const terrainPickerUI = createTerrainPicker(
|
|
terrainPicker,
|
|
(terrain) => { selectedAreaTerrain = terrain; },
|
|
(pattern) => { selectedPattern = pattern; },
|
|
);
|
|
|
|
createToolbar(toolbar, (mode) => {
|
|
currentMode = mode;
|
|
terrainPickerUI.setMode(mode);
|
|
});
|
|
|
|
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 ---
|
|
function scheduleSave() {
|
|
if (saveTimeout) clearTimeout(saveTimeout);
|
|
saveTimeout = setTimeout(() => {
|
|
if (!hexMap.dirty) return;
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(hexMap.serialize()));
|
|
hexMap.markClean();
|
|
} catch (err) {
|
|
console.error('[save] localStorage failed:', err);
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
// --- File export/import ---
|
|
function exportMap() {
|
|
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');
|
|
a.href = url;
|
|
a.download = 'hexmap.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function importMap() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.json';
|
|
input.addEventListener('change', () => {
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
try {
|
|
const data = JSON.parse(reader.result as string);
|
|
hexMap.clear();
|
|
for (const hex of data.hexes) {
|
|
hexMap.setTerrain({ q: hex.q, r: hex.r }, {
|
|
base: hex.base,
|
|
features: hex.features,
|
|
});
|
|
}
|
|
hexMap.markClean();
|
|
hexLayer.redraw();
|
|
scheduleSave();
|
|
} catch (err) {
|
|
console.error('[import] Failed:', err);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
input.click();
|
|
}
|
|
|
|
(window as any).__hexifyer = { exportMap, importMap };
|
|
|
|
// --- Interaction handlers ---
|
|
|
|
function handleSelect(event: HexClickEvent) {
|
|
selectedHex = event.coord;
|
|
hexLayer.setSelectedHex(selectedHex);
|
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
|
}
|
|
|
|
function handlePaint(event: HexClickEvent) {
|
|
if (!selectedAreaTerrain) return;
|
|
hexMap.setBase(event.coord, selectedAreaTerrain.id);
|
|
hexLayer.redraw();
|
|
selectedHex = event.coord;
|
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
|
scheduleSave();
|
|
}
|
|
|
|
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);
|
|
|
|
// Enforce edge constraints on neighbors
|
|
const actions = enforceEdgeConstraints(hexMap, coord, selectedPattern.terrainId, selectedPattern.mask);
|
|
applyConstraintActions(hexMap, actions);
|
|
|
|
selectedHex = coord;
|
|
hexLayer.setSelectedHex(selectedHex);
|
|
hexLayer.redraw();
|
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
|
scheduleSave();
|
|
}
|
|
|
|
// --- Hex Interaction ---
|
|
attachHexInteraction(
|
|
map, hexSize, origin,
|
|
(event) => {
|
|
if (currentMode === 'select') handleSelect(event);
|
|
else if (currentMode === 'paint') handlePaint(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);
|
|
if (saved) {
|
|
const data = JSON.parse(saved);
|
|
for (const hex of data.hexes) {
|
|
hexMap.setTerrain({ q: hex.q, r: hex.r }, {
|
|
base: hex.base,
|
|
features: hex.features,
|
|
});
|
|
}
|
|
hexMap.markClean();
|
|
hexLayer.redraw();
|
|
}
|
|
} catch (err) {
|
|
console.warn('[init] Failed to load from localStorage:', err);
|
|
}
|