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:
187
editor.html
187
editor.html
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user