diff --git a/Dockerfile b/Dockerfile
index 1164ee9..28d12a2 100644
--- a/Dockerfile
+++ b/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"]
diff --git a/editor.html b/editor.html
index 613afee..7507044 100644
--- a/editor.html
+++ b/editor.html
@@ -111,9 +111,10 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
Click an entity to edit
@@ -123,6 +124,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste
Tile: —
—
+ Arrows: Pan | +/-: Zoom | Ctrl+S: Save | RClick+Drag: Pan
Zoom: 3x
@@ -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 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: : [],
+ 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');
+});