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);
|
||||||
|
}
|
||||||
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 { createSidebar } from './ui/sidebar.js';
|
||||||
import { createToolbar, type ToolMode } from './ui/toolbar.js';
|
import { createToolbar, type ToolMode } from './ui/toolbar.js';
|
||||||
import { createTerrainPicker } from './ui/terrain-picker.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 { createMapSettings } from './ui/map-settings.js';
|
||||||
|
import type { SelectedPattern } from './ui/tile-palette.js';
|
||||||
import {
|
import {
|
||||||
edgeMask,
|
|
||||||
toggleEdge,
|
|
||||||
enforceEdgeConstraints,
|
enforceEdgeConstraints,
|
||||||
applyConstraintActions,
|
applyConstraintActions,
|
||||||
} from '../core/edge-connectivity.js';
|
} from '../core/edge-connectivity.js';
|
||||||
@@ -21,9 +20,10 @@ const STORAGE_KEY = 'hexifyer_map';
|
|||||||
// --- State ---
|
// --- State ---
|
||||||
const hexMap = new HexMap();
|
const hexMap = new HexMap();
|
||||||
let currentMode: ToolMode = 'select';
|
let currentMode: ToolMode = 'select';
|
||||||
let selectedTerrain: TerrainType | null = null;
|
let selectedAreaTerrain: TerrainType | null = null;
|
||||||
|
let selectedPattern: SelectedPattern | null = null;
|
||||||
let selectedHex: AxialCoord | null = null;
|
let selectedHex: AxialCoord | null = null;
|
||||||
const hexSize = 48; // Fixed per map — set once
|
const hexSize = 48;
|
||||||
const origin = { x: 0, y: 0 };
|
const origin = { x: 0, y: 0 };
|
||||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -44,30 +44,43 @@ hexLayer.addTo(map);
|
|||||||
const sidebarEl = document.getElementById('sidebar')!;
|
const sidebarEl = document.getElementById('sidebar')!;
|
||||||
const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl);
|
const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl);
|
||||||
|
|
||||||
const terrainPickerUI = createTerrainPicker(terrainPicker, (terrain) => {
|
const terrainPickerUI = createTerrainPicker(
|
||||||
selectedTerrain = terrain;
|
terrainPicker,
|
||||||
});
|
(terrain) => { selectedAreaTerrain = terrain; },
|
||||||
|
(pattern) => { selectedPattern = pattern; },
|
||||||
|
);
|
||||||
|
|
||||||
createToolbar(toolbar, (mode) => {
|
createToolbar(toolbar, (mode) => {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
terrainPickerUI.setMode(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) => {
|
createMapSettings(settings, { showGrid: true, opacity: 0.7 }, (s) => {
|
||||||
hexLayer.setShowGrid(s.showGrid);
|
hexLayer.setShowGrid(s.showGrid);
|
||||||
hexLayer.setHexOpacity(s.opacity);
|
hexLayer.setHexOpacity(s.opacity);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Auto-save to localStorage (debounced) ---
|
// --- Auto-save to localStorage ---
|
||||||
function scheduleSave() {
|
function scheduleSave() {
|
||||||
if (saveTimeout) clearTimeout(saveTimeout);
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
saveTimeout = setTimeout(() => {
|
saveTimeout = setTimeout(() => {
|
||||||
if (!hexMap.dirty) return;
|
if (!hexMap.dirty) return;
|
||||||
try {
|
try {
|
||||||
const data = hexMap.serialize();
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(hexMap.serialize()));
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
||||||
hexMap.markClean();
|
hexMap.markClean();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[save] localStorage failed:', err);
|
console.error('[save] localStorage failed:', err);
|
||||||
@@ -77,8 +90,7 @@ function scheduleSave() {
|
|||||||
|
|
||||||
// --- File export/import ---
|
// --- File export/import ---
|
||||||
function exportMap() {
|
function exportMap() {
|
||||||
const data = hexMap.serialize();
|
const json = JSON.stringify(hexMap.serialize(), null, 2);
|
||||||
const json = JSON.stringify(data, null, 2);
|
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -118,7 +130,6 @@ function importMap() {
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose to sidebar buttons
|
|
||||||
(window as any).__hexifyer = { exportMap, importMap };
|
(window as any).__hexifyer = { exportMap, importMap };
|
||||||
|
|
||||||
// --- Interaction handlers ---
|
// --- Interaction handlers ---
|
||||||
@@ -130,29 +141,24 @@ function handleSelect(event: HexClickEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePaint(event: HexClickEvent) {
|
function handlePaint(event: HexClickEvent) {
|
||||||
if (!selectedTerrain) return;
|
if (!selectedAreaTerrain) return;
|
||||||
hexMap.setBase(event.coord, selectedTerrain.id);
|
hexMap.setBase(event.coord, selectedAreaTerrain.id);
|
||||||
hexLayer.redraw();
|
hexLayer.redraw();
|
||||||
selectedHex = event.coord;
|
selectedHex = event.coord;
|
||||||
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
hexInspectorUI.update(selectedHex, hexMap.getTerrain(selectedHex));
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFeature(event: HexClickEvent) {
|
function handleFeaturePlacement(event: HexClickEvent) {
|
||||||
if (!selectedTerrain) return;
|
if (!selectedPattern) return;
|
||||||
const coord = event.coord;
|
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) {
|
// Enforce edge constraints on neighbors
|
||||||
const addedEdgeMask = edgeMask(event.edge);
|
const actions = enforceEdgeConstraints(hexMap, coord, selectedPattern.terrainId, selectedPattern.mask);
|
||||||
const actions = enforceEdgeConstraints(hexMap, coord, selectedTerrain.id, addedEdgeMask);
|
applyConstraintActions(hexMap, actions);
|
||||||
applyConstraintActions(hexMap, actions);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedHex = coord;
|
selectedHex = coord;
|
||||||
hexLayer.setSelectedHex(selectedHex);
|
hexLayer.setSelectedHex(selectedHex);
|
||||||
@@ -167,13 +173,34 @@ attachHexInteraction(
|
|||||||
(event) => {
|
(event) => {
|
||||||
if (currentMode === 'select') handleSelect(event);
|
if (currentMode === 'select') handleSelect(event);
|
||||||
else if (currentMode === 'paint') handlePaint(event);
|
else if (currentMode === 'paint') handlePaint(event);
|
||||||
else if (currentMode === 'feature') handleFeature(event);
|
else if (currentMode === 'feature') handleFeaturePlacement(event);
|
||||||
},
|
},
|
||||||
(event) => {
|
(event) => {
|
||||||
if (currentMode === 'paint') handlePaint(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 ---
|
// --- Load from localStorage ---
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -187,7 +214,6 @@ try {
|
|||||||
}
|
}
|
||||||
hexMap.markClean();
|
hexMap.markClean();
|
||||||
hexLayer.redraw();
|
hexLayer.redraw();
|
||||||
console.log(`[init] Loaded ${data.hexes.length} hexes from localStorage`);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[init] Failed to load from localStorage:', err);
|
console.warn('[init] Failed to load from localStorage:', err);
|
||||||
|
|||||||
@@ -168,3 +168,108 @@ html, body {
|
|||||||
color: #666;
|
color: #666;
|
||||||
margin: 8px 0 4px 0;
|
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 type { AxialCoord, HexTerrain } from '../../core/types.js';
|
||||||
import { getTerrainType } from '../../core/terrain.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';
|
import { HexEdge } from '../../core/types.js';
|
||||||
|
|
||||||
const EDGE_NAMES: Record<HexEdge, string> = {
|
const EDGE_NAMES: Record<HexEdge, string> = {
|
||||||
@@ -12,39 +12,118 @@ const EDGE_NAMES: Record<HexEdge, string> = {
|
|||||||
[HexEdge.NW]: 'NW',
|
[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;
|
update: (coord: AxialCoord | null, terrain: HexTerrain | null) => void;
|
||||||
} {
|
} {
|
||||||
function update(coord: AxialCoord | null, terrain: HexTerrain | null) {
|
let currentCoord: AxialCoord | null = null;
|
||||||
if (!coord || !terrain) {
|
let currentTerrain: HexTerrain | null = null;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!currentCoord || !currentTerrain) {
|
||||||
container.innerHTML = '<div style="color:#666;font-size:12px">No hex selected</div>';
|
container.innerHTML = '<div style="color:#666;font-size:12px">No hex selected</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseType = getTerrainType(terrain.base);
|
const baseType = getTerrainType(currentTerrain.base);
|
||||||
let html = '<div class="hex-info">';
|
container.innerHTML = '';
|
||||||
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>`;
|
|
||||||
|
|
||||||
if (terrain.features.length > 0) {
|
// Coordinate
|
||||||
html += '<div style="margin-top:6px;font-size:11px;color:#888">Features:</div>';
|
const coordEl = document.createElement('div');
|
||||||
for (const feature of terrain.features) {
|
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 type = getTerrainType(feature.terrainId);
|
||||||
const edges = connectedEdges(feature.edgeMask)
|
const edges = connectedEdges(feature.edgeMask).map(e => EDGE_NAMES[e]).join(', ');
|
||||||
.map(e => EDGE_NAMES[e])
|
|
||||||
.join(', ');
|
const row = document.createElement('div');
|
||||||
html += `<div class="terrain-label" style="margin-top:2px">`;
|
row.className = 'feature-row';
|
||||||
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}`;
|
const label = document.createElement('div');
|
||||||
html += `</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>';
|
function update(coord: AxialCoord | null, terrain: HexTerrain | null) {
|
||||||
container.innerHTML = html;
|
currentCoord = coord;
|
||||||
|
currentTerrain = terrain;
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
update(null, null);
|
update(null, null);
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import type { TerrainType } from '../../core/types.js';
|
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 type { ToolMode } from './toolbar.js';
|
||||||
|
import { createTilePalette, type SelectedPattern } from './tile-palette.js';
|
||||||
|
|
||||||
export function createTerrainPicker(
|
export function createTerrainPicker(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
onChange: (terrain: TerrainType) => void,
|
onAreaSelect: (terrain: TerrainType) => void,
|
||||||
|
onPatternSelect: (pattern: SelectedPattern | null) => void,
|
||||||
): {
|
): {
|
||||||
setMode: (mode: ToolMode) => 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 currentMode: ToolMode = 'select';
|
||||||
|
let palette: ReturnType<typeof createTilePalette> | null = null;
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -20,50 +25,59 @@ export function createTerrainPicker(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const terrains = currentMode === 'paint' ? getAreaTerrains() : getLinearTerrains();
|
if (currentMode === 'paint') {
|
||||||
const label = currentMode === 'paint' ? 'Area Terrain' : 'Linear Features';
|
const terrains = getAreaTerrains();
|
||||||
|
|
||||||
const sectionLabel = document.createElement('div');
|
const sectionLabel = document.createElement('div');
|
||||||
sectionLabel.className = 'terrain-section-label';
|
sectionLabel.className = 'terrain-section-label';
|
||||||
sectionLabel.textContent = label;
|
sectionLabel.textContent = 'Area Terrain';
|
||||||
container.appendChild(sectionLabel);
|
container.appendChild(sectionLabel);
|
||||||
|
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'terrain-grid';
|
grid.className = 'terrain-grid';
|
||||||
|
|
||||||
for (const terrain of terrains) {
|
for (const terrain of terrains) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'terrain-btn';
|
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');
|
const swatch = document.createElement('span');
|
||||||
swatch.className = 'terrain-swatch';
|
swatch.className = 'terrain-swatch';
|
||||||
swatch.style.backgroundColor = terrain.color;
|
swatch.style.backgroundColor = terrain.color;
|
||||||
|
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.textContent = terrain.name;
|
name.textContent = terrain.name;
|
||||||
|
|
||||||
btn.appendChild(swatch);
|
btn.appendChild(swatch);
|
||||||
btn.appendChild(name);
|
btn.appendChild(name);
|
||||||
|
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
selected = terrain;
|
selectedArea = terrain;
|
||||||
onChange(terrain);
|
onAreaSelect(terrain);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.appendChild(btn);
|
grid.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(grid);
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.appendChild(grid);
|
if (currentMode === 'feature') {
|
||||||
|
// Tile palette with previews
|
||||||
// Auto-select first if nothing selected
|
const paletteContainer = document.createElement('div');
|
||||||
if (!selected || !terrains.find(t => t.id === selected!.id)) {
|
container.appendChild(paletteContainer);
|
||||||
selected = terrains[0] ?? null;
|
palette = createTilePalette(paletteContainer, onPatternSelect);
|
||||||
if (selected) onChange(selected);
|
return;
|
||||||
// Re-render to show selection
|
|
||||||
const firstBtn = grid.querySelector('.terrain-btn');
|
|
||||||
firstBtn?.classList.add('selected');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +86,12 @@ export function createTerrainPicker(
|
|||||||
return {
|
return {
|
||||||
setMode(mode: ToolMode) {
|
setMode(mode: ToolMode) {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
selected = null;
|
selectedArea = null;
|
||||||
|
palette = null;
|
||||||
render();
|
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