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