- 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>
1140 lines
47 KiB
HTML
1140 lines
47 KiB
HTML
<!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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||
|
||
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>
|