Files
Light_And_Dark/light_and_dark/level_editor_v43.html
T
Lena faa8b16658 more rooms
more rooms, level editor changes, more logic for ability checks and trigger doors, platform body collision fixes
2026-09-30 12:00:56 +02:00

3935 lines
150 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; overflow:hidden; 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:0; overflow:hidden;}
.left{background:var(--panel); border-right:1px solid var(--border); padding:12px; overflow-y:auto; height:100vh; box-sizing:border-box;}
.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%; overflow:hidden;}
/* Floating tool palette pinned to the right edge of the canvas area */
#toolbar{
position:absolute; right:10px; top:70px; z-index:60;
display:flex; flex-direction:column; gap:6px; padding:8px;
background:rgba(17,24,39,0.92); border:1px solid var(--border); border-radius:12px;
box-shadow:0 4px 18px rgba(0,0,0,0.45);
}
.toolIconBtn{
width:36px; height:36px; padding:0; margin:0;
display:flex; align-items:center; justify-content:center;
border-radius:8px; border:1px solid var(--border); background:var(--panel2);
cursor:pointer; color:var(--muted); transition:color .12s, border-color .12s, background .12s;
}
.toolIconBtn:hover{ color:var(--fg); border-color:#334155; }
.toolIconBtn svg{ width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:2; stroke-linejoin:round; stroke-linecap:round; }
.toolIconBtn.active, .toolIconBtn.primary, .toolIconBtn.warn{
color:#fff; border-color:rgba(96,165,250,0.8);
background:rgba(96,165,250,0.22); box-shadow:0 0 0 1px rgba(96,165,250,0.25) inset;
}
.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);}
.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="muted">
Tools are on the <b>icon toolbar</b> at the right edge of the canvas.
</div>
<div class="row" style="margin-top:8px;">
<label class="muted" style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="snapChk" checked/>
Grid snap (20px) on placement / drag
</label>
</div>
<div class="row" style="margin-top:8px;">
<label class="muted" style="flex:1;">New platform type:</label>
<select id="platformGroup" style="flex:1; min-width:120px;">
<option value="ground">Ground</option>
<option value="floating">Floating</option>
<option value="hazard">Hazard (red - damage)</option>
<option value="moving">Moving (path)</option>
<option value="trigger">Trigger (name)</option>
</select>
</div>
<div class="muted" style="margin-top:8px;">
<b>Layers:</b> every object (platforms, houses, NPCs, enemies, props, doors, exits) has a <b>layer</b> in its Properties — lower = further back, higher = further forward. The canvas and game draw back-to-front by (layer, category), and the select tool respects layers.
</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). Built-ins (enemy/miniboss/boss) are always available; importing overrides them.</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;">NPC Types</h3>
<div class="muted">Pick which NPC the <b>NPC Spawn</b> tool places. You can also change an NPC's type in Properties after placing it.</div>
<div class="row" style="margin-top:8px;">
<select id="npcTypeSelect" style="flex:1; min-width:200px;">
<option value="vendor">vendor</option>
<option value="upgrader">upgrader</option>
<option value="necklace_vendor">necklace_vendor</option>
<option value="combat_master">combat_master</option>
</select>
</div>
<div class="hr"></div>
<h3 style="margin-top:10px;">Objects Catalog</h3>
<div class="muted">Load objects.json to get default properties. Built-ins are always available: <b>flow_butterfly</b> (light), <b>lamp</b> (save point), <b>flow_butterfly_jar</b> (collectible), <b>house</b>, <b>diary</b>, <b>player</b> (size reference).</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>
</div>
<!-- Floating tool palette (right edge) -->
<div id="toolbar" title="Tools">
<button class="toolBtn toolIconBtn active" data-tool="select" title="Select / Move (V)">
<svg viewBox="0 0 24 24"><path d="M5 3 L5 17 L9.5 14 L12 20 L14.5 19 L12 13 L17 12 Z"/></svg>
</button>
<button class="toolBtn toolIconBtn" data-tool="platform" title="Platform (P)">
<svg viewBox="0 0 24 24"><rect x="3" y="8" width="18" height="9" rx="1"/></svg>
</button>
<button class="toolBtn toolIconBtn" data-tool="enemy_spawn" title="Enemy Spawn (E)">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"/><path d="M8 12 H16 M12 8 V16"/></svg>
</button>
<button class="toolBtn toolIconBtn" data-tool="npc_spawn" title="NPC Spawn (N)">
<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="3"/><path d="M6 20 C6 16 8.5 14 12 14 C15.5 14 18 16 18 20 Z"/></svg>
</button>
<button class="toolBtn toolIconBtn" data-tool="player_spawn" title="Player Spawn">
<svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3"/><path d="M5 20 C5 16 8 14 12 14 C16 14 19 16 19 20 Z"/><path d="M12 2 L14 5 M12 2 L10 5 M12 2 V5"/></svg>
</button>
<button class="toolBtn toolIconBtn" data-tool="entry_spawn" title="Entry Spawn">
<svg viewBox="0 0 24 24"><path d="M6 4 L18 4 L15 9 L18 14 L6 14 Z"/><path d="M6 4 V20"/></svg>
</button>
<button class="toolBtn toolIconBtn" data-tool="exit" title="Exit / Doorway">
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="1"/><path d="M10 9 L14 12 L10 15"/><path d="M14 12 H6"/></svg>
</button>
<button class="toolBtn toolIconBtn" data-tool="ability_door" title="Ability Door">
<svg viewBox="0 0 24 24"><rect x="6" y="3" width="12" height="18" rx="1"/><circle cx="15" cy="12" r="1.5"/></svg>
</button>
<button class="toolBtn toolIconBtn" data-tool="object_spawn" title="Object Spawn (O)">
<svg viewBox="0 0 24 24"><path d="M12 3 L21 12 L12 21 L3 12 Z"/><circle cx="12" cy="12" r="2"/></svg>
</button>
</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: {} };
// Built-in object catalog so the new assets (butterfly light, lantern save point,
// butterfly-in-jar collectible) are always placeable even before loading objects.json.
const DEFAULT_OBJECT_CATALOG = {
flow_butterfly: { type: "light_source", charge_regen_range: 180 },
lamp: { type: "save_point", charge_regen_range: 90 },
flow_butterfly_jar: { type: "butterfly_jar" },
house: { type: "house" },
diary: { type: "diary" },
player: { type: "player" },
quest_point: { type: "quest_giver" },
};
// Type fallbacks used when no object def provides a radius.
const TYPE_DEFAULT_RADII = { light_source: 180, save_point: 90 };
// Built-in enemy types (mirrors data/enemies/enemy_types.json) so the editor works
// immediately; importing an enemy types JSON overrides these.
const DEFAULT_ENEMY_TYPES = {
enemy: { rect: [40, 60], health: 3, speed: 2.2, air_speed: 2.0, detection_range: 240, contact_damage: 1, drop_profile: "normal" },
miniboss: { rect: [70, 95], health: 18, speed: 1.8, air_speed: 1.6, detection_range: 360, contact_damage: 2, drop_profile: "miniboss" },
boss: { rect: [110, 120], health: 10, speed: 1.6, air_speed: 1.4, detection_range: 500, contact_damage: 3, drop_profile: "boss" },
};
function seedDefaultObjectCatalog(){
objectCatalog.byType = {};
objectCatalog.byId = {};
for(const [id, def] of Object.entries(DEFAULT_OBJECT_CATALOG)){
objectCatalog.byId[id] = def;
if(def.type) objectCatalog.byType[def.type] = def;
}
}
seedDefaultObjectCatalog();
const snap = (v)=> state.snapEnabled ? Math.round(v / state.gridSize) * state.gridSize : Math.round(v);
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) : 0;
if(val > 0) return val;
return TYPE_DEFAULT_RADII[type] || 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: [],
npcs: [],
exits: [],
ability_doors: [],
props: []
};
}
function normPlatformEntry(rect, group){
if(Array.isArray(rect)){
return { x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group, layer: rect.length>=5 ? (+rect[4]||0) : 0 };
}
if(rect && typeof rect === "object"){
const out = { x:+rect.x||0, y:+rect.y||0, w:+rect.w||0, h:+rect.h||0, group: rect.group || group || "ground", layer: +(rect.layer||0) };
if(Array.isArray(rect.path)) out.path = deepClone(rect.path);
if(rect.speed !== undefined) out.speed = +rect.speed;
if(rect.name) out.name = String(rect.name);
if(rect.quest) out.quest = String(rect.quest);
if(rect.quest_complete) out.quest_complete = String(rect.quest_complete);
if(rect.quest_turned_in) out.quest_turned_in = String(rect.quest_turned_in);
if(rect.lock_when_pass) out.lock_when_pass = true;
if(rect.open_on_miniboss_dead) out.open_on_miniboss_dead = true;
if(Array.isArray(rect.respawn)) out.respawn = deepClone(rect.respawn);
return out;
}
return null;
}
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){
const np = normPlatformEntry(rect, group);
if(np) r.platforms.push(np);
}
}
// if nothing came through, try also accepting editor-native arrays inside game rooms
if(Array.isArray(src.platforms)){
for(const p of src.platforms){
const np = normPlatformEntry(p, p && p.group ? p.group : "ground");
if(np) r.platforms.push(np);
}
}
} else {
// editor-native
if(Array.isArray(src.platforms)){
for(const p of src.platforms){
const np = normPlatformEntry(p, p && p.group ? p.group : "ground");
if(np) r.platforms.push(np);
}
} 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){
const np = normPlatformEntry(rect, group);
if(np) r.platforms.push(np);
}
}
}
}
// ----- 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", layer: +(e.layer||0) });
} 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", layer: +(e.layer||0) });
}
}
}
}
// ----- npcs -----
r.npcs = [];
if(Array.isArray(src.npcs)){
for(const n of src.npcs){
if(n && typeof n === "object"){
if(Array.isArray(n.pos)){
r.npcs.push({ x:+n.pos[0]||0, y:+n.pos[1]||0, type: n.type || "vendor", layer: +(n.layer||0) });
} else if(typeof n.x === "number" || typeof n.y === "number"){
r.npcs.push({ x:+n.x||0, y:+n.y||0, type: n.type || "vendor", layer: +(n.layer||0) });
}
}
}
}
// ----- 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, layer: +(p.layer||0), quest_id: p.quest_id || p.questId || "", coins: Array.isArray(p.coins) ? deepClone(p.coins) : undefined });
}
}
// ----- 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 || "",
layer: +(ex.layer||0)
});
} 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 || "",
layer: +(ex.layer||0)
});
}
}
}
// ----- ability doors -----
r.ability_doors = [];
if(Array.isArray(src.ability_doors)){
for(const d of src.ability_doors){
if(!d) continue;
const kind = (typeof d === "object" && d.kind === "shade") ? "shade" : "ranged";
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, layer: d.length>=5 ? (+d[4]||0) : 0, kind });
} 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, layer: +(d.layer||0), kind });
} else {
r.ability_doors.push({ x:+d.x||0, y:+d.y||0, w:+d.w||0, h:+d.h||0, layer: +(d.layer||0), kind });
}
}
}
}
// ----- keep other top-level fields (future-proof) -----
r._extras = {};
if(src && typeof src === "object"){
const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","npcs","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,
snapEnabled: true,
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: deepClone(DEFAULT_ENEMY_TYPES),
enemyTypeKeys: Object.keys(DEFAULT_ENEMY_TYPES),
enemyPlaceType: "enemy",
npcPlaceType: "vendor",
showAggro: false,
objectPlaceId: null,
waitingPathPt: 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"),
showLinksChk: document.getElementById("showLinksChk"),
props: document.getElementById("props"),
canvasA: document.getElementById("canvasA"),
canvasB: document.getElementById("canvasB"),
platformGroup: document.getElementById("platformGroup"),
snapChk: document.getElementById("snapChk"),
enemyTypesInput: document.getElementById("enemyTypesInput"),
enemyTypeSelect: document.getElementById("enemyTypeSelect"),
npcTypeSelect: document.getElementById("npcTypeSelect"),
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;
// Adding a path point to a selected moving platform?
if(state.waitingPathPt){
if(ev.button === 0){
ev.preventDefault();
const wp = state.waitingPathPt;
const wrap = state.rooms.get(wp.roomId);
const plat = wrap && wrap.data.platforms && wrap.data.platforms[wp.index];
if(plat){
if(!Array.isArray(plat.path)) plat.path = [[plat.x, plat.y]];
plat.path.push(snapPt(wx, wy));
}
state.waitingPathPt = null;
refreshUI();
setStatus("Path point added.");
return;
}
}
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, group: state.platformGroup || "ground"};
if(p.group === "moving") p.path = [[sx1, sy1]];
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 === "npc_spawn"){
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
room.npcs = room.npcs || [];
room.npcs.push({x: sx1, y: sy1, type: (state.npcPlaceType || "vendor")});
state.selection = {kind:"npc", index:room.npcs.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);
}
for(const n of (room.npcs||[])){
minX = Math.min(minX, n.x-60); minY = Math.min(minY, n.y-60);
maxX = Math.max(maxX, n.x+60); maxY = Math.max(maxY, n.y+60);
}
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];
}
// Replicates the game's procedural vendor house backdrop (drawn in world units).
function drawHouseShape(ctx, centerX, bottomY, dpr, canvas){
const house_w = 140, house_h = 90;
const bx = centerX - house_w/2;
const by = bottomY - house_h;
const wallA = worldToScreen(bx, by + 30, canvas);
const wallB = worldToScreen(bx + house_w, by + house_h, canvas);
const roofA = worldToScreen(bx - 6, by + 34, canvas);
const roofB = worldToScreen(bx + house_w/2, by - 8, canvas);
const roofC = worldToScreen(bx + house_w + 6, by + 34, canvas);
const doorA = worldToScreen(bx + house_w/2 - 13, by + house_h - 34, canvas);
const doorB = worldToScreen(bx + house_w/2 + 13, by + house_h, canvas);
// wall
ctx.fillStyle = "rgba(180,170,150,0.80)";
ctx.fillRect(wallA[0], wallA[1], wallB[0]-wallA[0], wallB[1]-wallA[1]);
ctx.strokeStyle = "rgba(120,110,95,0.9)";
ctx.lineWidth = 1.5*dpr;
ctx.strokeRect(wallA[0], wallA[1], wallB[0]-wallA[0], wallB[1]-wallA[1]);
// door
ctx.fillStyle = "rgba(150,170,195,0.85)";
ctx.fillRect(doorA[0], doorA[1], doorB[0]-doorA[0], doorB[1]-doorA[1]);
ctx.strokeStyle = "rgba(95,110,130,0.9)";
ctx.strokeRect(doorA[0], doorA[1], doorB[0]-doorA[0], doorB[1]-doorA[1]);
// roof
ctx.beginPath();
ctx.moveTo(roofA[0], roofA[1]);
ctx.lineTo(roofB[0], roofB[1]);
ctx.lineTo(roofC[0], roofC[1]);
ctx.closePath();
ctx.fillStyle = "rgba(210,170,110,0.9)";
ctx.fill();
ctx.strokeStyle = "rgba(135,100,65,0.9)";
ctx.stroke();
}
// Replicates the game's DiaryObject: a 40x50 book with a center spine (top-left anchored).
function drawBookShape(ctx, x, y, dpr, canvas){
const a = worldToScreen(x, y, canvas);
const b = worldToScreen(x + 40, y + 50, canvas);
const w = b[0]-a[0], h = b[1]-a[1];
ctx.fillStyle = "rgba(150,110,70,0.9)";
ctx.fillRect(a[0], a[1], w, h);
ctx.strokeStyle = "rgba(90,60,30,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(a[0], a[1], w, h);
ctx.beginPath();
ctx.moveTo(a[0]+w/2, a[1]);
ctx.lineTo(a[0]+w/2, a[1]+h);
ctx.stroke();
}
// Clickable body for props with a real in-world shape (null = small point marker).
function getPropHitRect(p){
if(!p) return null;
const t = p.type || "light_source";
if(t === "house") return { x: p.x - 70, y: p.y - 90, w: 140, h: 90 }; // bottom-center
if(t === "diary") return { x: p.x, y: p.y, w: 40, h: 50 }; // top-left book
if(t === "player") return { x: p.x, y: p.y, w: 48, h: 72 }; // top-left hitbox
return null;
}
// Corner/edge handle detection for resizing (platforms, doors, exits).
function getResizeEdge(r, wx, wy, tol2){
const x0=r.x, x1=r.x+r.w, y0=r.y, y1=r.y+r.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'];
for(const tag of order){
const h = handles.find(h=>h[2]===tag);
const dx=wx-h[0], dy=wy-h[1];
if(dx*dx+dy*dy <= tol2) return tag;
}
return null;
}
// Draws a single prop (house/diary/player get their real shape; others get a marker).
function drawPropMarker(ctx, p, dpr, canvas){
const PROP_STYLES = {
light_source: { ring: "rgba(250,204,21,0.25)", fill: "rgba(250,204,21,0.85)", label: "Butterfly" },
save_point: { ring: "rgba(56,189,248,0.30)", fill: "rgba(56,189,248,0.9)", label: "Lantern" },
butterfly_jar: { ring: "rgba(168,85,247,0.22)", fill: "rgba(168,85,247,0.9)", label: "Jar" },
diary: { ring: "rgba(150,110,70,0.25)", fill: "rgba(150,110,70,0.95)", label: "Diary" },
};
const ptype = p.type || "light_source";
const st = PROP_STYLES[ptype] || { ring: "rgba(148,163,184,0.25)", fill: "rgba(148,163,184,0.9)", label: ptype };
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const v = getViewForCanvas(canvas);
const r = Math.max(20, +p.radius || getDefaultRadiusForType(ptype, p.object_id || p.objectId || null));
if(ptype === "house"){
drawHouseShape(ctx, p.x, p.y, dpr, canvas);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText("House", sx+10*dpr, sy+4*dpr);
return;
}
if(ptype === "diary"){
drawBookShape(ctx, p.x, p.y, dpr, canvas);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText("Diary", sx+10*dpr, sy-4*dpr);
return;
}
if(ptype === "player"){
const a = worldToScreen(p.x, p.y, canvas);
const b = worldToScreen(p.x+48, p.y+72, canvas);
ctx.fillStyle = "rgba(200,200,220,0.15)";
ctx.fillRect(a[0], a[1], b[0]-a[0], b[1]-a[1]);
ctx.strokeStyle = "rgba(200,200,220,0.8)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(a[0], a[1], b[0]-a[0], b[1]-a[1]);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText("Player", a[0], a[1]-4*dpr);
return;
}
// radius ring (only for types that actually use it in-game)
if(ptype === "light_source" || ptype === "save_point"){
const rPx = r * (v.zoom || 1) * dpr;
ctx.strokeStyle = st.ring;
ctx.lineWidth = 2*dpr;
ctx.beginPath(); ctx.arc(sx, sy, rPx, 0, Math.PI*2); ctx.stroke();
}
// marker
ctx.fillStyle = st.fill;
if(ptype === "save_point"){
ctx.beginPath();
ctx.moveTo(sx, sy-7*dpr);
ctx.lineTo(sx+7*dpr, sy);
ctx.lineTo(sx, sy+7*dpr);
ctx.lineTo(sx-7*dpr, sy);
ctx.closePath();
ctx.fill();
} else if(ptype === "butterfly_jar"){
ctx.fillRect(sx-6*dpr, sy-6*dpr, 12*dpr, 12*dpr);
} else {
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(st.label, sx+10*dpr, sy+4*dpr);
}
// House backdrops are explicit house props now; this shape helper is used for them.
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);
// layerable objects: props/houses (0), platforms (1), ability doors (2), exits (3),
// enemies (4), NPCs (5) drawn back-to-front by (layer, category) so you can push
// any of them into the fore- or background.
const NPC_STYLES = {
vendor: { body: "rgba(220,220,190,0.95)", border: "rgba(140,140,110,0.9)" },
upgrader: { body: "rgba(140,140,150,0.95)", border: "rgba(100,100,110,0.9)" },
necklace_vendor: { body: "rgba(190,160,230,0.95)", border: "rgba(130,100,170,0.9)" },
combat_master: { body: "rgba(200,120,110,0.95)", border: "rgba(150,80,70,0.9)" },
};
const layerable = [];
for(const p of (room.props || [])) if(p) layerable.push({ z: +(p.layer||0), cat: 0, kind: "prop", p });
for(const p of (room.platforms || [])){
if(p && (p.group === "moving")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "moving", p });
else if(p && (p.group === "trigger")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "trigger", p });
else if(p && (p.group === "hazard")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "hazard", p });
else layerable.push({ z: +(p.layer||0), cat: 1, kind: "platform", p });
}
for(const d of (room.ability_doors || [])) if(d) layerable.push({ z: +(d.layer||0), cat: 2, kind: "door", d });
for(const ex of (room.exits || [])) if(ex) layerable.push({ z: +(ex.layer||0), cat: 3, kind: "exit", ex });
for(const e of (room.enemies || [])) if(e) layerable.push({ z: +(e.layer||0), cat: 4, kind: "enemy", e });
for(const n of (room.npcs || [])) if(n) layerable.push({ z: +(n.layer||0), cat: 5, kind: "npc", n });
layerable.sort((a,b)=> (a.z - b.z) || (a.cat - b.cat));
for(const it of layerable){
if(it.kind === "prop"){
drawPropMarker(ctx, it.p, dpr, canvas);
} else if(it.kind === "platform"){
const p = it.p;
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);
} else if(it.kind === "moving"){
const p = it.p;
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(80,180,220,0.30)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(150,220,255,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
// draw the movement path (dashed line + waypoint markers)
const path = p.path || [];
if(path.length >= 2){
ctx.strokeStyle = "rgba(150,220,255,0.5)";
ctx.lineWidth = 1.5*dpr;
ctx.setLineDash([4*dpr, 4*dpr]);
ctx.beginPath();
path.forEach((pt, pi)=>{
const [xx,yy] = worldToScreen(pt[0], pt[1], canvas);
if(pi===0) ctx.moveTo(xx,yy); else ctx.lineTo(xx,yy);
});
// loop back to start
const [exx,eyy] = worldToScreen(path[0][0], path[0][1], canvas);
ctx.lineTo(exx, eyy);
ctx.stroke();
ctx.setLineDash([]);
for(const pt of path){
const [xx,yy] = worldToScreen(pt[0], pt[1], canvas);
ctx.fillStyle = "rgba(150,220,255,0.9)";
ctx.beginPath(); ctx.arc(xx, yy, 3*dpr, 0, Math.PI*2); ctx.fill();
}
// ghost outline of the platform at its final waypoint
const lastPt = path[path.length-1];
if(lastPt){
const [gx,gy] = worldToScreen(lastPt[0], lastPt[1], canvas);
const gw = sx2 - sx, gh = sy2 - sy;
ctx.strokeStyle = "rgba(150,220,255,0.45)";
ctx.lineWidth = 2*dpr;
ctx.setLineDash([5*dpr, 4*dpr]);
ctx.strokeRect(gx, gy, gw, gh);
ctx.setLineDash([]);
}
}
} else if(it.kind === "trigger"){
const p = it.p;
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(220,190,60,0.30)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(255,230,120,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
const label = p.name ? ("Trigger: " + p.name) : "Trigger";
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText(label, sx+4*dpr, sy-4*dpr);
} else if(it.kind === "hazard"){
const p = it.p;
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(239,68,68,0.35)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(239,68,68,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText("Hazard", sx+4*dpr, sy-4*dpr);
// linked respawn marker
if(Array.isArray(p.respawn) && p.respawn.length>=2){
const [rx,ry] = worldToScreen(p.respawn[0], p.respawn[1], canvas);
ctx.strokeStyle = "rgba(52,211,153,0.9)";
ctx.lineWidth = 2*dpr;
ctx.beginPath(); ctx.arc(rx, ry, 6*dpr, 0, Math.PI*2); ctx.stroke();
ctx.fillStyle = "rgba(52,211,153,0.9)";
ctx.beginPath(); ctx.arc(rx, ry, 3*dpr, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = "rgba(52,211,153,0.5)";
ctx.setLineDash([3*dpr,3*dpr]);
ctx.beginPath();
ctx.moveTo((sx+sx2)/2, (sy+sy2)/2);
ctx.lineTo(rx, ry);
ctx.stroke();
ctx.setLineDash([]);
}
} else if(it.kind === "door"){
const d = it.d;
const [sx,sy] = worldToScreen(d.x, d.y, canvas);
const [sx2,sy2] = worldToScreen(d.x+d.w, d.y+d.h, canvas);
if(d.kind === "shade"){
ctx.fillStyle = "rgba(129,110,190,0.20)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(129,110,190,0.80)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
} else {
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);
}
} else if(it.kind === "exit"){
const ex = it.ex;
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);
} else if(it.kind === "enemy"){
const e = it.e;
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;
if(w>0 && h>0){
const hx = e.x - w/2, 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);
}
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([]);
}
}
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);
} else if(it.kind === "npc"){
const n = it.n;
const ntype = n.type || "vendor";
const st = NPC_STYLES[ntype] || { body: "rgba(150,150,160,0.95)", border: "rgba(110,110,120,0.9)" };
const [sx1,sy1] = worldToScreen(n.x, n.y, canvas);
const [sx2,sy2] = worldToScreen(n.x+36, n.y+54, canvas);
ctx.fillStyle = st.body;
ctx.fillRect(sx1, sy1, sx2-sx1, sy2-sy1);
ctx.strokeStyle = st.border;
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx1, sy1, sx2-sx1, sy2-sy1);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText(ntype, sx1 + 10*dpr, sy1 - 4*dpr);
}
}
// 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);
}
// enemies are drawn in the layerable pass above (layer support)
// NPCs are drawn in the layerable pass above (layer support)
// props are drawn in the layerable pass above (layer support)
// 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==="npc"){
const n = room.npcs && room.npcs[sel.index];
if(n){
const [sx1,sy1] = worldToScreen(n.x,n.y,canvas);
const [sx2,sy2] = worldToScreen(n.x+36,n.y+54,canvas);
ctx.strokeRect(sx1, sy1, sx2-sx1, sy2-sy1);
}
} else if(sel.kind==="prop"){
const p = room.props && room.props[sel.index];
if(p){
const ptype = p.type || "light_source";
const hr = getPropHitRect(p);
if(hr){
const a = worldToScreen(hr.x, hr.y, canvas);
const b = worldToScreen(hr.x+hr.w, hr.y+hr.h, canvas);
ctx.strokeRect(a[0], a[1], b[0]-a[0], b[1]-a[1]);
} else {
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();
}
// link lines: draw LAST (on top of this canvas's content). Each canvas draws its
// own clipped half of the line, so it's always visible regardless of z-index.
drawLinkLinesForCanvas(ctx, canvas, isCompare);
}
// Draws the active-room->compare-room link lines on the given canvas, converting
// both endpoints into this canvas's local device coordinates (canvas clips the rest).
function drawLinkLinesForCanvas(ctx, canvas, isCompare){
if(!els.showLinksChk || !els.showLinksChk.checked) return;
if(!state.compareEnabled) return;
const srcWrap = getActiveRoom();
const dstWrap = getCompareRoom();
if(!srcWrap || !dstWrap || srcWrap === dstWrap) return;
const dpr = window.devicePixelRatio || 1;
const aRect = els.canvasA.getBoundingClientRect();
const bRect = els.canvasB.getBoundingClientRect();
const thisRect = (canvas === els.canvasA) ? aRect : bRect;
const thisLeft = thisRect.left * dpr, thisTop = thisRect.top * dpr;
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;
// End the line at the ENTRY point in the compare room (spawn / entry spawn),
// i.e. exit -> entry, not exit -> exit.
const sid = ex.spawn_id || null;
let pos = null;
if(sid === "__player_spawn__"){
pos = dstWrap.data.player_spawn || null;
} else if(sid){
pos = (dstWrap.data.entry_spawns && dstWrap.data.entry_spawns[sid]) ? dstWrap.data.entry_spawns[sid] : null;
}
// Only fall back to the raw spawn coordinate when the exit has NO spawn_id.
// A stale spawn_id (e.g. from a target room that doesn't define it) would
// point the line at an arbitrary spot, so skip those broken links instead.
if(!pos && !sid && Array.isArray(ex.spawn) && ex.spawn.length >= 2){
pos = ex.spawn;
}
if(!pos) continue;
// viewport device coords of both endpoints
const [sx,sy] = worldToScreen(ex.x + ex.w/2, ex.y + ex.h/2, els.canvasA);
const [tx,ty] = worldToScreen(pos[0], pos[1], els.canvasB);
const p1x = aRect.left*dpr + sx, p1y = aRect.top*dpr + sy;
const p2x = bRect.left*dpr + tx, p2y = bRect.top*dpr + ty;
// to this canvas's local device coords
const lx1 = p1x - thisLeft, ly1 = p1y - thisTop;
const lx2 = p2x - thisLeft, ly2 = p2y - thisTop;
ctx.save();
ctx.strokeStyle = "rgba(167,139,250,0.9)";
ctx.lineWidth = 2*dpr;
ctx.setLineDash([6*dpr, 6*dpr]);
ctx.beginPath();
ctx.moveTo(lx1, ly1);
ctx.lineTo(lx2, ly2);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
function render(){
drawRoom(ctxA, els.canvasA, getActiveRoom(), els.labelA, false);
if(state.compareEnabled){
drawRoom(ctxB, els.canvasB, getCompareRoom(), els.labelB, true);
}
requestAnimationFrame(render);
}
// Compatibility: older code paths call redraw(), but we run a continuous render loop.
function redraw(){
// no-op on purpose
}
// ---------- Hit Testing ----------
function hitTest(roomWrap, wx, wy, canvas){
if(!roomWrap) return null;
const room = roomWrap.data;
// exits, ability doors and enemies are resolved in the layerable pass below (layer-aware)
// 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};
}
}
// layerable objects (props, platforms, doors, exits, enemies, NPCs): pick the
// TOPMOST by layer, so an object in a higher layer wins over one behind it.
const vL = canvas ? getViewForCanvas(canvas) : {zoom:1};
const tolL = (8 / (vL.zoom||1));
const tol2L = tolL*tolL;
let best = null; // {key, kind, index, edge?}
const consider = (key, kind, index, edge)=>{
if(!best || key > best.key){
best = {key, kind, index, roomId: roomWrap.id, edge};
}
};
// props (category 0): house/diary/player use their shape, others a point marker
for(let i=(room.props||[]).length-1;i>=0;i--){
const p = room.props[i];
if(!p) continue;
const hr = getPropHitRect(p);
if(hr){
if(wx>=hr.x && wx<=hr.x+hr.w && wy>=hr.y && wy<=hr.y+hr.h){
consider((p.layer||0)*10 + 0, "prop", i, null);
}
} else {
const dx = wx - p.x, dy = wy - p.y;
if((dx*dx + dy*dy) <= 100){
consider((p.layer||0)*10 + 0, "prop", i, null);
}
}
}
// platforms (category 1) with edge detection for resize
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){
const edge = getResizeEdge(p, wx, wy, tol2L);
consider((p.layer||0)*10 + 1, "platform", i, edge);
}
}
// ability doors (category 2) with edge detection
for(let i=(room.ability_doors||[]).length-1;i>=0;i--){
const d=room.ability_doors[i];
if(d && wx>=d.x && wx<=d.x+d.w && wy>=d.y && wy<=d.y+d.h){
const edge = getResizeEdge(d, wx, wy, tol2L);
consider((d.layer||0)*10 + 2, "ability_door", i, edge);
}
}
// exits (category 3) with edge detection
for(let i=(room.exits||[]).length-1;i>=0;i--){
const ex=room.exits[i];
if(ex && wx>=ex.x && wx<=ex.x+ex.w && wy>=ex.y && wy<=ex.y+ex.h){
const edge = getResizeEdge(ex, wx, wy, tol2L);
consider((ex.layer||0)*10 + 3, "exit", i, edge);
}
}
// enemies (category 4)
for(let i=(room.enemies||[]).length-1;i>=0;i--){
const e=room.enemies[i];
if(e && Math.abs(wx-e.x)<10 && Math.abs(wy-e.y)<10){
consider((e.layer||0)*10 + 4, "enemy", i, null);
}
}
// npcs (category 5)
for(let i=(room.npcs||[]).length-1;i>=0;i--){
const n=room.npcs[i];
if(!n) continue;
if(wx>=n.x && wx<=n.x+36 && wy>=n.y && wy<=n.y+54){
consider((n.layer||0)*10 + 5, "npc", i, null);
}
}
if(best) return best;
return null;
}
// ---------- Editing ----------
function setTool(tool){
state.tool = tool;
document.querySelectorAll(".toolBtn").forEach(b=>{
const isActive = b.dataset.tool === tool;
b.classList.toggle("primary", isActive && tool!=="select");
b.classList.toggle("warn", isActive && tool==="select");
b.classList.toggle("active", isActive);
});
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="mini">layer (lower = back)</div><input id="pl" type="number" value="${p.layer||0}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type</div>
<select id="pg">
<option value="ground" ${p.group==="ground"||!p.group?"selected":""}>Ground</option>
<option value="floating" ${p.group==="floating"?"selected":""}>Floating</option>
<option value="hazard" ${p.group==="hazard"?"selected":""}>Hazard (red - damage)</option>
<option value="moving" ${p.group==="moving"?"selected":""}>Moving (path)</option>
<option value="trigger" ${p.group==="trigger"?"selected":""}>Trigger (name)</option>
</select>
</div>
${(p.group||"ground") === "trigger" ? `
<div style="margin-top:8px;">
<div class="mini">name</div>
<input id="ptname" type="text" value="${p.name||""}" placeholder="e.g. opens_bridge"/>
</div>
<div style="margin-top:8px;">
<div class="mini">unlock when quest accepted (id)</div>
<input id="ptquest" type="text" value="${p.quest||""}" placeholder="e.g. quest_first_kills"/>
</div>
<div style="margin-top:8px;">
<div class="mini">unlock when quest complete (id)</div>
<input id="ptqcomplete" type="text" value="${p.quest_complete||""}" placeholder="e.g. quest_first_kills"/>
</div>
<div style="margin-top:8px;">
<div class="mini">unlock when quest turned in (id)</div>
<input id="ptqturnedin" type="text" value="${p.quest_turned_in||""}" placeholder="e.g. quest_first_kills"/>
</div>
<div style="margin-top:8px;">
<label><input id="ptlockpass" type="checkbox" ${p.lock_when_pass?"checked":""}/> Appear once the player walks through (locks them in)</label>
</div>
<div style="margin-top:8px;">
<label><input id="ptminibossdead" type="checkbox" ${p.open_on_miniboss_dead?"checked":""}/> Present until the room's miniboss is slain</label>
</div>` : ""}
${(p.group||"ground") === "hazard" ? `
<div style="margin-top:8px;">
<div class="mini">respawn point (hazard teleports here)</div>
<select id="hzrespawn">
<option value="">(none - room default)</option>
${Object.keys(room.entry_spawns||{}).map(k=>`<option value="${k}">Entry: ${k}</option>`).join("")}
</select>
</div>` : ""}
${(p.group||"ground") === "moving" ? `
<div style="margin-top:8px;">
<div class="mini">path (waypoints - click Add, then the canvas)</div>
<button id="addPathPt" class="primary" style="width:100%;">Add path point</button>
<div id="pathList" style="margin-top:6px; max-height:120px; overflow:auto;"></div>
</div>
<div style="margin-top:8px;">
<div class="mini">speed (px/s)</div>
<input id="ptspeed" type="number" value="${p.speed||120}"/>
</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="mini">layer (lower = back)</div><input id="exlayer" type="number" value="${ex.layer||0}"/></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="mini">layer (lower = back)</div><input id="adlayer" type="number" value="${d.layer||0}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type</div>
<select id="adkind">
<option value="ranged" ${(d.kind||"ranged")==="ranged"?"selected":""}>Ranged door (broken by the Ranged bolt)</option>
<option value="shade" ${d.kind==="shade"?"selected":""}>Shade door (passable in shade form, solid in light)</option>
</select>
</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 style="margin-top:8px;">
<div class="mini">layer (lower = back)</div>
<input id="enlayer" type="number" value="${e.layer||0}"/>
</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
`;
}
if(sel.kind==="npc"){
const n=room.npcs && room.npcs[sel.index];
if(!n) return "<div class='muted'>Selection missing.</div>";
return `
<div><b>NPC</b> #${sel.index}</div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">x</div><input id="npx" type="number" value="${n.x}"/></div>
<div><div class="mini">y</div><input id="npy" type="number" value="${n.y}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type / name</div>
<input id="npt" type="text" value="${n.type||"vendor"}"/>
</div>
<div class="muted" style="margin-top:6px;">Any name works (e.g. quest_giver_gwen). vendor / upgrader / necklace_vendor / combat_master have game behavior; custom names are inert until coded.</div>
<div style="margin-top:8px;">
<div class="mini">layer (lower = back)</div>
<input id="npl" type="number" value="${n.layer||0}"/>
</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);
const objId = p.object_id || p.objectId || "";
const hint = ({light_source:"Butterfly (charge regen 2x)", save_point:"Lantern (save point - press F to save)", butterfly_jar:"Butterfly-in-jar (collectible)", diary:"Boss diary (interact)", house:"Standalone house", player:"Player size reference (no in-game effect)", quest_giver:"Quest point (name it below)"})[p.type||"light_source"] || "";
const isQuest = (p.type||"") === "quest_giver";
return `
<div><b>Prop</b> #${sel.index}${hint ? ` <span class="mini">· ${hint}</span>` : ""}</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>
${isQuest ? `
<div style="margin-top:8px;">
<div class="mini">quest id (name)</div>
<input id="prqid" type="text" value="${p.quest_id||""}" placeholder="e.g. quest_save_the_well"/>
</div>` : ""}
<div style="margin-top:8px;">
<div class="mini">object id</div>
<input id="probjid" type="text" value="${objId}" placeholder="(e.g. flow_butterfly, lamp, flow_butterfly_jar)"/>
</div>
<div style="margin-top:8px;">
<div class="mini">radius</div>
<input id="prrad" type="number" value="${p.radius||prDefault}"/>
</div>
<div style="margin-top:8px;">
<div class="mini">layer (lower = back; houses use it)</div>
<input id="prlayer" type="number" value="${p.layer||0}"/>
</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 pl=q("pl"); if(pl) pl.addEventListener("change", ()=>pushAnd(()=>{ p.layer = Math.round(num(pl.value)); }));
const pg=q("pg"); if(pg) pg.addEventListener("change", ()=>pushAnd(()=>{
p.group = pg.value || "ground";
if(p.group === "moving" && !Array.isArray(p.path)) p.path = [[p.x, p.y]];
if(p.group !== "trigger" && p.name !== undefined && p.name !== "") delete p.name;
}));
const ptname=q("ptname"); if(ptname) ptname.addEventListener("change", ()=>pushAnd(()=>{
const v = ptname.value.trim();
if(v){ p.name = v; } else { delete p.name; }
}));
const ptquest=q("ptquest"); if(ptquest) ptquest.addEventListener("change", ()=>pushAnd(()=>{
const v = ptquest.value.trim();
if(v){ p.quest = v; } else { delete p.quest; }
}));
const ptqcomplete=q("ptqcomplete"); if(ptqcomplete) ptqcomplete.addEventListener("change", ()=>pushAnd(()=>{
const v = ptqcomplete.value.trim();
if(v){ p.quest_complete = v; } else { delete p.quest_complete; }
}));
const ptqturnedin=q("ptqturnedin"); if(ptqturnedin) ptqturnedin.addEventListener("change", ()=>pushAnd(()=>{
const v = ptqturnedin.value.trim();
if(v){ p.quest_turned_in = v; } else { delete p.quest_turned_in; }
}));
const ptlockpass=q("ptlockpass"); if(ptlockpass) ptlockpass.addEventListener("change", ()=>pushAnd(()=>{
if(ptlockpass.checked){ p.lock_when_pass = true; } else { delete p.lock_when_pass; }
}));
const ptminibossdead=q("ptminibossdead"); if(ptminibossdead) ptminibossdead.addEventListener("change", ()=>pushAnd(()=>{
if(ptminibossdead.checked){ p.open_on_miniboss_dead = true; } else { delete p.open_on_miniboss_dead; }
}));
const ptspeed=q("ptspeed"); if(ptspeed) ptspeed.addEventListener("change", ()=>pushAnd(()=>{ p.speed = Math.max(1, num(ptspeed.value)); }));
const hzrespawn=q("hzrespawn");
if(hzrespawn){
// preselect current respawn if it matches an entry spawn
let cur = "";
if(Array.isArray(p.respawn)){
const es = room.entry_spawns || {};
for(const k of Object.keys(es)){
const pos = es[k];
if(Array.isArray(pos) && pos.length>=2 && Math.round(pos[0])===Math.round(p.respawn[0]) && Math.round(pos[1])===Math.round(p.respawn[1])){
cur = k; break;
}
}
}
hzrespawn.value = cur;
hzrespawn.addEventListener("change", ()=>pushAnd(()=>{
const v = hzrespawn.value;
if(v === ""){ delete p.respawn; }
else {
const pos = (room.entry_spawns||{})[v];
if(Array.isArray(pos) && pos.length>=2) p.respawn = [Math.round(pos[0]), Math.round(pos[1])];
}
}));
}
const addPathPt=q("addPathPt"); if(addPathPt) addPathPt.addEventListener("click", ()=>{
state.waitingPathPt = { roomId: roomWrap.id, index: sel.index };
setStatus("Click on the canvas to add a path point to this moving platform.");
refreshProps();
});
const pathList=q("pathList");
if(pathList && Array.isArray(p.path)){
pathList.innerHTML = "";
p.path.forEach((pt, pi)=>{
const row = document.createElement("div");
row.style.cssText = "display:flex; align-items:center; gap:6px; margin-bottom:4px;";
row.innerHTML = `<span style="flex:1; font-size:12px;">${pi+1}. (${Math.round(pt[0])}, ${Math.round(pt[1])})</span>`;
const delBtn = document.createElement("button");
delBtn.textContent = "x";
delBtn.style.cssText = "padding:2px 8px;";
delBtn.addEventListener("click", ()=>pushAnd(()=>{
p.path.splice(pi,1);
}));
row.appendChild(delBtn);
pathList.appendChild(row);
});
if(state.waitingPathPt && state.waitingPathPt.index === sel.index){
const hint = document.createElement("div");
hint.className = "muted";
hint.textContent = "Click the canvas to add point " + (p.path.length + 1);
pathList.appendChild(hint);
}
}
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 exlayer=q("exlayer"); if(exlayer) exlayer.addEventListener("change", ()=>pushAnd(()=>{ ex.layer = Math.round(num(exlayer.value)); }));
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 adlayer=q("adlayer"); if(adlayer) adlayer.addEventListener("change", ()=>pushAnd(()=>{ d.layer = Math.round(num(adlayer.value)); }));
const adkind=q("adkind"); if(adkind) adkind.addEventListener("change", ()=>pushAnd(()=>{ d.kind = adkind.value === "shade" ? "shade" : "ranged"; }));
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"), enlayer=q("enlayer");
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"; }));
if(enlayer) enlayer.addEventListener("change", ()=>pushAnd(()=>{ e.layer = Math.round(num(enlayer.value)); }));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.enemies.splice(sel.index,1);
state.selection=null;
}));
} else if(sel.kind==="npc"){
const n = room.npcs && room.npcs[sel.index]; if(!n) return;
const npx=q("npx"), npy=q("npy"), npt=q("npt"), npl=q("npl");
if(npx) npx.addEventListener("change", ()=>pushAnd(()=>{ n.x=num(npx.value); }));
if(npy) npy.addEventListener("change", ()=>pushAnd(()=>{ n.y=num(npy.value); }));
if(npt) npt.addEventListener("change", ()=>pushAnd(()=>{ n.type=npt.value.trim()||"vendor"; }));
if(npl) npl.addEventListener("change", ()=>pushAnd(()=>{ n.layer = Math.round(num(npl.value)); }));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.npcs.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"), probjid=q("probjid"), prrad=q("prrad"), prlayer=q("prlayer"), prqid=q("prqid");
if(prx) prx.addEventListener("change", ()=>pushAnd(()=>{ p.x=num(prx.value); }));
if(pry) pry.addEventListener("change", ()=>pushAnd(()=>{ p.y=num(pry.value); }));
if(prlayer) prlayer.addEventListener("change", ()=>pushAnd(()=>{ p.layer = Math.round(num(prlayer.value)); }));
if(prqid) prqid.addEventListener("change", ()=>pushAnd(()=>{
const v = prqid.value.trim();
if(v){ p.quest_id = v; } else { delete p.quest_id; }
}));
if(probjid) probjid.addEventListener("change", ()=>pushAnd(()=>{
const v = probjid.value.trim();
if(v){ p.object_id = v; } else { delete p.object_id; }
}));
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;
// Adding a path point to a selected moving platform?
if(state.waitingPathPt){
if(ev.button === 0){
ev.preventDefault();
const wp = state.waitingPathPt;
const wrap = state.rooms.get(wp.roomId);
const plat = wrap && wrap.data.platforms && wrap.data.platforms[wp.index];
if(plat){
if(!Array.isArray(plat.path)) plat.path = [[plat.x, plat.y]];
plat.path.push(snapPt(wx, wy));
}
state.waitingPathPt = null;
refreshUI();
setStatus("Path point added.");
return;
}
}
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, group: state.platformGroup || "ground"};
if(p.group === "moving") p.path = [[sx1, sy1]];
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 === "npc_spawn"){
pushHistory(roomWrap.id);
const [sx1,sy1]=snapPt(wx,wy);
room.npcs = room.npcs || [];
room.npcs.push({x: sx1, y: sy1, type: (state.npcPlaceType || "vendor")});
state.selection = {kind:"npc", index:room.npcs.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==="npc"){
const n=room.npcs && room.npcs[sel.index]; if(n) base.n = {x:n.x,y:n.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==="npc"){
const n=room.npcs && room.npcs[sel.index];
if(n && state.dragging.base.n){
n.x = snap(state.dragging.base.n.x + offX);
n.y = snap(state.dragging.base.n.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==="npc") return {kind:"npc", data: deepClone(room.npcs[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==="npc") room.npcs.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==="npc"){
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
room.npcs = room.npcs || [];
room.npcs.push(c.data);
state.selection = {kind:"npc", index: room.npcs.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==="npc") room.npcs.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 (layer appended as 5th element when non-zero;
// moving/trigger platforms serialize as objects to keep their path/name/speed)
const platforms = {};
for(const p of (d.platforms || [])){
const group = p.group || "ground";
if(!platforms[group]) platforms[group] = [];
if(group === "moving" || group === "trigger" || group === "hazard"){
const obj = { x: p.x, y: p.y, w: p.w, h: p.h };
if(p.layer) obj.layer = p.layer;
if(group === "moving"){
if(Array.isArray(p.path)) obj.path = p.path;
if(p.speed !== undefined) obj.speed = p.speed;
}
if(p.name) obj.name = p.name;
if(p.quest) obj.quest = p.quest;
if(p.quest_complete) obj.quest_complete = p.quest_complete;
if(p.quest_turned_in) obj.quest_turned_in = p.quest_turned_in;
if(p.lock_when_pass) obj.lock_when_pass = true;
if(p.open_on_miniboss_dead) obj.open_on_miniboss_dead = true;
if(Array.isArray(p.respawn)) obj.respawn = p.respawn;
platforms[group].push(obj);
} else {
const rect = [p.x, p.y, p.w, p.h];
if(p.layer) rect.push(p.layer);
platforms[group].push(rect);
}
}
// enemies back to {type, pos:[x,y]} (+ layer when set)
const enemies = (d.enemies || []).map(e => {
const out = { type: e.type || "enemy", pos: [e.x, e.y] };
if(e.layer) out.layer = e.layer;
return out;
});
// npcs back to {type, pos:[x,y]} (+ layer when set)
const npcs = (d.npcs || []).map(n => {
const out = { type: n.type || "vendor", pos: [n.x, n.y] };
if(n.layer) out.layer = n.layer;
return out;
});
// exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint} (+ layer when set)
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;
if(ex.layer) out.layer = ex.layer;
return out;
});
// ability doors back to {rect:[x,y,w,h]} (+ layer/kind when set)
const ability_doors = (d.ability_doors || []).map(ad => {
const out = { rect: [ad.x, ad.y, ad.w, ad.h] };
if(ad.layer) out.layer = ad.layer;
if(ad.kind === "shade") out.kind = "shade";
return out;
});
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;
if(p.layer) out.layer = p.layer;
if(p.quest_id) out.quest_id = p.quest_id;
if(Array.isArray(p.coins)) out.coins = p.coins;
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,
npcs,
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){
// Keep the built-in defaults so the core assets are always available; a loaded
// objects.json overrides individual definitions (same id) and adds new ones.
seedDefaultObjectCatalog();
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}`);
});
}
if(els.npcTypeSelect){
els.npcTypeSelect.value = state.npcPlaceType || "vendor";
els.npcTypeSelect.addEventListener("change", ()=>{
state.npcPlaceType = els.npcTypeSelect.value || "vendor";
setStatus(`NPC placement type: ${state.npcPlaceType}`);
});
}
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.snapChk){
els.snapChk.checked = !!state.snapEnabled;
els.snapChk.addEventListener("change", ()=>{
state.snapEnabled = !!els.snapChk.checked;
setStatus(state.snapEnabled ? "Grid snap ON" : "Grid snap OFF (free movement)");
});
}
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;
}
// Tool switching shortcuts (match the toolbar button titles).
const toolByKey = { v:"select", p:"platform", e:"enemy_spawn", n:"npc_spawn", o:"object_spawn" };
const toolKey = toolByKey[e.key.toLowerCase()];
if(toolKey){
e.preventDefault();
setTool(toolKey);
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>