feat: Node.js server + editor save/load + keyboard controls

- Replace nginx with Node.js server that serves static files AND
  provides API endpoints for reading/writing maps.js directly
  (GET/PUT /api/maps). Creates .bak backup before each save.
- Editor: arrow keys to pan, +/- to zoom, Ctrl+S to save
- Editor: "Save" button writes maps.js directly on the server
- Editor: "Load" button reads and parses maps.js from server
- Editor: auto-loads from server on page open
- Dockerfile changed from nginx:alpine to node:20-alpine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 16:37:12 +01:00
parent 71321e8e88
commit 943ba0b51c
3 changed files with 324 additions and 10 deletions

View File

@@ -111,9 +111,10 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
<div id="props-content"><span id="no-selection">Click an entity to edit</span></div>
</div>
<div id="panel-footer">
<button id="btn-import">📂 Import</button>
<button id="btn-export">💾 Export JSON</button>
<button id="btn-apply">⚡ Copy maps.js</button>
<button id="btn-load-server">📂 Load</button>
<button id="btn-save-server">💾 Save</button>
<button id="btn-export">📋 JSON</button>
<button id="btn-apply">⚡ maps.js</button>
</div>
</div>
@@ -123,6 +124,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
<div id="status-bar">
<span id="tile-pos">Tile: —</span>
<span id="tile-info"></span>
<span style="color:#555">Arrows: Pan | +/-: Zoom | Ctrl+S: Save | RClick+Drag: Pan</span>
<span id="zoom-info">Zoom: 3x</span>
</div>
</div>
@@ -234,9 +236,10 @@ function init() {
// Footer buttons
document.getElementById('btn-export').addEventListener('click', exportJSON);
document.getElementById('btn-apply').addEventListener('click', exportMapsJS);
document.getElementById('btn-import').addEventListener('click', () => openModal('import-modal'));
document.getElementById('btn-do-import').addEventListener('click', doImport);
document.getElementById('btn-copy-export').addEventListener('click', copyExport);
document.getElementById('btn-save-server').addEventListener('click', saveToServer);
document.getElementById('btn-load-server').addEventListener('click', loadFromServer);
resizeCanvas();
updateEntityList();
@@ -588,9 +591,15 @@ function onWheel(e) {
render();
}
const PAN_SPEED = 40;
function onKeyDown(e) {
// Don't capture keys when typing in inputs
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
// Delete selected entity
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEntity) {
e.preventDefault();
const md = getData();
const { type, index } = selectedEntity;
if (type === 'npc') md.npcs.splice(index, 1);
@@ -598,7 +607,50 @@ function onKeyDown(e) {
else if (type === 'interaction') md.interactions.splice(index, 1);
selectedEntity = null;
updateEntityList(); updateProps(); render();
return;
}
// Zoom with + / -
if (e.key === '+' || e.key === '=') {
e.preventDefault();
const cx = canvas.width / 2, cy = canvas.height / 2;
const oldScale = scale;
scale = Math.min(8, scale + 0.5);
const factor = scale / oldScale;
camera.x = cx - (cx - camera.x) * factor;
camera.y = cy - (cy - camera.y) * factor;
TILE_PX = TILE * scale;
document.getElementById('zoom-info').textContent = `Zoom: ${scale}x`;
render();
return;
}
if (e.key === '-' || e.key === '_') {
e.preventDefault();
const cx = canvas.width / 2, cy = canvas.height / 2;
const oldScale = scale;
scale = Math.max(1, scale - 0.5);
const factor = scale / oldScale;
camera.x = cx - (cx - camera.x) * factor;
camera.y = cy - (cy - camera.y) * factor;
TILE_PX = TILE * scale;
document.getElementById('zoom-info').textContent = `Zoom: ${scale}x`;
render();
return;
}
// Pan with arrow keys
if (e.key === 'ArrowUp') { e.preventDefault(); camera.y += PAN_SPEED; render(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); camera.y -= PAN_SPEED; render(); return; }
if (e.key === 'ArrowLeft') { e.preventDefault(); camera.x += PAN_SPEED; render(); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); camera.x -= PAN_SPEED; render(); return; }
// Ctrl+S = save to server
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveToServer();
return;
}
// Tool shortcuts
if (e.key === '1') selectTool('wall');
if (e.key === '2') selectTool('erase');
@@ -952,9 +1004,136 @@ function toast(msg) {
setTimeout(() => el.classList.remove('show'), 2000);
}
// ==================== Server save/load ====================
async function saveToServer() {
const code = generateMapsJS();
try {
const res = await fetch('/api/maps', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: code })
});
const data = await res.json();
if (data.ok) {
toast(`Saved maps.js (${data.bytes} bytes)`);
} else {
toast('Error: ' + (data.error || 'Unknown'));
}
} catch (e) {
toast('Save failed: ' + e.message);
}
}
async function loadFromServer() {
try {
const res = await fetch('/api/maps');
const data = await res.json();
if (!data.content) { toast('No maps.js found on server'); return; }
// Parse the maps.js to extract wall data and entities
// We parse the JS source to extract the wall objects and map definitions
const src = data.content;
parseAndLoadMapsJS(src);
updateEntityList(); updateProps(); render();
toast('Loaded maps.js from server');
} catch (e) {
toast('Load failed: ' + e.message);
}
}
/**
* Parse generated maps.js source and populate mapData
* This handles the format produced by generateMapsJS()
*/
function parseAndLoadMapsJS(src) {
// Extract wall data objects: const xxxWalls = { ... };
// And map objects: const xxxMap = { ... };
// Strategy: use regex to find wall definitions and eval them safely
// Extract walls for each known map by finding "const <name>Walls = {"
for (const mapId of Object.keys(mapConfigs)) {
const vName = varName(mapId);
// Parse walls
const wallMatch = src.match(new RegExp(`const\\s+${vName}Walls\\s*=\\s*\\{([\\s\\S]*?)\\};`));
if (wallMatch) {
const wallBody = wallMatch[1];
const wallData = {};
// Match each row: <number>: [<cols>],
const rowRegex = /(\d+)\s*:\s*\[([\d,\s.r()]*)\]/g;
let rm;
while ((rm = rowRegex.exec(wallBody)) !== null) {
const row = parseInt(rm[1]);
const colStr = rm[2].trim();
if (!colStr) continue;
// Handle ...r(a,b) ranges and plain numbers
const cols = [];
const parts = colStr.split(',');
for (let i = 0; i < parts.length; i++) {
const p = parts[i].trim();
const rangeMatch = p.match(/\.\.\.r\((\d+)\s*$/);
if (rangeMatch) {
// Next part has the end: "b)"
const end = parseInt(parts[++i]);
for (let c = parseInt(rangeMatch[1]); c <= end; c++) cols.push(c);
} else {
const n = parseInt(p);
if (!isNaN(n)) cols.push(n);
}
}
wallData[row] = cols;
}
mapData[mapId].walls = wallDataToSet(wallData);
}
// Parse spawn
const spawnMatch = src.match(new RegExp(`${vName}Map[\\s\\S]*?spawn:\\s*\\{\\s*x:\\s*(\\d+)\\s*,\\s*y:\\s*(\\d+)`));
if (spawnMatch) {
mapData[mapId].spawn = { x: parseInt(spawnMatch[1]), y: parseInt(spawnMatch[2]) };
}
// Parse NPCs - find the npcs array
const npcsMatch = src.match(new RegExp(`${vName}Map[\\s\\S]*?npcs:\\s*\\[([\\s\\S]*?)\\]\\s*,\\s*\\n\\s*interactions`));
if (npcsMatch) {
try {
const npcsStr = '[' + npcsMatch[1] + ']';
// Use Function constructor to safely evaluate the array literal
const npcs = new Function('return ' + npcsStr.replace(/'/g, '"'))();
mapData[mapId].npcs = npcs;
} catch (e) { console.warn(`Failed to parse NPCs for ${mapId}:`, e); }
}
// Parse exits
const exitsMatch = src.match(new RegExp(`${vName}Map[\\s\\S]*?exits:\\s*\\[([\\s\\S]*?)\\]\\s*,\\s*\\n\\s*npcs`));
if (exitsMatch) {
try {
const exitsStr = '[' + exitsMatch[1] + ']';
const exits = new Function('return ' + exitsStr.replace(/'/g, '"'))();
mapData[mapId].exits = exits;
} catch (e) { console.warn(`Failed to parse exits for ${mapId}:`, e); }
}
// Parse interactions
const interMatch = src.match(new RegExp(`${vName}Map[\\s\\S]*?interactions:\\s*\\[([\\s\\S]*?)\\]\\s*\\n\\s*\\}`));
if (interMatch) {
try {
const interStr = '[' + interMatch[1] + ']';
const interactions = new Function('return ' + interStr.replace(/'/g, '"'))();
mapData[mapId].interactions = interactions;
} catch (e) { console.warn(`Failed to parse interactions for ${mapId}:`, e); }
}
}
}
// ==================== Boot ====================
init();
// Auto-load from server on start
loadFromServer().catch(() => {
console.log('[editor] Server load failed, using embedded defaults');
});
</script>
</body>
</html>