Files
Light_And_Dark/light_and_dark/level_editor_v43.html
T
Lena 17d4e9cdf3 full rename
rename of folder, subfolders and main.py
2026-09-28 09:47:18 +02:00

3204 lines
112 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Room JSON Level Editor (Multi-Room + Linking)</title>
<style>
:root{
--bg:#0b0f14; --panel:#111827; --panel2:#0f172a; --fg:#e5e7eb; --muted:#9ca3af;
--accent:#60a5fa; --accent2:#34d399; --warn:#f59e0b; --danger:#ef4444; --border:#1f2937;
}
html,body{height:100%; margin:0; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;}
.wrap{display:grid; grid-template-columns: 360px 1fr; height:100vh; min-height:100vh;}
.left{background:var(--panel); border-right:1px solid var(--border); padding:12px; overflow:auto;}
.section{background:rgba(255,255,255,0.03); border:1px solid var(--border); border-radius:12px; padding:10px; margin-bottom:12px;}
.section h3{margin:0 0 8px 0; font-size:14px; color:var(--fg); display:flex; align-items:center; gap:8px;}
.row{display:flex; gap:8px; flex-wrap:wrap; align-items:center;}
button, select, input[type="text"], input[type="number"]{
background:var(--panel2); color:var(--fg); border:1px solid var(--border); border-radius:10px;
padding:8px 10px; font-size:13px;
}
button{cursor:pointer;}
button:hover{border-color:#334155;}
button.primary{border-color:rgba(96,165,250,0.7); box-shadow:0 0 0 1px rgba(96,165,250,0.15) inset;}
button.good{border-color:rgba(52,211,153,0.7); box-shadow:0 0 0 1px rgba(52,211,153,0.12) inset;}
button.warn{border-color:rgba(245,158,11,0.7); box-shadow:0 0 0 1px rgba(245,158,11,0.12) inset;}
button.danger{border-color:rgba(239,68,68,0.7); box-shadow:0 0 0 1px rgba(239,68,68,0.12) inset;}
.muted{color:var(--muted); font-size:12px; line-height:1.35;}
.roomsList{display:flex; flex-direction:column; gap:6px; max-height:220px; overflow:auto; padding-right:2px;}
.roomItem{display:flex; gap:6px; align-items:center;}
.roomBtn{flex:1; display:flex; justify-content:space-between; align-items:center;}
.badge{font-size:11px; padding:2px 6px; border-radius:999px; border:1px solid var(--border); color:var(--muted);}
.badge.dirty{border-color:rgba(245,158,11,0.6); color:#fbbf24;}
.kbd{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; padding:2px 6px; border:1px solid var(--border); border-radius:6px; color:var(--muted);}
.right{position:relative; display:flex; flex-direction:column; min-width:0; height:100%;}
.topbar{display:flex; gap:10px; align-items:center; padding:10px 12px; border-bottom:1px solid var(--border); background:rgba(255,255,255,0.02);}
/* IMPORTANT: force the grid to actually take height inside the flex column */
.canvasRow{position:relative; flex:1; min-height:0; height:100%; display:grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; gap:1px; background:var(--border);}
#linkOverlay{position:absolute; left:0; top:0; width:100%; height:100%; pointer-events:none; z-index:50;}
.canvasWrap{position:relative; background:#05070a; min-height:0;}
canvas{width:100%; height:100%; display:block;}
.overlayLabel{
position:absolute; top:10px; left:10px; padding:6px 10px; border-radius:10px;
background:rgba(17,24,39,0.7); border:1px solid var(--border); font-size:12px; color:var(--fg);
backdrop-filter: blur(6px);
}
.overlayLabel small{color:var(--muted);}
.status{margin-left:auto; color:var(--muted); font-size:12px;}
.hr{height:1px; background:var(--border); margin:8px 0;}
.grid2{display:grid; grid-template-columns: 1fr 1fr; gap:8px;}
.mini{font-size:12px; color:var(--muted);}
</style>
</head>
<body>
<div class="wrap">
<div class="left">
<div class="section">
<h3>Rooms</h3>
<div class="row">
<input id="fileInput" type="file" accept=".json,application/json" multiple />
<button id="newRoomBtn" class="primary">New Room</button>
</div>
<div class="muted" style="margin-top:6px;">
Load multiple room JSONs here. Click a room to edit it. Linking lets you pick an exit in one room and a spawn in another.
</div>
<div class="hr"></div>
<div class="roomsList" id="roomsList"></div>
<div class="hr"></div>
<div class="row">
<button id="downloadActiveBtn" class="good">Download Active</button>
<button id="downloadCheckedBtn">Download Checked</button>
</div>
<div class="muted" style="margin-top:6px;">
Downloads are per-room. Checked download will save one file per checked room.
</div>
</div>
<div class="section">
<h3>Tools</h3>
<div class="row">
<button class="toolBtn primary" data-tool="platform">Platform</button>
<button class="toolBtn" data-tool="enemy_spawn">Enemy Spawn</button>
<button class="toolBtn" data-tool="player_spawn">Player Spawn</button>
<button class="toolBtn" data-tool="entry_spawn">Entry Spawn</button>
<button class="toolBtn" data-tool="exit">Exit</button>
<button class="toolBtn" data-tool="ability_door">Ability Door</button>
<button class="toolBtn" data-tool="object_spawn">Object Spawn</button>
<button class="toolBtn warn" data-tool="select">Select/Move</button>
</div>
<div class="muted" style="margin-top:8px;">
Shortcuts: <span class="kbd">Ctrl</span>+<span class="kbd">Z</span> undo,
<span class="kbd">Ctrl</span>+<span class="kbd">Y</span> redo,
<span class="kbd">Ctrl</span>+<span class="kbd">C</span>/<span class="kbd">V</span> copy/paste,
<span class="kbd">Del</span> delete, <span class="kbd">Ctrl</span>+<span class="kbd">D</span> duplicate.
</div>
<div class="hr"></div>
<h3 style="margin-top:10px;">Enemy Types</h3>
<div class="muted">Load an enemy types JSON (keyed by enemy id). Used for placement dropdown, hitbox preview and aggro range preview.</div>
<div class="row" style="margin-top:8px;">
<input id="enemyTypesInput" type="file" accept=".json,application/json" />
</div>
<div class="row" style="margin-top:8px;">
<select id="enemyTypeSelect" style="flex:1; min-width:200px;"></select>
</div>
<label class="muted" style="display:flex; align-items:center; gap:8px; margin-top:8px;">
<input type="checkbox" id="showAggroChk"/>
Draw enemy aggro ranges
</label>
<div class="hr"></div>
<h3 style="margin-top:10px;">Objects Catalog</h3>
<div class="muted">Load objects.json to get default properties (lamp ranges, future objects).</div>
<div class="row" style="margin-top:8px;">
<input id="objectsInput" type="file" accept=".json,application/json" />
</div>
<div class="muted" id="objectsStatus" style="margin-top:6px;">No objects loaded.</div>
<div class="row" style="margin-top:8px;">
<select id="objectSelect" style="flex:1; min-width:200px;"></select>
</div>
</div>
<div class="section">
<h3>Multi-Room Linking</h3>
<div class="muted">
1) Select an <b>Exit</b> in the active room<br/>
2) Choose target room + spawn (entry spawn)<br/>
3) Click <b>Link</b>
</div>
<div class="hr"></div>
<div class="row" style="margin-bottom:8px;">
<button id="toggleLinkModeBtn" class="warn">Enable Link Mode</button>
</div> <div class="row" style="margin-bottom:8px;">
<label class="muted" style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="showLinksChk"/>
Draw link lines (auto-enables Compare)
</label>
</div>
<div class="muted" style="margin-top:-2px;">
<b>Link Mode:</b> select an Exit in the active room, then click an entry spawn marker in the compare canvas to auto-link.
</div>
</div>
<div class="section">
<h3>Compare View</h3>
<div class="row">
<button id="toggleCompareBtn">Enable Compare</button>
<select id="compareRoomSelect"></select>
</div>
<div class="muted" style="margin-top:8px;">
Compare shows a second room canvas (read-only) to visually match door positions.
</div>
</div>
<div class="section">
<h3>Properties</h3>
<div id="props" class="muted">Select something to edit properties.</div>
</div>
</div>
<div class="right">
<div class="topbar">
<div id="activeRoomLabel">Active: (none)</div>
<div class="status" id="status">—</div>
</div>
<div class="canvasRow" id="canvasRow" style="grid-template-columns: 1fr;">
<div class="canvasWrap">
<div class="overlayLabel" id="labelA">Active room</div>
<canvas id="canvasA"></canvas>
</div>
<div class="canvasWrap" id="compareWrap" style="display:none;">
<div class="overlayLabel" id="labelB">Compare room</div>
<canvas id="canvasB"></canvas>
</div>
<svg id="linkOverlay"></svg>
</div>
</div>
</div>
<script>
(() => {
// ---------- Utilities ----------
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
const deepClone = (obj)=>JSON.parse(JSON.stringify(obj));
const nowId = ()=>Math.random().toString(16).slice(2) + Date.now().toString(16);
const objectCatalog = { byType: {}, byId: {} };
const snap = (v)=> Math.round(v / state.gridSize) * state.gridSize;
const snapPt = (x,y)=> [snap(x), snap(y)];
function downloadText(filename, text){
const blob = new Blob([text], {type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(()=>URL.revokeObjectURL(url), 1000);
}
function getObjectDefByType(type){
if(!type) return null;
return objectCatalog.byType[type] || null;
}
function getObjectDefById(id){
if(!id) return null;
return objectCatalog.byId[id] || null;
}
function getDefaultRadiusForType(type, objectId){
const def = getObjectDefById(objectId) || getObjectDefByType(type);
const val = def && Number.isFinite(Number(def.charge_regen_range)) ? Number(def.charge_regen_range) : 180;
return val > 0 ? val : 180;
}
function roomTemplate(name="room_new"){
return {
name,
room_w: 1600,
room_h: 900,
player_spawn: [100, 300],
entry_spawns: {}, // {spawn_id: [x,y]}
platforms: [],
enemies: [],
exits: [],
ability_doors: [],
props: []
};
}
function normalizeRoom(json, fallbackName, filename){
// Supports both:
// A) editor-native rooms (name, platforms:[{x,y,w,h}], exits:[{x,y,w,h}...])
// B) your game rooms (meta, platforms:{ground:[[x,y,w,h],...], ...}, exits:[{rect:[...]}], enemies:[{pos:[x,y]}])
const src = json || {};
const r = {};
// ----- format detection -----
const isGameFormat = (src && typeof src === "object" && src.meta && (src.platforms && !Array.isArray(src.platforms)));
r._format = isGameFormat ? "game_v1" : "editor_v1";
// ----- naming / meta -----
if(isGameFormat){
r.meta = deepClone(src.meta || {});
const id = (r.meta && r.meta.id) ? r.meta.id : (fallbackName || "room");
// internally we use r.name as the room id (so exits use stable ids)
r.name = id;
// keep display name too
r.display_name = (r.meta && r.meta.name) ? r.meta.name : id;
r.bounds = deepClone(src.bounds || {});
} else {
r.name = src.name || fallbackName || "room";
r.display_name = src.display_name || r.name;
r.meta = deepClone(src.meta || { id: r.name, name: r.display_name });
r.bounds = deepClone(src.bounds || {});
}
// ----- spawns -----
r.player_spawn = Array.isArray(src.player_spawn) ? [Number(src.player_spawn[0])||0, Number(src.player_spawn[1])||0] : [100,300];
r.entry_spawns = (src.entry_spawns && typeof src.entry_spawns === "object") ? deepClone(src.entry_spawns) : {};
// ----- platforms -----
r.platforms = [];
if(isGameFormat){
const platObj = (src.platforms && typeof src.platforms === "object") ? src.platforms : {};
for(const group of Object.keys(platObj)){
const arr = platObj[group];
if(!Array.isArray(arr)) continue;
for(const rect of arr){
if(!Array.isArray(rect) || rect.length < 4) continue;
r.platforms.push({ x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group });
}
}
// if nothing came through, try also accepting editor-native arrays inside game rooms
if(Array.isArray(src.platforms)){
for(const p of src.platforms){
if(p && typeof p === "object"){
r.platforms.push({ x:+p.x||0, y:+p.y||0, w:+p.w||0, h:+p.h||0, group: p.group || "ground" });
}
}
}
} else {
// editor-native
if(Array.isArray(src.platforms)){
for(const p of src.platforms){
if(p && typeof p === "object"){
r.platforms.push({ x:+p.x||0, y:+p.y||0, w:+p.w||0, h:+p.h||0, group: p.group || "ground" });
} else if(Array.isArray(p) && p.length>=4){
r.platforms.push({ x:+p[0]||0, y:+p[1]||0, w:+p[2]||0, h:+p[3]||0, group:"ground" });
}
}
} else if(src.platforms && typeof src.platforms === "object"){
// allow grouped platforms even in editor rooms
for(const group of Object.keys(src.platforms)){
const arr = src.platforms[group];
if(!Array.isArray(arr)) continue;
for(const rect of arr){
if(!Array.isArray(rect) || rect.length < 4) continue;
r.platforms.push({ x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group });
}
}
}
}
// ----- enemies -----
r.enemies = [];
if(Array.isArray(src.enemies)){
for(const e of src.enemies){
if(e && typeof e === "object"){
if(Array.isArray(e.pos)){
r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy" });
} else if(typeof e.x === "number" || typeof e.y === "number"){
r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy" });
}
}
}
}
// ----- props -----
r.props = [];
if(Array.isArray(src.props)){
for(const p of src.props){
if(!p || typeof p !== "object") continue;
let x = 0, y = 0;
if(Array.isArray(p.pos)){
x = +p.pos[0] || 0; y = +p.pos[1] || 0;
} else {
x = +p.x || 0; y = +p.y || 0;
}
const type = p.type || p.kind || "light_source";
const object_id = p.object_id || p.objectId || null;
const radius = +p.radius || getDefaultRadiusForType(type, object_id);
r.props.push({ x, y, type, radius, object_id });
}
}
// ----- exits -----
r.exits = [];
if(Array.isArray(src.exits)){
for(const ex of src.exits){
if(!ex || typeof ex !== "object") continue;
if(Array.isArray(ex.rect)){
r.exits.push({
x:+ex.rect[0]||0, y:+ex.rect[1]||0, w:+ex.rect[2]||0, h:+ex.rect[3]||0,
to: ex.to || "",
spawn_id: ex.spawn_id || "",
spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null,
hint: ex.hint || ""
});
} else {
// editor-native exit
r.exits.push({
x:+ex.x||0, y:+ex.y||0, w:+ex.w||0, h:+ex.h||0,
to: ex.to || "",
spawn_id: ex.spawn_id || "",
spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null,
hint: ex.hint || ""
});
}
}
}
// ----- ability doors -----
r.ability_doors = [];
if(Array.isArray(src.ability_doors)){
for(const d of src.ability_doors){
if(!d) continue;
if(Array.isArray(d) && d.length >= 4){
r.ability_doors.push({ x:+d[0]||0, y:+d[1]||0, w:+d[2]||0, h:+d[3]||0 });
} else if(typeof d === "object"){
if(Array.isArray(d.rect)){
r.ability_doors.push({ x:+d.rect[0]||0, y:+d.rect[1]||0, w:+d.rect[2]||0, h:+d.rect[3]||0 });
} else {
r.ability_doors.push({ x:+d.x||0, y:+d.y||0, w:+d.w||0, h:+d.h||0 });
}
}
}
}
// ----- keep other top-level fields (future-proof) -----
r._extras = {};
if(src && typeof src === "object"){
const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","exits","ability_doors","entry_spawns","props","name","display_name","room_w","room_h"]);
for(const k of Object.keys(src)){
if(!keep.has(k)) r._extras[k] = deepClone(src[k]);
}
}
// some editor fields for convenience
if(!r.room_w) r.room_w = src.room_w || 1600;
if(!r.room_h) r.room_h = src.room_h || 900;
return r;
}
// ---------- App State ----------
const state = {
rooms: new Map(), // id -> {id, filename, data, dirty, history, future, checked}
activeId: null,
compareId: null,
compareEnabled: false,
linkMode: false,
tool: "select",
platformGroup: "ground",
gridSize: 20,
selection: null, // {kind, index, roomId}
dragging: null,
// per-canvas cameras (so compare can pan/zoom independently)
viewA: { zoom: 1.0, panX: 0, panY: 0 },
viewB: { zoom: 1.0, panX: 0, panY: 0 },
clipboard: null,
enemyTypes: null,
enemyTypeKeys: [],
enemyPlaceType: "enemy",
showAggro: false,
objectPlaceId: null
};
const els = {
fileInput: document.getElementById("fileInput"),
roomsList: document.getElementById("roomsList"),
newRoomBtn: document.getElementById("newRoomBtn"),
downloadActiveBtn: document.getElementById("downloadActiveBtn"),
downloadCheckedBtn: document.getElementById("downloadCheckedBtn"),
activeRoomLabel: document.getElementById("activeRoomLabel"),
status: document.getElementById("status"),
linkTargetRoom: document.getElementById("linkTargetRoom"),
linkTargetSpawn: document.getElementById("linkTargetSpawn"),
linkApplyBtn: document.getElementById("linkApplyBtn"),
openTargetBtn: document.getElementById("openTargetBtn"),
toggleLinkModeBtn: document.getElementById("toggleLinkModeBtn"),
toggleCompareBtn: document.getElementById("toggleCompareBtn"),
compareWrap: document.getElementById("compareWrap"),
canvasRow: document.getElementById("canvasRow"),
compareRoomSelect: document.getElementById("compareRoomSelect"),
labelA: document.getElementById("labelA"),
labelB: document.getElementById("labelB"),
linkOverlay: document.getElementById("linkOverlay"),
showLinksChk: document.getElementById("showLinksChk"),
props: document.getElementById("props"),
canvasA: document.getElementById("canvasA"),
canvasB: document.getElementById("canvasB"),
platformGroup: document.getElementById("platformGroup"),
enemyTypesInput: document.getElementById("enemyTypesInput"),
enemyTypeSelect: document.getElementById("enemyTypeSelect"),
showAggroChk: document.getElementById("showAggroChk"),
objectsInput: document.getElementById("objectsInput"),
objectsStatus: document.getElementById("objectsStatus"),
objectSelect: document.getElementById("objectSelect")
};
const ctxA = els.canvasA.getContext("2d");
const ctxB = els.canvasB.getContext("2d");
// Ensure pointer interactions behave consistently
[els.canvasA, els.canvasB].forEach(c=>{
c.style.touchAction = "none";
c.addEventListener("contextmenu", (e)=>e.preventDefault());
});
function getActiveRoom(){
return state.activeId ? state.rooms.get(state.activeId) : null;
}
function getCompareRoom(){
return state.compareId ? state.rooms.get(state.compareId) : null;
}
function markDirty(roomId, dirty=true){
const r = state.rooms.get(roomId);
if(!r) return;
r.dirty = dirty;
}
// ---------- History (per-room) ----------
function pushHistory(roomId){
const r = state.rooms.get(roomId);
if(!r) return;
r.history.push(deepClone(r.data));
if(r.history.length > 80) r.history.shift();
r.future = [];
markDirty(roomId, true);
}
function undo(){
const r = getActiveRoom(); if(!r) return;
if(!r.history.length) return;
r.future.push(deepClone(r.data));
r.data = r.history.pop();
state.selection = null;
markDirty(r.id, true);
refreshUI();
}
function redo(){
const r = getActiveRoom(); if(!r) return;
if(!r.future.length) return;
r.history.push(deepClone(r.data));
r.data = r.future.pop();
state.selection = null;
markDirty(r.id, true);
refreshUI();
}
// ---------- Rooms UI ----------
function addRoomFromJSON(json, filenameHint){
const id = nowId();
const baseName = (json && json.name) ? json.name : (filenameHint ? filenameHint.replace(/\.json$/i,"") : "room");
const data = normalizeRoom(json, baseName);
const filename = (filenameHint && filenameHint.endsWith(".json")) ? filenameHint : (data.name + ".json");
state.rooms.set(id, {id, filename, data, dirty:false, history:[], future:[], checked:false});
if(!state.activeId) state.activeId = id;
if(!state.compareId) state.compareId = id;
refreshUI();
}
function setActiveRoom(id){
if(!state.rooms.has(id)) return;
const prevActive = state.activeId;
const prevCompare = state.compareId;
state.activeId = id;
state.selection = null;
if(state.compareEnabled){
const keys = [...state.rooms.keys()];
const idx = keys.indexOf(state.activeId);
const pickNextOther = ()=>{
if(keys.length <= 1) return null;
for(let step=1; step<keys.length; step++){
const cand = keys[(idx + step) % keys.length];
if(cand !== state.activeId) return cand;
}
return null;
};
// If we promoted the compare room to active, advance compare to the next room (e.g. room_03)
if(prevCompare === state.activeId){
state.compareId = pickNextOther();
} else if(!state.compareId || !state.rooms.has(state.compareId) || state.compareId === state.activeId){
state.compareId = pickNextOther();
}
}
fitViewToRoom(getActiveRoom(),'A');
if(state.compareEnabled) fitViewToRoom(getCompareRoom(),'B');
refreshUI();
}
function toggleRoomChecked(id){
const r = state.rooms.get(id);
if(!r) return;
r.checked = !r.checked;
refreshRoomsList();
}
function removeRoom(id){
if(!state.rooms.has(id)) return;
state.rooms.delete(id);
if(state.activeId === id){
state.activeId = [...state.rooms.keys()][0] || null;
state.selection = null;
}
if(state.compareId === id){
state.compareId = [...state.rooms.keys()][0] || null;
}
refreshUI();
}
function sanitizeRoomName(name){
return String(name||"").trim()
.replace(/\s+/g, "_")
.replace(/[^A-Za-z0-9_\-]/g, "_")
.replace(/_+/g, "_");
}
function renameRoom(roomId, newName){
const r = state.rooms.get(roomId);
if(!r) return;
const oldName = r.data && r.data.name ? r.data.name : "";
const clean = sanitizeRoomName(newName);
if(!clean){ setStatus("Rename canceled (empty name)."); return; }
// prevent duplicates
for(const [oid, other] of state.rooms.entries()){
if(oid !== roomId && other && other.data && other.data.name === clean){
alert('A room named "' + clean + '" already exists.');
return;
}
}
// record history for this room
pushHistory(roomId);
r.data.name = clean;
r.data.display_name = clean;
r.data.meta = r.data.meta || {};
r.data.meta.id = clean;
r.data.meta.name = clean;
// update filename if it was generic or name-based
if(r.filename === "room_new.json" || r.filename === (oldName + ".json")){
r.filename = clean + ".json";
}
// update cross-room exit links that point to the old name
for(const [oid, wrap] of state.rooms.entries()){
if(!wrap || !wrap.data || !Array.isArray(wrap.data.exits)) continue;
let changed = false;
for(const ex of wrap.data.exits){
if(ex && ex.to === oldName){
if(!changed){
pushHistory(oid);
changed = true;
}
ex.to = clean;
}
}
if(changed){
markDirty(oid, true);
}
}
refreshUI();
setStatus('Renamed "' + oldName + '" -> "' + clean + '"');
}
function refreshRoomsList(){
els.roomsList.innerHTML = "";
for(const [id, r] of state.rooms.entries()){
const row = document.createElement("div");
row.className = "roomItem";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = !!r.checked;
cb.addEventListener("change", ()=>toggleRoomChecked(id));
const btn = document.createElement("button");
btn.className = "roomBtn " + (id === state.activeId ? "primary" : "");
btn.textContent = r.data.name;
btn.addEventListener("click", ()=>setActiveRoom(id));
const rn = document.createElement("button");
rn.className = "warn";
rn.textContent = "Rename";
rn.title = "Rename this room";
rn.addEventListener("click", (e)=>{
e.stopPropagation();
const cur = (r && r.data && r.data.name) ? r.data.name : "";
const next = window.prompt("Rename room:", cur);
if(next === null) return;
renameRoom(id, next);
});
const badges = document.createElement("div");
badges.style.display = "flex";
badges.style.gap = "6px";
const b1 = document.createElement("span");
b1.className = "badge" + (r.dirty ? " dirty" : "");
b1.textContent = r.dirty ? "modified" : "saved";
badges.appendChild(b1);
const rm = document.createElement("button");
rm.className = "danger";
rm.textContent = "×";
rm.title = "Remove from workspace (does not delete your file)";
rm.addEventListener("click", (e)=>{
e.stopPropagation();
const rr = state.rooms.get(id);
if(rr && rr.dirty){
// Ask to save before removing
const save = window.confirm(`"${rr.data.name}" has unsaved changes.
OK = Download (save) then remove
Cancel = Choose whether to remove without saving`);
if(save){
downloadText(rr.filename || (rr.data.name + ".json"), serializeRoom(rr));
rr.dirty = false;
removeRoom(id);
return;
} else {
const discard = window.confirm(`Remove "${rr.data.name}" without saving?`);
if(!discard) return; // abort
removeRoom(id);
return;
}
}
removeRoom(id);
});
// compose
const wrapper = document.createElement("div");
wrapper.style.display="flex";
wrapper.style.flex="1";
wrapper.style.gap="6px";
wrapper.appendChild(btn);
wrapper.appendChild(badges);
row.appendChild(cb);
row.appendChild(wrapper);
row.appendChild(rn);
row.appendChild(rm);
els.roomsList.appendChild(row);
}
}
function refreshRoomSelectors(){
const rooms = [...state.rooms.entries()].map(([id,r])=>({id, name:r.data.name}));
const activeId = state.activeId;
function fill(select, filterFn, selectedId){
if(!select) return;
const prev = select.value;
select.innerHTML = "";
for(const it of rooms.filter(filterFn)){
const opt = document.createElement("option");
opt.value = it.id;
opt.textContent = it.name;
select.appendChild(opt);
}
// keep selection if possible
const wanted = selectedId || prev;
if([...select.options].some(o=>o.value===wanted)) select.value = wanted;
else if(select.options.length) select.value = select.options[0].value;
}
fill(els.linkTargetRoom, it => it.id !== activeId, state.compareEnabled ? null : null);
fill(els.compareRoomSelect, it => it.id !== activeId, state.compareId);
// maintain compareId if enabled
if(state.compareEnabled){
const v = els.compareRoomSelect.value;
state.compareId = v || null;
}
updateLinkSpawnList();
}
function updateLinkSpawnList(){
if(!els.linkTargetRoom || !els.linkTargetSpawn) return;
const targetId = els.linkTargetRoom.value;
const targetRoom = state.rooms.get(targetId);
els.linkTargetSpawn.innerHTML = "";
if(!targetRoom){
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(no target room)";
els.linkTargetSpawn.appendChild(opt);
return;
}
const keys = Object.keys(targetRoom.data.entry_spawns || {}).sort();
if(keys.length === 0){
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(no entry spawns)";
els.linkTargetSpawn.appendChild(opt);
return;
}
for(const k of keys){
const opt = document.createElement("option");
opt.value = k;
opt.textContent = k;
els.linkTargetSpawn.appendChild(opt);
}
}
// ---------- Link Mode (click-to-link via compare canvas) ----------
function setLinkMode(on){
state.linkMode = !!on;
if(state.linkMode){
// Safety: switch to Select/Move so linking misclicks never place new objects
state.tool = "select";
// compare view is required to click a target spawn
if(!state.compareEnabled){
state.compareEnabled = true;
const other = [...state.rooms.keys()].find(k => k !== state.activeId);
state.compareId = other || state.activeId;
}
setStatus("Link Mode: select an Exit, then click a spawn in the compare canvas");
} else {
setStatus("Link Mode disabled");
}
refreshUI();
}
function hitTestSpawnNode(roomWrap, wx, wy){
if(!roomWrap) return null;
const room = roomWrap.data;
// We choose the closest node within a radius, so overlapping nodes don't pick the "first" key.
let best = null;
let bestD2 = Infinity;
const R = 14;
// player spawn (single)
if(room.player_spawn){
const dx = wx - room.player_spawn[0];
const dy = wy - room.player_spawn[1];
const d2 = dx*dx + dy*dy;
if(d2 <= R*R){
best = {kind:"player_spawn", key:"__player_spawn__", roomId: roomWrap.id};
bestD2 = d2;
}
}
// entry spawns (named)
const entries = room.entry_spawns || {};
for(const k of Object.keys(entries)){
const [ex,ey] = entries[k];
const dx = wx - ex;
const dy = wy - ey;
const d2 = dx*dx + dy*dy;
if(d2 <= R*R && d2 < bestD2){
best = {kind:"entry_spawn", key:k, roomId: roomWrap.id};
bestD2 = d2;
}
}
return best;
}
function onComparePointerDown(ev){
if(!state.compareEnabled) return;
const roomWrap = getCompareRoom(); if(!roomWrap) return;
const rect = els.canvasB.getBoundingClientRect();
const sx = ev.clientX - rect.left;
const sy = ev.clientY - rect.top;
const [wx,wy] = screenToWorld(sx,sy,els.canvasB);
const room = roomWrap.data;
if(ev.button === 1){
// middle mouse pan handled by mousedown
return;
}
if(ev.button === 0 && ev.altKey){
ev.preventDefault();
state.dragging = {mode:"pan", canvas:"B", startX:sx, startY:sy, startPanX:state.viewB.panX, startPanY:state.viewB.panY, startZoom:state.viewB.zoom};
return;
}
// LINK MODE: pick spawn node in compare room by clicking it
if(state.linkMode){
const activeRoomWrap = getActiveRoom();
const sel = state.selection;
if(!activeRoomWrap || !sel || sel.kind !== "exit" || sel.roomId !== activeRoomWrap.id){
setStatus("Link Mode: select an Exit in the active room first");
return;
}
const hitNode = hitTestSpawnNode(roomWrap, wx, wy);
if(!hitNode){
setStatus("Link Mode: click a spawn node (Player Spawn or Entry Spawn) in the compare room");
return;
}
const exit = activeRoomWrap.data.exits[sel.index];
if(!exit) return;
const spawnId = hitNode.key;
const pos = (spawnId==="__player_spawn__") ? roomWrap.data.player_spawn : roomWrap.data.entry_spawns[spawnId];
pushHistory(activeRoomWrap.id);
exit.to = roomWrap.data.name;
exit.spawn_id = spawnId;
exit.spawn = [pos[0], pos[1]];
if(els.linkTargetRoom && els.linkTargetSpawn){
els.linkTargetRoom.value = roomWrap.id;
updateLinkSpawnList();
els.linkTargetSpawn.value = spawnId;
}
refreshUI();
setStatus(`Linked exit → ${exit.to}:${spawnId}`);
// auto-disable after one link
setLinkMode(false);
return;
}
// Normal editing on compare room
if(state.tool === "select"){
const hit = hitTest(roomWrap, wx, wy, els.canvasB);
state.selection = hit;
refreshProps();
if(hit){
if(hit.kind==="platform" && hit.edge){
const p = room.platforms[hit.index];
if(p){
pushHistory(roomWrap.id);
state.dragging = {
mode:"resizePlatformEdge",
canvas:"B",
roomId: roomWrap.id,
index: hit.index,
edge: hit.edge,
base: {x:p.x,y:p.y,w:p.w,h:p.h},
startWX: wx,
startWY: wy
};
return;
}
}
if(hit.kind==="exit" && hit.edge){
const ex = room.exits[hit.index];
if(ex){
pushHistory(roomWrap.id);
state.dragging = {
mode:"resizeExitEdge",
canvas:"B",
roomId: roomWrap.id,
index: hit.index,
edge: hit.edge,
base: {x:ex.x,y:ex.y,w:ex.w,h:ex.h},
startWX: wx,
startWY: wy
};
return;
}
}
if(hit.kind==="ability_door" && hit.edge){
const d = room.ability_doors && room.ability_doors[hit.index];
if(d){
pushHistory(roomWrap.id);
state.dragging = {
mode:"resizeAbilityDoorEdge",
canvas:"B",
roomId: roomWrap.id,
index: hit.index,
edge: hit.edge,
base: {x:d.x,y:d.y,w:d.w,h:d.h},
startWX: wx,
startWY: wy
};
return;
}
}
state.dragging = {mode:"move", canvas:"B", roomId: roomWrap.id, sel:deepClone(hit), startWX:wx, startWY:wy, base:null};
} else {
state.dragging = null;
}
return;
}
// creation tools apply to compare room too
if(state.tool === "platform"){
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
// Start as a 1x1 rect at the anchor; resizing can go in any direction.
const p = {x: sx1, y: sy1, w: 1, h: 1};
room.platforms.push(p);
state.selection = {kind:"platform", index: room.platforms.length-1, roomId: roomWrap.id};
refreshUI();
state.dragging = {
mode:"resizePlatform",
canvas:"B",
roomId: roomWrap.id,
index: room.platforms.length-1,
startWX: wx,
startWY: wy,
anchorX: sx1,
anchorY: sy1
};
return;
}
if(state.tool === "enemy_spawn"){
if(!state.enemyTypes || !state.enemyTypeKeys || state.enemyTypeKeys.length === 0){
setStatus("Please import an enemy types JSON first.");
return;
}
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
room.enemies.push({x: sx1, y: sy1, type: (state.enemyPlaceType || "enemy")});
state.selection = {kind:"enemy", index:room.enemies.length-1, roomId: roomWrap.id};
refreshUI();
return;
}
if(state.tool === "player_spawn"){
pushHistory(roomWrap.id);
room.player_spawn = snapPt(wx,wy);
state.selection = {kind:"player_spawn", index:0, roomId: roomWrap.id};
refreshUI();
return;
}
if(state.tool === "entry_spawn"){
pushHistory(roomWrap.id);
let base = "entry_";
let n = 1;
while(room.entry_spawns[base+n]) n++;
const key = base+n;
room.entry_spawns[key] = snapPt(wx,wy);
state.selection = {kind:"entry_spawn", key, roomId: roomWrap.id};
refreshUI();
return;
}
if(state.tool === "exit"){
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
room.exits.push({x: sx1, y: sy1, w: 10, h: 10, to:"", spawn_id:"", spawn:[sx1,sy1]});
state.selection = {kind:"exit", index:room.exits.length-1, roomId: roomWrap.id};
refreshUI();
state.dragging = {
mode:"resizeExit",
canvas:"B",
roomId: roomWrap.id,
index: room.exits.length-1,
startWX: wx,
startWY: wy,
anchorX: sx1,
anchorY: sy1
};
return;
}
if(state.tool === "ability_door"){
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
room.ability_doors.push({x: sx1, y: sy1, w: 10, h: 10});
state.selection = {kind:"ability_door", index:room.ability_doors.length-1, roomId: roomWrap.id};
refreshUI();
state.dragging = {
mode:"resizeAbilityDoor",
canvas:"B",
roomId: roomWrap.id,
index: room.ability_doors.length-1,
startWX: wx,
startWY: wy,
anchorX: sx1,
anchorY: sy1
};
return;
}
if(state.tool === "object_spawn"){
if(!objectCatalog.byId || Object.keys(objectCatalog.byId).length === 0){
setStatus("Please import an objects JSON first.");
return;
}
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
const props = room.props || (room.props = []);
const objectId = state.objectPlaceId || null;
const def = getObjectDefById(objectId) || getObjectDefByType("light_source");
const type = (def && def.type) ? def.type : "light_source";
const radius = getDefaultRadiusForType(type, objectId);
props.push({x:sx1, y:sy1, type, radius, object_id: objectId});
state.selection = {kind:"prop", index:props.length-1, roomId: roomWrap.id};
refreshUI();
return;
}
}
function fitViewToRoom(roomWrap, canvasKey){
if(!roomWrap) return;
const room = roomWrap.data;
const canvas = (canvasKey==="B") ? els.canvasB : els.canvasA;
const view = (canvasKey==="B") ? state.viewB : state.viewA;
const rect = canvas.getBoundingClientRect();
const w = rect.width || canvas.clientWidth || 1;
const h = rect.height || canvas.clientHeight || 1;
// compute bounds from content, include room border
let minX = 0, minY = 0, maxX = room.room_w || 1600, maxY = room.room_h || 900;
for(const p of (room.platforms||[])){
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x+p.w); maxY = Math.max(maxY, p.y+p.h);
}
for(const ex of (room.exits||[])){
minX = Math.min(minX, ex.x); minY = Math.min(minY, ex.y);
maxX = Math.max(maxX, ex.x+ex.w); maxY = Math.max(maxY, ex.y+ex.h);
}
for(const ad of (room.ability_doors||[])){
minX = Math.min(minX, ad.x); minY = Math.min(minY, ad.y);
maxX = Math.max(maxX, ad.x+ad.w); maxY = Math.max(maxY, ad.y+ad.h);
}
if(Array.isArray(room.player_spawn)){
minX = Math.min(minX, room.player_spawn[0]-50);
minY = Math.min(minY, room.player_spawn[1]-50);
maxX = Math.max(maxX, room.player_spawn[0]+50);
maxY = Math.max(maxY, room.player_spawn[1]+50);
}
const entries = room.entry_spawns||{};
for(const k of Object.keys(entries)){
const [x,y]=entries[k];
minX = Math.min(minX, x-50); minY = Math.min(minY, y-50);
maxX = Math.max(maxX, x+50); maxY = Math.max(maxY, y+50);
}
for(const e of (room.enemies||[])){
minX = Math.min(minX, e.x-50); minY = Math.min(minY, e.y-50);
maxX = Math.max(maxX, e.x+50); maxY = Math.max(maxY, e.y+50);
}
const pad = 60;
minX -= pad; minY -= pad; maxX += pad; maxY += pad;
const contentW = Math.max(1, maxX - minX);
const contentH = Math.max(1, maxY - minY);
const dpr = window.devicePixelRatio || 1;
// pick zoom that fits
const zx = (w) / contentW;
const zy = (h) / contentH;
const targetZoom = clamp(Math.min(zx, zy), 0.2, 3.0);
view.zoom = targetZoom;
// center
const cx = (minX + maxX)/2;
const cy = (minY + maxY)/2;
view.panX = (w/(2*view.zoom)) - cx;
view.panY = (h/(2*view.zoom)) - cy;
}
// ---------- Drawing ----------
function resizeCanvasToDisplaySize(canvas){
let w = canvas.clientWidth, h = canvas.clientHeight;
if((!w || !h) && canvas.getBoundingClientRect){ const br = canvas.getBoundingClientRect(); w = br.width; h = br.height; }
// last resort: try parent size
if((!w || !h) && canvas.parentElement){ const pr = canvas.parentElement.getBoundingClientRect(); w = pr.width; h = pr.height; }
const dpr = window.devicePixelRatio || 1;
const nw = Math.max(1, Math.floor(w*dpr));
const nh = Math.max(1, Math.floor(h*dpr));
if(canvas.width !== nw || canvas.height !== nh){
canvas.width = nw; canvas.height = nh;
}
return dpr;
}
function getViewForCanvas(canvas){
return (canvas === els.canvasB) ? state.viewB : state.viewA;
}
function worldToScreen(x,y, canvas){
const v = getViewForCanvas(canvas);
const dpr = window.devicePixelRatio || 1;
const cx = (x + v.panX) * v.zoom;
const cy = (y + v.panY) * v.zoom;
return [cx*dpr, cy*dpr];
}
function screenToWorld(sx,sy, canvas){
const v = getViewForCanvas(canvas);
// sx/sy come from mouse/touch events in CSS pixels (clientX/clientY).
// Do NOT divide by devicePixelRatio here; worldToScreen already accounts for DPR.
const x = (sx)/v.zoom - v.panX;
const y = (sy)/v.zoom - v.panY;
return [x,y];
}
function drawRoom(ctx, canvas, roomWrap, labelEl, isCompare=false){
const dpr = resizeCanvasToDisplaySize(canvas);
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0,0,canvas.width,canvas.height);
if(!roomWrap){
labelEl.innerHTML = (isCompare ? "Compare room" : "Active room") + "<br/><small>(none)</small>";
return;
}
const room = roomWrap.data;
labelEl.innerHTML = (isCompare ? "Compare room" : "Active room") + "<br/><small>" + room.name + "</small>";
const v = getViewForCanvas(canvas);
// background
ctx.fillStyle = "#05070a";
ctx.fillRect(0,0,canvas.width,canvas.height);
// room border
const [x0,y0] = worldToScreen(0,0,canvas);
const [x1,y1] = worldToScreen(room.room_w, room.room_h, canvas);
ctx.strokeStyle = "rgba(148,163,184,0.35)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(x0,y0, x1-x0, y1-y0);
// platforms
for(let i=0;i<room.platforms.length;i++){
const p = room.platforms[i];
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
ctx.fillStyle = "rgba(96,165,250,0.25)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(96,165,250,0.75)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
}
// ability doors
for(let i=0;i<(room.ability_doors||[]).length;i++){
const d = room.ability_doors[i];
const [sx,sy] = worldToScreen(d.x, d.y, canvas);
const [sx2,sy2] = worldToScreen(d.x+d.w, d.y+d.h, canvas);
ctx.fillStyle = "rgba(16,185,129,0.18)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(16,185,129,0.75)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
}
// grid overlay (always covers the full canvas)
const grid = state.gridSize;
ctx.strokeStyle = "rgba(148,163,184,0.16)";
ctx.lineWidth = 1*dpr;
// Visible world bounds for the current view (independent of room size)
const viewLeft = (0 / dpr) / v.zoom - v.panX;
const viewTop = (0 / dpr) / v.zoom - v.panY;
const viewRight = (canvas.width / dpr) / v.zoom - v.panX;
const viewBottom = (canvas.height / dpr) / v.zoom - v.panY;
const gx0 = Math.floor(Math.min(viewLeft, viewRight) / grid) * grid;
const gx1 = Math.ceil(Math.max(viewLeft, viewRight) / grid) * grid;
const gy0 = Math.floor(Math.min(viewTop, viewBottom) / grid) * grid;
const gy1 = Math.ceil(Math.max(viewTop, viewBottom) / grid) * grid;
for(let gx = gx0; gx <= gx1; gx += grid){
const [sx,sy] = worldToScreen(gx, gy0, canvas);
const [sx2,sy2] = worldToScreen(gx, gy1, canvas);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx2, sy2);
ctx.stroke();
}
for(let gy = gy0; gy <= gy1; gy += grid){
const [sx,sy] = worldToScreen(gx0, gy, canvas);
const [sx2,sy2] = worldToScreen(gx1, gy, canvas);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx2, sy2);
ctx.stroke();
}
// player spawn
{
const [px,py] = room.player_spawn;
const [sx,sy] = worldToScreen(px,py,canvas);
ctx.fillStyle = "rgba(52,211,153,0.95)";
ctx.beginPath(); ctx.arc(sx,sy, 6*dpr, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "rgba(52,211,153,0.55)";
ctx.fillRect(sx-12*dpr, sy-3*dpr, 24*dpr, 6*dpr);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${12*dpr}px system-ui`;
ctx.fillText("P", sx+10*dpr, sy-8*dpr);
}
// entry spawns
const entries = room.entry_spawns || {};
const entryKeys = Object.keys(entries);
for(const k of entryKeys){
const [ex,ey] = entries[k];
const [sx,sy] = worldToScreen(ex,ey,canvas);
ctx.fillStyle = "rgba(245,158,11,0.95)";
ctx.beginPath(); ctx.arc(sx,sy, 6*dpr, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = "rgba(245,158,11,0.65)";
ctx.lineWidth = 2*dpr;
ctx.beginPath(); ctx.moveTo(sx-10*dpr, sy); ctx.lineTo(sx+10*dpr, sy); ctx.stroke();
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText(k, sx+10*dpr, sy+4*dpr);
}
// enemy spawns
for(let i=0;i<room.enemies.length;i++){
const e = room.enemies[i];
const typeId = (e.type || "enemy");
const def = state.enemyTypes && state.enemyTypes[typeId] ? state.enemyTypes[typeId] : null;
const w = def && Array.isArray(def.rect) ? (+def.rect[0]||0) : 0;
const h = def && Array.isArray(def.rect) ? (+def.rect[1]||0) : 0;
// Optional transparent hitbox preview (centered on the spawn point)
if(w>0 && h>0){
const hx = e.x - w/2;
const hy = e.y - h/2;
const [sx1,sy1] = worldToScreen(hx, hy, canvas);
const [sx2,sy2] = worldToScreen(hx+w, hy+h, canvas);
ctx.fillStyle = "rgba(239,68,68,0.12)";
ctx.fillRect(sx1,sy1, sx2-sx1, sy2-sy1);
ctx.strokeStyle = "rgba(239,68,68,0.35)";
ctx.lineWidth = 1.5*dpr;
ctx.strokeRect(sx1,sy1, sx2-sx1, sy2-sy1);
}
// Optional aggro range preview
if(state.showAggro && def && typeof def.detection_range === "number"){
const r = +def.detection_range || 0;
if(r>0){
const [sx,sy] = worldToScreen(e.x, e.y, canvas);
ctx.strokeStyle = "rgba(239,68,68,0.18)";
ctx.lineWidth = 2*dpr;
ctx.setLineDash([6*dpr, 6*dpr]);
ctx.beginPath();
ctx.arc(sx,sy, r*state[canvas===els.canvasA?"viewA":"viewB"].zoom*dpr, 0, Math.PI*2);
ctx.stroke();
ctx.setLineDash([]);
}
}
// marker + label
const [sx,sy] = worldToScreen(e.x, e.y, canvas);
ctx.fillStyle = "rgba(239,68,68,0.95)";
ctx.fillRect(sx-5*dpr, sy-5*dpr, 10*dpr, 10*dpr);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText(typeId, sx+10*dpr, sy+4*dpr);
}
// props (light sources)
for(let i=0;i<(room.props||[]).length;i++){
const p = room.props[i];
if(p.type !== "light_source") continue;
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const r = Math.max(20, +p.radius || getDefaultRadiusForType(p.type || "light_source", p.object_id || p.objectId || null));
const v = getViewForCanvas(canvas);
const rPx = r * (v.zoom || 1) * dpr;
ctx.strokeStyle = "rgba(250,204,21,0.25)";
ctx.lineWidth = 2*dpr;
ctx.beginPath(); ctx.arc(sx, sy, rPx, 0, Math.PI*2); ctx.stroke();
ctx.fillStyle = "rgba(250,204,21,0.85)";
ctx.beginPath(); ctx.arc(sx, sy, 6*dpr, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText("Lamp", sx+10*dpr, sy+4*dpr);
}
// exits
for(let i=0;i<room.exits.length;i++){
const ex = room.exits[i];
const [sx,sy] = worldToScreen(ex.x, ex.y, canvas);
const [sx2,sy2] = worldToScreen(ex.x+ex.w, ex.y+ex.h, canvas);
ctx.fillStyle = "rgba(167,139,250,0.18)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(167,139,250,0.75)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
const label = `${ex.to || "?"}${ex.spawn_id ? " → " + ex.spawn_id : ""}`;
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${12*dpr}px system-ui`;
ctx.fillText(label, sx+6*dpr, sy-6*dpr);
}
// selection highlight
if(state.selection && state.selection.roomId === roomWrap.id){
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.lineWidth = 2*dpr;
ctx.setLineDash([6*dpr, 4*dpr]);
const sel = state.selection;
if(sel.kind==="platform"){
const p = room.platforms[sel.index];
if(p){
const [sx,sy] = worldToScreen(p.x,p.y,canvas);
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
// resize handles
const hs = 6*dpr;
const hx0 = sx, hx1 = sx2, hy0 = sy, hy1 = sy2;
const hxm = (hx0+hx1)/2, hym = (hy0+hy1)/2;
const handles = [
[hx0,hy0],[hxm,hy0],[hx1,hy0],
[hx1,hym],[hx1,hy1],[hxm,hy1],
[hx0,hy1],[hx0,hym]
];
ctx.setLineDash([]);
ctx.fillStyle = "rgba(255,255,255,0.95)";
ctx.strokeStyle = "rgba(0,0,0,0.55)";
ctx.lineWidth = 1*dpr;
for(const [hx,hy] of handles){
ctx.beginPath();
ctx.rect(hx-hs, hy-hs, hs*2, hs*2);
ctx.fill();
ctx.stroke();
}
}
} else if(sel.kind==="exit"){
const ex = room.exits[sel.index];
if(ex){
const [sx,sy] = worldToScreen(ex.x,ex.y,canvas);
const [sx2,sy2] = worldToScreen(ex.x+ex.w, ex.y+ex.h, canvas);
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
// resize handles (same as platforms)
const hs = 6*dpr;
const hx0 = sx, hx1 = sx2, hy0 = sy, hy1 = sy2;
const hxm = (hx0+hx1)/2, hym = (hy0+hy1)/2;
const handles = [
[hx0,hy0],[hxm,hy0],[hx1,hy0],
[hx1,hym],[hx1,hy1],[hxm,hy1],
[hx0,hy1],[hx0,hym]
];
ctx.setLineDash([]);
ctx.fillStyle = "rgba(255,255,255,0.95)";
ctx.strokeStyle = "rgba(0,0,0,0.55)";
ctx.lineWidth = 1*dpr;
for(const [hx,hy] of handles){
ctx.beginPath();
ctx.rect(hx-hs, hy-hs, hs*2, hs*2);
ctx.fill();
ctx.stroke();
}
}
} else if(sel.kind==="ability_door"){
const d = room.ability_doors && room.ability_doors[sel.index];
if(d){
const [sx,sy] = worldToScreen(d.x,d.y,canvas);
const [sx2,sy2] = worldToScreen(d.x+d.w, d.y+d.h, canvas);
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
// resize handles (same as platforms)
const hs = 6*dpr;
const hx0 = sx, hx1 = sx2, hy0 = sy, hy1 = sy2;
const hxm = (hx0+hx1)/2, hym = (hy0+hy1)/2;
const handles = [
[hx0,hy0],[hxm,hy0],[hx1,hy0],
[hx1,hym],[hx1,hy1],[hxm,hy1],
[hx0,hy1],[hx0,hym]
];
ctx.setLineDash([]);
ctx.fillStyle = "rgba(255,255,255,0.95)";
ctx.strokeStyle = "rgba(0,0,0,0.55)";
ctx.lineWidth = 1*dpr;
for(const [hx,hy] of handles){
ctx.beginPath();
ctx.rect(hx-hs, hy-hs, hs*2, hs*2);
ctx.fill();
ctx.stroke();
}
}
} else if(sel.kind==="enemy"){
const e = room.enemies[sel.index];
if(e){
const [sx,sy] = worldToScreen(e.x,e.y,canvas);
ctx.strokeRect(sx-8*dpr, sy-8*dpr, 16*dpr, 16*dpr);
}
} else if(sel.kind==="prop"){
const p = room.props && room.props[sel.index];
if(p){
const [sx,sy] = worldToScreen(p.x,p.y,canvas);
ctx.strokeRect(sx-10*dpr, sy-10*dpr, 20*dpr, 20*dpr);
}
} else if(sel.kind==="player_spawn"){
const [px,py] = room.player_spawn;
const [sx,sy] = worldToScreen(px,py,canvas);
ctx.strokeRect(sx-10*dpr, sy-10*dpr, 20*dpr, 20*dpr);
} else if(sel.kind==="entry_spawn"){
const k = sel.key;
const pos = room.entry_spawns && room.entry_spawns[k];
if(pos){
const [sx,sy] = worldToScreen(pos[0],pos[1],canvas);
ctx.strokeRect(sx-10*dpr, sy-10*dpr, 20*dpr, 20*dpr);
}
}
ctx.restore();
}
}
function render(){
drawRoom(ctxA, els.canvasA, getActiveRoom(), els.labelA, false);
if(state.compareEnabled){
drawRoom(ctxB, els.canvasB, getCompareRoom(), els.labelB, true);
}
updateLinkOverlay();
requestAnimationFrame(render);
}
// Compatibility: older code paths call redraw(), but we run a continuous render loop.
function redraw(){
// no-op on purpose
}
// ---------- Link Overlay (SVG across both canvases) ----------
function clearLinkOverlay(){
if(!els.linkOverlay) return;
els.linkOverlay.innerHTML = "";
}
function updateLinkOverlay(){
if(!els.linkOverlay || !els.showLinksChk) return;
if(!els.showLinksChk.checked){ clearLinkOverlay(); return; }
if(!state.compareEnabled){ clearLinkOverlay(); return; }
const srcWrap = getActiveRoom();
const dstWrap = getCompareRoom();
if(!srcWrap || !dstWrap){ clearLinkOverlay(); return; }
const rowRect = els.canvasRow.getBoundingClientRect();
const aRect = els.canvasA.getBoundingClientRect();
const bRect = els.canvasB.getBoundingClientRect();
const w = Math.max(1, Math.round(rowRect.width));
const h = Math.max(1, Math.round(rowRect.height));
els.linkOverlay.setAttribute("viewBox", `0 0 ${w} ${h}`);
els.linkOverlay.setAttribute("width", w);
els.linkOverlay.setAttribute("height", h);
// clear and redraw
els.linkOverlay.innerHTML = "";
// draw only links from active room exits that point to the compare room
const exits = (srcWrap.data.exits || []);
for(let i=0;i<exits.length;i++){
const ex = exits[i];
if(!ex || ex.to !== dstWrap.data.name) continue;
const sid = ex.spawn_id || null;
if(!sid) continue;
let pos = null;
if(sid === "__player_spawn__"){
pos = dstWrap.data.player_spawn || null;
} else {
pos = (dstWrap.data.entry_spawns && dstWrap.data.entry_spawns[sid]) ? dstWrap.data.entry_spawns[sid] : null;
}
if(!pos) continue;
// source exit center -> screen in A
const sxw = ex.x + ex.w/2;
const syw = ex.y + ex.h/2;
const [sx,sy] = worldToScreen(sxw, syw, els.canvasA);
// target spawn -> screen in B
const [tx,ty] = worldToScreen(pos[0], pos[1], els.canvasB);
const x1 = (aRect.left - rowRect.left) + sx;
const y1 = (aRect.top - rowRect.top) + sy;
const x2 = (bRect.left - rowRect.left) + tx;
const y2 = (bRect.top - rowRect.top) + ty;
const line = document.createElementNS("http://www.w3.org/2000/svg","line");
line.setAttribute("x1", x1);
line.setAttribute("y1", y1);
line.setAttribute("x2", x2);
line.setAttribute("y2", y2);
line.setAttribute("stroke", "rgba(167,139,250,0.85)");
line.setAttribute("stroke-width", "2");
line.setAttribute("stroke-dasharray", "6 6");
els.linkOverlay.appendChild(line);
}
}
// ---------- Hit Testing ----------
function hitTest(roomWrap, wx, wy, canvas){
if(!roomWrap) return null;
const room = roomWrap.data;
// exits first (so door selection is easy)
const v0 = canvas ? getViewForCanvas(canvas) : {zoom:1};
const tolExit = (8 / (v0.zoom||1)); // ~8px in world-units
const tol2Exit = tolExit*tolExit;
for(let i=room.exits.length-1;i>=0;i--){
const ex = room.exits[i];
if(wx>=ex.x && wx<=ex.x+ex.w && wy>=ex.y && wy<=ex.y+ex.h){
// handle detection (corners/edges) for exits
const x0=ex.x, x1=ex.x+ex.w, y0=ex.y, y1=ex.y+ex.h;
const xm=(x0+x1)/2, ym=(y0+y1)/2;
const handles = [
[x0,y0,'nw'], [xm,y0,'n'], [x1,y0,'ne'],
[x1,ym,'e'], [x1,y1,'se'], [xm,y1,'s'],
[x0,y1,'sw'], [x0,ym,'w']
];
const order = ['nw','ne','se','sw','n','e','s','w'];
let edge = null;
for(const tag of order){
const h = handles.find(h=>h[2]===tag);
const dx=wx-h[0], dy=wy-h[1];
const d2=dx*dx+dy*dy;
if(d2<=tol2Exit){ edge = tag; break; }
}
return {kind:"exit", index:i, roomId: roomWrap.id, edge};
}
}
// ability doors
for(let i=(room.ability_doors||[]).length-1;i>=0;i--){
const d = room.ability_doors[i];
if(wx>=d.x && wx<=d.x+d.w && wy>=d.y && wy<=d.y+d.h){
const x0=d.x, x1=d.x+d.w, y0=d.y, y1=d.y+d.h;
const xm=(x0+x1)/2, ym=(y0+y1)/2;
const handles = [
[x0,y0,'nw'], [xm,y0,'n'], [x1,y0,'ne'],
[x1,ym,'e'], [x1,y1,'se'], [xm,y1,'s'],
[x0,y1,'sw'], [x0,ym,'w']
];
const order = ['nw','ne','se','sw','n','e','s','w'];
let edge = null;
for(const tag of order){
const h = handles.find(h=>h[2]===tag);
const dx=wx-h[0], dy=wy-h[1];
const d2=dx*dx+dy*dy;
if(d2<=tol2Exit){ edge = tag; break; }
}
return {kind:"ability_door", index:i, roomId: roomWrap.id, edge};
}
}
// props (light sources)
for(let i=(room.props||[]).length-1;i>=0;i--){
const p = room.props[i];
if(!p) continue;
const dx = wx - p.x;
const dy = wy - p.y;
if((dx*dx + dy*dy) <= 100){
return {kind:"prop", index:i, roomId: roomWrap.id};
}
}
// platforms (with edge detection for resize)
const v = canvas ? getViewForCanvas(canvas) : {zoom:1};
const tol = (8 / (v.zoom||1)); // ~8px in world-units
for(let i=room.platforms.length-1;i>=0;i--){
const p=room.platforms[i];
if(wx>=p.x && wx<=p.x+p.w && wy>=p.y && wy<=p.y+p.h){
// handle detection (corners/edges)
const x0=p.x, x1=p.x+p.w, y0=p.y, y1=p.y+p.h;
const xm=(x0+x1)/2, ym=(y0+y1)/2;
const tol2 = tol*tol;
const handles = [
[x0,y0,'nw'], [xm,y0,'n'], [x1,y0,'ne'],
[x1,ym,'e'], [x1,y1,'se'], [xm,y1,'s'],
[x0,y1,'sw'], [x0,ym,'w']
];
let edge = null;
// prefer corners first
const cornerOrder = ['nw','ne','se','sw','n','e','s','w'];
let best = null;
for(const tag of cornerOrder){
const h = handles.find(h=>h[2]===tag);
const dx=wx-h[0], dy=wy-h[1];
const d2=dx*dx+dy*dy;
if(d2<=tol2){ best = {tag, d2}; break; }
}
if(best) edge = best.tag;
return {kind:"platform", index:i, roomId: roomWrap.id, edge};
}
}
// enemies
for(let i=room.enemies.length-1;i>=0;i--){
const e=room.enemies[i];
if(Math.abs(wx-e.x)<10 && Math.abs(wy-e.y)<10){
return {kind:"enemy", index:i, roomId: roomWrap.id};
}
}
// player spawn
{
const [px,py]=room.player_spawn;
if(Math.abs(wx-px)<12 && Math.abs(wy-py)<12){
return {kind:"player_spawn", index:0, roomId: roomWrap.id};
}
}
// entry spawns
const entries = room.entry_spawns || {};
for(const k of Object.keys(entries)){
const [ex,ey]=entries[k];
if(Math.abs(wx-ex)<12 && Math.abs(wy-ey)<12){
return {kind:"entry_spawn", key:k, roomId: roomWrap.id};
}
}
return null;
}
// ---------- Editing ----------
function setTool(tool){
state.tool = tool;
document.querySelectorAll(".toolBtn").forEach(b=>{
b.classList.toggle("primary", b.dataset.tool===tool && tool!=="select");
b.classList.toggle("warn", b.dataset.tool===tool && tool==="select");
});
setStatus(`Tool: ${tool}`);
}
function setStatus(msg){
els.status.textContent = msg;
}
function propsHTMLForSelection(){
const sel = state.selection;
const roomWrap = sel ? state.rooms.get(sel.roomId) : getActiveRoom();
if(!roomWrap) return "<div class='muted'>No room loaded.</div>";
const room = roomWrap.data;
if(!sel) return "<div class='muted'>Select something to edit properties.</div>";
if(sel.kind==="platform"){
const p=room.platforms[sel.index];
if(!p) return "<div class='muted'>Selection missing.</div>";
return `
<div><b>Platform</b> #${sel.index}</div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">x</div><input id="px" type="number" value="${p.x}"/></div>
<div><div class="mini">y</div><input id="py" type="number" value="${p.y}"/></div>
<div><div class="mini">w</div><input id="pw" type="number" value="${p.w}"/></div>
<div><div class="mini">h</div><input id="ph" type="number" value="${p.h}"/></div>
</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
`;
}
if(sel.kind==="exit"){
const ex=room.exits[sel.index];
if(!ex) return "<div class='muted'>Selection missing.</div>";
return `
<div><b>Exit</b> #${sel.index}</div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">x</div><input id="exx" type="number" value="${ex.x}"/></div>
<div><div class="mini">y</div><input id="exy" type="number" value="${ex.y}"/></div>
<div><div class="mini">w</div><input id="exw" type="number" value="${ex.w}"/></div>
<div><div class="mini">h</div><input id="exh" type="number" value="${ex.h}"/></div>
</div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">to</div><input id="exto" type="text" value="${ex.to||""}" placeholder="room_02"/></div>
<div><div class="mini">spawn_id</div><input id="exsid" type="text" value="${ex.spawn_id||""}" placeholder="from_room_01"/></div>
</div>
<div class="muted" style="margin-top:8px;">Tip: use the Linking panel to set these safely.</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
`;
}
if(sel.kind==="ability_door"){
const d = room.ability_doors && room.ability_doors[sel.index];
if(!d) return "<div class='muted'>Selection missing.</div>";
return `
<div><b>Ability Door</b> #${sel.index}</div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">x</div><input id="adx" type="number" value="${d.x}"/></div>
<div><div class="mini">y</div><input id="ady" type="number" value="${d.y}"/></div>
<div><div class="mini">w</div><input id="adw" type="number" value="${d.w}"/></div>
<div><div class="mini">h</div><input id="adh" type="number" value="${d.h}"/></div>
</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
`;
}
if(sel.kind==="enemy"){
const e=room.enemies[sel.index];
if(!e) return "<div class='muted'>Selection missing.</div>";
return `
<div><b>Enemy Spawn</b> #${sel.index}</div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">x</div><input id="enx" type="number" value="${e.x}"/></div>
<div><div class="mini">y</div><input id="eny" type="number" value="${e.y}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type</div>
<input id="ent" type="text" value="${e.type||"enemy"}"/>
</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
`;
}
if(sel.kind==="prop"){
const p=room.props && room.props[sel.index];
if(!p) return "<div class='muted'>Selection missing.</div>";
const prDefault = getDefaultRadiusForType(p.type || "light_source", p.object_id || p.objectId || null);
return `
<div><b>Prop</b> #${sel.index}</div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">x</div><input id="prx" type="number" value="${p.x}"/></div>
<div><div class="mini">y</div><input id="pry" type="number" value="${p.y}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type</div>
<input id="prtype" type="text" value="${p.type||"light_source"}"/>
</div>
<div style="margin-top:8px;">
<div class="mini">radius</div>
<input id="prrad" type="number" value="${p.radius||prDefault}"/>
</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
`;
}
if(sel.kind==="player_spawn"){
const [x,y]=room.player_spawn;
return `
<div><b>Player Spawn</b></div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">x</div><input id="psx" type="number" value="${x}"/></div>
<div><div class="mini">y</div><input id="psy" type="number" value="${y}"/></div>
</div>
`;
}
if(sel.kind==="entry_spawn"){
const k=sel.key;
const pos = room.entry_spawns[k];
const [x,y]=pos||[0,0];
return `
<div><b>Entry Spawn</b></div>
<div class="hr"></div>
<div style="margin-bottom:8px;">
<div class="mini">id</div>
<input id="esid" type="text" value="${k}"/>
</div>
<div class="grid2">
<div><div class="mini">x</div><input id="esx" type="number" value="${x}"/></div>
<div><div class="mini">y</div><input id="esy" type="number" value="${y}"/></div>
</div>
<div class="row" style="margin-top:8px;">
<button id="delEntry" class="danger">Delete</button>
</div>
`;
}
return "<div class='muted'>Select something to edit properties.</div>";
}
function wireProps(){
const roomWrap = getActiveRoom();
if(!roomWrap) return;
const room = roomWrap.data;
const sel = state.selection;
const q = (id)=>document.getElementById(id);
function num(v){ const n = Number(v); return Number.isFinite(n) ? n : 0; }
if(!sel) return;
const pushAnd = (fn)=>{
pushHistory(roomWrap.id);
fn();
refreshUI();
};
if(sel.kind==="platform"){
const p = room.platforms[sel.index]; if(!p) return;
["px","py","pw","ph"].forEach(id=>{
const el=q(id); if(!el) return;
el.addEventListener("change", ()=>pushAnd(()=>{
if(id==="px") p.x = num(el.value);
if(id==="py") p.y = num(el.value);
if(id==="pw") p.w = Math.max(1,num(el.value));
if(id==="ph") p.h = Math.max(1,num(el.value));
}));
});
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.platforms.splice(sel.index,1);
state.selection=null;
}));
} else if(sel.kind==="exit"){
const ex = room.exits[sel.index]; if(!ex) return;
["exx","exy","exw","exh"].forEach(id=>{
const el=q(id); if(!el) return;
el.addEventListener("change", ()=>pushAnd(()=>{
if(id==="exx") ex.x = num(el.value);
if(id==="exy") ex.y = num(el.value);
if(id==="exw") ex.w = Math.max(1,num(el.value));
if(id==="exh") ex.h = Math.max(1,num(el.value));
}));
});
const exto=q("exto"); if(exto) exto.addEventListener("change", ()=>pushAnd(()=>{ ex.to = exto.value.trim(); }));
const exsid=q("exsid"); if(exsid) exsid.addEventListener("change", ()=>pushAnd(()=>{ ex.spawn_id = exsid.value.trim(); }));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.exits.splice(sel.index,1);
state.selection=null;
}));
} else if(sel.kind==="ability_door"){
const d = room.ability_doors && room.ability_doors[sel.index]; if(!d) return;
["adx","ady","adw","adh"].forEach(id=>{
const el=q(id); if(!el) return;
el.addEventListener("change", ()=>pushAnd(()=>{
if(id==="adx") d.x = num(el.value);
if(id==="ady") d.y = num(el.value);
if(id==="adw") d.w = Math.max(1,num(el.value));
if(id==="adh") d.h = Math.max(1,num(el.value));
}));
});
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.ability_doors.splice(sel.index,1);
state.selection=null;
}));
} else if(sel.kind==="enemy"){
const e = room.enemies[sel.index]; if(!e) return;
const enx=q("enx"), eny=q("eny"), ent=q("ent");
if(enx) enx.addEventListener("change", ()=>pushAnd(()=>{ e.x=num(enx.value); }));
if(eny) eny.addEventListener("change", ()=>pushAnd(()=>{ e.y=num(eny.value); }));
if(ent) ent.addEventListener("change", ()=>pushAnd(()=>{ e.type=ent.value.trim()||"enemy"; }));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.enemies.splice(sel.index,1);
state.selection=null;
}));
} else if(sel.kind==="prop"){
const p = room.props && room.props[sel.index]; if(!p) return;
const prx=q("prx"), pry=q("pry"), prtype=q("prtype"), prrad=q("prrad");
if(prx) prx.addEventListener("change", ()=>pushAnd(()=>{ p.x=num(prx.value); }));
if(pry) pry.addEventListener("change", ()=>pushAnd(()=>{ p.y=num(pry.value); }));
if(prtype) prtype.addEventListener("change", ()=>pushAnd(()=>{
p.type=prtype.value.trim()||"light_source";
if(!(Number.isFinite(Number(p.radius)) && Number(p.radius) > 0)){
p.radius = getDefaultRadiusForType(p.type, p.object_id || p.objectId || null);
if(prrad) prrad.value = String(p.radius);
}
}));
if(prrad) prrad.addEventListener("change", ()=>pushAnd(()=>{ p.radius=Math.max(0,num(prrad.value)); }));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.props.splice(sel.index,1);
state.selection=null;
}));
} else if(sel.kind==="player_spawn"){
const psx=q("psx"), psy=q("psy");
if(psx) psx.addEventListener("change", ()=>pushAnd(()=>{ room.player_spawn[0]=num(psx.value); }));
if(psy) psy.addEventListener("change", ()=>pushAnd(()=>{ room.player_spawn[1]=num(psy.value); }));
} else if(sel.kind==="entry_spawn"){
const oldKey = sel.key;
const esid=q("esid"), esx=q("esx"), esy=q("esy"), del=q("delEntry");
if(esid) esid.addEventListener("change", ()=>pushAnd(()=>{
const newKey = esid.value.trim() || oldKey;
if(newKey !== oldKey){
room.entry_spawns[newKey] = room.entry_spawns[oldKey];
delete room.entry_spawns[oldKey];
state.selection = {kind:"entry_spawn", key:newKey, roomId: roomWrap.id};
}
}));
if(esx) esx.addEventListener("change", ()=>pushAnd(()=>{ room.entry_spawns[state.selection.key][0]=num(esx.value); }));
if(esy) esy.addEventListener("change", ()=>pushAnd(()=>{ room.entry_spawns[state.selection.key][1]=num(esy.value); }));
if(del) del.addEventListener("click", ()=>pushAnd(()=>{
delete room.entry_spawns[state.selection.key];
state.selection=null;
}));
}
}
function refreshProps(){
els.props.innerHTML = propsHTMLForSelection();
wireProps();
}
// ---------- Main Interaction (Active Canvas) ----------
function onPointerDown(ev){
const roomWrap = getActiveRoom(); if(!roomWrap) return;
const rect = els.canvasA.getBoundingClientRect();
const sx = ev.clientX - rect.left;
const sy = ev.clientY - rect.top;
const [wx,wy] = screenToWorld(sx,sy,els.canvasA);
const room = roomWrap.data;
if(ev.button === 1){
// middle mouse pan is handled by the mousedown listener (better cross-browser)
return;
}
if(ev.button === 0 && ev.altKey){
// alt-drag pan
ev.preventDefault();
state.dragging = {mode:"pan", canvas:"A", startX:sx, startY:sy, startPanX:state.viewA.panX, startPanY:state.viewA.panY, startZoom:state.viewA.zoom};
return;
}
if(state.tool === "select"){
const hit = hitTest(roomWrap, wx, wy, els.canvasA);
state.selection = hit;
refreshProps();
if(hit){
if(hit.kind==="platform" && hit.edge){
const p = room.platforms[hit.index];
if(p){
pushHistory(roomWrap.id);
state.dragging = {
mode:"resizePlatformEdge",
canvas:"A",
roomId: roomWrap.id,
index: hit.index,
edge: hit.edge,
base: {x:p.x,y:p.y,w:p.w,h:p.h},
startWX: wx,
startWY: wy
};
return;
}
}
if(hit.kind==="exit" && hit.edge){
const ex = room.exits[hit.index];
if(ex){
pushHistory(roomWrap.id);
state.dragging = {
mode:"resizeExitEdge",
canvas:"A",
roomId: roomWrap.id,
index: hit.index,
edge: hit.edge,
base: {x:ex.x,y:ex.y,w:ex.w,h:ex.h},
startWX: wx,
startWY: wy
};
return;
}
}
if(hit.kind==="ability_door" && hit.edge){
const d = room.ability_doors && room.ability_doors[hit.index];
if(d){
pushHistory(roomWrap.id);
state.dragging = {
mode:"resizeAbilityDoorEdge",
canvas:"A",
roomId: roomWrap.id,
index: hit.index,
edge: hit.edge,
base: {x:d.x,y:d.y,w:d.w,h:d.h},
startWX: wx,
startWY: wy
};
return;
}
}
state.dragging = {mode:"move", canvas:"A", roomId: roomWrap.id, sel:deepClone(hit), startWX:wx, startWY:wy, base:null};
} else {
state.dragging = null;
}
return;
}
// creation tools
if(state.tool === "platform"){
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
// Start as a 1x1 rect at the anchor; resizing can go in any direction.
const p = {x: sx1, y: sy1, w: 1, h: 1};
room.platforms.push(p);
state.selection = {kind:"platform", index: room.platforms.length-1, roomId: roomWrap.id};
refreshUI();
state.dragging = {
mode:"resizePlatform",
canvas:"A",
roomId: roomWrap.id,
index: room.platforms.length-1,
startWX: wx,
startWY: wy,
anchorX: sx1,
anchorY: sy1
};
return;
}
if(state.tool === "enemy_spawn"){
if(!state.enemyTypes || !state.enemyTypeKeys || state.enemyTypeKeys.length === 0){
setStatus("Please import an enemy types JSON first.");
return;
}
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
room.enemies.push({x: sx1, y: sy1, type: (state.enemyPlaceType || "enemy")});
state.selection = {kind:"enemy", index:room.enemies.length-1, roomId: roomWrap.id};
refreshUI();
return;
}
if(state.tool === "player_spawn"){
pushHistory(roomWrap.id);
room.player_spawn = snapPt(wx,wy);
state.selection = {kind:"player_spawn", index:0, roomId: roomWrap.id};
refreshUI();
return;
}
if(state.tool === "entry_spawn"){
pushHistory(roomWrap.id);
// auto-name
let base = "entry_";
let n = 1;
while(room.entry_spawns[base+n]) n++;
const key = base+n;
room.entry_spawns[key] = snapPt(wx,wy);
state.selection = {kind:"entry_spawn", key, roomId: roomWrap.id};
refreshUI();
return;
}
if(state.tool === "exit"){
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
room.exits.push({x: sx1, y: sy1, w: 10, h: 10, to:"", spawn_id:"", spawn:[sx1,sy1]});
state.selection = {kind:"exit", index:room.exits.length-1, roomId: roomWrap.id};
refreshUI();
state.dragging = {
mode:"resizeExit",
canvas:"A",
roomId: roomWrap.id,
index: room.exits.length-1,
startWX: wx,
startWY: wy,
anchorX: sx1,
anchorY: sy1
};
return;
}
if(state.tool === "ability_door"){
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
room.ability_doors.push({x: sx1, y: sy1, w: 10, h: 10});
state.selection = {kind:"ability_door", index:room.ability_doors.length-1, roomId: roomWrap.id};
refreshUI();
state.dragging = {
mode:"resizeAbilityDoor",
canvas:"A",
roomId: roomWrap.id,
index: room.ability_doors.length-1,
startWX: wx,
startWY: wy,
anchorX: sx1,
anchorY: sy1
};
return;
}
if(state.tool === "object_spawn"){
if(!objectCatalog.byId || Object.keys(objectCatalog.byId).length === 0){
setStatus("Please import an objects JSON first.");
return;
}
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
const props = room.props || (room.props = []);
const objectId = state.objectPlaceId || null;
const def = getObjectDefById(objectId) || getObjectDefByType("light_source");
const type = (def && def.type) ? def.type : "light_source";
const radius = getDefaultRadiusForType(type, objectId);
props.push({x:sx1, y:sy1, type, radius, object_id: objectId});
state.selection = {kind:"prop", index:props.length-1, roomId: roomWrap.id};
refreshUI();
return;
}
}
function onPointerMove(ev){
if(!state.dragging) return;
// pan (works for both canvases)
if(state.dragging.mode === "pan"){
const canvas = (state.dragging.canvas === "B") ? els.canvasB : els.canvasA;
const v = (state.dragging.canvas === "B") ? state.viewB : state.viewA;
const r2 = canvas.getBoundingClientRect();
const sx2 = ev.clientX - r2.left;
const sy2 = ev.clientY - r2.top;
const dx = (sx2 - state.dragging.startX) / v.zoom;
const dy = (sy2 - state.dragging.startY) / v.zoom;
v.panX = state.dragging.startPanX + dx;
v.panY = state.dragging.startPanY + dy;
return;
}
const canvas = (state.dragging.canvas === "B") ? els.canvasB : els.canvasA;
const r = canvas.getBoundingClientRect();
const sx = ev.clientX - r.left;
const sy = ev.clientY - r.top;
const [wx,wy] = screenToWorld(sx,sy,canvas);
const roomId = state.dragging.roomId || (state.activeId || null);
const roomWrap = state.rooms.get(roomId);
if(!roomWrap) return;
const room = roomWrap.data;
if(state.dragging.mode === "move" && state.selection){
const sel = state.dragging.sel;
if(sel.roomId !== roomWrap.id) return;
// first movement triggers history push once (but only for moves, not edge-resize which already pushed)
if(!state.dragging.didPush){
pushHistory(roomWrap.id);
state.dragging.didPush = true;
}
// initialize base positions once
if(!state.dragging.base){
const base = { kind: sel.kind };
if(sel.kind==="platform"){
const p=room.platforms[sel.index]; if(p) base.p = {x:p.x,y:p.y};
} else if(sel.kind==="exit"){
const ex=room.exits[sel.index]; if(ex) base.ex = {x:ex.x,y:ex.y};
} else if(sel.kind==="ability_door"){
const d=room.ability_doors && room.ability_doors[sel.index]; if(d) base.ad = {x:d.x,y:d.y};
} else if(sel.kind==="enemy"){
const e=room.enemies[sel.index]; if(e) base.e = {x:e.x,y:e.y};
} else if(sel.kind==="prop"){
const p=room.props && room.props[sel.index]; if(p) base.pr = {x:p.x,y:p.y};
} else if(sel.kind==="player_spawn"){
base.ps = {x:room.player_spawn[0], y:room.player_spawn[1]};
} else if(sel.kind==="entry_spawn"){
const pos=room.entry_spawns[sel.key]; if(pos) base.es = {x:pos[0], y:pos[1]};
}
base.startWX = wx;
base.startWY = wy;
state.dragging.base = base;
}
const offX = wx - state.dragging.base.startWX;
const offY = wy - state.dragging.base.startWY;
if(sel.kind==="platform"){
const p=room.platforms[sel.index];
if(p && state.dragging.base.p){
p.x = snap(state.dragging.base.p.x + offX);
p.y = snap(state.dragging.base.p.y + offY);
}
} else if(sel.kind==="exit"){
const ex=room.exits[sel.index];
if(ex && state.dragging.base.ex){
ex.x = snap(state.dragging.base.ex.x + offX);
ex.y = snap(state.dragging.base.ex.y + offY);
}
} else if(sel.kind==="ability_door"){
const d=room.ability_doors && room.ability_doors[sel.index];
if(d && state.dragging.base.ad){
d.x = snap(state.dragging.base.ad.x + offX);
d.y = snap(state.dragging.base.ad.y + offY);
}
} else if(sel.kind==="enemy"){
const e=room.enemies[sel.index];
if(e && state.dragging.base.e){
e.x = snap(state.dragging.base.e.x + offX);
e.y = snap(state.dragging.base.e.y + offY);
}
} else if(sel.kind==="prop"){
const p=room.props && room.props[sel.index];
if(p && state.dragging.base.pr){
p.x = snap(state.dragging.base.pr.x + offX);
p.y = snap(state.dragging.base.pr.y + offY);
}
} else if(sel.kind==="player_spawn"){
if(state.dragging.base.ps){
room.player_spawn[0] = snap(state.dragging.base.ps.x + offX);
room.player_spawn[1] = snap(state.dragging.base.ps.y + offY);
}
} else if(sel.kind==="entry_spawn"){
const pos=room.entry_spawns[sel.key];
if(pos && state.dragging.base.es){
pos[0] = snap(state.dragging.base.es.x + offX);
pos[1] = snap(state.dragging.base.es.y + offY);
}
}
markDirty(roomWrap.id, true);
refreshProps();
return;
}
if(state.dragging.mode === "resizePlatform"){
const p = room.platforms[state.dragging.index]; if(!p) return;
// Allow resizing in all directions while creating:
// anchor is the initial click position (snapped). We expand towards the current mouse position.
const ax = (typeof state.dragging.anchorX === "number") ? state.dragging.anchorX : snap(state.dragging.startWX);
const ay = (typeof state.dragging.anchorY === "number") ? state.dragging.anchorY : snap(state.dragging.startWY);
const cx = snap(wx);
const cy = snap(wy);
const x0 = Math.min(ax, cx);
const y0 = Math.min(ay, cy);
const x1 = Math.max(ax, cx);
const y1 = Math.max(ay, cy);
p.x = x0;
p.y = y0;
p.w = Math.max(1, x1 - x0);
p.h = Math.max(1, y1 - y0);
markDirty(roomWrap.id, true);
refreshProps();
return;
}
if(state.dragging.mode === "resizeExit"){
const ex = room.exits[state.dragging.index]; if(!ex) return;
const ax = (typeof state.dragging.anchorX === "number") ? state.dragging.anchorX : snap(state.dragging.startWX);
const ay = (typeof state.dragging.anchorY === "number") ? state.dragging.anchorY : snap(state.dragging.startWY);
const cx = snap(wx);
const cy = snap(wy);
const x0 = Math.min(ax, cx);
const y0 = Math.min(ay, cy);
const x1 = Math.max(ax, cx);
const y1 = Math.max(ay, cy);
ex.x = x0;
ex.y = y0;
ex.w = Math.max(10, x1 - x0);
ex.h = Math.max(10, y1 - y0);
markDirty(roomWrap.id, true);
refreshProps();
return;
}
if(state.dragging.mode === "resizeAbilityDoor"){
const d = room.ability_doors && room.ability_doors[state.dragging.index]; if(!d) return;
const ax = (typeof state.dragging.anchorX === "number") ? state.dragging.anchorX : snap(state.dragging.startWX);
const ay = (typeof state.dragging.anchorY === "number") ? state.dragging.anchorY : snap(state.dragging.startWY);
const cx = snap(wx);
const cy = snap(wy);
const x0 = Math.min(ax, cx);
const y0 = Math.min(ay, cy);
const x1 = Math.max(ax, cx);
const y1 = Math.max(ay, cy);
d.x = x0;
d.y = y0;
d.w = Math.max(10, x1 - x0);
d.h = Math.max(10, y1 - y0);
markDirty(roomWrap.id, true);
refreshProps();
return;
}
if(state.dragging.mode === "resizePlatformEdge"){
const p = room.platforms[state.dragging.index]; if(!p) return;
const b = state.dragging.base;
const dx = snap(wx) - snap(state.dragging.startWX);
const dy = snap(wy) - snap(state.dragging.startWY);
const edge = state.dragging.edge;
// horizontal
if(edge === 'l' || edge === 'w' || edge === 'nw' || edge === 'sw'){
const newX = b.x + dx;
const newW = (b.x + b.w) - newX;
p.x = newX;
p.w = Math.max(1, newW);
}
if(edge === 'r' || edge === 'e' || edge === 'ne' || edge === 'se'){
p.w = Math.max(1, b.w + dx);
}
// vertical
if(edge === 't' || edge === 'n' || edge === 'nw' || edge === 'ne'){
const newY = b.y + dy;
const newH = (b.y + b.h) - newY;
p.y = newY;
p.h = Math.max(1, newH);
}
if(edge === 'b' || edge === 's' || edge === 'sw' || edge === 'se'){
p.h = Math.max(1, b.h + dy);
}
// clamp if inverted due to dragging past opposite edge
if(p.w < 1){ p.w = 1; }
if(p.h < 1){ p.h = 1; }
markDirty(roomWrap.id, true);
refreshProps();
return;
}
if(state.dragging.mode === "resizeExitEdge"){
const ex = room.exits[state.dragging.index]; if(!ex) return;
const b = state.dragging.base;
const dx = snap(wx) - snap(state.dragging.startWX);
const dy = snap(wy) - snap(state.dragging.startWY);
const edge = state.dragging.edge;
// horizontal
if(edge === 'w' || edge === 'nw' || edge === 'sw'){
const newX = b.x + dx;
const newW = (b.x + b.w) - newX;
ex.x = newX;
ex.w = Math.max(10, newW);
}
if(edge === 'e' || edge === 'ne' || edge === 'se'){
ex.w = Math.max(10, b.w + dx);
}
// vertical
if(edge === 'n' || edge === 'nw' || edge === 'ne'){
const newY = b.y + dy;
const newH = (b.y + b.h) - newY;
ex.y = newY;
ex.h = Math.max(10, newH);
}
if(edge === 's' || edge === 'sw' || edge === 'se'){
ex.h = Math.max(10, b.h + dy);
}
markDirty(roomWrap.id, true);
refreshProps();
return;
}
if(state.dragging.mode === "resizeAbilityDoorEdge"){
const d = room.ability_doors && room.ability_doors[state.dragging.index]; if(!d) return;
const b = state.dragging.base;
const dx = snap(wx) - snap(state.dragging.startWX);
const dy = snap(wy) - snap(state.dragging.startWY);
const edge = state.dragging.edge;
if(edge === 'w' || edge === 'nw' || edge === 'sw'){
const newX = b.x + dx;
const newW = (b.x + b.w) - newX;
d.x = newX;
d.w = Math.max(10, newW);
}
if(edge === 'e' || edge === 'ne' || edge === 'se'){
d.w = Math.max(10, b.w + dx);
}
if(edge === 'n' || edge === 'nw' || edge === 'ne'){
const newY = b.y + dy;
const newH = (b.y + b.h) - newY;
d.y = newY;
d.h = Math.max(10, newH);
}
if(edge === 's' || edge === 'sw' || edge === 'se'){
d.h = Math.max(10, b.h + dy);
}
markDirty(roomWrap.id, true);
refreshProps();
return;
}
}
function onPointerUp(ev){
state.dragging = null;
}
function onWheel(ev){
// zoom around pointer
const v = state.viewA;
const rect = els.canvasA.getBoundingClientRect();
const sx = ev.clientX - rect.left;
const sy = ev.clientY - rect.top;
const [wx1,wy1] = screenToWorld(sx,sy,els.canvasA);
const delta = -ev.deltaY * 0.001;
const newZoom = clamp(v.zoom * (1+delta), 0.2, 4.0);
v.zoom = newZoom;
const [wx2,wy2] = screenToWorld(sx,sy,els.canvasA);
v.panX += (wx2 - wx1);
v.panY += (wy2 - wy1);
ev.preventDefault();
}
function onWheelB(ev){
const v = state.viewB;
const rect = els.canvasB.getBoundingClientRect();
const sx = ev.clientX - rect.left;
const sy = ev.clientY - rect.top;
const [wx1,wy1] = screenToWorld(sx,sy,els.canvasB);
const delta = -ev.deltaY * 0.001;
const newZoom = clamp(v.zoom * (1+delta), 0.2, 4.0);
v.zoom = newZoom;
const [wx2,wy2] = screenToWorld(sx,sy,els.canvasB);
v.panX += (wx2 - wx1);
v.panY += (wy2 - wy1);
ev.preventDefault();
}
// ---------- Clipboard ----------
function getSelectedObject(){
const roomWrap = getActiveRoom(); if(!roomWrap) return null;
const room = roomWrap.data;
const sel = state.selection;
if(!sel) return null;
if(sel.kind==="platform") return {kind:"platform", data: deepClone(room.platforms[sel.index])};
if(sel.kind==="exit") return {kind:"exit", data: deepClone(room.exits[sel.index])};
if(sel.kind==="ability_door") return {kind:"ability_door", data: deepClone(room.ability_doors[sel.index])};
if(sel.kind==="enemy") return {kind:"enemy", data: deepClone(room.enemies[sel.index])};
if(sel.kind==="prop") return {kind:"prop", data: deepClone(room.props[sel.index])};
if(sel.kind==="entry_spawn"){
const pos = room.entry_spawns[sel.key];
return {kind:"entry_spawn", key: sel.key, data: deepClone(pos)};
}
return null;
}
function copySelection(){
const obj = getSelectedObject();
if(!obj) return;
state.clipboard = obj;
setStatus("Copied " + obj.kind);
}
function cutSelection(){
const roomWrap = getActiveRoom(); if(!roomWrap) return;
const room = roomWrap.data;
const sel = state.selection;
if(!sel) return;
copySelection();
pushHistory(roomWrap.id);
if(sel.kind==="platform") room.platforms.splice(sel.index,1);
else if(sel.kind==="exit") room.exits.splice(sel.index,1);
else if(sel.kind==="ability_door") room.ability_doors.splice(sel.index,1);
else if(sel.kind==="enemy") room.enemies.splice(sel.index,1);
else if(sel.kind==="prop") room.props.splice(sel.index,1);
else if(sel.kind==="entry_spawn") delete room.entry_spawns[sel.key];
state.selection=null;
refreshUI();
setStatus("Cut");
}
function pasteClipboard(){
const roomWrap = getActiveRoom(); if(!roomWrap || !state.clipboard) return;
const room = roomWrap.data;
pushHistory(roomWrap.id);
const c = deepClone(state.clipboard);
const offset = 12;
if(c.kind==="platform"){
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
room.platforms.push(c.data);
state.selection = {kind:"platform", index: room.platforms.length-1, roomId: roomWrap.id};
} else if(c.kind==="exit"){
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
room.exits.push(c.data);
state.selection = {kind:"exit", index: room.exits.length-1, roomId: roomWrap.id};
} else if(c.kind==="ability_door"){
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
room.ability_doors.push(c.data);
state.selection = {kind:"ability_door", index: room.ability_doors.length-1, roomId: roomWrap.id};
} else if(c.kind==="enemy"){
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
room.enemies.push(c.data);
state.selection = {kind:"enemy", index: room.enemies.length-1, roomId: roomWrap.id};
} else if(c.kind==="prop"){
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
(room.props || (room.props = [])).push(c.data);
state.selection = {kind:"prop", index: room.props.length-1, roomId: roomWrap.id};
} else if(c.kind==="entry_spawn"){
// ensure unique key
let key = c.key || "entry_1";
let n=1;
while(room.entry_spawns[key]){ key = (c.key||"entry_") + "_" + (n++); }
room.entry_spawns[key] = [snap(c.data[0]+offset), snap(c.data[1]+offset)];
state.selection = {kind:"entry_spawn", key, roomId: roomWrap.id};
}
refreshUI();
setStatus("Pasted");
}
function duplicateSelection(){
copySelection();
pasteClipboard();
}
function deleteSelection(){
const roomWrap = getActiveRoom(); if(!roomWrap) return;
const room = roomWrap.data;
const sel = state.selection;
if(!sel) return;
pushHistory(roomWrap.id);
if(sel.kind==="platform") room.platforms.splice(sel.index,1);
else if(sel.kind==="exit") room.exits.splice(sel.index,1);
else if(sel.kind==="ability_door") room.ability_doors.splice(sel.index,1);
else if(sel.kind==="enemy") room.enemies.splice(sel.index,1);
else if(sel.kind==="prop") room.props.splice(sel.index,1);
else if(sel.kind==="entry_spawn") delete room.entry_spawns[sel.key];
else if(sel.kind==="player_spawn") room.player_spawn = [100,300];
state.selection = null;
refreshUI();
setStatus("Deleted");
}
// ---------- Linking ----------
function applyLink(){
if(!els.linkTargetRoom || !els.linkTargetSpawn){
setStatus("Link Mode: use compare click linking.");
return;
}
const roomWrap = getActiveRoom(); if(!roomWrap) return;
const room = roomWrap.data;
const sel = state.selection;
if(!sel || sel.kind!=="exit") { setStatus("Select an Exit first."); return; }
const exit = room.exits[sel.index];
if(!exit) return;
const targetId = els.linkTargetRoom.value;
const targetRoomWrap = state.rooms.get(targetId);
if(!targetRoomWrap){ setStatus("Pick a target room."); return; }
const spawnId = els.linkTargetSpawn.value;
if(!spawnId){ setStatus("Pick a target spawn."); return; }
const pos = targetRoomWrap.data.entry_spawns && targetRoomWrap.data.entry_spawns[spawnId];
if(!pos){ setStatus("Target spawn not found."); return; }
pushHistory(roomWrap.id);
exit.to = targetRoomWrap.data.name;
exit.spawn_id = spawnId;
exit.spawn = [pos[0], pos[1]]; // legacy fallback
refreshUI();
setStatus(`Linked exit → ${exit.to}:${spawnId}`);
// auto-disable after one link
setLinkMode(false);
}
// ---------- Export ----------
function serializeRoom(roomWrap){
const d = deepClone(roomWrap.data || {});
const fmt = d._format || "editor_v1";
if(fmt === "game_v1"){
// rebuild grouped platforms
const platforms = {};
for(const p of (d.platforms || [])){
const group = p.group || "ground";
if(!platforms[group]) platforms[group] = [];
platforms[group].push([p.x, p.y, p.w, p.h]);
}
// enemies back to {type, pos:[x,y]}
const enemies = (d.enemies || []).map(e => ({ type: e.type || "enemy", pos: [e.x, e.y] }));
// exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint}
const exits = (d.exits || []).map(ex => {
const out = {
rect: [ex.x, ex.y, ex.w, ex.h],
to: ex.to || "",
spawn: Array.isArray(ex.spawn) ? [ex.spawn[0], ex.spawn[1]] : [0,0],
hint: ex.hint || ""
};
if(ex.spawn_id) out.spawn_id = ex.spawn_id;
return out;
});
const ability_doors = (d.ability_doors || []).map(ad => ({
rect: [ad.x, ad.y, ad.w, ad.h]
}));
const props = (d.props || []).map(p => {
const out = {
type: p.type || "light_source",
pos: [p.x, p.y],
radius: p.radius || 180
};
if(p.object_id) out.object_id = p.object_id;
return out;
});
const meta = d.meta && typeof d.meta === "object" ? d.meta : { id: d.name || "room", name: d.display_name || d.name || "room" };
if(!meta.id) meta.id = d.name || "room";
if(!meta.name) meta.name = d.display_name || meta.id;
const out = {
meta,
player_spawn: Array.isArray(d.player_spawn) ? d.player_spawn : [100,300],
bounds: d.bounds && typeof d.bounds === "object" ? d.bounds : {},
platforms,
enemies,
exits,
ability_doors
};
// include entry spawns (new feature) for your spawn linking
if(d.entry_spawns && typeof d.entry_spawns === "object") out.entry_spawns = d.entry_spawns;
if(props.length) out.props = props;
// carry over any extra keys that were in the original
if(d._extras && typeof d._extras === "object"){
for(const k of Object.keys(d._extras)){
out[k] = d._extras[k];
}
}
return JSON.stringify(out, null, 2);
}
// editor-native export (keeps internal structure)
const out = deepClone(d);
delete out._format;
delete out._extras;
return JSON.stringify(out, null, 2);
}
// ---------- UI Refresh ----------
function refreshUI(){
const __leftEl = document.querySelector(".left");
const __leftScroll = __leftEl ? __leftEl.scrollTop : 0;
refreshRoomsList();
refreshRoomSelectors();
const r = getActiveRoom();
els.activeRoomLabel.textContent = r ? `Active: ${r.data.name}` : "Active: (none)";
refreshProps();
// compare layout
if(state.compareEnabled){
els.compareWrap.style.display = "block";
els.canvasRow.style.gridTemplateColumns = "1fr 1fr";
els.toggleCompareBtn.textContent = "Disable Compare";
} else {
els.compareWrap.style.display = "none";
els.canvasRow.style.gridTemplateColumns = "1fr";
els.toggleCompareBtn.textContent = "Enable Compare";
}
// keep link spawns updated when switching rooms
updateLinkSpawnList();
// link mode button label
if(els.toggleLinkModeBtn){
els.toggleLinkModeBtn.textContent = state.linkMode ? "Disable Link Mode" : "Enable Link Mode";
}
// restore sidebar scroll (refreshRoomsList rebuilds DOM)
if(__leftEl) __leftEl.scrollTop = __leftScroll;
}
// ---------- Event Wiring ----------
document.querySelectorAll(".toolBtn").forEach(btn=>{
btn.addEventListener("click", ()=>setTool(btn.dataset.tool));
});
if(els.platformGroup){
els.platformGroup.value = state.platformGroup;
els.platformGroup.addEventListener("change", ()=>{
state.platformGroup = els.platformGroup.value || "ground";
setStatus(`Platform layer: ${state.platformGroup}`);
});
}
// Enemy types (optional)
function refreshEnemyTypeSelect(){
if(!els.enemyTypeSelect) return;
const sel = els.enemyTypeSelect;
sel.innerHTML = "";
const keys = state.enemyTypeKeys && state.enemyTypeKeys.length ? state.enemyTypeKeys : [];
if(keys.length === 0){
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(no enemy types loaded)";
sel.appendChild(opt);
state.enemyPlaceType = null;
return;
}
for(const k of keys){
const opt = document.createElement("option");
opt.value = k;
opt.textContent = k;
sel.appendChild(opt);
}
if(keys.includes(state.enemyPlaceType)) sel.value = state.enemyPlaceType;
else { state.enemyPlaceType = keys[0]; sel.value = keys[0]; }
}
if(els.enemyTypesInput){
els.enemyTypesInput.addEventListener("change", async ()=>{
const f = els.enemyTypesInput.files && els.enemyTypesInput.files[0];
if(!f) return;
try{
const txt = await f.text();
const data = JSON.parse(txt);
if(!data || typeof data !== "object" || Array.isArray(data)){
throw new Error("Enemy types JSON must be an object keyed by type id.");
}
state.enemyTypes = data;
state.enemyTypeKeys = Object.keys(data);
if(state.enemyTypeKeys.length===0){
state.enemyTypeKeys = ["enemy"];
state.enemyTypes = null;
}
state.enemyPlaceType = state.enemyTypeKeys[0] || "enemy";
refreshEnemyTypeSelect();
setStatus(`Loaded enemy types (${state.enemyTypeKeys.length}) from ${f.name}`);
redraw();
}catch(err){
console.error(err);
setStatus("Failed to load enemy types JSON. Check console.");
}finally{
els.enemyTypesInput.value = "";
}
});
}
function applyObjectDefaultsToRooms(){
let changedAny = false;
for(const [id, wrap] of state.rooms.entries()){
if(!wrap || !wrap.data || !Array.isArray(wrap.data.props)) continue;
let changed = false;
for(const p of wrap.data.props){
if(!p || typeof p !== "object") continue;
const type = p.type || "light_source";
if(!(Number.isFinite(Number(p.radius)) && Number(p.radius) > 0)){
p.radius = getDefaultRadiusForType(type);
changed = true;
}
}
if(changed){
markDirty(id, true);
changedAny = true;
}
}
if(changedAny) refreshUI();
}
function loadObjectCatalog(data, filename){
objectCatalog.byType = {};
objectCatalog.byId = {};
if(data && typeof data === "object" && data.objects && typeof data.objects === "object"){
for(const [id, def] of Object.entries(data.objects)){
if(!def || typeof def !== "object") continue;
objectCatalog.byId[id] = def;
if(def.type){
objectCatalog.byType[def.type] = def;
}
}
}
const count = Object.keys(objectCatalog.byId).length;
if(els.objectsStatus){
els.objectsStatus.textContent = count ? `Loaded ${count} object(s) from ${filename}` : "No objects loaded.";
}
refreshObjectSelect();
applyObjectDefaultsToRooms();
}
if(els.objectsInput){
els.objectsInput.addEventListener("change", async ()=>{
const f = els.objectsInput.files && els.objectsInput.files[0];
if(!f) return;
try{
const txt = await f.text();
const data = JSON.parse(txt);
loadObjectCatalog(data, f.name);
setStatus(`Loaded objects catalog: ${f.name}`);
}catch(err){
console.error(err);
setStatus("Failed to load objects.json. Check console.");
}finally{
els.objectsInput.value = "";
}
});
}
if(els.objectSelect){
refreshObjectSelect();
els.objectSelect.addEventListener("change", ()=>{
state.objectPlaceId = els.objectSelect.value || null;
if(state.objectPlaceId){
setStatus(`Object placement: ${state.objectPlaceId}`);
}
});
}
if(els.enemyTypeSelect){
refreshEnemyTypeSelect();
els.enemyTypeSelect.addEventListener("change", ()=>{
state.enemyPlaceType = els.enemyTypeSelect.value || "enemy";
setStatus(`Enemy placement type: ${state.enemyPlaceType}`);
});
}
function refreshObjectSelect(){
if(!els.objectSelect) return;
const sel = els.objectSelect;
sel.innerHTML = "";
const ids = Object.keys(objectCatalog.byId);
if(ids.length === 0){
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(no objects loaded)";
sel.appendChild(opt);
state.objectPlaceId = null;
sel.value = "";
return;
}
for(const id of ids){
const def = objectCatalog.byId[id];
const label = def && def.type ? `${id} (${def.type})` : id;
const opt = document.createElement("option");
opt.value = id;
opt.textContent = label;
sel.appendChild(opt);
}
if(state.objectPlaceId && ids.includes(state.objectPlaceId)){
sel.value = state.objectPlaceId;
} else {
state.objectPlaceId = ids[0];
sel.value = ids[0];
}
}
if(els.showAggroChk){
els.showAggroChk.checked = !!state.showAggro;
els.showAggroChk.addEventListener("change", ()=>{
state.showAggro = !!els.showAggroChk.checked;
redraw();
});
}
if(els.linkTargetRoom){
els.linkTargetRoom.addEventListener("change", ()=>{
updateLinkSpawnList();
});
}
if(els.linkApplyBtn){
els.linkApplyBtn.addEventListener("click", applyLink);
}
if(els.openTargetBtn){
els.openTargetBtn.addEventListener("click", ()=>{
const targetId = els.linkTargetRoom.value;
if(targetId) setActiveRoom(targetId);
});
}
els.toggleCompareBtn.addEventListener("click", ()=>{
state.compareEnabled = !state.compareEnabled;
if(state.compareEnabled){
// Prefer the user-selected compare room (dropdown), then the previous compareId, otherwise pick any other room.
const selected = els.compareRoomSelect && els.compareRoomSelect.value ? els.compareRoomSelect.value : null;
const isValidOther = (id)=> !!id && state.rooms.has(id) && id !== state.activeId;
if(isValidOther(selected)){
state.compareId = selected;
} else if(isValidOther(state.compareId)){
// keep previous compareId
} else {
const other = [...state.rooms.keys()].find(k => k !== state.activeId);
state.compareId = other || state.activeId;
}
}
refreshUI();
});
els.showLinksChk.addEventListener("change", ()=>{
if(els.showLinksChk.checked){
if(!state.compareEnabled){
state.compareEnabled = true;
const other = [...state.rooms.keys()].find(k => k !== state.activeId);
state.compareId = other || state.activeId;
}
}
// overlay updates in render loop
refreshUI();
});
els.compareRoomSelect.addEventListener("change", ()=>{
state.compareId = els.compareRoomSelect.value || null;
});
// Link mode toggle
if(els.toggleLinkModeBtn){
els.toggleLinkModeBtn.addEventListener("click", ()=>{
setLinkMode(!state.linkMode);
});
}
els.newRoomBtn.addEventListener("click", ()=>{
const name = "room_" + String(state.rooms.size+1).padStart(2,"0");
addRoomFromJSON(roomTemplate(name), name + ".json");
setTool("select");
});
els.fileInput.addEventListener("change", async ()=>{
const files = [...els.fileInput.files];
if(!files.length) return;
for(const f of files){
const text = await f.text();
let json=null;
try{ json = JSON.parse(text); }catch(e){ alert("Invalid JSON in " + f.name); continue; }
addRoomFromJSON(json, f.name);
// auto-fit view to newly added room (active only)
fitViewToRoom(getActiveRoom(), 'A');
if(state.compareEnabled) fitViewToRoom(getCompareRoom(), 'B');
}
els.fileInput.value = "";
});
els.downloadActiveBtn.addEventListener("click", ()=>{
const r = getActiveRoom(); if(!r) return;
downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
r.dirty = false;
refreshRoomsList();
setStatus("Downloaded active room.");
});
els.downloadCheckedBtn.addEventListener("click", async ()=>{
const checked = [...state.rooms.values()].filter(r=>r.checked);
if(!checked.length){ setStatus("No checked rooms."); return; }
// Trigger sequential downloads (browser will prompt/save each)
let i=0;
for(const r of checked){
downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
r.dirty = false;
i++;
await new Promise(res=>setTimeout(res, 250)); // small delay so downloads are not collapsed
}
refreshRoomsList();
setStatus(`Downloaded ${i} checked room(s).`);
});
// canvas pointer handlers
// middle-mouse pan (and prevent browser autoscroll / sidebar scrolling)
function startMousePan(e, canvasKey){
e.preventDefault();
const rect = (canvasKey==="A"? els.canvasA : els.canvasB).getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
state.dragging = {mode:"pan", canvas:canvasKey, startX:sx, startY:sy, startPanX:(canvasKey==="A"? state.viewA.panX:state.viewB.panX), startPanY:(canvasKey==="A"? state.viewA.panY:state.viewB.panY)};
}
[els.canvasA, els.canvasB].forEach((c, idx)=>{
const key = idx===0 ? "A" : "B";
c.addEventListener("mousedown", (e)=>{
if(e.button===1){
startMousePan(e, key);
}
}, {passive:false});
// firefox sometimes triggers auxclick without pointer events for middle button
c.addEventListener("auxclick", (e)=>{ if(e.button===1) e.preventDefault(); }, {passive:false});
});
window.addEventListener("mousemove", (e)=>{
if(!state.dragging || state.dragging.mode!=="pan") return;
const key = state.dragging.canvas;
const rect = (key==="A"? els.canvasA : els.canvasB).getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const view = key==="A" ? state.viewA : state.viewB;
const dx = (sx - state.dragging.startX) / view.zoom;
const dy = (sy - state.dragging.startY) / view.zoom;
view.panX = state.dragging.startPanX + dx;
view.panY = state.dragging.startPanY + dy;
}, {passive:false});
window.addEventListener("mouseup", (e)=>{
if(state.dragging && state.dragging.mode==="pan"){
state.dragging = null;
}
});
els.canvasA.addEventListener("pointerdown", onPointerDown);
els.canvasB.addEventListener("pointerdown", onComparePointerDown);
// (Removed mousedown fallback that called onPointerDown/onComparePointerDown.
// It caused double placement because both pointerdown and mousedown fired on left-click.)
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
els.canvasA.addEventListener("wheel", onWheel, {passive:false});
els.canvasB.addEventListener("wheel", onWheelB, {passive:false});
// hover cursor (resize/move) on both canvases
function cursorForEdge(edge){
if(!edge) return "move";
if(edge==="n"||edge==="s") return "ns-resize";
if(edge==="e"||edge==="w") return "ew-resize";
if(edge==="nw"||edge==="se") return "nwse-resize";
if(edge==="ne"||edge==="sw") return "nesw-resize";
return "move";
}
function updateHover(ev, canvasKey){
if(state.dragging) return;
// Only show smart cursors in select tool
if(state.tool !== "select"){
const c = canvasKey==="A" ? els.canvasA : els.canvasB;
c.style.cursor = "crosshair"; // placement feel
return;
}
const c = canvasKey==="A" ? els.canvasA : els.canvasB;
const roomWrap = (canvasKey==="A") ? getActiveRoom() : (state.compareEnabled ? getCompareRoom() : null);
if(!roomWrap){ c.style.cursor = "default"; return; }
const r = c.getBoundingClientRect();
const sx = ev.clientX - r.left;
const sy = ev.clientY - r.top;
const [wx,wy] = screenToWorld(sx,sy,c);
// prefer platform handles/edges
const hit = hitTest(roomWrap, wx, wy, c);
if(hit && hit.kind==="platform"){
c.style.cursor = cursorForEdge(hit.edge);
return;
}
if(hit && (hit.kind==="exit" || hit.kind==="ability_door")){
c.style.cursor = cursorForEdge(hit.edge);
return;
}
if(hit && (hit.kind==="enemy"||hit.kind==="player_spawn"||hit.kind==="entry_spawn")){
c.style.cursor = "move";
return;
}
c.style.cursor = "default";
}
els.canvasA.addEventListener("pointermove", (e)=>updateHover(e,"A"));
els.canvasB.addEventListener("pointermove", (e)=>updateHover(e,"B"));
// keyboard shortcuts
window.addEventListener("keydown", (e)=>{
const isMac = navigator.platform.toLowerCase().includes("mac");
const ctrl = isMac ? e.metaKey : e.ctrlKey;
const tgt = e.target;
const tag = tgt && tgt.tagName ? tgt.tagName.toUpperCase() : "";
const typing = tgt && (tag==="INPUT" || tag==="TEXTAREA" || tag==="SELECT" || tgt.isContentEditable);
if(typing){
// don't steal keys while user is typing in a field (e.g. 'F' should type, not Fit View)
return;
}
if(ctrl && e.key.toLowerCase()==="z"){
e.preventDefault();
if(e.shiftKey) redo(); else undo();
return;
}
if(ctrl && e.key.toLowerCase()==="y"){
e.preventDefault();
redo(); return;
}
if(ctrl && e.key.toLowerCase()==="c"){
e.preventDefault();
copySelection(); return;
}
if(ctrl && e.key.toLowerCase()==="x"){
e.preventDefault();
cutSelection(); return;
}
if(ctrl && e.key.toLowerCase()==="v"){
e.preventDefault();
pasteClipboard(); return;
}
if(ctrl && e.key.toLowerCase()==="d"){
e.preventDefault();
duplicateSelection(); return;
}
if(e.key==="Delete" || e.key==="Backspace"){
// avoid backspace navigating back
e.preventDefault();
deleteSelection(); return;
}
if(e.key.toLowerCase()==='f'){
e.preventDefault();
fitViewToRoom(getActiveRoom(),'A');
if(state.compareEnabled) fitViewToRoom(getCompareRoom(),'B');
return;
}
if(e.key==="Escape"){
state.selection=null;
refreshUI();
return;
}
});
// tool init
setTool("select");
// Boot with an empty room so canvas isn't blank
addRoomFromJSON(roomTemplate("room_01"), "room_01.json");
fitViewToRoom(getActiveRoom(), 'A');
// If user already has room_01/02 files locally: they can load both via file input.
refreshUI();
render();
})();
</script>
</body>
</html>