Files
logic-gates/editor.html
Jose Luis 0c0ab2fc9b fix: rebuild props panel when interaction type changes
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>
2026-03-21 00:44:09 +01:00

1756 lines
75 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Level Editor — Logic Gates</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0f1119; --panel: #181c2a; --border: #2a2f45;
--text: #c8cad0; --text2: #888; --accent: #00e599;
--red: #ff5555; --green: #55ff55; --yellow: #ffdd44;
--purple: #cc55ff; --blue: #4488ff; --orange: #ff8844;
}
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; font-size: 13px; }
/* ===== Left Panel ===== */
#panel { width: 300px; min-width: 300px; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
#panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
#panel-header h1 { font-size: 15px; font-weight: 700; color: var(--accent); flex: 1; }
#panel-header select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; font-size: 12px; }
/* Toolbar */
#tools { padding: 8px 12px; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 4px; }
.tool-btn { padding: 5px 10px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text2); cursor: pointer; font-size: 11px; transition: all 0.15s; }
.tool-btn:hover { border-color: var(--accent); color: var(--text); }
.tool-btn.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
.tool-btn.danger { border-color: var(--red); color: var(--red); }
.tool-btn.danger:hover { background: var(--red); color: #000; }
/* Entity list */
#entity-section { flex: 1; overflow-y: auto; padding: 8px 0; }
#entity-section h3 { padding: 4px 16px; font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; }
.entity-item { padding: 6px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 12px; border-left: 3px solid transparent; }
.entity-item:hover { background: rgba(255,255,255,0.04); }
.entity-item.selected { background: rgba(0,229,153,0.1); border-left-color: var(--accent); }
.entity-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.entity-item .coords { color: var(--text2); margin-left: auto; font-size: 10px; font-family: monospace; }
/* Properties panel */
#props { border-top: 1px solid var(--border); padding: 12px 16px; max-height: 320px; overflow-y: auto; }
#props h3 { font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
#props-content { display: flex; flex-direction: column; gap: 6px; }
.prop-row { display: flex; align-items: center; gap: 8px; }
.prop-row label { width: 70px; font-size: 11px; color: var(--text2); flex-shrink: 0; }
.prop-row input, .prop-row select, .prop-row textarea { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; padding: 4px 6px; color: var(--text); font-size: 12px; font-family: inherit; }
.prop-row textarea { resize: vertical; min-height: 50px; }
.prop-row input:focus, .prop-row select:focus, .prop-row textarea:focus { outline: none; border-color: var(--accent); }
.prop-row input[type="number"] { width: 50px; flex: 0; }
#no-selection { color: var(--text2); font-size: 12px; font-style: italic; }
/* Footer */
#panel-footer { padding: 8px 12px; border-top: 1px solid var(--border); display: flex; gap: 6px; }
#panel-footer button { flex: 1; padding: 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); cursor: pointer; font-size: 11px; font-weight: 600; }
#panel-footer button:hover { border-color: var(--accent); }
#btn-export { background: var(--accent) !important; color: #000 !important; border-color: var(--accent) !important; }
#btn-export:hover { filter: brightness(1.15); }
/* ===== Canvas area ===== */
#canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0a0b10; }
#editor-canvas { display: block; cursor: crosshair; }
#status-bar { position: absolute; bottom: 0; left: 0; right: 0; padding: 4px 12px; background: rgba(15,17,25,0.9); border-top: 1px solid var(--border); font-size: 11px; color: var(--text2); display: flex; gap: 16px; }
#status-bar span { font-family: monospace; }
#zoom-info { margin-left: auto; }
/* 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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
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>