Add a map editor

This commit is contained in:
Jared Miller 2026-02-07 16:28:21 -05:00
parent 910597e92d
commit 3945887e62
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 846 additions and 0 deletions

View file

@ -18,3 +18,6 @@ debug:
render:
uv run python scripts/render_map.py
edit:
python -c "import webbrowser, pathlib; webbrowser.open('file://' + str(pathlib.Path('scripts/map_editor.html').resolve()))"

843
scripts/map_editor.html Normal file
View file

@ -0,0 +1,843 @@
<!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 {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.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>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-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">
<canvas id="canvas"></canvas>
</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);
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;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
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;
}
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';
}
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 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;
}
paintCell(x, y, symbol);
}
function saveState() {
const state = {
width: gridWidth,
height: gridHeight,
grid: grid,
symbols: symbols,
activeSlot: activeSlot,
palette: palette,
};
localStorage.setItem('map_editor_state', JSON.stringify(state));
}
function loadState() {
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;
widthInput.value = gridWidth;
heightInput.value = gridHeight;
m1Slot.textContent = symbols.m1;
m2Slot.textContent = symbols.m2;
updateActiveSlot();
updateSymbolSlotColors();
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]})`;
}
// 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();
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();
saveState();
});
editBtn.addEventListener('click', () => {
showPaletteForm(entry);
});
delBtn.addEventListener('click', () => {
palette = palette.filter(e => e !== entry);
renderPalette();
renderCanvas();
updateSymbolSlotColors();
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 = true;
} else {
charInput.value = '';
labelInput.value = '';
colorInput.value = '#cccccc';
charInput.disabled = false;
}
form.style.display = 'block';
form.dataset.editing = existing ? existing.char : '';
if (!existing) charInput.focus();
else labelInput.focus();
}
function hidePaletteForm() {
document.getElementById('palette-form').style.display = 'none';
}
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;
}
} 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();
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) => {
if (isDrawing) {
handlePaint(e, drawButton);
}
});
// 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;
});
});
// Symbol slot selection
m1Slot.addEventListener('click', () => {
activeSlot = 'm1';
updateActiveSlot();
saveState();
});
m2Slot.addEventListener('click', () => {
activeSlot = 'm2';
updateActiveSlot();
saveState();
});
// Keyboard input for symbols
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();
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 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();
initGrid(100, 100, false);
}
</script>
</body>
</html>