Files
logic-gates/editor.html
Jose Luis 943ba0b51c 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>
2026-03-20 16:37:12 +01:00

1140 lines
47 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Level Editor — Logic Gates</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0f1119; --panel: #181c2a; --border: #2a2f45;
--text: #c8cad0; --text2: #888; --accent: #00e599;
--red: #ff5555; --green: #55ff55; --yellow: #ffdd44;
--purple: #cc55ff; --blue: #4488ff; --orange: #ff8844;
}
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; font-size: 13px; }
/* ===== Left Panel ===== */
#panel { width: 300px; min-width: 300px; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
#panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
#panel-header h1 { font-size: 15px; font-weight: 700; color: var(--accent); flex: 1; }
#panel-header select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; font-size: 12px; }
/* Toolbar */
#tools { padding: 8px 12px; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 4px; }
.tool-btn { padding: 5px 10px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text2); cursor: pointer; font-size: 11px; transition: all 0.15s; }
.tool-btn:hover { border-color: var(--accent); color: var(--text); }
.tool-btn.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
.tool-btn.danger { border-color: var(--red); color: var(--red); }
.tool-btn.danger:hover { background: var(--red); color: #000; }
/* Entity list */
#entity-section { flex: 1; overflow-y: auto; padding: 8px 0; }
#entity-section h3 { padding: 4px 16px; font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; }
.entity-item { padding: 6px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 12px; border-left: 3px solid transparent; }
.entity-item:hover { background: rgba(255,255,255,0.04); }
.entity-item.selected { background: rgba(0,229,153,0.1); border-left-color: var(--accent); }
.entity-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.entity-item .coords { color: var(--text2); margin-left: auto; font-size: 10px; font-family: monospace; }
/* Properties panel */
#props { border-top: 1px solid var(--border); padding: 12px 16px; max-height: 320px; overflow-y: auto; }
#props h3 { font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
#props-content { display: flex; flex-direction: column; gap: 6px; }
.prop-row { display: flex; align-items: center; gap: 8px; }
.prop-row label { width: 70px; font-size: 11px; color: var(--text2); flex-shrink: 0; }
.prop-row input, .prop-row select, .prop-row textarea { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; padding: 4px 6px; color: var(--text); font-size: 12px; font-family: inherit; }
.prop-row textarea { resize: vertical; min-height: 50px; }
.prop-row input:focus, .prop-row select:focus, .prop-row textarea:focus { outline: none; border-color: var(--accent); }
.prop-row input[type="number"] { width: 50px; flex: 0; }
#no-selection { color: var(--text2); font-size: 12px; font-style: italic; }
/* Footer */
#panel-footer { padding: 8px 12px; border-top: 1px solid var(--border); display: flex; gap: 6px; }
#panel-footer button { flex: 1; padding: 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); cursor: pointer; font-size: 11px; font-weight: 600; }
#panel-footer button:hover { border-color: var(--accent); }
#btn-export { background: var(--accent) !important; color: #000 !important; border-color: var(--accent) !important; }
#btn-export:hover { filter: brightness(1.15); }
/* ===== Canvas area ===== */
#canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0a0b10; }
#editor-canvas { display: block; cursor: crosshair; }
#status-bar { position: absolute; bottom: 0; left: 0; right: 0; padding: 4px 12px; background: rgba(15,17,25,0.9); border-top: 1px solid var(--border); font-size: 11px; color: var(--text2); display: flex; gap: 16px; }
#status-bar span { font-family: monospace; }
#zoom-info { margin-left: auto; }
/* Toast */
#toast { position: fixed; top: 16px; right: 16px; padding: 10px 18px; background: var(--accent); color: #000; border-radius: 6px; font-weight: 600; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; }
#toast.show { opacity: 1; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.show { display: flex; }
.modal { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; min-width: 400px; max-width: 600px; max-height: 80vh; overflow-y: auto; }
.modal h2 { font-size: 15px; margin-bottom: 12px; color: var(--accent); }
.modal pre { background: var(--bg); padding: 12px; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 400px; white-space: pre; font-family: 'Cascadia Code', 'Fira Code', monospace; line-height: 1.4; }
.modal-actions { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; }
.modal-actions button { padding: 6px 14px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); cursor: pointer; font-size: 12px; }
.modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
</style>
</head>
<body>
<!-- Left panel -->
<div id="panel">
<div id="panel-header">
<h1>⚡ Level Editor</h1>
<select id="map-select"></select>
</div>
<div id="tools">
<button class="tool-btn active" data-tool="wall" title="Click/drag to paint walls">🧱 Wall</button>
<button class="tool-btn" data-tool="erase" title="Click/drag to erase walls">🧹 Erase</button>
<button class="tool-btn" data-tool="spawn" title="Click to set player spawn">🏠 Spawn</button>
<button class="tool-btn" data-tool="npc" title="Click to place NPC">👤 NPC</button>
<button class="tool-btn" data-tool="exit" title="Click to place exit">🚪 Exit</button>
<button class="tool-btn" data-tool="interaction" title="Click to place interaction">⚡ Interact</button>
<button class="tool-btn" data-tool="select" title="Click entity to select/move">🔍 Select</button>
<button class="tool-btn danger" data-tool="delete" title="Click entity to delete">✕ Delete</button>
</div>
<div id="entity-section">
<h3>Spawn</h3>
<div id="spawn-list"></div>
<h3>NPCs</h3>
<div id="npc-list"></div>
<h3>Exits</h3>
<div id="exit-list"></div>
<h3>Interactions</h3>
<div id="interaction-list"></div>
</div>
<div id="props">
<h3>Properties</h3>
<div id="props-content"><span id="no-selection">Click an entity to edit</span></div>
</div>
<div id="panel-footer">
<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>
<!-- Canvas area -->
<div id="canvas-wrap">
<canvas id="editor-canvas"></canvas>
<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>
<!-- Toast -->
<div id="toast"></div>
<!-- Export modal -->
<div class="modal-overlay" id="export-modal">
<div class="modal">
<h2>Export Map Data</h2>
<pre id="export-code"></pre>
<div class="modal-actions">
<button onclick="closeModal('export-modal')">Close</button>
<button class="primary" id="btn-copy-export">📋 Copy to Clipboard</button>
</div>
</div>
</div>
<!-- Import modal -->
<div class="modal-overlay" id="import-modal">
<div class="modal">
<h2>Import Map Data (JSON)</h2>
<textarea id="import-textarea" style="width:100%; min-height:200px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; padding:8px; font-family:monospace; font-size:11px;" placeholder="Paste JSON here..."></textarea>
<div class="modal-actions">
<button onclick="closeModal('import-modal')">Cancel</button>
<button class="primary" id="btn-do-import">Import</button>
</div>
</div>
</div>
<script>
// ==================== State ====================
const TILE = 16;
let scale = 3;
let TILE_PX = TILE * scale;
const mapConfigs = {
lab: { image: 'assets/map/lab.png', widthTiles: 10, heightTiles: 12, name: 'Circuit Lab' },
'pallet-town': { image: 'assets/map/pallet-town.png', widthTiles: 20, heightTiles: 18, name: 'Neon Town' },
'house-a-1f': { image: 'assets/map/house-a-1f.png', widthTiles: 8, heightTiles: 8, name: 'House Interior' },
'route-1': { image: 'assets/map/route-1.png', widthTiles: 20, heightTiles: 36, name: 'Route 1' }
};
let currentMapId = 'lab';
let mapData = {}; // { [mapId]: { walls: Set, spawn, npcs, exits, interactions } }
let selectedTool = 'wall';
let selectedEntity = null; // { type, index }
let isPainting = false;
let camera = { x: 0, y: 0 };
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let camStart = { x: 0, y: 0 };
let mapImages = {};
const canvas = document.getElementById('editor-canvas');
const ctx = canvas.getContext('2d');
// ==================== Init ====================
function init() {
// Build map selector
const sel = document.getElementById('map-select');
for (const [id, cfg] of Object.entries(mapConfigs)) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = `${cfg.name} (${cfg.widthTiles}×${cfg.heightTiles})`;
sel.appendChild(opt);
}
sel.addEventListener('change', () => switchMap(sel.value));
// Init map data for all maps
for (const id of Object.keys(mapConfigs)) {
mapData[id] = { walls: new Set(), spawn: { x: 0, y: 0 }, npcs: [], exits: [], interactions: [] };
}
// Load current game data
loadCurrentGameData();
// Load images
let loaded = 0;
const total = Object.keys(mapConfigs).length;
for (const [id, cfg] of Object.entries(mapConfigs)) {
const img = new Image();
img.onload = () => { mapImages[id] = img; loaded++; if (loaded === total) { resizeCanvas(); render(); } };
img.onerror = () => { loaded++; if (loaded === total) { resizeCanvas(); render(); } };
img.src = cfg.image;
}
// Tool buttons
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedTool = btn.dataset.tool;
if (selectedTool !== 'select') { selectedEntity = null; updateProps(); }
});
});
// Canvas events
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('resize', () => { resizeCanvas(); render(); });
document.addEventListener('keydown', onKeyDown);
// Footer buttons
document.getElementById('btn-export').addEventListener('click', exportJSON);
document.getElementById('btn-apply').addEventListener('click', exportMapsJS);
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();
}
// ==================== Load game data ====================
function loadCurrentGameData() {
// Load from the actual maps.js definitions embedded here
// Lab
const labWalls = {
0: r(0,9), 1: [0,1,7,8,9], 2: [0,7,8,9], 3: [0,2,3,9], 4: [0,2,3,9],
5: [0,9], 6: [0,1,2,6,7,8,9], 7: [0,1,2,6,7,8,9], 8: [0,9], 9: [0,9], 10: [0,9],
11: [0,1,2,3,6,7,8,9]
};
mapData.lab.walls = wallDataToSet(labWalls);
mapData.lab.spawn = { x: 4, y: 10 };
mapData.lab.npcs = [
{ id: 'professor', x: 5, y: 1, facing: 'down', dialog: ['Welcome to the Circuit Lab!','I\'m the Professor. We study logic gates here.','Use the workshop tables to design circuits.','Press TAB to open the Workshop anytime!'] }
];
mapData.lab.exits = [
{ x: 4, y: 11, targetMap: 'pallet-town', targetX: 10, targetY: 7 },
{ x: 5, y: 11, targetMap: 'pallet-town', targetX: 10, targetY: 7 }
];
mapData.lab.interactions = [
{ x: 2, y: 3, type: 'workshop', label: 'Workshop Table' },
{ x: 3, y: 3, type: 'workshop', label: 'Workshop Table' },
{ x: 2, y: 4, type: 'workshop', label: 'Workshop Table' },
{ x: 3, y: 4, type: 'workshop', label: 'Workshop Table' },
{ x: 1, y: 6, type: 'sign', label: 'Bookshelf', dialog: ['A collection of logic circuit manuals.'] },
{ x: 7, y: 6, type: 'sign', label: 'Bookshelf', dialog: ['Advanced boolean algebra textbooks.'] },
{ x: 8, y: 1, type: 'terminal', label: 'Terminal', dialog: ['Circuit analysis terminal.','Connect components to solve puzzles.'] },
{ x: 8, y: 2, type: 'puzzle_door', puzzleId: 'lab_door_1', requiredOutputs: [1,0,1,1], label: 'Locked Door' }
];
// Town
const townW = {};
function ta(row, cols) { if (!townW[row]) townW[row] = []; townW[row].push(...cols); }
ta(0, r(0,19)); ta(1, [0,1,2,3,4,5,6,7,8,9,10,11,18,19]);
for (let rr = 2; rr <= 4; rr++) { ta(rr, [0,1,2,3,4,5,19]); }
ta(5, [0,1,2,4,5,19]);
for (let rr = 2; rr <= 5; rr++) { ta(rr, [12,13,14,15,16,17,18,19]); }
ta(6, [0,19]); ta(7, [0,19]); ta(8, [0,1,18,19]);
ta(9, [0,1,2,3,4,5,18,19]); ta(10, [0,1,2,3,4,5,18,19]);
ta(11, [0,1,18,19]); ta(12, [0,1,18,19]);
ta(13, [0,1,14,15,16,17,18,19]); ta(14, [0,1,2,3,14,15,16,17,18,19]);
ta(15, [0,1,2,3,14,15,16,17,18,19]); ta(16, [0,1,2,3,14,15,16,17,18,19]);
ta(17, r(0,19));
mapData['pallet-town'].walls = wallDataToSet(townW);
mapData['pallet-town'].spawn = { x: 10, y: 8 };
mapData['pallet-town'].npcs = [
{ id: 'merchant', x: 8, y: 10, facing: 'right', dialog: ['Welcome to Neon Town!','I trade in rare logic components.'] },
{ id: 'guide', x: 11, y: 12, facing: 'down', dialog: ['The Circuit Lab is in the big building up north.','Press TAB anytime to open your Workshop.'] }
];
mapData['pallet-town'].exits = [
{ x: 10, y: 7, targetMap: 'lab', targetX: 4, targetY: 10 }
];
mapData['pallet-town'].interactions = [
{ x: 3, y: 5, type: 'door', label: 'House', dialog: ['The door is locked.'] },
{ x: 10, y: 9, type: 'sign', label: 'Sign', dialog: ['Welcome to Neon Town!','Circuit Lab ↑'] },
{ x: 12, y: 6, type: 'sign', label: 'Sign', dialog: ['CIRCUIT LAB','Open for research!'] }
];
}
function r(a, b) { const arr = []; for (let i = a; i <= b; i++) arr.push(i); return arr; }
function wallDataToSet(data) {
const s = new Set();
for (const [row, cols] of Object.entries(data)) {
for (const col of cols) s.add(`${col},${row}`);
}
return s;
}
// ==================== Map switching ====================
function switchMap(id) {
currentMapId = id;
selectedEntity = null;
// Center camera on spawn
const md = mapData[id];
const cfg = mapConfigs[id];
if (md && cfg) {
camera.x = -(md.spawn.x * TILE_PX - canvas.width / 2 + TILE_PX / 2);
camera.y = -(md.spawn.y * TILE_PX - canvas.height / 2 + TILE_PX / 2);
}
updateEntityList();
updateProps();
render();
}
function getCfg() { return mapConfigs[currentMapId]; }
function getData() { return mapData[currentMapId]; }
// ==================== Canvas ====================
function resizeCanvas() {
const wrap = document.getElementById('canvas-wrap');
canvas.width = wrap.clientWidth;
canvas.height = wrap.clientHeight;
render();
}
// ==================== Rendering ====================
function render() {
if (!ctx) return;
const w = canvas.width, h = canvas.height;
ctx.fillStyle = '#0a0b10';
ctx.fillRect(0, 0, w, h);
const cfg = getCfg();
const md = getData();
if (!cfg) return;
ctx.save();
ctx.translate(camera.x, camera.y);
ctx.imageSmoothingEnabled = false;
// Map image
const img = mapImages[currentMapId];
if (img) {
ctx.drawImage(img, 0, 0, img.width * scale, img.height * scale);
}
const mw = cfg.widthTiles, mh = cfg.heightTiles;
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 0.5;
for (let x = 0; x <= mw; x++) {
ctx.beginPath(); ctx.moveTo(x * TILE_PX, 0); ctx.lineTo(x * TILE_PX, mh * TILE_PX); ctx.stroke();
}
for (let y = 0; y <= mh; y++) {
ctx.beginPath(); ctx.moveTo(0, y * TILE_PX); ctx.lineTo(mw * TILE_PX, y * TILE_PX); ctx.stroke();
}
// Walls
for (const key of md.walls) {
const [x, y] = key.split(',').map(Number);
ctx.fillStyle = 'rgba(255, 50, 50, 0.3)';
ctx.fillRect(x * TILE_PX, y * TILE_PX, TILE_PX, TILE_PX);
// X pattern
ctx.strokeStyle = 'rgba(255, 50, 50, 0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x * TILE_PX + 2, y * TILE_PX + 2);
ctx.lineTo((x+1) * TILE_PX - 2, (y+1) * TILE_PX - 2);
ctx.moveTo((x+1) * TILE_PX - 2, y * TILE_PX + 2);
ctx.lineTo(x * TILE_PX + 2, (y+1) * TILE_PX - 2);
ctx.stroke();
}
// Spawn
const sp = md.spawn;
ctx.fillStyle = 'rgba(0, 229, 153, 0.4)';
ctx.fillRect(sp.x * TILE_PX, sp.y * TILE_PX, TILE_PX, TILE_PX);
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 2;
ctx.strokeRect(sp.x * TILE_PX, sp.y * TILE_PX, TILE_PX, TILE_PX);
drawLabel(ctx, '🏠', sp.x, sp.y);
// Exits
md.exits.forEach((e, i) => {
ctx.fillStyle = 'rgba(50, 255, 50, 0.35)';
ctx.fillRect(e.x * TILE_PX, e.y * TILE_PX, TILE_PX, TILE_PX);
ctx.strokeStyle = selectedEntity?.type === 'exit' && selectedEntity.index === i ? '#fff' : '#55ff55';
ctx.lineWidth = selectedEntity?.type === 'exit' && selectedEntity.index === i ? 2.5 : 1.5;
ctx.strokeRect(e.x * TILE_PX, e.y * TILE_PX, TILE_PX, TILE_PX);
drawLabel(ctx, '🚪', e.x, e.y);
});
// Interactions
md.interactions.forEach((inter, i) => {
ctx.fillStyle = 'rgba(255, 220, 0, 0.3)';
ctx.fillRect(inter.x * TILE_PX, inter.y * TILE_PX, TILE_PX, TILE_PX);
ctx.strokeStyle = selectedEntity?.type === 'interaction' && selectedEntity.index === i ? '#fff' : '#ffdd44';
ctx.lineWidth = selectedEntity?.type === 'interaction' && selectedEntity.index === i ? 2.5 : 1.5;
ctx.strokeRect(inter.x * TILE_PX, inter.y * TILE_PX, TILE_PX, TILE_PX);
const icon = inter.type === 'workshop' ? '🔧' : inter.type === 'puzzle_door' ? '🔒' : inter.type === 'terminal' ? '💻' : '📋';
drawLabel(ctx, icon, inter.x, inter.y);
});
// NPCs
md.npcs.forEach((npc, i) => {
ctx.fillStyle = 'rgba(200, 50, 255, 0.35)';
ctx.fillRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX);
ctx.strokeStyle = selectedEntity?.type === 'npc' && selectedEntity.index === i ? '#fff' : '#cc55ff';
ctx.lineWidth = selectedEntity?.type === 'npc' && selectedEntity.index === i ? 2.5 : 1.5;
ctx.strokeRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX);
drawLabel(ctx, '👤', npc.x, npc.y);
});
// Tile coordinates (when zoomed in enough)
if (scale >= 3) {
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = `${Math.max(8, scale * 3)}px monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let y = 0; y < mh; y++) {
for (let x = 0; x < mw; x++) {
const sx = x * TILE_PX + camera.x, sy = y * TILE_PX + camera.y;
// Only render visible coords
if (sx + TILE_PX >= 0 && sx <= canvas.width && sy + TILE_PX >= 0 && sy <= canvas.height) {
// Use untranslated check but draw in translated space
ctx.fillText(`${x},${y}`, x * TILE_PX + 2, y * TILE_PX + TILE_PX - scale * 3 - 1);
}
}
}
}
ctx.restore();
}
function drawLabel(ctx, emoji, tx, ty) {
ctx.font = `${Math.max(12, TILE_PX * 0.5)}px serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText(emoji, tx * TILE_PX + TILE_PX / 2, ty * TILE_PX + TILE_PX / 2);
}
// ==================== Mouse events ====================
function screenToTile(mx, my) {
const x = Math.floor((mx - camera.x) / TILE_PX);
const y = Math.floor((my - camera.y) / TILE_PX);
return { x, y };
}
function inBounds(tx, ty) {
const cfg = getCfg();
return tx >= 0 && tx < cfg.widthTiles && ty >= 0 && ty < cfg.heightTiles;
}
function onMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
// Middle-click or right-click: pan
if (e.button === 1 || e.button === 2) {
isDragging = true;
dragStart = { x: e.clientX, y: e.clientY };
camStart = { x: camera.x, y: camera.y };
canvas.style.cursor = 'grabbing';
return;
}
const tile = screenToTile(mx, my);
if (!inBounds(tile.x, tile.y)) return;
const md = getData();
const key = `${tile.x},${tile.y}`;
switch (selectedTool) {
case 'wall':
isPainting = true;
md.walls.add(key);
render();
break;
case 'erase':
isPainting = true;
md.walls.delete(key);
render();
break;
case 'spawn':
md.spawn = { x: tile.x, y: tile.y };
updateEntityList(); render();
break;
case 'npc':
md.npcs.push({ id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] });
selectedEntity = { type: 'npc', index: md.npcs.length - 1 };
updateEntityList(); updateProps(); render();
break;
case 'exit':
md.exits.push({ x: tile.x, y: tile.y, targetMap: 'lab', targetX: 0, targetY: 0 });
selectedEntity = { type: 'exit', index: md.exits.length - 1 };
updateEntityList(); updateProps(); render();
break;
case 'interaction':
md.interactions.push({ x: tile.x, y: tile.y, type: 'sign', label: 'Sign', dialog: ['...'] });
selectedEntity = { type: 'interaction', index: md.interactions.length - 1 };
updateEntityList(); updateProps(); render();
break;
case 'select':
selectEntityAt(tile.x, tile.y);
render();
break;
case 'delete':
deleteEntityAt(tile.x, tile.y);
render();
break;
}
}
function onMouseMove(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const tile = screenToTile(mx, my);
// Status bar
document.getElementById('tile-pos').textContent = `Tile: (${tile.x}, ${tile.y})`;
const md = getData();
const key = `${tile.x},${tile.y}`;
let info = '';
if (md.walls.has(key)) info += 'Wall ';
if (md.spawn.x === tile.x && md.spawn.y === tile.y) info += 'Spawn ';
md.npcs.forEach(n => { if (n.x === tile.x && n.y === tile.y) info += `NPC:${n.id} `; });
md.exits.forEach(e => { if (e.x === tile.x && e.y === tile.y) info += `Exit→${e.targetMap} `; });
md.interactions.forEach(i => { if (i.x === tile.x && i.y === tile.y) info += `${i.type}:${i.label} `; });
document.getElementById('tile-info').textContent = info || '(empty)';
if (isDragging) {
camera.x = camStart.x + (e.clientX - dragStart.x);
camera.y = camStart.y + (e.clientY - dragStart.y);
render();
return;
}
if (isPainting && inBounds(tile.x, tile.y)) {
if (selectedTool === 'wall') md.walls.add(key);
else if (selectedTool === 'erase') md.walls.delete(key);
render();
}
}
function onMouseUp(e) {
isPainting = false;
if (isDragging) {
isDragging = false;
canvas.style.cursor = 'crosshair';
}
}
function onWheel(e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const oldScale = scale;
if (e.deltaY < 0) scale = Math.min(8, scale + 0.5);
else scale = Math.max(1, scale - 0.5);
// Zoom toward mouse
const factor = scale / oldScale;
camera.x = mx - (mx - camera.x) * factor;
camera.y = my - (my - camera.y) * factor;
TILE_PX = TILE * scale;
document.getElementById('zoom-info').textContent = `Zoom: ${scale}x`;
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);
else if (type === 'exit') md.exits.splice(index, 1);
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');
if (e.key === '3') selectTool('spawn');
if (e.key === '4') selectTool('npc');
if (e.key === '5') selectTool('exit');
if (e.key === '6') selectTool('interaction');
if (e.key === '7') selectTool('select');
}
function selectTool(tool) {
selectedTool = tool;
document.querySelectorAll('.tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === tool));
}
// ==================== Entity selection ====================
function selectEntityAt(tx, ty) {
const md = getData();
// Check NPCs first, then exits, then interactions
let idx = md.npcs.findIndex(n => n.x === tx && n.y === ty);
if (idx >= 0) { selectedEntity = { type: 'npc', index: idx }; updateEntityList(); updateProps(); return; }
idx = md.exits.findIndex(e => e.x === tx && e.y === ty);
if (idx >= 0) { selectedEntity = { type: 'exit', index: idx }; updateEntityList(); updateProps(); return; }
idx = md.interactions.findIndex(i => i.x === tx && i.y === ty);
if (idx >= 0) { selectedEntity = { type: 'interaction', index: idx }; updateEntityList(); updateProps(); return; }
// Spawn?
if (md.spawn.x === tx && md.spawn.y === ty) { selectedEntity = { type: 'spawn', index: 0 }; updateEntityList(); updateProps(); return; }
selectedEntity = null;
updateEntityList(); updateProps();
}
function deleteEntityAt(tx, ty) {
const md = getData();
let idx = md.npcs.findIndex(n => n.x === tx && n.y === ty);
if (idx >= 0) { md.npcs.splice(idx, 1); selectedEntity = null; updateEntityList(); updateProps(); return; }
idx = md.exits.findIndex(e => e.x === tx && e.y === ty);
if (idx >= 0) { md.exits.splice(idx, 1); selectedEntity = null; updateEntityList(); updateProps(); return; }
idx = md.interactions.findIndex(i => i.x === tx && i.y === ty);
if (idx >= 0) { md.interactions.splice(idx, 1); selectedEntity = null; updateEntityList(); updateProps(); return; }
}
// ==================== Entity list ====================
function updateEntityList() {
const md = getData();
document.getElementById('spawn-list').innerHTML = makeEntityItem('spawn', 0, '🏠', `Spawn`, md.spawn.x, md.spawn.y);
document.getElementById('npc-list').innerHTML = md.npcs.map((n, i) =>
makeEntityItem('npc', i, '👤', n.id, n.x, n.y, '#cc55ff')
).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>';
document.getElementById('exit-list').innerHTML = md.exits.map((e, i) =>
makeEntityItem('exit', i, '🚪', `${e.targetMap}`, e.x, e.y, '#55ff55')
).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>';
document.getElementById('interaction-list').innerHTML = md.interactions.map((inter, i) =>
makeEntityItem('interaction', i, '⚡', `${inter.type}: ${inter.label}`, inter.x, inter.y, '#ffdd44')
).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>';
// Wire click events
document.querySelectorAll('.entity-item').forEach(el => {
el.addEventListener('click', () => {
selectedEntity = { type: el.dataset.type, index: parseInt(el.dataset.index) };
updateEntityList(); updateProps(); render();
// Center camera on entity
const ent = getSelectedEntityData();
if (ent) {
camera.x = -(ent.x * TILE_PX - canvas.width / 2 + TILE_PX / 2);
camera.y = -(ent.y * TILE_PX - canvas.height / 2 + TILE_PX / 2);
render();
}
});
});
}
function makeEntityItem(type, index, icon, name, x, y, dotColor = '#00e599') {
const sel = selectedEntity?.type === type && selectedEntity?.index === index;
return `<div class="entity-item ${sel ? 'selected' : ''}" data-type="${type}" data-index="${index}">
<div class="entity-dot" style="background:${dotColor}"></div>
${icon} ${name}
<span class="coords">(${x},${y})</span>
</div>`;
}
// ==================== Properties panel ====================
function getSelectedEntityData() {
if (!selectedEntity) return null;
const md = getData();
switch (selectedEntity.type) {
case 'spawn': return md.spawn;
case 'npc': return md.npcs[selectedEntity.index];
case 'exit': return md.exits[selectedEntity.index];
case 'interaction': return md.interactions[selectedEntity.index];
}
return null;
}
function updateProps() {
const container = document.getElementById('props-content');
const ent = getSelectedEntityData();
if (!ent) {
container.innerHTML = '<span id="no-selection">Click an entity to edit</span>';
return;
}
let html = '';
const t = selectedEntity.type;
// Position (all entities)
html += propNum('X', 'x', ent.x);
html += propNum('Y', 'y', ent.y);
if (t === 'npc') {
html += propText('ID', 'id', ent.id);
html += propSelect('Facing', 'facing', ent.facing, ['down','up','left','right']);
html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n'));
} else if (t === 'exit') {
html += propSelect('Target Map', 'targetMap', ent.targetMap, Object.keys(mapConfigs));
html += propNum('Target X', 'targetX', ent.targetX);
html += propNum('Target Y', 'targetY', ent.targetY);
} else if (t === 'interaction') {
html += propSelect('Type', 'type', ent.type, ['sign','workshop','puzzle_door','terminal','door']);
html += propText('Label', 'label', ent.label || '');
html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n'));
if (ent.type === 'puzzle_door') {
html += propText('Puzzle ID', 'puzzleId', ent.puzzleId || '');
html += propText('Req. Outputs', 'requiredOutputs', (ent.requiredOutputs || []).join(','));
}
}
container.innerHTML = html;
// Wire change events
container.querySelectorAll('input, select, textarea').forEach(el => {
el.addEventListener('change', () => applyPropChange(el.dataset.prop, el.value, el.type));
el.addEventListener('input', () => {
if (el.type === 'number') applyPropChange(el.dataset.prop, el.value, el.type);
});
});
}
function propNum(label, prop, val) {
return `<div class="prop-row"><label>${label}</label><input type="number" data-prop="${prop}" value="${val}"></div>`;
}
function propText(label, prop, val) {
return `<div class="prop-row"><label>${label}</label><input type="text" data-prop="${prop}" value="${esc(val)}"></div>`;
}
function propSelect(label, prop, val, options) {
const opts = options.map(o => `<option value="${o}" ${o === val ? 'selected' : ''}>${o}</option>`).join('');
return `<div class="prop-row"><label>${label}</label><select data-prop="${prop}">${opts}</select></div>`;
}
function propTextarea(label, prop, val) {
return `<div class="prop-row"><label>${label}</label><textarea data-prop="${prop}">${esc(val)}</textarea></div>`;
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
function applyPropChange(prop, value, inputType) {
const ent = getSelectedEntityData();
if (!ent) return;
if (prop === 'dialog') {
ent.dialog = value.split('\n').filter(l => l.trim());
} else if (prop === 'requiredOutputs') {
ent.requiredOutputs = value.split(',').map(Number);
} else if (inputType === 'number' || prop === 'x' || prop === 'y' || prop === 'targetX' || prop === 'targetY') {
ent[prop] = parseInt(value) || 0;
} else {
ent[prop] = value;
}
updateEntityList();
render();
}
// ==================== Export ====================
function exportJSON() {
const md = getData();
const cfg = getCfg();
const data = {
id: currentMapId,
name: cfg.name,
widthTiles: cfg.widthTiles,
heightTiles: cfg.heightTiles,
walls: wallSetToData(md.walls),
spawn: md.spawn,
npcs: md.npcs,
exits: md.exits,
interactions: md.interactions
};
document.getElementById('export-code').textContent = JSON.stringify(data, null, 2);
openModal('export-modal');
}
function exportMapsJS() {
// Generate complete maps.js content
let code = generateMapsJS();
document.getElementById('export-code').textContent = code;
openModal('export-modal');
}
function generateMapsJS() {
let out = `/**\n * maps.js - PNG-based world maps (auto-generated by Level Editor)\n */\n\n`;
out += `function buildWallSet(wallData) {\n const set = new Set();\n for (const [row, cols] of Object.entries(wallData)) {\n for (const col of cols) set.add(col + ',' + row);\n }\n return set;\n}\n\nfunction r(a, b) { const arr = []; for (let i = a; i <= b; i++) arr.push(i); return arr; }\n\n`;
// Generate each map
for (const [mapId, cfg] of Object.entries(mapConfigs)) {
const md = mapData[mapId];
if (!md) continue;
const wallObj = wallSetToData(md.walls);
const gameMapId = mapId === 'pallet-town' ? 'town' : mapId;
const imageKey = `map:${mapId}`;
out += `// ==================== ${cfg.name} ====================\n\n`;
// Walls
out += `const ${varName(mapId)}Walls = {\n`;
const rows = Object.keys(wallObj).map(Number).sort((a,b) => a - b);
for (const row of rows) {
const cols = wallObj[row].sort((a,b) => a - b);
// Compress into ranges where possible
out += ` ${row}: [${compressCols(cols)}],\n`;
}
out += `};\n\n`;
// Map object
out += `const ${varName(mapId)}Map = {\n`;
out += ` id: '${gameMapId}',\n`;
out += ` name: '${cfg.name}',\n`;
out += ` image: '${imageKey}',\n`;
out += ` widthTiles: ${cfg.widthTiles},\n`;
out += ` heightTiles: ${cfg.heightTiles},\n`;
out += ` spawn: { x: ${md.spawn.x}, y: ${md.spawn.y} },\n`;
out += ` wallSet: buildWallSet(${varName(mapId)}Walls),\n\n`;
// Exits
out += ` exits: [\n`;
for (const e of md.exits) {
const tMap = e.targetMap === 'pallet-town' ? 'town' : e.targetMap;
out += ` { x: ${e.x}, y: ${e.y}, targetMap: '${tMap}', targetX: ${e.targetX}, targetY: ${e.targetY} },\n`;
}
out += ` ],\n\n`;
// NPCs
out += ` npcs: [\n`;
for (const n of md.npcs) {
out += ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}', dialog: ${JSON.stringify(n.dialog)} },\n`;
}
out += ` ],\n\n`;
// Interactions
out += ` interactions: [\n`;
for (const inter of md.interactions) {
let line = ` { x: ${inter.x}, y: ${inter.y}, type: '${inter.type}', label: '${inter.label}'`;
if (inter.dialog) line += `, dialog: ${JSON.stringify(inter.dialog)}`;
if (inter.puzzleId) line += `, puzzleId: '${inter.puzzleId}'`;
if (inter.requiredOutputs) line += `, requiredOutputs: [${inter.requiredOutputs}]`;
line += ` },\n`;
out += line;
}
out += ` ]\n};\n\n`;
}
// Registry
out += `// ==================== Registry ====================\n\n`;
out += `const maps = {\n`;
for (const mapId of Object.keys(mapConfigs)) {
const gameMapId = mapId === 'pallet-town' ? 'town' : mapId;
out += ` '${gameMapId}': ${varName(mapId)}Map,\n`;
}
out += `};\n\n`;
// API functions
out += `export function getMap(id) { return maps[id] || null; }\n\n`;
out += `export function isWall(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return true;\n if (x < 0 || x >= map.widthTiles || y < 0 || y >= map.heightTiles) return true;\n return map.wallSet.has(x + ',' + y);\n}\n\n`;
out += `export function isWalkable(mapId, x, y) {\n if (isWall(mapId, x, y)) return false;\n if (getNPC(mapId, x, y)) return false;\n return true;\n}\n\n`;
out += `export function getInteraction(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.interactions.find(i => i.x === x && i.y === y) || null;\n}\n\n`;
out += `export function getNPC(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.npcs.find(npc => npc.x === x && npc.y === y) || null;\n}\n\n`;
out += `export function getExit(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.exits.find(e => e.x === x && e.y === y) || null;\n}\n\n`;
out += `export function getTile(mapId, x, y) { return isWall(mapId, x, y) ? 1 : 0; }\n\n`;
out += `export { maps };\n`;
return out;
}
function varName(mapId) {
return mapId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
function compressCols(cols) {
if (cols.length === 0) return '';
// Check if it's a full range
const min = cols[0], max = cols[cols.length - 1];
const isRange = cols.length === max - min + 1 && cols.every((c, i) => c === min + i);
if (isRange && cols.length > 4) return `...r(${min},${max})`;
return cols.join(',');
}
function wallSetToData(wallSet) {
const data = {};
for (const key of wallSet) {
const [x, y] = key.split(',').map(Number);
if (!data[y]) data[y] = [];
data[y].push(x);
}
// Sort each row
for (const row of Object.keys(data)) data[row].sort((a, b) => a - b);
return data;
}
// ==================== Import ====================
function doImport() {
try {
const text = document.getElementById('import-textarea').value.trim();
const data = JSON.parse(text);
const md = getData();
if (data.walls) md.walls = wallDataToSet(data.walls);
if (data.spawn) md.spawn = data.spawn;
if (data.npcs) md.npcs = data.npcs;
if (data.exits) md.exits = data.exits;
if (data.interactions) md.interactions = data.interactions;
closeModal('import-modal');
updateEntityList(); updateProps(); render();
toast('Map data imported!');
} catch (e) {
alert('Invalid JSON: ' + e.message);
}
}
// ==================== Helpers ====================
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function copyExport() {
const code = document.getElementById('export-code').textContent;
navigator.clipboard.writeText(code).then(() => toast('Copied to clipboard!'));
}
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
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>