Spawn can now be deleted in the editor (click same tile with Spawn tool, use Delete tool, or press Delete key). Interior maps no longer have spawn objects. The editor shows "None" when no spawn is set, and the generated maps.js omits the spawn field for maps without one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1310 lines
56 KiB
HTML
1310 lines
56 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Level Editor — Logic Gates</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
:root {
|
||
--bg: #0f1119; --panel: #181c2a; --border: #2a2f45;
|
||
--text: #c8cad0; --text2: #888; --accent: #00e599;
|
||
--red: #ff5555; --green: #55ff55; --yellow: #ffdd44;
|
||
--purple: #cc55ff; --blue: #4488ff; --orange: #ff8844;
|
||
}
|
||
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; font-size: 13px; }
|
||
|
||
/* ===== Left Panel ===== */
|
||
#panel { width: 300px; min-width: 300px; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
||
#panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
|
||
#panel-header h1 { font-size: 15px; font-weight: 700; color: var(--accent); flex: 1; }
|
||
#panel-header select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; font-size: 12px; }
|
||
|
||
/* Toolbar */
|
||
#tools { padding: 8px 12px; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 4px; }
|
||
.tool-btn { padding: 5px 10px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text2); cursor: pointer; font-size: 11px; transition: all 0.15s; }
|
||
.tool-btn:hover { border-color: var(--accent); color: var(--text); }
|
||
.tool-btn.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
|
||
.tool-btn.danger { border-color: var(--red); color: var(--red); }
|
||
.tool-btn.danger:hover { background: var(--red); color: #000; }
|
||
|
||
/* Entity list */
|
||
#entity-section { flex: 1; overflow-y: auto; padding: 8px 0; }
|
||
#entity-section h3 { padding: 4px 16px; font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.entity-item { padding: 6px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 12px; border-left: 3px solid transparent; }
|
||
.entity-item:hover { background: rgba(255,255,255,0.04); }
|
||
.entity-item.selected { background: rgba(0,229,153,0.1); border-left-color: var(--accent); }
|
||
.entity-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||
.entity-item .coords { color: var(--text2); margin-left: auto; font-size: 10px; font-family: monospace; }
|
||
|
||
/* Properties panel */
|
||
#props { border-top: 1px solid var(--border); padding: 12px 16px; max-height: 320px; overflow-y: auto; }
|
||
#props h3 { font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||
#props-content { display: flex; flex-direction: column; gap: 6px; }
|
||
.prop-row { display: flex; align-items: center; gap: 8px; }
|
||
.prop-row label { width: 70px; font-size: 11px; color: var(--text2); flex-shrink: 0; }
|
||
.prop-row input, .prop-row select, .prop-row textarea { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; padding: 4px 6px; color: var(--text); font-size: 12px; font-family: inherit; }
|
||
.prop-row textarea { resize: vertical; min-height: 50px; }
|
||
.prop-row input:focus, .prop-row select:focus, .prop-row textarea:focus { outline: none; border-color: var(--accent); }
|
||
.prop-row input[type="number"] { width: 50px; flex: 0; }
|
||
#no-selection { color: var(--text2); font-size: 12px; font-style: italic; }
|
||
|
||
/* Footer */
|
||
#panel-footer { padding: 8px 12px; border-top: 1px solid var(--border); display: flex; gap: 6px; }
|
||
#panel-footer button { flex: 1; padding: 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); cursor: pointer; font-size: 11px; font-weight: 600; }
|
||
#panel-footer button:hover { border-color: var(--accent); }
|
||
#btn-export { background: var(--accent) !important; color: #000 !important; border-color: var(--accent) !important; }
|
||
#btn-export:hover { filter: brightness(1.15); }
|
||
|
||
/* ===== Canvas area ===== */
|
||
#canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0a0b10; }
|
||
#editor-canvas { display: block; cursor: crosshair; }
|
||
#status-bar { position: absolute; bottom: 0; left: 0; right: 0; padding: 4px 12px; background: rgba(15,17,25,0.9); border-top: 1px solid var(--border); font-size: 11px; color: var(--text2); display: flex; gap: 16px; }
|
||
#status-bar span { font-family: monospace; }
|
||
#zoom-info { margin-left: auto; }
|
||
|
||
/* Toast */
|
||
#toast { position: fixed; top: 16px; right: 16px; padding: 10px 18px; background: var(--accent); color: #000; border-radius: 6px; font-weight: 600; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; }
|
||
#toast.show { opacity: 1; }
|
||
|
||
/* Modal */
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; z-index: 100; align-items: center; justify-content: center; }
|
||
.modal-overlay.show { display: flex; }
|
||
.modal { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; min-width: 400px; max-width: 600px; max-height: 80vh; overflow-y: auto; }
|
||
.modal h2 { font-size: 15px; margin-bottom: 12px; color: var(--accent); }
|
||
.modal pre { background: var(--bg); padding: 12px; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 400px; white-space: pre; font-family: 'Cascadia Code', 'Fira Code', monospace; line-height: 1.4; }
|
||
.modal-actions { margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; }
|
||
.modal-actions button { padding: 6px 14px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); cursor: pointer; font-size: 12px; }
|
||
.modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Left panel -->
|
||
<div id="panel">
|
||
<div id="panel-header">
|
||
<h1>⚡ Level Editor</h1>
|
||
<select id="map-select"></select>
|
||
</div>
|
||
<div id="tools">
|
||
<button class="tool-btn active" data-tool="wall" title="Click/drag to paint walls">🧱 Wall</button>
|
||
<button class="tool-btn" data-tool="erase" title="Click/drag to erase walls">🧹 Erase</button>
|
||
<button class="tool-btn" data-tool="spawn" title="Click to set player spawn">🏠 Spawn</button>
|
||
<button class="tool-btn" data-tool="npc" title="Click to place NPC">👤 NPC</button>
|
||
<button class="tool-btn" data-tool="exit" title="Click to place exit (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="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>
|
||
|
||
<script>
|
||
// ==================== State ====================
|
||
const TILE = 16;
|
||
let scale = 3;
|
||
let TILE_PX = TILE * scale;
|
||
|
||
const mapConfigs = {
|
||
lab: { image: 'assets/map/lab.png', widthTiles: 10, heightTiles: 12, name: 'Circuit Lab' },
|
||
'pallet-town': { image: 'assets/map/pallet-town.png', widthTiles: 20, heightTiles: 18, name: 'Neon Town' },
|
||
'house-a-1f': { image: 'assets/map/house-a-1f.png', widthTiles: 8, heightTiles: 8, name: 'House Interior' },
|
||
'route-1': { image: 'assets/map/route-1.png', widthTiles: 20, heightTiles: 36, name: 'Route 1' }
|
||
};
|
||
|
||
let currentMapId = 'lab';
|
||
let mapData = {}; // { [mapId]: { walls: Set, spawn, npcs, exits, interactions } }
|
||
let selectedTool = 'wall';
|
||
let selectedEntity = null; // { type, index }
|
||
let isPainting = false;
|
||
let camera = { x: 0, y: 0 };
|
||
let isDragging = false;
|
||
let dragStart = { x: 0, y: 0 };
|
||
let camStart = { x: 0, y: 0 };
|
||
let mapImages = {};
|
||
|
||
const canvas = document.getElementById('editor-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// ==================== Init ====================
|
||
|
||
function init() {
|
||
// Build map selector
|
||
const sel = document.getElementById('map-select');
|
||
for (const [id, cfg] of Object.entries(mapConfigs)) {
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
opt.textContent = `${cfg.name} (${cfg.widthTiles}×${cfg.heightTiles})`;
|
||
sel.appendChild(opt);
|
||
}
|
||
sel.addEventListener('change', () => switchMap(sel.value));
|
||
|
||
// Init map data for all maps
|
||
for (const id of Object.keys(mapConfigs)) {
|
||
mapData[id] = { walls: new Set(), spawn: 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);
|
||
|
||
resizeCanvas();
|
||
updateEntityList();
|
||
}
|
||
|
||
// ==================== Load game data ====================
|
||
|
||
function loadCurrentGameData() {
|
||
// Load from the actual maps.js definitions embedded here
|
||
// Lab
|
||
const labWalls = {
|
||
0: r(0,9), 1: [0,1,7,8,9], 2: [0,7,8,9], 3: [0,2,3,9], 4: [0,2,3,9],
|
||
5: [0,9], 6: [0,1,2,6,7,8,9], 7: [0,1,2,6,7,8,9], 8: [0,9], 9: [0,9], 10: [0,9],
|
||
11: [0,1,2,3,6,7,8,9]
|
||
};
|
||
mapData.lab.walls = wallDataToSet(labWalls);
|
||
mapData.lab.spawn = 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 === 'terminal' ? '💻' : '📋';
|
||
drawLabel(ctx, icon, inter.x, inter.y);
|
||
});
|
||
|
||
// NPCs
|
||
md.npcs.forEach((npc, i) => {
|
||
ctx.fillStyle = 'rgba(200, 50, 255, 0.35)';
|
||
ctx.fillRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX);
|
||
ctx.strokeStyle = selectedEntity?.type === 'npc' && selectedEntity.index === i ? '#fff' : '#cc55ff';
|
||
ctx.lineWidth = selectedEntity?.type === 'npc' && selectedEntity.index === i ? 2.5 : 1.5;
|
||
ctx.strokeRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX);
|
||
drawLabel(ctx, '👤', npc.x, npc.y);
|
||
});
|
||
|
||
// Tile coordinates (when zoomed in enough)
|
||
if (scale >= 3) {
|
||
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
||
ctx.font = `${Math.max(8, scale * 3)}px monospace`;
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
for (let y = 0; y < mh; y++) {
|
||
for (let x = 0; x < mw; x++) {
|
||
const sx = x * TILE_PX + camera.x, sy = y * TILE_PX + camera.y;
|
||
// Only render visible coords
|
||
if (sx + TILE_PX >= 0 && sx <= canvas.width && sy + TILE_PX >= 0 && sy <= canvas.height) {
|
||
// Use untranslated check but draw in translated space
|
||
ctx.fillText(`${x},${y}`, x * TILE_PX + 2, y * TILE_PX + TILE_PX - scale * 3 - 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawLabel(ctx, emoji, tx, ty) {
|
||
ctx.font = `${Math.max(12, TILE_PX * 0.5)}px serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillText(emoji, tx * TILE_PX + TILE_PX / 2, ty * TILE_PX + TILE_PX / 2);
|
||
}
|
||
|
||
// ==================== Mouse events ====================
|
||
|
||
function screenToTile(mx, my) {
|
||
const x = Math.floor((mx - camera.x) / TILE_PX);
|
||
const y = Math.floor((my - camera.y) / TILE_PX);
|
||
return { x, y };
|
||
}
|
||
|
||
function inBounds(tx, ty) {
|
||
const cfg = getCfg();
|
||
return tx >= 0 && tx < cfg.widthTiles && ty >= 0 && ty < cfg.heightTiles;
|
||
}
|
||
|
||
function onMouseDown(e) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||
|
||
// Middle-click or right-click: pan
|
||
if (e.button === 1 || e.button === 2) {
|
||
isDragging = true;
|
||
dragStart = { x: e.clientX, y: e.clientY };
|
||
camStart = { x: camera.x, y: camera.y };
|
||
canvas.style.cursor = 'grabbing';
|
||
return;
|
||
}
|
||
|
||
const tile = screenToTile(mx, my);
|
||
if (!inBounds(tile.x, tile.y)) return;
|
||
const md = getData();
|
||
const key = `${tile.x},${tile.y}`;
|
||
|
||
switch (selectedTool) {
|
||
case 'wall':
|
||
isPainting = true;
|
||
md.walls.add(key);
|
||
render();
|
||
break;
|
||
case 'erase':
|
||
isPainting = true;
|
||
md.walls.delete(key);
|
||
render();
|
||
break;
|
||
case 'spawn':
|
||
// 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':
|
||
md.npcs.push({ id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] });
|
||
selectedEntity = { type: 'npc', index: md.npcs.length - 1 };
|
||
updateEntityList(); updateProps(); render();
|
||
break;
|
||
case 'exit': {
|
||
// 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) =>
|
||
makeEntityItem('npc', i, '👤', n.id, n.x, n.y, '#cc55ff')
|
||
).join('') || '<div style="padding:4px 16px;color:var(--text2);font-size:11px;">None</div>';
|
||
|
||
document.getElementById('exit-list').innerHTML = md.exits.map((e, i) => {
|
||
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']);
|
||
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','puzzle_door','terminal','door']);
|
||
html += propText('Label', 'label', ent.label || '');
|
||
html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n'));
|
||
if (ent.type === 'puzzle_door') {
|
||
html += propText('Puzzle ID', 'puzzleId', ent.puzzleId || '');
|
||
html += propText('Req. Outputs', 'requiredOutputs', (ent.requiredOutputs || []).join(','));
|
||
}
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
|
||
// Wire change events
|
||
container.querySelectorAll('input, select, textarea').forEach(el => {
|
||
el.addEventListener('change', () => applyPropChange(el.dataset.prop, el.value, el.type));
|
||
el.addEventListener('input', () => {
|
||
if (el.type === 'number') applyPropChange(el.dataset.prop, el.value, el.type);
|
||
});
|
||
});
|
||
}
|
||
|
||
function propNum(label, prop, val) {
|
||
return `<div class="prop-row"><label>${label}</label><input type="number" data-prop="${prop}" value="${val}"></div>`;
|
||
}
|
||
function propText(label, prop, val) {
|
||
return `<div class="prop-row"><label>${label}</label><input type="text" data-prop="${prop}" value="${esc(val)}"></div>`;
|
||
}
|
||
function propSelect(label, prop, val, options) {
|
||
const opts = options.map(o => `<option value="${o}" ${o === val ? 'selected' : ''}>${o}</option>`).join('');
|
||
return `<div class="prop-row"><label>${label}</label><select data-prop="${prop}">${opts}</select></div>`;
|
||
}
|
||
function propTextarea(label, prop, val) {
|
||
return `<div class="prop-row"><label>${label}</label><textarea data-prop="${prop}">${esc(val)}</textarea></div>`;
|
||
}
|
||
function esc(s) { return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||
|
||
function applyPropChange(prop, value, inputType) {
|
||
const ent = getSelectedEntityData();
|
||
if (!ent) return;
|
||
|
||
if (prop === 'dialog') {
|
||
ent.dialog = value.split('\n').filter(l => l.trim());
|
||
} else if (prop === 'requiredOutputs') {
|
||
ent.requiredOutputs = value.split(',').map(Number);
|
||
} else if (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();
|
||
}
|
||
|
||
// ==================== 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) {
|
||
out += ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}', dialog: ${JSON.stringify(n.dialog)} },\n`;
|
||
}
|
||
out += ` ],\n\n`;
|
||
|
||
// Interactions
|
||
out += ` interactions: [\n`;
|
||
for (const inter of md.interactions) {
|
||
let line = ` { x: ${inter.x}, y: ${inter.y}, type: '${inter.type}', label: '${inter.label}'`;
|
||
if (inter.dialog) line += `, dialog: ${JSON.stringify(inter.dialog)}`;
|
||
if (inter.puzzleId) line += `, puzzleId: '${inter.puzzleId}'`;
|
||
if (inter.requiredOutputs) line += `, requiredOutputs: [${inter.requiredOutputs}]`;
|
||
line += ` },\n`;
|
||
out += line;
|
||
}
|
||
out += ` ]\n};\n\n`;
|
||
}
|
||
|
||
// Registry
|
||
out += `// ==================== Registry ====================\n\n`;
|
||
out += `const maps = {\n`;
|
||
for (const mapId of Object.keys(mapConfigs)) {
|
||
const gameMapId = mapId === 'pallet-town' ? 'town' : mapId;
|
||
out += ` '${gameMapId}': ${varName(mapId)}Map,\n`;
|
||
}
|
||
out += `};\n\n`;
|
||
|
||
// API functions
|
||
out += `export function getMap(id) { return maps[id] || null; }\n\n`;
|
||
out += `export function isWall(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return true;\n if (x < 0 || x >= map.widthTiles || y < 0 || y >= map.heightTiles) return true;\n return map.wallSet.has(x + ',' + y);\n}\n\n`;
|
||
out += `export function isWalkable(mapId, x, y) {\n if (isWall(mapId, x, y)) return false;\n if (getNPC(mapId, x, y)) return false;\n return true;\n}\n\n`;
|
||
out += `export function getInteraction(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.interactions.find(i => i.x === x && i.y === y) || null;\n}\n\n`;
|
||
out += `export function getNPC(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.npcs.find(npc => npc.x === x && npc.y === y) || null;\n}\n\n`;
|
||
out += `export function getExit(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.exits.find(e => e.x === x && e.y === y) || null;\n}\n\n`;
|
||
out += `export function getTile(mapId, x, y) { return isWall(mapId, x, y) ? 1 : 0; }\n\n`;
|
||
out += `export { maps };\n`;
|
||
|
||
return out;
|
||
}
|
||
|
||
function varName(mapId) {
|
||
return mapId.replace(/-(\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 {
|
||
const res = await fetch('/api/maps', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ content: code })
|
||
});
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
toast(`Saved maps.js (${data.bytes} bytes)`);
|
||
} else {
|
||
toast('Error: ' + (data.error || 'Unknown'));
|
||
}
|
||
} catch (e) {
|
||
toast('Save failed: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function loadFromServer() {
|
||
try {
|
||
const res = await fetch('/api/maps');
|
||
const data = await res.json();
|
||
if (!data.content) { toast('No maps.js found on server'); return; }
|
||
|
||
// Parse the maps.js to extract wall data and entities
|
||
// We parse the JS source to extract the wall objects and map definitions
|
||
const src = data.content;
|
||
parseAndLoadMapsJS(src);
|
||
updateEntityList(); updateProps(); render();
|
||
toast('Loaded maps.js from server');
|
||
} catch (e) {
|
||
toast('Load failed: ' + e.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse generated maps.js source and populate mapData
|
||
* This handles the format produced by generateMapsJS()
|
||
*/
|
||
function parseAndLoadMapsJS(src) {
|
||
// Extract wall data objects: const xxxWalls = { ... };
|
||
// And map objects: const xxxMap = { ... };
|
||
|
||
// Strategy: use regex to find wall definitions and eval them safely
|
||
// Extract walls for each known map by finding "const <name>Walls = {"
|
||
for (const mapId of Object.keys(mapConfigs)) {
|
||
const vName = varName(mapId);
|
||
|
||
// Parse walls
|
||
const wallMatch = src.match(new RegExp(`const\\s+${vName}Walls\\s*=\\s*\\{([\\s\\S]*?)\\};`));
|
||
if (wallMatch) {
|
||
const wallBody = wallMatch[1];
|
||
const wallData = {};
|
||
// Match each row: <number>: [<cols>],
|
||
const rowRegex = /(\d+)\s*:\s*\[([\d,\s.r()]*)\]/g;
|
||
let rm;
|
||
while ((rm = rowRegex.exec(wallBody)) !== null) {
|
||
const row = parseInt(rm[1]);
|
||
const colStr = rm[2].trim();
|
||
if (!colStr) continue;
|
||
// Handle ...r(a,b) ranges and plain numbers
|
||
const cols = [];
|
||
const parts = colStr.split(',');
|
||
for (let i = 0; i < parts.length; i++) {
|
||
const p = parts[i].trim();
|
||
const rangeMatch = p.match(/\.\.\.r\((\d+)\s*$/);
|
||
if (rangeMatch) {
|
||
// Next part has the end: "b)"
|
||
const end = parseInt(parts[++i]);
|
||
for (let c = parseInt(rangeMatch[1]); c <= end; c++) cols.push(c);
|
||
} else {
|
||
const n = parseInt(p);
|
||
if (!isNaN(n)) cols.push(n);
|
||
}
|
||
}
|
||
wallData[row] = cols;
|
||
}
|
||
mapData[mapId].walls = wallDataToSet(wallData);
|
||
}
|
||
|
||
// Parse spawn (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();
|
||
|
||
// Auto-load from server on start
|
||
loadFromServer().catch(() => {
|
||
console.log('[editor] Server load failed, using embedded defaults');
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|