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

@@ -1,7 +1,11 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/
COPY editor.html /usr/share/nginx/html/
COPY css/ /usr/share/nginx/html/css/
COPY js/ /usr/share/nginx/html/js/
COPY assets/ /usr/share/nginx/html/assets/
FROM node:20-alpine
WORKDIR /app
COPY server.js .
RUN mkdir -p public
COPY index.html public/
COPY editor.html public/
COPY css/ public/css/
COPY js/ public/js/
COPY assets/ public/assets/
EXPOSE 80
CMD ["node", "server.js"]

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>

131
server.js Normal file
View File

@@ -0,0 +1,131 @@
// Lightweight static file server + editor API for saving maps.js
// Used in production Docker container
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 80;
const STATIC_DIR = path.join(__dirname, 'public');
const MAPS_FILE = path.join(STATIC_DIR, 'js', 'world', 'maps.js');
const MIME = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
const server = http.createServer((req, res) => {
// CORS headers for editor
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// === API: GET /api/maps — read maps.js source ===
if (req.method === 'GET' && req.url === '/api/maps') {
fs.readFile(MAPS_FILE, 'utf-8', (err, data) => {
if (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read maps.js' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ content: data }));
});
return;
}
// === API: PUT /api/maps — write maps.js source ===
if (req.method === 'PUT' && req.url === '/api/maps') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const { content } = JSON.parse(body);
if (!content || typeof content !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing content field' }));
return;
}
// Backup before overwrite
const backup = MAPS_FILE + '.bak';
if (fs.existsSync(MAPS_FILE)) {
fs.copyFileSync(MAPS_FILE, backup);
}
fs.writeFileSync(MAPS_FILE, content, 'utf-8');
console.log(`[server] maps.js saved (${content.length} bytes)`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, bytes: content.length }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
// === API: GET /api/maps/json — parse current map data as JSON ===
if (req.method === 'GET' && req.url === '/api/maps/json') {
fs.readFile(MAPS_FILE, 'utf-8', (err, data) => {
if (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read maps.js' }));
return;
}
// Extract JSON-serializable data from the JS source
// This is a best-effort parser for the generated maps.js format
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ source: data }));
});
return;
}
// === Static file serving ===
let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url);
// Prevent directory traversal
if (!filePath.startsWith(STATIC_DIR)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
res.writeHead(404);
res.end('Not found');
} else {
res.writeHead(500);
res.end('Server error');
}
return;
}
// Cache static assets
if (ext === '.png' || ext === '.jpg' || ext === '.woff2') {
res.setHeader('Cache-Control', 'public, max-age=86400');
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
});
server.listen(PORT, () => {
console.log(`[server] Logic Gates running on port ${PORT}`);
console.log(`[server] Static: ${STATIC_DIR}`);
console.log(`[server] Maps file: ${MAPS_FILE}`);
});