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:
89
core/tile-patterns.ts
Normal file
89
core/tile-patterns.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Canonical tile patterns for linear features.
|
||||
* Each pattern is defined by a base edge mask and a human-readable name.
|
||||
* Patterns can be rotated by 60° increments to produce all placements.
|
||||
*/
|
||||
|
||||
import type { EdgeMask } from './types.js';
|
||||
import { HexEdge } from './types.js';
|
||||
import { edgeMask, rotateMask, edgeCount } from './edge-connectivity.js';
|
||||
|
||||
export interface TilePattern {
|
||||
id: string;
|
||||
name: string;
|
||||
baseMask: EdgeMask;
|
||||
/** Number of distinct rotations (accounting for symmetry) */
|
||||
rotations: number;
|
||||
}
|
||||
|
||||
/** All canonical patterns for linear features */
|
||||
export const TILE_PATTERNS: TilePattern[] = [
|
||||
{
|
||||
id: 'dead-end',
|
||||
name: 'Dead End',
|
||||
baseMask: edgeMask(HexEdge.E),
|
||||
rotations: 6,
|
||||
},
|
||||
{
|
||||
id: 'straight',
|
||||
name: 'Straight',
|
||||
baseMask: edgeMask(HexEdge.E, HexEdge.W),
|
||||
rotations: 3, // Symmetric: E-W = same as W-E
|
||||
},
|
||||
{
|
||||
id: 'gentle-curve',
|
||||
name: 'Wide Curve',
|
||||
baseMask: edgeMask(HexEdge.E, HexEdge.SW),
|
||||
rotations: 6,
|
||||
},
|
||||
{
|
||||
id: 'sharp-bend',
|
||||
name: 'Sharp Bend',
|
||||
baseMask: edgeMask(HexEdge.E, HexEdge.SE),
|
||||
rotations: 6,
|
||||
},
|
||||
{
|
||||
id: 'y-junction',
|
||||
name: 'Y Split',
|
||||
baseMask: edgeMask(HexEdge.NE, HexEdge.SE, HexEdge.W),
|
||||
rotations: 6,
|
||||
},
|
||||
{
|
||||
id: 'y-junction-wide',
|
||||
name: 'Y Wide',
|
||||
baseMask: edgeMask(HexEdge.NE, HexEdge.SW, HexEdge.SE),
|
||||
rotations: 6,
|
||||
},
|
||||
{
|
||||
id: 'crossroads',
|
||||
name: 'Cross',
|
||||
baseMask: edgeMask(HexEdge.NE, HexEdge.E, HexEdge.SW, HexEdge.W),
|
||||
rotations: 3,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all unique rotations for a pattern.
|
||||
* Returns array of { mask, rotation } where rotation is the number of 60° steps.
|
||||
*/
|
||||
export function getPatternRotations(pattern: TilePattern): Array<{ mask: EdgeMask; rotation: number }> {
|
||||
const seen = new Set<EdgeMask>();
|
||||
const result: Array<{ mask: EdgeMask; rotation: number }> = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a mask by N steps (each step = 60° clockwise).
|
||||
*/
|
||||
export function rotatePattern(mask: EdgeMask, steps: number): EdgeMask {
|
||||
return rotateMask(mask, steps);
|
||||
}
|
||||
86
src/main.ts
86
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);
|
||||
// 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);
|
||||
|
||||
@@ -168,3 +168,108 @@ html, body {
|
||||
color: #666;
|
||||
margin: 8px 0 4px 0;
|
||||
}
|
||||
|
||||
/* Tile Palette */
|
||||
.palette-terrain-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.palette-terrain-header .terrain-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.palette-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 3px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 2px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.palette-item:hover {
|
||||
background: #2a2a4a;
|
||||
border-color: #3a3a6a;
|
||||
}
|
||||
|
||||
.palette-item.selected {
|
||||
background: #2a2a5a;
|
||||
border-color: #7a7aff;
|
||||
}
|
||||
|
||||
.palette-item canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.palette-label {
|
||||
font-size: 8px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.palette-hint {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid #2a2a4a;
|
||||
}
|
||||
|
||||
/* Feature rotation controls */
|
||||
.feature-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rotate-controls {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rotate-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 3px;
|
||||
background: #1a1a2e;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rotate-btn:hover {
|
||||
background: #2a2a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rotate-btn.remove-btn:hover {
|
||||
background: #4a2020;
|
||||
border-color: #6a3030;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AxialCoord, HexTerrain } from '../../core/types.js';
|
||||
import { getTerrainType } from '../../core/terrain.js';
|
||||
import { connectedEdges } from '../../core/edge-connectivity.js';
|
||||
import { connectedEdges, rotateMask } from '../../core/edge-connectivity.js';
|
||||
import { HexEdge } from '../../core/types.js';
|
||||
|
||||
const EDGE_NAMES: Record<HexEdge, string> = {
|
||||
@@ -12,39 +12,118 @@ const EDGE_NAMES: Record<HexEdge, string> = {
|
||||
[HexEdge.NW]: 'NW',
|
||||
};
|
||||
|
||||
export function createHexInspector(container: HTMLElement): {
|
||||
export interface FeatureRotateEvent {
|
||||
coord: AxialCoord;
|
||||
terrainId: string;
|
||||
newMask: number;
|
||||
}
|
||||
|
||||
export function createHexInspector(
|
||||
container: HTMLElement,
|
||||
onRotateFeature?: (event: FeatureRotateEvent) => void,
|
||||
): {
|
||||
update: (coord: AxialCoord | null, terrain: HexTerrain | null) => void;
|
||||
} {
|
||||
function update(coord: AxialCoord | null, terrain: HexTerrain | null) {
|
||||
if (!coord || !terrain) {
|
||||
let currentCoord: AxialCoord | null = null;
|
||||
let currentTerrain: HexTerrain | null = null;
|
||||
|
||||
function render() {
|
||||
if (!currentCoord || !currentTerrain) {
|
||||
container.innerHTML = '<div style="color:#666;font-size:12px">No hex selected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const baseType = getTerrainType(terrain.base);
|
||||
let html = '<div class="hex-info">';
|
||||
html += `<div class="coord">q: ${coord.q}, r: ${coord.r}</div>`;
|
||||
html += `<div class="terrain-label">`;
|
||||
html += `<span class="terrain-swatch" style="background:${baseType?.color ?? '#666'};display:inline-block;width:10px;height:10px;border-radius:2px"></span>`;
|
||||
html += `${baseType?.name ?? terrain.base}`;
|
||||
html += `</div>`;
|
||||
const baseType = getTerrainType(currentTerrain.base);
|
||||
container.innerHTML = '';
|
||||
|
||||
if (terrain.features.length > 0) {
|
||||
html += '<div style="margin-top:6px;font-size:11px;color:#888">Features:</div>';
|
||||
for (const feature of terrain.features) {
|
||||
// Coordinate
|
||||
const coordEl = document.createElement('div');
|
||||
coordEl.className = 'coord';
|
||||
coordEl.textContent = `q: ${currentCoord.q}, r: ${currentCoord.r}`;
|
||||
container.appendChild(coordEl);
|
||||
|
||||
// Base terrain
|
||||
const baseEl = document.createElement('div');
|
||||
baseEl.className = 'terrain-label';
|
||||
baseEl.innerHTML = `<span class="terrain-swatch" style="background:${baseType?.color ?? '#666'};display:inline-block;width:10px;height:10px;border-radius:2px"></span> ${baseType?.name ?? currentTerrain.base}`;
|
||||
container.appendChild(baseEl);
|
||||
|
||||
// Features with rotation controls
|
||||
if (currentTerrain.features.length > 0) {
|
||||
const featHeader = document.createElement('div');
|
||||
featHeader.style.cssText = 'margin-top:6px;font-size:11px;color:#888';
|
||||
featHeader.textContent = 'Features:';
|
||||
container.appendChild(featHeader);
|
||||
|
||||
for (const feature of currentTerrain.features) {
|
||||
const type = getTerrainType(feature.terrainId);
|
||||
const edges = connectedEdges(feature.edgeMask)
|
||||
.map(e => EDGE_NAMES[e])
|
||||
.join(', ');
|
||||
html += `<div class="terrain-label" style="margin-top:2px">`;
|
||||
html += `<span class="terrain-swatch" style="background:${type?.color ?? '#666'};display:inline-block;width:10px;height:10px;border-radius:2px"></span>`;
|
||||
html += `${type?.name ?? feature.terrainId}: ${edges}`;
|
||||
html += `</div>`;
|
||||
const edges = connectedEdges(feature.edgeMask).map(e => EDGE_NAMES[e]).join(', ');
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'feature-row';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'terrain-label';
|
||||
label.innerHTML = `<span class="terrain-swatch" style="background:${type?.color ?? '#666'};display:inline-block;width:10px;height:10px;border-radius:2px"></span> ${type?.name ?? feature.terrainId}: ${edges}`;
|
||||
row.appendChild(label);
|
||||
|
||||
if (onRotateFeature) {
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'rotate-controls';
|
||||
|
||||
const ccwBtn = document.createElement('button');
|
||||
ccwBtn.className = 'rotate-btn';
|
||||
ccwBtn.textContent = '\u21B6'; // ↶
|
||||
ccwBtn.title = 'Rotate CCW';
|
||||
ccwBtn.addEventListener('click', () => {
|
||||
const newMask = rotateMask(feature.edgeMask, 5); // -1 = +5 mod 6
|
||||
onRotateFeature({
|
||||
coord: currentCoord!,
|
||||
terrainId: feature.terrainId,
|
||||
newMask,
|
||||
});
|
||||
});
|
||||
|
||||
const cwBtn = document.createElement('button');
|
||||
cwBtn.className = 'rotate-btn';
|
||||
cwBtn.textContent = '\u21B7'; // ↷
|
||||
cwBtn.title = 'Rotate CW';
|
||||
cwBtn.addEventListener('click', () => {
|
||||
const newMask = rotateMask(feature.edgeMask, 1);
|
||||
onRotateFeature({
|
||||
coord: currentCoord!,
|
||||
terrainId: feature.terrainId,
|
||||
newMask,
|
||||
});
|
||||
});
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'rotate-btn remove-btn';
|
||||
removeBtn.textContent = '\u2715'; // ✕
|
||||
removeBtn.title = 'Remove';
|
||||
removeBtn.addEventListener('click', () => {
|
||||
onRotateFeature({
|
||||
coord: currentCoord!,
|
||||
terrainId: feature.terrainId,
|
||||
newMask: 0, // 0 = remove
|
||||
});
|
||||
});
|
||||
|
||||
controls.appendChild(ccwBtn);
|
||||
controls.appendChild(cwBtn);
|
||||
controls.appendChild(removeBtn);
|
||||
row.appendChild(controls);
|
||||
}
|
||||
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
function update(coord: AxialCoord | null, terrain: HexTerrain | null) {
|
||||
currentCoord = coord;
|
||||
currentTerrain = terrain;
|
||||
render();
|
||||
}
|
||||
|
||||
update(null, null);
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { TerrainType } from '../../core/types.js';
|
||||
import { getAreaTerrains, getLinearTerrains } from '../../core/terrain.js';
|
||||
import { getAreaTerrains } from '../../core/terrain.js';
|
||||
import type { ToolMode } from './toolbar.js';
|
||||
import { createTilePalette, type SelectedPattern } from './tile-palette.js';
|
||||
|
||||
export function createTerrainPicker(
|
||||
container: HTMLElement,
|
||||
onChange: (terrain: TerrainType) => void,
|
||||
onAreaSelect: (terrain: TerrainType) => void,
|
||||
onPatternSelect: (pattern: SelectedPattern | null) => void,
|
||||
): {
|
||||
setMode: (mode: ToolMode) => void;
|
||||
getSelected: () => TerrainType | null;
|
||||
getSelectedArea: () => TerrainType | null;
|
||||
rotateCW: () => void;
|
||||
rotateCCW: () => void;
|
||||
} {
|
||||
let selected: TerrainType | null = null;
|
||||
let selectedArea: TerrainType | null = null;
|
||||
let currentMode: ToolMode = 'select';
|
||||
let palette: ReturnType<typeof createTilePalette> | null = null;
|
||||
|
||||
function render() {
|
||||
container.innerHTML = '';
|
||||
@@ -20,12 +25,12 @@ export function createTerrainPicker(
|
||||
return;
|
||||
}
|
||||
|
||||
const terrains = currentMode === 'paint' ? getAreaTerrains() : getLinearTerrains();
|
||||
const label = currentMode === 'paint' ? 'Area Terrain' : 'Linear Features';
|
||||
if (currentMode === 'paint') {
|
||||
const terrains = getAreaTerrains();
|
||||
|
||||
const sectionLabel = document.createElement('div');
|
||||
sectionLabel.className = 'terrain-section-label';
|
||||
sectionLabel.textContent = label;
|
||||
sectionLabel.textContent = 'Area Terrain';
|
||||
container.appendChild(sectionLabel);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
@@ -34,7 +39,7 @@ export function createTerrainPicker(
|
||||
for (const terrain of terrains) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'terrain-btn';
|
||||
if (selected?.id === terrain.id) btn.classList.add('selected');
|
||||
if (selectedArea?.id === terrain.id) btn.classList.add('selected');
|
||||
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'terrain-swatch';
|
||||
@@ -47,8 +52,8 @@ export function createTerrainPicker(
|
||||
btn.appendChild(name);
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
selected = terrain;
|
||||
onChange(terrain);
|
||||
selectedArea = terrain;
|
||||
onAreaSelect(terrain);
|
||||
render();
|
||||
});
|
||||
|
||||
@@ -57,13 +62,22 @@ export function createTerrainPicker(
|
||||
|
||||
container.appendChild(grid);
|
||||
|
||||
// Auto-select first if nothing selected
|
||||
if (!selected || !terrains.find(t => t.id === selected!.id)) {
|
||||
selected = terrains[0] ?? null;
|
||||
if (selected) onChange(selected);
|
||||
// Re-render to show selection
|
||||
const firstBtn = grid.querySelector('.terrain-btn');
|
||||
firstBtn?.classList.add('selected');
|
||||
// Auto-select first
|
||||
if (!selectedArea || !terrains.find(t => t.id === selectedArea!.id)) {
|
||||
selectedArea = terrains[0] ?? null;
|
||||
if (selectedArea) onAreaSelect(selectedArea);
|
||||
grid.querySelector('.terrain-btn')?.classList.add('selected');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMode === 'feature') {
|
||||
// Tile palette with previews
|
||||
const paletteContainer = document.createElement('div');
|
||||
container.appendChild(paletteContainer);
|
||||
palette = createTilePalette(paletteContainer, onPatternSelect);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +86,12 @@ export function createTerrainPicker(
|
||||
return {
|
||||
setMode(mode: ToolMode) {
|
||||
currentMode = mode;
|
||||
selected = null;
|
||||
selectedArea = null;
|
||||
palette = null;
|
||||
render();
|
||||
},
|
||||
getSelected: () => selected,
|
||||
getSelectedArea: () => selectedArea,
|
||||
rotateCW() { palette?.rotateCW(); },
|
||||
rotateCCW() { palette?.rotateCCW(); },
|
||||
};
|
||||
}
|
||||
|
||||
152
src/ui/tile-palette.ts
Normal file
152
src/ui/tile-palette.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { TerrainType, EdgeMask } from '../../core/types.js';
|
||||
import { getLinearTerrains, getTerrainType } from '../../core/terrain.js';
|
||||
import { TILE_PATTERNS, rotatePattern } from '../../core/tile-patterns.js';
|
||||
import { computeHexGeometry } from '../../core/coords.js';
|
||||
import { renderHex } from '../svg/renderer.js';
|
||||
|
||||
export interface SelectedPattern {
|
||||
terrainId: string;
|
||||
mask: EdgeMask;
|
||||
rotation: number;
|
||||
patternId: string;
|
||||
}
|
||||
|
||||
export function createTilePalette(
|
||||
container: HTMLElement,
|
||||
onSelect: (pattern: SelectedPattern | null) => void,
|
||||
): {
|
||||
getSelected: () => SelectedPattern | null;
|
||||
rotateCW: () => void;
|
||||
rotateCCW: () => void;
|
||||
} {
|
||||
let selected: SelectedPattern | null = null;
|
||||
let currentTerrainId: string | null = null;
|
||||
|
||||
function render() {
|
||||
container.innerHTML = '';
|
||||
|
||||
const linearTerrains = getLinearTerrains();
|
||||
|
||||
for (const terrain of linearTerrains) {
|
||||
// Terrain header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'palette-terrain-header';
|
||||
header.innerHTML = `<span class="terrain-swatch" style="background:${terrain.color}"></span>${terrain.name}`;
|
||||
container.appendChild(header);
|
||||
|
||||
// Pattern grid
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'palette-grid';
|
||||
|
||||
for (const pattern of TILE_PATTERNS) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'palette-item';
|
||||
|
||||
const isSelected = selected?.terrainId === terrain.id &&
|
||||
selected?.patternId === pattern.id;
|
||||
if (isSelected) item.classList.add('selected');
|
||||
|
||||
// Render preview hex
|
||||
const canvas = document.createElement('canvas');
|
||||
const previewSize = 18;
|
||||
canvas.width = previewSize * 2 + 4;
|
||||
canvas.height = previewSize * 2 + 4;
|
||||
|
||||
const mask = isSelected && selected
|
||||
? rotatePattern(pattern.baseMask, selected.rotation)
|
||||
: pattern.baseMask;
|
||||
|
||||
renderPreview(canvas, terrain, mask, previewSize);
|
||||
item.appendChild(canvas);
|
||||
|
||||
// Label
|
||||
const label = document.createElement('span');
|
||||
label.className = 'palette-label';
|
||||
label.textContent = pattern.name;
|
||||
item.appendChild(label);
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
if (isSelected) {
|
||||
// Deselect
|
||||
selected = null;
|
||||
currentTerrainId = null;
|
||||
} else {
|
||||
selected = {
|
||||
terrainId: terrain.id,
|
||||
mask: pattern.baseMask,
|
||||
rotation: 0,
|
||||
patternId: pattern.id,
|
||||
};
|
||||
currentTerrainId = terrain.id;
|
||||
}
|
||||
onSelect(selected);
|
||||
render();
|
||||
});
|
||||
|
||||
grid.appendChild(item);
|
||||
}
|
||||
|
||||
container.appendChild(grid);
|
||||
}
|
||||
|
||||
// Rotation hint if something is selected
|
||||
if (selected) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'palette-hint';
|
||||
hint.textContent = 'Q/E or scroll to rotate before placing';
|
||||
container.appendChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
function rotateCW() {
|
||||
if (!selected) return;
|
||||
selected.rotation = (selected.rotation + 1) % 6;
|
||||
selected.mask = rotatePattern(
|
||||
TILE_PATTERNS.find(p => p.id === selected!.patternId)!.baseMask,
|
||||
selected.rotation,
|
||||
);
|
||||
onSelect(selected);
|
||||
render();
|
||||
}
|
||||
|
||||
function rotateCCW() {
|
||||
if (!selected) return;
|
||||
selected.rotation = (selected.rotation + 5) % 6; // +5 = -1 mod 6
|
||||
selected.mask = rotatePattern(
|
||||
TILE_PATTERNS.find(p => p.id === selected!.patternId)!.baseMask,
|
||||
selected.rotation,
|
||||
);
|
||||
onSelect(selected);
|
||||
render();
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
getSelected: () => selected,
|
||||
rotateCW,
|
||||
rotateCCW,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview(
|
||||
canvas: HTMLCanvasElement,
|
||||
terrain: TerrainType,
|
||||
mask: EdgeMask,
|
||||
size: number,
|
||||
): void {
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
|
||||
const geom = computeHexGeometry(cx, cy, size);
|
||||
|
||||
renderHex(ctx, geom, {
|
||||
base: 'plains',
|
||||
features: [{ terrainId: terrain.id, edgeMask: mask }],
|
||||
}, {
|
||||
opacity: 0.9,
|
||||
showGrid: true,
|
||||
selected: false,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user