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:
@@ -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
960
editor.html
Normal 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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
|
||||
function applyPropChange(prop, value, inputType) {
|
||||
const ent = getSelectedEntityData();
|
||||
if (!ent) return;
|
||||
|
||||
if (prop === 'dialog') {
|
||||
ent.dialog = value.split('\n').filter(l => l.trim());
|
||||
} else if (prop === 'requiredOutputs') {
|
||||
ent.requiredOutputs = value.split(',').map(Number);
|
||||
} else if (inputType === 'number' || prop === 'x' || prop === 'y' || prop === 'targetX' || prop === 'targetY') {
|
||||
ent[prop] = parseInt(value) || 0;
|
||||
} else {
|
||||
ent[prop] = value;
|
||||
}
|
||||
|
||||
updateEntityList();
|
||||
render();
|
||||
}
|
||||
|
||||
// ==================== Export ====================
|
||||
|
||||
function exportJSON() {
|
||||
const md = getData();
|
||||
const cfg = getCfg();
|
||||
const data = {
|
||||
id: currentMapId,
|
||||
name: cfg.name,
|
||||
widthTiles: cfg.widthTiles,
|
||||
heightTiles: cfg.heightTiles,
|
||||
walls: wallSetToData(md.walls),
|
||||
spawn: md.spawn,
|
||||
npcs: md.npcs,
|
||||
exits: md.exits,
|
||||
interactions: md.interactions
|
||||
};
|
||||
document.getElementById('export-code').textContent = JSON.stringify(data, null, 2);
|
||||
openModal('export-modal');
|
||||
}
|
||||
|
||||
function exportMapsJS() {
|
||||
// Generate complete maps.js content
|
||||
let code = generateMapsJS();
|
||||
document.getElementById('export-code').textContent = code;
|
||||
openModal('export-modal');
|
||||
}
|
||||
|
||||
function generateMapsJS() {
|
||||
let out = `/**\n * maps.js - PNG-based world maps (auto-generated by Level Editor)\n */\n\n`;
|
||||
out += `function buildWallSet(wallData) {\n const set = new Set();\n for (const [row, cols] of Object.entries(wallData)) {\n for (const col of cols) set.add(col + ',' + row);\n }\n return set;\n}\n\nfunction r(a, b) { const arr = []; for (let i = a; i <= b; i++) arr.push(i); return arr; }\n\n`;
|
||||
|
||||
// Generate each map
|
||||
for (const [mapId, cfg] of Object.entries(mapConfigs)) {
|
||||
const md = mapData[mapId];
|
||||
if (!md) continue;
|
||||
|
||||
const wallObj = wallSetToData(md.walls);
|
||||
const gameMapId = mapId === 'pallet-town' ? 'town' : mapId;
|
||||
const imageKey = `map:${mapId}`;
|
||||
|
||||
out += `// ==================== ${cfg.name} ====================\n\n`;
|
||||
|
||||
// Walls
|
||||
out += `const ${varName(mapId)}Walls = {\n`;
|
||||
const rows = Object.keys(wallObj).map(Number).sort((a,b) => a - b);
|
||||
for (const row of rows) {
|
||||
const cols = wallObj[row].sort((a,b) => a - b);
|
||||
// Compress into ranges where possible
|
||||
out += ` ${row}: [${compressCols(cols)}],\n`;
|
||||
}
|
||||
out += `};\n\n`;
|
||||
|
||||
// Map object
|
||||
out += `const ${varName(mapId)}Map = {\n`;
|
||||
out += ` id: '${gameMapId}',\n`;
|
||||
out += ` name: '${cfg.name}',\n`;
|
||||
out += ` image: '${imageKey}',\n`;
|
||||
out += ` widthTiles: ${cfg.widthTiles},\n`;
|
||||
out += ` heightTiles: ${cfg.heightTiles},\n`;
|
||||
out += ` spawn: { x: ${md.spawn.x}, y: ${md.spawn.y} },\n`;
|
||||
out += ` wallSet: buildWallSet(${varName(mapId)}Walls),\n\n`;
|
||||
|
||||
// Exits
|
||||
out += ` exits: [\n`;
|
||||
for (const e of md.exits) {
|
||||
const tMap = e.targetMap === 'pallet-town' ? 'town' : e.targetMap;
|
||||
out += ` { x: ${e.x}, y: ${e.y}, targetMap: '${tMap}', targetX: ${e.targetX}, targetY: ${e.targetY} },\n`;
|
||||
}
|
||||
out += ` ],\n\n`;
|
||||
|
||||
// NPCs
|
||||
out += ` npcs: [\n`;
|
||||
for (const n of md.npcs) {
|
||||
out += ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}', dialog: ${JSON.stringify(n.dialog)} },\n`;
|
||||
}
|
||||
out += ` ],\n\n`;
|
||||
|
||||
// Interactions
|
||||
out += ` interactions: [\n`;
|
||||
for (const inter of md.interactions) {
|
||||
let line = ` { x: ${inter.x}, y: ${inter.y}, type: '${inter.type}', label: '${inter.label}'`;
|
||||
if (inter.dialog) line += `, dialog: ${JSON.stringify(inter.dialog)}`;
|
||||
if (inter.puzzleId) line += `, puzzleId: '${inter.puzzleId}'`;
|
||||
if (inter.requiredOutputs) line += `, requiredOutputs: [${inter.requiredOutputs}]`;
|
||||
line += ` },\n`;
|
||||
out += line;
|
||||
}
|
||||
out += ` ]\n};\n\n`;
|
||||
}
|
||||
|
||||
// Registry
|
||||
out += `// ==================== Registry ====================\n\n`;
|
||||
out += `const maps = {\n`;
|
||||
for (const mapId of Object.keys(mapConfigs)) {
|
||||
const gameMapId = mapId === 'pallet-town' ? 'town' : mapId;
|
||||
out += ` '${gameMapId}': ${varName(mapId)}Map,\n`;
|
||||
}
|
||||
out += `};\n\n`;
|
||||
|
||||
// API functions
|
||||
out += `export function getMap(id) { return maps[id] || null; }\n\n`;
|
||||
out += `export function isWall(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return true;\n if (x < 0 || x >= map.widthTiles || y < 0 || y >= map.heightTiles) return true;\n return map.wallSet.has(x + ',' + y);\n}\n\n`;
|
||||
out += `export function isWalkable(mapId, x, y) {\n if (isWall(mapId, x, y)) return false;\n if (getNPC(mapId, x, y)) return false;\n return true;\n}\n\n`;
|
||||
out += `export function getInteraction(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.interactions.find(i => i.x === x && i.y === y) || null;\n}\n\n`;
|
||||
out += `export function getNPC(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.npcs.find(npc => npc.x === x && npc.y === y) || null;\n}\n\n`;
|
||||
out += `export function getExit(mapId, x, y) {\n const map = maps[mapId];\n if (!map) return null;\n return map.exits.find(e => e.x === x && e.y === y) || null;\n}\n\n`;
|
||||
out += `export function getTile(mapId, x, y) { return isWall(mapId, x, y) ? 1 : 0; }\n\n`;
|
||||
out += `export { maps };\n`;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function varName(mapId) {
|
||||
return mapId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function compressCols(cols) {
|
||||
if (cols.length === 0) return '';
|
||||
// Check if it's a full range
|
||||
const min = cols[0], max = cols[cols.length - 1];
|
||||
const isRange = cols.length === max - min + 1 && cols.every((c, i) => c === min + i);
|
||||
if (isRange && cols.length > 4) return `...r(${min},${max})`;
|
||||
return cols.join(',');
|
||||
}
|
||||
|
||||
function wallSetToData(wallSet) {
|
||||
const data = {};
|
||||
for (const key of wallSet) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
if (!data[y]) data[y] = [];
|
||||
data[y].push(x);
|
||||
}
|
||||
// Sort each row
|
||||
for (const row of Object.keys(data)) data[row].sort((a, b) => a - b);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ==================== Import ====================
|
||||
|
||||
function doImport() {
|
||||
try {
|
||||
const text = document.getElementById('import-textarea').value.trim();
|
||||
const data = JSON.parse(text);
|
||||
const md = getData();
|
||||
|
||||
if (data.walls) md.walls = wallDataToSet(data.walls);
|
||||
if (data.spawn) md.spawn = data.spawn;
|
||||
if (data.npcs) md.npcs = data.npcs;
|
||||
if (data.exits) md.exits = data.exits;
|
||||
if (data.interactions) md.interactions = data.interactions;
|
||||
|
||||
closeModal('import-modal');
|
||||
updateEntityList(); updateProps(); render();
|
||||
toast('Map data imported!');
|
||||
} catch (e) {
|
||||
alert('Invalid JSON: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
function openModal(id) { document.getElementById(id).classList.add('show'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
||||
|
||||
function copyExport() {
|
||||
const code = document.getElementById('export-code').textContent;
|
||||
navigator.clipboard.writeText(code).then(() => toast('Copied to clipboard!'));
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 2000);
|
||||
}
|
||||
|
||||
// ==================== Boot ====================
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user