mud/scripts/map_editor.html

1244 lines
42 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASCII Map Editor</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
display: flex;
height: 100vh;
overflow: hidden;
}
#sidebar {
width: 250px;
padding: 1rem;
overflow: auto;
border-right: 1px solid;
}
#main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
min-width: 0;
min-height: 0;
}
fieldset {
margin-bottom: 1rem;
}
legend {
font-weight: bold;
}
.form-group {
margin: 0.5rem 0;
}
label {
display: inline-block;
min-width: 60px;
}
input[type="number"] {
width: 80px;
}
button {
margin-top: 0.5rem;
}
.symbol-slots {
display: flex;
gap: 1rem;
margin: 0.5rem 0;
}
.symbol-slot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: 2px solid;
cursor: pointer;
font-family: monospace;
font-size: 24px;
background: black;
}
.symbol-slot.active {
border-width: 4px;
}
#canvas-wrapper {
position: relative;
display: inline-block;
}
#canvas, #overlay-canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
#overlay-canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.actions button {
display: block;
width: 100%;
margin-bottom: 0.5rem;
}
input[type="file"] {
display: none;
}
.palette-list {
max-height: 200px;
overflow-y: auto;
margin: 0.5rem 0;
}
.palette-entry {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 2px 4px;
cursor: pointer;
}
.palette-entry:hover {
outline: 1px solid;
}
.color-swatch {
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid;
flex-shrink: 0;
}
.palette-char {
font-family: monospace;
font-weight: bold;
width: 1.5ch;
text-align: center;
}
.palette-label {
flex: 1;
font-size: 0.85em;
}
.palette-actions button {
font-size: 0.75em;
padding: 0 3px;
margin: 0;
cursor: pointer;
}
#palette-form {
border: 1px solid;
padding: 0.5rem;
margin: 0.5rem 0;
}
#palette-form .form-row {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
#palette-char {
width: 2.5ch;
font-family: monospace;
text-align: center;
}
#palette-label-input {
flex: 1;
min-width: 0;
}
#palette-color {
width: 30px;
height: 22px;
padding: 0;
border: 1px solid;
}
</style>
</head>
<body>
<div id="sidebar">
<fieldset>
<legend>Dimensions</legend>
<div class="form-group">
<label for="width">Width:</label>
<input type="number" id="width" value="100" min="1" max="1000">
</div>
<div class="form-group">
<label for="height">Height:</label>
<input type="number" id="height" value="100" min="1" max="1000">
</div>
<button id="resize">Resize</button>
</fieldset>
<fieldset>
<legend>Tools</legend>
<div class="form-group">
<label>
<input type="radio" name="tool" value="paintbrush" checked>
Paintbrush
</label>
</div>
<div class="form-group">
<label>
<input type="radio" name="tool" value="eraser">
Eraser
</label>
</div>
</fieldset>
<fieldset>
<legend>Brush</legend>
<div class="form-group">
<label for="brushSize">Size:</label>
<input type="range" id="brushSize" min="1" max="8" value="1" style="width: 100px; vertical-align: middle;">
<span id="brushSizeValue">1</span>
</div>
<div class="form-group">
<label>Shape:</label>
<button id="brushCircle" style="width: 30px; height: 30px; margin-right: 0.25rem; border: 2px solid;"></button>
<button id="brushSquare" style="width: 30px; height: 30px; border: 2px solid;"></button>
</div>
</fieldset>
<fieldset>
<legend>View</legend>
<div class="form-group">
<label>
<input type="checkbox" id="textView">
Show text
</label>
</div>
</fieldset>
<fieldset>
<legend>Symbols</legend>
<div class="symbol-slots">
<div class="symbol-slot active" id="m1" data-slot="m1" title="m1 (left click)">.</div>
<div class="symbol-slot" id="m2" data-slot="m2" title="m2 (right click)">~</div>
</div>
<small>Click slot, type to change</small>
</fieldset>
<fieldset>
<legend>Palette</legend>
<div id="palette-manager" style="margin-bottom: 0.5rem;">
<select id="palette-select" style="width: 100%; margin-bottom: 0.25rem;"></select>
<div style="display: flex; gap: 0.25rem;">
<button id="palette-new" style="flex: 1; font-size: 0.85em; padding: 2px 4px;">new</button>
<button id="palette-rename" style="flex: 1; font-size: 0.85em; padding: 2px 4px;">rename</button>
<button id="palette-delete" style="flex: 1; font-size: 0.85em; padding: 2px 4px;">delete</button>
</div>
</div>
<div id="palette-list" class="palette-list"></div>
<div id="palette-form" style="display: none">
<div class="form-row">
<input type="text" id="palette-char" maxlength="1" placeholder="char">
<input type="text" id="palette-label-input" placeholder="label">
<input type="color" id="palette-color" value="#cccccc">
</div>
<div>
<button id="palette-ok">ok</button>
<button id="palette-cancel">cancel</button>
</div>
</div>
<button id="add-palette">+ add</button>
</fieldset>
<fieldset>
<legend>Actions</legend>
<div class="actions">
<button id="save">Save</button>
<button id="load">Load</button>
<input type="file" id="fileInput" accept=".toml">
<button id="clear">Clear</button>
</div>
</fieldset>
</div>
<div id="main">
<div id="canvas-wrapper">
<canvas id="canvas"></canvas>
<canvas id="overlay-canvas"></canvas>
</div>
</div>
<script>
const DEFAULT_PALETTE = [
{ char: '~', label: 'water', color: '#2244aa' },
{ char: ':', label: 'sand', color: '#c2b280' },
{ char: '.', label: 'grass', color: '#228b22' },
{ char: 'T', label: 'forest', color: '#0a5f0a' },
{ char: '^', label: 'mountain', color: '#666666' },
];
let palette = structuredClone(DEFAULT_PALETTE);
let activePaletteName = 'default';
const DEFAULT_COLOR = [200, 200, 200]; // light gray
const CELL_SIZE = 12; // square cells
let grid = [];
let gridWidth = 100;
let gridHeight = 100;
let symbols = { m1: '.', m2: '~' };
let activeSlot = 'm1';
let currentTool = 'paintbrush';
let viewMode = 'pixels';
let isDrawing = false;
let drawButton = 0;
let lastPaintedCell = null;
let brushSize = 1;
let brushShape = 'circle';
let lastHoveredCell = { x: -1, y: -1 }; // for cursor overlay
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const overlayCanvas = document.getElementById('overlay-canvas');
const overlayCtx = overlayCanvas.getContext('2d');
const widthInput = document.getElementById('width');
const heightInput = document.getElementById('height');
const m1Slot = document.getElementById('m1');
const m2Slot = document.getElementById('m2');
const textViewCheckbox = document.getElementById('textView');
function hexToRgb(hex) {
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
}
function initGrid(width, height, preserveOld = false) {
const oldGrid = grid;
const oldWidth = gridWidth;
const oldHeight = gridHeight;
gridWidth = width;
gridHeight = height;
grid = Array(height).fill(null).map(() => Array(width).fill(' '));
if (preserveOld && oldGrid.length > 0) {
const copyHeight = Math.min(height, oldHeight);
const copyWidth = Math.min(width, oldWidth);
for (let y = 0; y < copyHeight; y++) {
for (let x = 0; x < copyWidth; x++) {
grid[y][x] = oldGrid[y][x];
}
}
}
sizeCanvas();
renderCanvas();
saveState();
}
function sizeCanvas() {
if (viewMode === 'text') {
canvas.width = gridWidth * CELL_SIZE;
canvas.height = gridHeight * CELL_SIZE;
} else {
canvas.width = gridWidth;
canvas.height = gridHeight;
}
// Sync overlay canvas size
overlayCanvas.width = canvas.width;
overlayCanvas.height = canvas.height;
fitCanvas();
}
function fitCanvas() {
const container = document.getElementById('main');
const pad = 2 * 16; // 1rem padding on each side
const maxW = container.clientWidth - pad;
const maxH = container.clientHeight - pad;
const ratio = canvas.width / canvas.height;
let cssW, cssH;
if (maxW / maxH > ratio) {
cssH = maxH;
cssW = cssH * ratio;
} else {
cssW = maxW;
cssH = cssW / ratio;
}
canvas.style.width = Math.floor(cssW) + 'px';
canvas.style.height = Math.floor(cssH) + 'px';
// Sync overlay canvas CSS size
overlayCanvas.style.width = Math.floor(cssW) + 'px';
overlayCanvas.style.height = Math.floor(cssH) + 'px';
}
window.addEventListener('resize', fitCanvas);
function getColor(char) {
if (char === ' ') return [0, 0, 0];
const entry = palette.find(e => e.char === char);
return entry ? hexToRgb(entry.color) : DEFAULT_COLOR;
}
function renderCanvas() {
if (viewMode === 'text') {
renderText();
} else {
renderPixels();
}
}
function renderPixels() {
const imageData = ctx.createImageData(gridWidth, gridHeight);
const data = imageData.data;
for (let y = 0; y < gridHeight; y++) {
for (let x = 0; x < gridWidth; x++) {
const char = grid[y][x];
const color = getColor(char);
const idx = (y * gridWidth + x) * 4;
data[idx] = color[0];
data[idx + 1] = color[1];
data[idx + 2] = color[2];
data[idx + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
}
function renderText() {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = CELL_SIZE + 'px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let y = 0; y < gridHeight; y++) {
for (let x = 0; x < gridWidth; x++) {
const char = grid[y][x];
if (char === ' ') continue;
const c = getColor(char);
ctx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`;
ctx.fillText(char, x * CELL_SIZE + CELL_SIZE / 2, y * CELL_SIZE + CELL_SIZE / 2);
}
}
}
function paintCell(x, y, symbol) {
if (x < 0 || x >= gridWidth || y < 0 || y >= gridHeight) return;
const cellKey = `${x},${y}`;
if (lastPaintedCell === cellKey) return;
grid[y][x] = symbol;
lastPaintedCell = cellKey;
renderCanvas();
}
function getCellsInBrush(cx, cy) {
const cells = [];
if (brushSize === 1) {
cells.push({ x: cx, y: cy });
return cells;
}
for (let dy = -(brushSize - 1); dy < brushSize; dy++) {
for (let dx = -(brushSize - 1); dx < brushSize; dx++) {
const nx = cx + dx;
const ny = cy + dy;
if (nx < 0 || nx >= gridWidth || ny < 0 || ny >= gridHeight) continue;
if (brushShape === 'circle') {
if (dx * dx + dy * dy < brushSize * brushSize) {
cells.push({ x: nx, y: ny });
}
} else { // square
if (Math.abs(dx) < brushSize && Math.abs(dy) < brushSize) {
cells.push({ x: nx, y: ny });
}
}
}
}
return cells;
}
function paintBrush(x, y, symbol) {
if (brushSize === 1) {
paintCell(x, y, symbol);
return;
}
// Temporarily disable single-cell tracking for brush painting
const oldLastPainted = lastPaintedCell;
lastPaintedCell = null;
const cells = getCellsInBrush(x, y);
for (const cell of cells) {
grid[cell.y][cell.x] = symbol;
}
lastPaintedCell = `${x},${y}`;
renderCanvas();
}
function getGridCoords(event) {
const rect = canvas.getBoundingClientRect();
const canvasX = (event.clientX - rect.left) * (canvas.width / rect.width);
const canvasY = (event.clientY - rect.top) * (canvas.height / rect.height);
if (viewMode === 'text') {
return { x: Math.floor(canvasX / CELL_SIZE), y: Math.floor(canvasY / CELL_SIZE) };
}
return { x: Math.floor(canvasX), y: Math.floor(canvasY) };
}
function handlePaint(event, button) {
const { x, y } = getGridCoords(event);
let symbol;
if (currentTool === 'eraser') {
symbol = ' ';
} else {
symbol = button === 0 ? symbols.m1 : symbols.m2;
}
paintBrush(x, y, symbol);
}
function saveState() {
const state = {
width: gridWidth,
height: gridHeight,
grid: grid,
symbols: symbols,
activeSlot: activeSlot,
palette: palette,
brushSize: brushSize,
brushShape: brushShape,
};
localStorage.setItem('map_editor_state', JSON.stringify(state));
}
function loadState() {
// Load palette manager state
activePaletteName = loadActivePaletteName();
const palettes = loadPalettesFromStorage();
if (palettes[activePaletteName]) {
palette = structuredClone(palettes[activePaletteName]);
}
updatePaletteSelect();
const saved = localStorage.getItem('map_editor_state');
if (saved) {
try {
const state = JSON.parse(saved);
gridWidth = state.width;
gridHeight = state.height;
grid = state.grid;
if (state.symbols) symbols = state.symbols;
if (state.activeSlot) activeSlot = state.activeSlot;
if (state.palette) palette = state.palette;
if (state.brushSize !== undefined) brushSize = state.brushSize;
if (state.brushShape) brushShape = state.brushShape;
widthInput.value = gridWidth;
heightInput.value = gridHeight;
m1Slot.textContent = symbols.m1;
m2Slot.textContent = symbols.m2;
updateActiveSlot();
updateSymbolSlotColors();
updateBrushUI();
sizeCanvas();
renderCanvas();
renderPalette();
return true;
} catch (e) {
console.error('Failed to load state:', e);
}
}
return false;
}
function saveTOML() {
let dataStr = '';
for (let y = 0; y < gridHeight; y++) {
dataStr += grid[y].join('') + '\n';
}
let paletteStr = '';
for (const entry of palette) {
paletteStr += `${entry.char}|${entry.label}|${entry.color}\n`;
}
const toml = `[map]
width = ${gridWidth}
height = ${gridHeight}
data = """
${dataStr}"""
[palette]
entries = """
${paletteStr}"""
`;
const blob = new Blob([toml], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'map.toml';
a.click();
URL.revokeObjectURL(url);
}
function loadTOML(text) {
try {
const widthMatch = text.match(/width\s*=\s*(\d+)/);
const heightMatch = text.match(/height\s*=\s*(\d+)/);
const dataMatch = text.match(/data\s*=\s*"""([\s\S]*?)"""/);
if (!widthMatch || !heightMatch || !dataMatch) {
throw new Error('Invalid TOML format');
}
const width = parseInt(widthMatch[1]);
const height = parseInt(heightMatch[1]);
const dataStr = dataMatch[1].trim();
const lines = dataStr.split('\n');
if (lines.length !== height) {
throw new Error('Height mismatch');
}
gridWidth = width;
gridHeight = height;
grid = Array(height).fill(null).map(() => Array(width).fill(' '));
for (let y = 0; y < height && y < lines.length; y++) {
const line = lines[y];
for (let x = 0; x < width && x < line.length; x++) {
grid[y][x] = line[x];
}
}
// Load palette if present
const paletteMatch = text.match(/\[palette\]\s*\nentries\s*=\s*"""([\s\S]*?)"""/);
if (paletteMatch) {
const paletteLines = paletteMatch[1].trim().split('\n');
palette = [];
for (const line of paletteLines) {
const parts = line.split('|');
if (parts.length === 3) {
palette.push({
char: parts[0],
label: parts[1],
color: parts[2],
});
}
}
}
widthInput.value = width;
heightInput.value = height;
sizeCanvas();
renderCanvas();
renderPalette();
updateSymbolSlotColors();
saveState();
} catch (e) {
alert('Failed to load TOML file: ' + e.message);
}
}
function updateActiveSlot() {
m1Slot.classList.toggle('active', activeSlot === 'm1');
m2Slot.classList.toggle('active', activeSlot === 'm2');
}
function updateSymbolSlotColors() {
const c1 = getColor(symbols.m1);
m1Slot.style.color = `rgb(${c1[0]},${c1[1]},${c1[2]})`;
const c2 = getColor(symbols.m2);
m2Slot.style.color = `rgb(${c2[0]},${c2[1]},${c2[2]})`;
}
function updateBrushUI() {
const slider = document.getElementById('brushSize');
const valueDisplay = document.getElementById('brushSizeValue');
const circleBtn = document.getElementById('brushCircle');
const squareBtn = document.getElementById('brushSquare');
slider.value = brushSize;
valueDisplay.textContent = brushSize;
if (brushShape === 'circle') {
circleBtn.style.borderWidth = '4px';
squareBtn.style.borderWidth = '2px';
} else {
circleBtn.style.borderWidth = '2px';
squareBtn.style.borderWidth = '4px';
}
}
// Palette manager functions
function loadPalettesFromStorage() {
const saved = localStorage.getItem('map_editor_palettes');
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error('Failed to load palettes:', e);
}
}
return { 'default': structuredClone(DEFAULT_PALETTE) };
}
function savePalettesToStorage(palettes) {
localStorage.setItem('map_editor_palettes', JSON.stringify(palettes));
}
function loadActivePaletteName() {
const saved = localStorage.getItem('map_editor_active_palette');
return saved || 'default';
}
function saveActivePaletteName(name) {
localStorage.setItem('map_editor_active_palette', name);
}
function saveCurrentPalette() {
const palettes = loadPalettesFromStorage();
palettes[activePaletteName] = structuredClone(palette);
savePalettesToStorage(palettes);
}
function loadPalette(name) {
const palettes = loadPalettesFromStorage();
if (palettes[name]) {
palette = structuredClone(palettes[name]);
activePaletteName = name;
saveActivePaletteName(name);
renderPalette();
renderCanvas();
updateSymbolSlotColors();
updatePaletteSelect();
saveState();
}
}
function updatePaletteSelect() {
const select = document.getElementById('palette-select');
const palettes = loadPalettesFromStorage();
select.innerHTML = '';
for (const name of Object.keys(palettes).sort()) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
option.selected = name === activePaletteName;
select.appendChild(option);
}
}
function createNewPalette() {
const name = prompt('Enter palette name:');
if (!name || !name.trim()) return;
const trimmedName = name.trim();
const palettes = loadPalettesFromStorage();
if (palettes[trimmedName]) {
alert('Palette with that name already exists.');
return;
}
palettes[trimmedName] = structuredClone(DEFAULT_PALETTE);
savePalettesToStorage(palettes);
loadPalette(trimmedName);
}
function renamePalette() {
const newName = prompt('Enter new name:', activePaletteName);
if (!newName || !newName.trim()) return;
const trimmedName = newName.trim();
if (trimmedName === activePaletteName) return;
const palettes = loadPalettesFromStorage();
if (palettes[trimmedName]) {
alert('Palette with that name already exists.');
return;
}
palettes[trimmedName] = palettes[activePaletteName];
delete palettes[activePaletteName];
savePalettesToStorage(palettes);
activePaletteName = trimmedName;
saveActivePaletteName(trimmedName);
updatePaletteSelect();
}
function deletePalette() {
const palettes = loadPalettesFromStorage();
const paletteNames = Object.keys(palettes);
if (paletteNames.length === 1) {
alert('Cannot delete the last palette.');
return;
}
if (!confirm(`Delete palette "${activePaletteName}"?`)) return;
delete palettes[activePaletteName];
savePalettesToStorage(palettes);
const remainingNames = Object.keys(palettes);
loadPalette(remainingNames[0]);
}
// Palette UI
function renderPalette() {
const list = document.getElementById('palette-list');
list.innerHTML = '';
for (const entry of palette) {
const div = document.createElement('div');
div.className = 'palette-entry';
const swatch = document.createElement('span');
swatch.className = 'color-swatch';
swatch.style.background = entry.color;
const charSpan = document.createElement('span');
charSpan.className = 'palette-char';
charSpan.textContent = entry.char;
const labelSpan = document.createElement('span');
labelSpan.className = 'palette-label';
labelSpan.textContent = entry.label;
const editBtn = document.createElement('button');
editBtn.className = 'edit-btn';
editBtn.textContent = 'edit';
const delBtn = document.createElement('button');
delBtn.className = 'del-btn';
delBtn.textContent = 'x';
div.append(swatch, charSpan, labelSpan, editBtn, delBtn);
// Left click palette entry → set m1
div.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') return;
symbols.m1 = entry.char;
m1Slot.textContent = entry.char;
updateSymbolSlotColors();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
// Right click palette entry → set m2
div.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (e.target.tagName === 'BUTTON') return;
symbols.m2 = entry.char;
m2Slot.textContent = entry.char;
updateSymbolSlotColors();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
editBtn.addEventListener('click', () => {
showPaletteForm(entry);
});
delBtn.addEventListener('click', () => {
palette = palette.filter(e => e !== entry);
renderPalette();
renderCanvas();
updateSymbolSlotColors();
saveCurrentPalette();
saveState();
});
list.appendChild(div);
}
}
function showPaletteForm(existing = null) {
const form = document.getElementById('palette-form');
const charInput = document.getElementById('palette-char');
const labelInput = document.getElementById('palette-label-input');
const colorInput = document.getElementById('palette-color');
if (existing) {
charInput.value = existing.char;
labelInput.value = existing.label;
colorInput.value = existing.color;
charInput.disabled = false;
} else {
charInput.value = '';
labelInput.value = '';
colorInput.value = '#cccccc';
charInput.disabled = false;
}
form.style.display = 'block';
form.dataset.editing = existing ? existing.char : '';
charInput.focus();
}
function hidePaletteForm() {
document.getElementById('palette-form').style.display = 'none';
}
function renderCursorOverlay(cellX, cellY) {
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
if (cellX < 0 || cellY < 0) return;
const cells = getCellsInBrush(cellX, cellY);
let fillColor;
if (currentTool === 'paintbrush') {
const activeChar = symbols[activeSlot];
const entry = palette.find(e => e.char === activeChar);
const rgb = entry ? hexToRgb(entry.color) : [255, 255, 255];
fillColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.35)`;
} else {
fillColor = 'rgba(220, 50, 50, 0.35)';
}
overlayCtx.fillStyle = fillColor;
if (viewMode === 'text') {
for (const cell of cells) {
overlayCtx.fillRect(cell.x * CELL_SIZE, cell.y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
} else {
for (const cell of cells) {
overlayCtx.fillRect(cell.x, cell.y, 1, 1);
}
}
}
function savePaletteEntry() {
const form = document.getElementById('palette-form');
const char = document.getElementById('palette-char').value;
const label = document.getElementById('palette-label-input').value;
const color = document.getElementById('palette-color').value;
if (!char || char.length !== 1) return;
const editingChar = form.dataset.editing;
if (editingChar) {
const entry = palette.find(e => e.char === editingChar);
if (entry) {
entry.label = label;
entry.color = color;
// If char changed, update grid and symbols
if (char !== editingChar) {
entry.char = char;
// Update all cells in grid
for (let y = 0; y < gridHeight; y++) {
for (let x = 0; x < gridWidth; x++) {
if (grid[y][x] === editingChar) {
grid[y][x] = char;
}
}
}
// Update symbols if they match old char
if (symbols.m1 === editingChar) {
symbols.m1 = char;
m1Slot.textContent = char;
}
if (symbols.m2 === editingChar) {
symbols.m2 = char;
m2Slot.textContent = char;
}
}
}
} else {
const existing = palette.find(e => e.char === char);
if (existing) {
existing.label = label;
existing.color = color;
} else {
palette.push({ char, label, color });
}
}
hidePaletteForm();
renderPalette();
renderCanvas();
updateSymbolSlotColors();
saveCurrentPalette();
saveState();
}
// Canvas events
canvas.addEventListener('mousedown', (e) => {
e.preventDefault();
if (e.button === 0 || e.button === 2) {
isDrawing = true;
drawButton = e.button;
lastPaintedCell = null;
handlePaint(e, e.button);
}
});
canvas.addEventListener('mousemove', (e) => {
const { x, y } = getGridCoords(e);
lastHoveredCell = { x, y };
renderCursorOverlay(x, y);
if (isDrawing) {
handlePaint(e, drawButton);
}
});
canvas.addEventListener('mouseenter', () => {
overlayCanvas.style.display = 'block';
});
canvas.addEventListener('mouseleave', () => {
overlayCanvas.style.display = 'none';
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
});
// Listen on document so releasing outside canvas still stops drawing
document.addEventListener('mouseup', () => {
if (isDrawing) {
isDrawing = false;
lastPaintedCell = null;
saveState();
}
});
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
// View toggle
textViewCheckbox.addEventListener('change', () => {
viewMode = textViewCheckbox.checked ? 'text' : 'pixels';
sizeCanvas();
renderCanvas();
});
// Tool selection
document.querySelectorAll('input[name="tool"]').forEach(radio => {
radio.addEventListener('change', (e) => {
currentTool = e.target.value;
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
});
});
// Brush controls
document.getElementById('brushSize').addEventListener('input', (e) => {
brushSize = parseInt(e.target.value);
document.getElementById('brushSizeValue').textContent = brushSize;
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
document.getElementById('brushCircle').addEventListener('click', () => {
brushShape = 'circle';
updateBrushUI();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
document.getElementById('brushSquare').addEventListener('click', () => {
brushShape = 'square';
updateBrushUI();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
// Symbol slot selection
m1Slot.addEventListener('click', () => {
activeSlot = 'm1';
updateActiveSlot();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
m2Slot.addEventListener('click', () => {
activeSlot = 'm2';
updateActiveSlot();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
});
// commands - add new commands here
const commands = {
'brush-size-up': () => {
if (brushSize < 8) {
brushSize++;
const slider = document.getElementById('brushSize');
const valueDisplay = document.getElementById('brushSizeValue');
slider.value = brushSize;
valueDisplay.textContent = brushSize;
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
}
},
'brush-size-down': () => {
if (brushSize > 1) {
brushSize--;
const slider = document.getElementById('brushSize');
const valueDisplay = document.getElementById('brushSizeValue');
slider.value = brushSize;
valueDisplay.textContent = brushSize;
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
}
},
'swap-symbols': () => {
const temp = symbols.m1;
symbols.m1 = symbols.m2;
symbols.m2 = temp;
m1Slot.textContent = symbols.m1;
m2Slot.textContent = symbols.m2;
updateSymbolSlotColors();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
},
};
// keybinding map - key string to command name
const keybindings = {
']': 'brush-size-up',
'[': 'brush-size-down',
'x': 'swap-symbols',
};
// Keyboard event handlers
document.addEventListener('keydown', (e) => {
const tag = document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
const command = keybindings[e.key];
if (command && commands[command]) {
e.preventDefault();
commands[command]();
}
});
document.addEventListener('keypress', (e) => {
if (e.target.tagName === 'INPUT') return;
const char = e.key;
if (char.length === 1 && char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126) {
symbols[activeSlot] = char;
if (activeSlot === 'm1') {
m1Slot.textContent = char;
} else {
m2Slot.textContent = char;
}
updateSymbolSlotColors();
renderCursorOverlay(lastHoveredCell.x, lastHoveredCell.y);
saveState();
}
});
// Resize button
document.getElementById('resize').addEventListener('click', () => {
const newWidth = parseInt(widthInput.value);
const newHeight = parseInt(heightInput.value);
if (newWidth > 0 && newHeight > 0 && newWidth <= 1000 && newHeight <= 1000) {
initGrid(newWidth, newHeight, true);
} else {
alert('Invalid dimensions. Must be between 1 and 1000.');
}
});
// Save button
document.getElementById('save').addEventListener('click', saveTOML);
// Load button
document.getElementById('load').addEventListener('click', () => {
document.getElementById('fileInput').click();
});
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
loadTOML(event.target.result);
};
reader.readAsText(file);
}
e.target.value = '';
});
// Clear button
document.getElementById('clear').addEventListener('click', () => {
if (confirm('Clear the entire map? This cannot be undone.')) {
initGrid(gridWidth, gridHeight, false);
}
});
// Palette manager buttons
document.getElementById('palette-select').addEventListener('change', (e) => {
loadPalette(e.target.value);
});
document.getElementById('palette-new').addEventListener('click', createNewPalette);
document.getElementById('palette-rename').addEventListener('click', renamePalette);
document.getElementById('palette-delete').addEventListener('click', deletePalette);
// Palette buttons
document.getElementById('add-palette').addEventListener('click', () => {
showPaletteForm();
});
document.getElementById('palette-ok').addEventListener('click', savePaletteEntry);
document.getElementById('palette-cancel').addEventListener('click', hidePaletteForm);
// Initialize
if (!loadState()) {
renderPalette();
updateSymbolSlotColors();
updateBrushUI();
initGrid(100, 100, false);
}
</script>
</body>
</html>