Add a map editor
This commit is contained in:
parent
910597e92d
commit
3945887e62
2 changed files with 846 additions and 0 deletions
3
justfile
3
justfile
|
|
@ -18,3 +18,6 @@ debug:
|
||||||
|
|
||||||
render:
|
render:
|
||||||
uv run python scripts/render_map.py
|
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
843
scripts/map_editor.html
Normal 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>
|
||||||
Loading…
Reference in a new issue