Files
hexifyer/src/main.ts
Axel Meyer b6bd66cd9e 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>
2026-04-07 11:09:28 +00:00

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);
}