feat: add standalone level editor for visual map editing

Full-featured editor at /editor.html with:
- Visual wall painting (click/drag to paint/erase collision tiles)
- Entity placement: NPCs, exits, interactions, spawn point
- Properties panel for editing dialog, facing, target maps, etc.
- Zoom/pan with scroll wheel and right-click drag
- Tile coordinate overlay on the map PNG backgrounds
- Color-coded overlays matching the F3 debug view
- Export as JSON or as complete maps.js source code
- Import JSON to load/restore map data
- Keyboard shortcuts: 1-7 for tools, Delete to remove entities
- All 4 maps supported: lab, pallet-town, house, route-1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 16:29:40 +01:00
parent bf34879390
commit 71321e8e88
2 changed files with 961 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/
COPY editor.html /usr/share/nginx/html/
COPY css/ /usr/share/nginx/html/css/
COPY js/ /usr/share/nginx/html/js/
COPY assets/ /usr/share/nginx/html/assets/

960
editor.html Normal file
View File

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