Without this, switching type to 'module' didn't show the moduleId, ports, and Open IDE button until manually reselecting the entity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1756 lines
75 KiB
HTML
1756 lines
75 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; }
|
||
|
||
/* Characters section */
|
||
#char-section { border-bottom: 1px solid var(--border); max-height: 200px; overflow-y: auto; }
|
||
#char-section h3 { padding: 4px 16px; font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; display: flex; align-items: center; gap: 6px; }
|
||
#char-section h3 button { background: var(--bg); border: 1px solid var(--border); color: var(--accent); border-radius: 3px; padding: 1px 6px; cursor: pointer; font-size: 10px; }
|
||
#char-section h3 button:hover { background: var(--accent); color: #000; }
|
||
.char-drop-zone { margin: 4px 12px 8px; padding: 12px; border: 2px dashed var(--border); border-radius: 6px; text-align: center; font-size: 11px; color: var(--text2); cursor: pointer; transition: all 0.2s; }
|
||
.char-drop-zone.dragover { border-color: var(--accent); background: rgba(0,229,153,0.08); color: var(--accent); }
|
||
.char-card { display: flex; align-items: center; gap: 8px; padding: 5px 12px; cursor: pointer; border-left: 3px solid transparent; font-size: 12px; }
|
||
.char-card:hover { background: rgba(255,255,255,0.04); }
|
||
.char-card.selected { background: rgba(0,229,153,0.1); border-left-color: var(--accent); }
|
||
.char-card canvas { border: 1px solid var(--border); border-radius: 2px; image-rendering: pixelated; flex-shrink: 0; }
|
||
.char-card .char-info { flex: 1; overflow: hidden; }
|
||
.char-card .char-name { color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.char-card .char-meta { font-size: 10px; color: var(--text2); }
|
||
.char-card .char-delete { background: none; border: none; color: var(--red); cursor: pointer; font-size: 14px; opacity: 0.5; padding: 2px 4px; }
|
||
.char-card .char-delete:hover { opacity: 1; }
|
||
|
||
/* 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; }
|
||
|
||
/* ===== Fullscreen Code Editor ===== */
|
||
#code-editor-overlay {
|
||
position: fixed; inset: 0; z-index: 200;
|
||
background: rgba(0,0,0,0.85);
|
||
display: none; flex-direction: column;
|
||
align-items: center; justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
#code-editor-overlay.show { display: flex; }
|
||
|
||
#code-editor-panel {
|
||
width: min(900px, 95vw); height: min(680px, 90vh);
|
||
background: #1e1e2e; border: 1px solid #333;
|
||
border-radius: 8px; display: flex; flex-direction: column;
|
||
overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,0.6);
|
||
}
|
||
|
||
/* Title bar (VSCode-like) */
|
||
#code-editor-titlebar {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 16px; background: #181825;
|
||
border-bottom: 1px solid #333; flex-shrink: 0;
|
||
}
|
||
#code-editor-titlebar .tab {
|
||
padding: 4px 12px; background: #1e1e2e;
|
||
border-radius: 4px 4px 0 0; font-size: 12px;
|
||
color: var(--accent); font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||
border: 1px solid #333; border-bottom: 1px solid #1e1e2e;
|
||
margin-bottom: -1px;
|
||
}
|
||
#code-editor-titlebar .title-hint {
|
||
color: #666; font-size: 11px; margin-left: auto;
|
||
}
|
||
#code-editor-titlebar .close-x {
|
||
width: 28px; height: 28px; border: none; background: transparent;
|
||
color: #888; cursor: pointer; font-size: 18px; border-radius: 4px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
#code-editor-titlebar .close-x:hover { background: #ff555530; color: var(--red); }
|
||
|
||
/* Editor body */
|
||
#code-editor-body {
|
||
flex: 1; display: flex; overflow: hidden;
|
||
}
|
||
|
||
/* Line numbers gutter */
|
||
#code-editor-gutter {
|
||
width: 48px; padding: 12px 8px 12px 0;
|
||
background: #181825; text-align: right;
|
||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||
font-size: 13px; line-height: 1.6;
|
||
color: #555; user-select: none; overflow: hidden;
|
||
flex-shrink: 0; white-space: pre;
|
||
}
|
||
|
||
/* Main textarea */
|
||
#code-editor-textarea {
|
||
flex: 1; resize: none; border: none; outline: none;
|
||
background: #1e1e2e; color: #cdd6f4;
|
||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||
font-size: 13px; line-height: 1.6;
|
||
padding: 12px 16px; tab-size: 2;
|
||
white-space: pre; overflow: auto;
|
||
}
|
||
#code-editor-textarea::selection { background: rgba(0,229,153,0.25); }
|
||
|
||
/* Status bar */
|
||
#code-editor-statusbar {
|
||
display: flex; align-items: center; gap: 16px;
|
||
padding: 4px 16px; background: #181825;
|
||
border-top: 1px solid #333; flex-shrink: 0;
|
||
font-size: 11px; color: #666;
|
||
}
|
||
#code-editor-statusbar .status-accent { color: var(--accent); }
|
||
|
||
/* Bottom actions */
|
||
#code-editor-actions {
|
||
display: flex; justify-content: flex-end; gap: 8px;
|
||
padding: 10px 16px; background: #181825;
|
||
border-top: 1px solid #333; flex-shrink: 0;
|
||
}
|
||
#code-editor-actions button {
|
||
padding: 6px 16px; border-radius: 4px; cursor: pointer;
|
||
font-size: 12px; font-weight: 600; border: 1px solid #333;
|
||
}
|
||
#code-editor-actions .btn-cancel { background: #2a2a3e; color: #aaa; }
|
||
#code-editor-actions .btn-cancel:hover { background: #333; }
|
||
#code-editor-actions .btn-save { background: var(--accent); color: #000; border-color: var(--accent); }
|
||
#code-editor-actions .btn-save:hover { filter: brightness(1.15); }
|
||
|
||
/* Expand button for property panel */
|
||
.btn-expand-code {
|
||
padding: 3px 8px; border: 1px solid var(--border); border-radius: 3px;
|
||
background: var(--bg); color: var(--text2); cursor: pointer;
|
||
font-size: 10px; transition: all 0.15s; flex-shrink: 0;
|
||
}
|
||
.btn-expand-code:hover { border-color: var(--accent); color: var(--accent); }
|
||
</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 (one-way)">🚪 Exit</button>
|
||
<button class="tool-btn" data-tool="bilink" title="Click to create bidirectional door link" style="background:rgba(0,200,100,0.15)">🔗 Bi-Link</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="char-section">
|
||
<h3>Characters <button id="btn-add-char" title="Add character from file">+ Add</button></h3>
|
||
<div id="char-list"></div>
|
||
<div class="char-drop-zone" id="char-drop-zone">
|
||
Drop spritesheet PNG here<br><span style="font-size:10px;color:#555;">3 cols (still, walk1, walk2) × 4 rows (↓↑←→)</span>
|
||
</div>
|
||
<input type="file" id="char-file-input" accept="image/png" style="display:none;">
|
||
</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>
|
||
|
||
<!-- Bi-Link modal -->
|
||
<div class="modal-overlay" id="bilink-modal">
|
||
<div class="modal">
|
||
<h2>🔗 Create Bidirectional Door Link</h2>
|
||
<p style="color:var(--text2);font-size:12px;margin-bottom:12px;">
|
||
This creates an exit on the current map AND the matching return exit on the target map.
|
||
</p>
|
||
<div id="bilink-form" style="display:flex;flex-direction:column;gap:8px;">
|
||
<div class="prop-row"><label>From tile</label><span id="bilink-from" style="color:var(--accent);font-family:monospace;"></span></div>
|
||
<div class="prop-row"><label>Target Map</label><select id="bilink-target-map" style="flex:1;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></select></div>
|
||
<div class="prop-row"><label>Enter at X</label><input type="number" id="bilink-enter-x" value="0" style="width:60px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></div>
|
||
<div class="prop-row"><label>Enter at Y</label><input type="number" id="bilink-enter-y" value="0" style="width:60px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></div>
|
||
<hr style="border-color:var(--border);margin:4px 0;">
|
||
<p style="color:var(--text2);font-size:11px;">Return exit on target map (where the player exits back to this map):</p>
|
||
<div class="prop-row"><label>Exit tile X</label><input type="number" id="bilink-return-x" value="0" style="width:60px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></div>
|
||
<div class="prop-row"><label>Exit tile Y</label><input type="number" id="bilink-return-y" value="0" style="width:60px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:4px 6px;"></div>
|
||
<div class="prop-row"><label>Return to X</label><span id="bilink-return-to" style="color:var(--accent);font-family:monospace;"></span></div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button onclick="closeModal('bilink-modal')">Cancel</button>
|
||
<button class="primary" id="btn-do-bilink">🔗 Create Link</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>
|
||
|
||
<!-- Fullscreen Code Editor -->
|
||
<div id="code-editor-overlay">
|
||
<div id="code-editor-panel">
|
||
<div id="code-editor-titlebar">
|
||
<div class="tab" id="code-editor-tab">verify.js</div>
|
||
<span class="title-hint">Module Verify Logic</span>
|
||
<button class="close-x" id="code-editor-close" title="Close (Esc)">✕</button>
|
||
</div>
|
||
<div id="code-editor-body">
|
||
<div id="code-editor-gutter"></div>
|
||
<textarea id="code-editor-textarea" spellcheck="false"></textarea>
|
||
</div>
|
||
<div id="code-editor-statusbar">
|
||
<span class="status-accent">JavaScript</span>
|
||
<span id="code-editor-cursor">Ln 1, Col 1</span>
|
||
<span>UTF-8</span>
|
||
<span style="margin-left:auto;">Tab Size: 2</span>
|
||
</div>
|
||
<div id="code-editor-actions">
|
||
<button class="btn-cancel" id="code-editor-discard">Discard</button>
|
||
<button class="btn-save" id="code-editor-apply">✓ Apply Changes</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 characters = {}; // { [charId]: { id, name, spritesheet (base64), frameW, frameH, img (HTMLImageElement) } }
|
||
let selectedCharId = null;
|
||
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: null, 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);
|
||
document.getElementById('btn-do-bilink').addEventListener('click', createBiLink);
|
||
|
||
initCharacterUI();
|
||
resizeCanvas();
|
||
updateEntityList();
|
||
}
|
||
|
||
// ==================== Character management ====================
|
||
|
||
function addCharacterFromFile(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const base64 = e.target.result;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// Auto-detect frame size: assume 3 cols × 4 rows
|
||
const frameW = Math.floor(img.width / 3);
|
||
const frameH = Math.floor(img.height / 4);
|
||
const id = file.name.replace(/\.png$/i, '').replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
|
||
const name = id.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||
|
||
characters[id] = { id, name, spritesheet: base64, frameW, frameH, img };
|
||
updateCharList();
|
||
selectCharacter(id);
|
||
toast(`Character "${name}" added (${frameW}×${frameH}px frames)`);
|
||
};
|
||
img.src = base64;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
function removeCharacter(charId) {
|
||
delete characters[charId];
|
||
if (selectedCharId === charId) selectedCharId = null;
|
||
// Remove charId from NPCs that reference it
|
||
for (const md of Object.values(mapData)) {
|
||
for (const npc of (md.npcs || [])) {
|
||
if (npc.charId === charId) delete npc.charId;
|
||
}
|
||
}
|
||
updateCharList();
|
||
updateEntityList();
|
||
render();
|
||
}
|
||
|
||
function selectCharacter(charId) {
|
||
selectedCharId = charId;
|
||
updateCharList();
|
||
}
|
||
|
||
function updateCharList() {
|
||
const container = document.getElementById('char-list');
|
||
const ids = Object.keys(characters);
|
||
if (ids.length === 0) {
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
container.innerHTML = ids.map(id => {
|
||
const c = characters[id];
|
||
const sel = id === selectedCharId ? ' selected' : '';
|
||
return `<div class="char-card${sel}" data-char-id="${id}">
|
||
<canvas class="char-preview" data-char-id="${id}" width="32" height="32"></canvas>
|
||
<div class="char-info">
|
||
<div class="char-name">${c.name}</div>
|
||
<div class="char-meta">${c.frameW}×${c.frameH}px · ${id}</div>
|
||
</div>
|
||
<button class="char-delete" data-char-id="${id}" title="Delete">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Draw sprite previews (down-still frame)
|
||
container.querySelectorAll('.char-preview').forEach(cvs => {
|
||
const c = characters[cvs.dataset.charId];
|
||
if (!c || !c.img) return;
|
||
const pctx = cvs.getContext('2d');
|
||
pctx.imageSmoothingEnabled = false;
|
||
pctx.clearRect(0, 0, 32, 32);
|
||
// Draw the down-still frame (row 0, col 0)
|
||
pctx.drawImage(c.img, 0, 0, c.frameW, c.frameH, 0, 0, 32, 32);
|
||
});
|
||
|
||
// Wire click events
|
||
container.querySelectorAll('.char-card').forEach(card => {
|
||
card.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('char-delete')) return;
|
||
selectCharacter(card.dataset.charId);
|
||
});
|
||
});
|
||
container.querySelectorAll('.char-delete').forEach(btn => {
|
||
btn.addEventListener('click', () => removeCharacter(btn.dataset.charId));
|
||
});
|
||
}
|
||
|
||
// Wire drag & drop + file input
|
||
function initCharacterUI() {
|
||
const dropZone = document.getElementById('char-drop-zone');
|
||
const fileInput = document.getElementById('char-file-input');
|
||
const addBtn = document.getElementById('btn-add-char');
|
||
|
||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||
dropZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('dragover');
|
||
for (const file of e.dataTransfer.files) {
|
||
if (file.type === 'image/png') addCharacterFromFile(file);
|
||
}
|
||
});
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
addBtn.addEventListener('click', () => fileInput.click());
|
||
fileInput.addEventListener('change', () => {
|
||
for (const file of fileInput.files) {
|
||
if (file.type === 'image/png') addCharacterFromFile(file);
|
||
}
|
||
fileInput.value = '';
|
||
});
|
||
}
|
||
|
||
// ==================== 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 = null; // No spawn — player enters via door from town
|
||
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: 8 },
|
||
{ x: 5, y: 11, targetMap: 'pallet-town', targetX: 10, targetY: 8 }
|
||
];
|
||
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 or map center
|
||
const md = mapData[id];
|
||
const cfg = mapConfigs[id];
|
||
if (md && cfg) {
|
||
const cx = md.spawn ? md.spawn.x : Math.floor(cfg.widthTiles / 2);
|
||
const cy = md.spawn ? md.spawn.y : Math.floor(cfg.heightTiles / 2);
|
||
camera.x = -(cx * TILE_PX - canvas.width / 2 + TILE_PX / 2);
|
||
camera.y = -(cy * 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 (optional — only needed on the starting map)
|
||
if (md.spawn) {
|
||
const sp = md.spawn;
|
||
const spSel = selectedEntity?.type === '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 = spSel ? '#fff' : '#00e599';
|
||
ctx.lineWidth = spSel ? 2.5 : 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) => {
|
||
const isSel = selectedEntity?.type === 'exit' && selectedEntity.index === 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 = isSel ? '#fff' : '#55ff55';
|
||
ctx.lineWidth = isSel ? 2.5 : 1.5;
|
||
ctx.strokeRect(e.x * TILE_PX, e.y * TILE_PX, TILE_PX, TILE_PX);
|
||
drawLabel(ctx, '🚪', e.x, e.y);
|
||
// Show target info label below the tile
|
||
if (scale >= 2) {
|
||
const label = `→${e.targetMap}(${e.targetX ?? '?'},${e.targetY ?? '?'})`;
|
||
ctx.save();
|
||
ctx.font = `bold ${Math.max(8, scale * 2.5)}px monospace`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||
const tw = ctx.measureText(label).width + 4;
|
||
const lx = e.x * TILE_PX + TILE_PX / 2;
|
||
const ly = (e.y + 1) * TILE_PX + 1;
|
||
ctx.fillRect(lx - tw / 2, ly, tw, scale * 3 + 2);
|
||
ctx.fillStyle = '#55ff55';
|
||
ctx.fillText(label, lx, ly + 1);
|
||
ctx.restore();
|
||
}
|
||
});
|
||
|
||
// 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 === 'module' ? '⚡' : inter.type === 'terminal' ? '💻' : '📋';
|
||
drawLabel(ctx, icon, inter.x, inter.y);
|
||
});
|
||
|
||
// NPCs
|
||
md.npcs.forEach((npc, i) => {
|
||
const sx = npc.x * TILE_PX, sy = npc.y * TILE_PX;
|
||
const char = npc.charId ? characters[npc.charId] : null;
|
||
if (char && char.img) {
|
||
// Draw spritesheet frame: down-still (row 0, col 0)
|
||
const dirRow = { down: 0, up: 1, left: 2, right: 3 };
|
||
const row = dirRow[npc.facing] ?? 0;
|
||
ctx.imageSmoothingEnabled = false;
|
||
ctx.drawImage(char.img, 0, row * char.frameH, char.frameW, char.frameH, sx, sy, TILE_PX, TILE_PX);
|
||
} else {
|
||
ctx.fillStyle = 'rgba(200, 50, 255, 0.35)';
|
||
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
|
||
drawLabel(ctx, '👤', npc.x, npc.y);
|
||
}
|
||
ctx.strokeStyle = selectedEntity?.type === 'npc' && selectedEntity.index === i ? '#fff' : '#cc55ff';
|
||
ctx.lineWidth = selectedEntity?.type === 'npc' && selectedEntity.index === i ? 2.5 : 1.5;
|
||
ctx.strokeRect(sx, sy, TILE_PX, TILE_PX);
|
||
});
|
||
|
||
// 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':
|
||
// Toggle: if spawn already at this tile, remove it; otherwise place/move
|
||
if (md.spawn && md.spawn.x === tile.x && md.spawn.y === tile.y) {
|
||
md.spawn = null;
|
||
selectedEntity = null;
|
||
} else {
|
||
md.spawn = { x: tile.x, y: tile.y };
|
||
selectedEntity = { type: 'spawn', index: 0 };
|
||
}
|
||
updateEntityList(); updateProps(); render();
|
||
break;
|
||
case 'npc': {
|
||
const npcData = { id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] };
|
||
if (selectedCharId && characters[selectedCharId]) npcData.charId = selectedCharId;
|
||
md.npcs.push(npcData);
|
||
selectedEntity = { type: 'npc', index: md.npcs.length - 1 };
|
||
updateEntityList(); updateProps(); render();
|
||
break;
|
||
}
|
||
case 'exit': {
|
||
// Default to a different map than the current one
|
||
const otherMaps = Object.keys(mapConfigs).filter(id => id !== currentMapId);
|
||
const defaultTarget = otherMaps[0] || currentMapId;
|
||
md.exits.push({ x: tile.x, y: tile.y, targetMap: defaultTarget, targetX: 0, targetY: 0 });
|
||
selectedEntity = { type: 'exit', index: md.exits.length - 1 };
|
||
updateEntityList(); updateProps(); render();
|
||
break;
|
||
}
|
||
case 'bilink': {
|
||
// Open bidirectional link dialog
|
||
openBiLinkDialog(tile.x, tile.y);
|
||
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 && 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}(${e.targetX ?? '?'},${e.targetY ?? '?'}) `; });
|
||
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 === 'spawn') md.spawn = null;
|
||
else 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 && 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();
|
||
// Spawn
|
||
if (md.spawn && md.spawn.x === tx && md.spawn.y === ty) { md.spawn = null; selectedEntity = null; updateEntityList(); updateProps(); return; }
|
||
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 = md.spawn
|
||
? makeEntityItem('spawn', 0, '🏠', `Spawn`, md.spawn.x, md.spawn.y)
|
||
: '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None (use 🏠 tool to place)</div>';
|
||
|
||
document.getElementById('npc-list').innerHTML = md.npcs.map((n, i) => {
|
||
const charLabel = n.charId && characters[n.charId] ? ` [${characters[n.charId].name}]` : '';
|
||
return makeEntityItem('npc', i, '👤', n.id + charLabel, 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) => {
|
||
const hasCoords = e.targetX != null && e.targetY != null;
|
||
const coordStr = hasCoords ? `(${e.targetX},${e.targetY})` : '⚠️ NO COORDS';
|
||
const color = hasCoords ? '#55ff55' : '#ff5555';
|
||
return makeEntityItem('exit', i, '🚪', `→ ${e.targetMap} ${coordStr}`, e.x, e.y, color);
|
||
}).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 || null;
|
||
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']);
|
||
// Character selector
|
||
const charOpts = ['(none)', ...Object.keys(characters)];
|
||
html += propSelect('Character', 'charId', ent.charId || '(none)', charOpts);
|
||
html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n'));
|
||
} else if (t === 'exit') {
|
||
// Show map options with both editor ID and game ID
|
||
const mapOptions = Object.keys(mapConfigs);
|
||
// Handle game IDs like 'town' being stored as 'pallet-town' internally
|
||
const currentTarget = ent.targetMap === 'town' ? 'pallet-town' : ent.targetMap;
|
||
html += propSelect('Target Map', 'targetMap', currentTarget, mapOptions);
|
||
html += propNum('Target X', 'targetX', ent.targetX ?? 0);
|
||
html += propNum('Target Y', 'targetY', ent.targetY ?? 0);
|
||
// Warning if no target coords set
|
||
if (ent.targetX == null || ent.targetY == null) {
|
||
html += '<div style="color:var(--red);font-size:10px;padding:2px 0;">⚠️ Set target X/Y! Every exit needs explicit coordinates.</div>';
|
||
}
|
||
} else if (t === 'interaction') {
|
||
html += propSelect('Type', 'type', ent.type, ['sign','workshop','terminal','door','module']); // puzzle_door hidden for now
|
||
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(','));
|
||
}
|
||
if (ent.type === 'module') {
|
||
html += propText('Module ID', 'moduleId', ent.moduleId || '');
|
||
// Ports editor — compact format: "A:out, B:out, C:in"
|
||
const portsStr = (ent.ports || []).map(p => `${p.name}:${p.dir}`).join(', ');
|
||
html += propText('Ports', 'ports', portsStr);
|
||
html += `<div class="prop-row" style="font-size:10px;color:#888;">Format: A:out, B:out, C:in</div>`;
|
||
// Verify code — hidden textarea + "Open IDE" button
|
||
const verifyCode = ent.verify || `(test) => {\n return test({A:0, B:0}).C === 0\n && test({A:1, B:1}).C === 1;\n}`;
|
||
html += `<textarea id="verify-textarea" data-prop="verify" style="display:none;">${esc(verifyCode)}</textarea>`;
|
||
html += `<div class="prop-row"><label>Verify (JS)</label><button class="btn-expand-code" id="btn-expand-verify" style="flex:1;padding:6px 12px;font-size:12px;">⚡ Open IDE</button></div>`;
|
||
}
|
||
}
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
// Wire expand button for verify code editor
|
||
const btnExpand = document.getElementById('btn-expand-verify');
|
||
const verifyTA = document.getElementById('verify-textarea');
|
||
if (btnExpand && verifyTA) {
|
||
btnExpand.addEventListener('click', () => openCodeEditor(verifyTA, 'verify.js'));
|
||
}
|
||
}
|
||
|
||
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 === 'charId') {
|
||
if (value === '(none)') delete ent.charId;
|
||
else ent.charId = value;
|
||
} else if (prop === 'dialog') {
|
||
ent.dialog = value.split('\n').filter(l => l.trim());
|
||
} else if (prop === 'requiredOutputs') {
|
||
ent.requiredOutputs = value.split(',').map(Number);
|
||
} else if (prop === 'ports') {
|
||
// Parse "A:out, B:out, C:in" format
|
||
ent.ports = value.split(',').map(s => s.trim()).filter(Boolean).map(s => {
|
||
const [name, dir] = s.split(':').map(p => p.trim());
|
||
return { name: name || '?', dir: dir || 'out', bits: 1 };
|
||
});
|
||
} else if (prop === 'verify') {
|
||
ent.verify = value;
|
||
} else if (prop === 'targetMap') {
|
||
// Store as game ID (pallet-town → town)
|
||
ent.targetMap = value === 'pallet-town' ? 'town' : value;
|
||
} else if (inputType === 'number' || prop === 'x' || prop === 'y' || prop === 'targetX' || prop === 'targetY') {
|
||
ent[prop] = parseInt(value) || 0;
|
||
} else {
|
||
ent[prop] = value;
|
||
}
|
||
|
||
updateEntityList();
|
||
render();
|
||
// Rebuild props panel when type changes (shows/hides type-specific fields)
|
||
if (prop === 'type') updateProps();
|
||
}
|
||
|
||
// ==================== 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`;
|
||
if (md.spawn) {
|
||
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) {
|
||
let npcLine = ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}'`;
|
||
if (n.charId) npcLine += `, charId: '${n.charId}'`;
|
||
npcLine += `, dialog: ${JSON.stringify(n.dialog)} },\n`;
|
||
out += npcLine;
|
||
}
|
||
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}]`;
|
||
if (inter.moduleId) line += `,\n moduleId: '${inter.moduleId}'`;
|
||
if (inter.ports && inter.ports.length > 0) {
|
||
line += `,\n ports: [\n`;
|
||
for (const p of inter.ports) {
|
||
line += ` { name: '${p.name}', dir: '${p.dir}', bits: ${p.bits || 1} },\n`;
|
||
}
|
||
line += ` ]`;
|
||
}
|
||
if (inter.verify) {
|
||
// Output verify as raw JS (not a string) so it's executable
|
||
line += `,\n verify: \`${inter.verify.replace(/`/g, '\\`')}\``;
|
||
}
|
||
line += `\n },\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(/-(\w)/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 {
|
||
// Save maps.js
|
||
const res = await fetch('/api/maps', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ content: code })
|
||
});
|
||
const data = await res.json();
|
||
|
||
// Save characters
|
||
const charData = {};
|
||
for (const [id, c] of Object.entries(characters)) {
|
||
charData[id] = { id: c.id, name: c.name, spritesheet: c.spritesheet, frameW: c.frameW, frameH: c.frameH };
|
||
}
|
||
await fetch('/api/characters', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ characters: charData })
|
||
});
|
||
|
||
if (data.ok) {
|
||
const charCount = Object.keys(charData).length;
|
||
toast(`Saved maps.js (${data.bytes}b) + ${charCount} character(s)`);
|
||
} 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
|
||
const src = data.content;
|
||
parseAndLoadMapsJS(src);
|
||
|
||
// Load characters
|
||
try {
|
||
const charRes = await fetch('/api/characters');
|
||
const charData = await charRes.json();
|
||
if (charData.characters) {
|
||
characters = {};
|
||
for (const [id, c] of Object.entries(charData.characters)) {
|
||
const img = new Image();
|
||
img.src = c.spritesheet;
|
||
characters[id] = { id: c.id, name: c.name, spritesheet: c.spritesheet, frameW: c.frameW, frameH: c.frameH, img };
|
||
}
|
||
updateCharList();
|
||
}
|
||
} catch (ce) { console.warn('[editor] Character load failed:', ce); }
|
||
|
||
updateEntityList(); updateProps(); render();
|
||
toast('Loaded maps.js + characters 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 (optional — only present on initial map)
|
||
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]) };
|
||
} else {
|
||
mapData[mapId].spawn = null;
|
||
}
|
||
|
||
// 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); }
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== Bidirectional Link ====================
|
||
|
||
let bilinkSourceTile = null;
|
||
|
||
function openBiLinkDialog(tx, ty) {
|
||
bilinkSourceTile = { x: tx, y: ty };
|
||
|
||
// Populate target map dropdown (exclude current map)
|
||
const sel = document.getElementById('bilink-target-map');
|
||
sel.innerHTML = '';
|
||
for (const [id, cfg] of Object.entries(mapConfigs)) {
|
||
if (id === currentMapId) continue;
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
opt.textContent = `${cfg.name} (${id})`;
|
||
sel.appendChild(opt);
|
||
}
|
||
|
||
document.getElementById('bilink-from').textContent = `(${tx}, ${ty}) on ${mapConfigs[currentMapId].name}`;
|
||
|
||
// Smart defaults: return tile = one tile below the source (in front of the door)
|
||
const returnX = tx;
|
||
const returnY = Math.min(ty + 1, mapConfigs[currentMapId].heightTiles - 1);
|
||
document.getElementById('bilink-return-to').textContent = `(${returnX}, ${returnY})`;
|
||
|
||
// Update return-to display when source changes
|
||
document.getElementById('bilink-enter-x').value = 0;
|
||
document.getElementById('bilink-enter-y').value = 0;
|
||
document.getElementById('bilink-return-x').value = 0;
|
||
document.getElementById('bilink-return-y').value = 0;
|
||
|
||
openModal('bilink-modal');
|
||
}
|
||
|
||
// btn-do-bilink wired after init below
|
||
|
||
function createBiLink() {
|
||
if (!bilinkSourceTile) return;
|
||
|
||
const targetMapId = document.getElementById('bilink-target-map').value;
|
||
const enterX = parseInt(document.getElementById('bilink-enter-x').value) || 0;
|
||
const enterY = parseInt(document.getElementById('bilink-enter-y').value) || 0;
|
||
const returnExitX = parseInt(document.getElementById('bilink-return-x').value) || 0;
|
||
const returnExitY = parseInt(document.getElementById('bilink-return-y').value) || 0;
|
||
|
||
// Return to one tile below the source door (in front of it)
|
||
const returnToX = bilinkSourceTile.x;
|
||
const returnToY = Math.min(bilinkSourceTile.y + 1, mapConfigs[currentMapId].heightTiles - 1);
|
||
|
||
// Game IDs: pallet-town → town
|
||
const toGameId = (id) => id === 'pallet-town' ? 'town' : id;
|
||
|
||
// 1. Create exit on current map: door → target interior
|
||
const currentMd = mapData[currentMapId];
|
||
currentMd.exits.push({
|
||
x: bilinkSourceTile.x,
|
||
y: bilinkSourceTile.y,
|
||
targetMap: toGameId(targetMapId),
|
||
targetX: enterX,
|
||
targetY: enterY
|
||
});
|
||
|
||
// 2. Create matching return exit on target map: interior exit → back to this door
|
||
const targetMd = mapData[targetMapId];
|
||
if (targetMd) {
|
||
targetMd.exits.push({
|
||
x: returnExitX,
|
||
y: returnExitY,
|
||
targetMap: toGameId(currentMapId),
|
||
targetX: returnToX,
|
||
targetY: returnToY
|
||
});
|
||
toast(`Bidirectional link created: ${currentMapId} ↔ ${targetMapId}`);
|
||
} else {
|
||
toast(`Exit created on ${currentMapId} (target map ${targetMapId} not loaded)`);
|
||
}
|
||
|
||
closeModal('bilink-modal');
|
||
selectedEntity = { type: 'exit', index: currentMd.exits.length - 1 };
|
||
updateEntityList(); updateProps(); render();
|
||
}
|
||
|
||
// ==================== Boot ====================
|
||
|
||
init();
|
||
|
||
// ==================== Fullscreen Code Editor ====================
|
||
|
||
const codeEditorOverlay = document.getElementById('code-editor-overlay');
|
||
const codeEditorTextarea = document.getElementById('code-editor-textarea');
|
||
const codeEditorGutter = document.getElementById('code-editor-gutter');
|
||
const codeEditorCursor = document.getElementById('code-editor-cursor');
|
||
const codeEditorTab = document.getElementById('code-editor-tab');
|
||
let codeEditorTarget = null; // the prop textarea we're editing for
|
||
|
||
function openCodeEditor(propTextarea, tabName) {
|
||
codeEditorTarget = propTextarea;
|
||
codeEditorTab.textContent = tabName || 'verify.js';
|
||
codeEditorTextarea.value = propTextarea.value;
|
||
codeEditorOverlay.classList.add('show');
|
||
updateGutter();
|
||
updateCursorPos();
|
||
// Focus after animation frame
|
||
requestAnimationFrame(() => codeEditorTextarea.focus());
|
||
}
|
||
|
||
function closeCodeEditor(apply) {
|
||
if (apply && codeEditorTarget) {
|
||
codeEditorTarget.value = codeEditorTextarea.value;
|
||
// Trigger change event so applyPropChange fires
|
||
codeEditorTarget.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
codeEditorOverlay.classList.remove('show');
|
||
codeEditorTarget = null;
|
||
}
|
||
|
||
function updateGutter() {
|
||
const lines = codeEditorTextarea.value.split('\n').length;
|
||
let html = '';
|
||
for (let i = 1; i <= Math.max(lines, 20); i++) {
|
||
html += i + '\n';
|
||
}
|
||
codeEditorGutter.textContent = html;
|
||
}
|
||
|
||
function updateCursorPos() {
|
||
const val = codeEditorTextarea.value;
|
||
const pos = codeEditorTextarea.selectionStart;
|
||
const before = val.substring(0, pos);
|
||
const line = before.split('\n').length;
|
||
const col = pos - before.lastIndexOf('\n');
|
||
codeEditorCursor.textContent = `Ln ${line}, Col ${col}`;
|
||
}
|
||
|
||
// Sync gutter with scroll
|
||
codeEditorTextarea.addEventListener('scroll', () => {
|
||
codeEditorGutter.scrollTop = codeEditorTextarea.scrollTop;
|
||
});
|
||
|
||
// Update gutter + cursor on input
|
||
codeEditorTextarea.addEventListener('input', () => { updateGutter(); updateCursorPos(); });
|
||
codeEditorTextarea.addEventListener('click', updateCursorPos);
|
||
codeEditorTextarea.addEventListener('keyup', updateCursorPos);
|
||
|
||
// Tab key inserts spaces
|
||
codeEditorTextarea.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
const start = codeEditorTextarea.selectionStart;
|
||
const end = codeEditorTextarea.selectionEnd;
|
||
const val = codeEditorTextarea.value;
|
||
codeEditorTextarea.value = val.substring(0, start) + ' ' + val.substring(end);
|
||
codeEditorTextarea.selectionStart = codeEditorTextarea.selectionEnd = start + 2;
|
||
updateGutter();
|
||
}
|
||
// Escape closes
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
closeCodeEditor(false);
|
||
}
|
||
// Ctrl+Enter applies
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
closeCodeEditor(true);
|
||
}
|
||
});
|
||
|
||
// Buttons
|
||
document.getElementById('code-editor-close').addEventListener('click', () => closeCodeEditor(false));
|
||
document.getElementById('code-editor-discard').addEventListener('click', () => closeCodeEditor(false));
|
||
document.getElementById('code-editor-apply').addEventListener('click', () => closeCodeEditor(true));
|
||
|
||
// Click outside panel to close
|
||
codeEditorOverlay.addEventListener('click', (e) => {
|
||
if (e.target === codeEditorOverlay) closeCodeEditor(false);
|
||
});
|
||
|
||
// Auto-load from server on start
|
||
loadFromServer().catch(() => {
|
||
console.log('[editor] Server load failed, using embedded defaults');
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|