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:
16
Dockerfile
16
Dockerfile
@@ -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"]
|
||||
|
||||
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>
|
||||
|
||||
131
server.js
Normal file
131
server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user