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'); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..a2d3887 --- /dev/null +++ b/server.js @@ -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}`); +});