4048 lines
156 KiB
HTML
4048 lines
156 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; overflow:hidden; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;}
|
||
.wrap{display:grid; grid-template-columns: 360px 1fr; height:100vh; min-height:0; overflow:hidden;}
|
||
.left{background:var(--panel); border-right:1px solid var(--border); padding:12px; overflow-y:auto; height:100vh; box-sizing:border-box;}
|
||
.section{background:rgba(255,255,255,0.03); border:1px solid var(--border); border-radius:12px; padding:10px; margin-bottom:12px;}
|
||
.section h3{margin:0 0 8px 0; font-size:14px; color:var(--fg); display:flex; align-items:center; gap:8px;}
|
||
.row{display:flex; gap:8px; flex-wrap:wrap; align-items:center;}
|
||
button, select, input[type="text"], input[type="number"]{
|
||
background:var(--panel2); color:var(--fg); border:1px solid var(--border); border-radius:10px;
|
||
padding:8px 10px; font-size:13px;
|
||
}
|
||
button{cursor:pointer;}
|
||
button:hover{border-color:#334155;}
|
||
button.primary{border-color:rgba(96,165,250,0.7); box-shadow:0 0 0 1px rgba(96,165,250,0.15) inset;}
|
||
button.good{border-color:rgba(52,211,153,0.7); box-shadow:0 0 0 1px rgba(52,211,153,0.12) inset;}
|
||
button.warn{border-color:rgba(245,158,11,0.7); box-shadow:0 0 0 1px rgba(245,158,11,0.12) inset;}
|
||
button.danger{border-color:rgba(239,68,68,0.7); box-shadow:0 0 0 1px rgba(239,68,68,0.12) inset;}
|
||
.muted{color:var(--muted); font-size:12px; line-height:1.35;}
|
||
.roomsList{display:flex; flex-direction:column; gap:6px; max-height:220px; overflow:auto; padding-right:2px;}
|
||
.roomItem{display:flex; gap:6px; align-items:center;}
|
||
.roomBtn{flex:1; display:flex; justify-content:space-between; align-items:center;}
|
||
.badge{font-size:11px; padding:2px 6px; border-radius:999px; border:1px solid var(--border); color:var(--muted);}
|
||
.badge.dirty{border-color:rgba(245,158,11,0.6); color:#fbbf24;}
|
||
.kbd{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; padding:2px 6px; border:1px solid var(--border); border-radius:6px; color:var(--muted);}
|
||
.right{position:relative; display:flex; flex-direction:column; min-width:0; height:100%; overflow:hidden;}
|
||
/* Floating tool palette pinned to the right edge of the canvas area */
|
||
#toolbar{
|
||
position:absolute; right:10px; top:70px; z-index:60;
|
||
display:flex; flex-direction:column; gap:6px; padding:8px;
|
||
background:rgba(17,24,39,0.92); border:1px solid var(--border); border-radius:12px;
|
||
box-shadow:0 4px 18px rgba(0,0,0,0.45);
|
||
}
|
||
.toolIconBtn{
|
||
width:36px; height:36px; padding:0; margin:0;
|
||
display:flex; align-items:center; justify-content:center;
|
||
border-radius:8px; border:1px solid var(--border); background:var(--panel2);
|
||
cursor:pointer; color:var(--muted); transition:color .12s, border-color .12s, background .12s;
|
||
}
|
||
.toolIconBtn:hover{ color:var(--fg); border-color:#334155; }
|
||
.toolIconBtn svg{ width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:2; stroke-linejoin:round; stroke-linecap:round; }
|
||
.toolIconBtn.active, .toolIconBtn.primary, .toolIconBtn.warn{
|
||
color:#fff; border-color:rgba(96,165,250,0.8);
|
||
background:rgba(96,165,250,0.22); box-shadow:0 0 0 1px rgba(96,165,250,0.25) inset;
|
||
}
|
||
.topbar{display:flex; gap:10px; align-items:center; padding:10px 12px; border-bottom:1px solid var(--border); background:rgba(255,255,255,0.02);}
|
||
/* IMPORTANT: force the grid to actually take height inside the flex column */
|
||
.canvasRow{position:relative; flex:1; min-height:0; height:100%; display:grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; gap:1px; background:var(--border);}
|
||
.canvasWrap{position:relative; background:#05070a; min-height:0;}
|
||
canvas{width:100%; height:100%; display:block;}
|
||
.overlayLabel{
|
||
position:absolute; top:10px; left:10px; padding:6px 10px; border-radius:10px;
|
||
background:rgba(17,24,39,0.7); border:1px solid var(--border); font-size:12px; color:var(--fg);
|
||
backdrop-filter: blur(6px);
|
||
}
|
||
.overlayLabel small{color:var(--muted);}
|
||
.status{margin-left:auto; color:var(--muted); font-size:12px;}
|
||
.hr{height:1px; background:var(--border); margin:8px 0;}
|
||
.grid2{display:grid; grid-template-columns: 1fr 1fr; gap:8px;}
|
||
.mini{font-size:12px; color:var(--muted);}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="left">
|
||
|
||
<div class="section">
|
||
<h3>Rooms</h3>
|
||
<div class="row">
|
||
<input id="fileInput" type="file" accept=".json,application/json" multiple />
|
||
<button id="newRoomBtn" class="primary">New Room</button>
|
||
</div>
|
||
<div class="muted" style="margin-top:6px;">
|
||
Load multiple room JSONs here. Click a room to edit it. Linking lets you pick an exit in one room and a spawn in another.
|
||
</div>
|
||
<div class="hr"></div>
|
||
<div class="roomsList" id="roomsList"></div>
|
||
<div class="hr"></div>
|
||
<div class="row">
|
||
<button id="downloadActiveBtn" class="good">Download Active</button>
|
||
<button id="downloadCheckedBtn">Download Checked</button>
|
||
<button id="downloadAllBtn">Download Modified</button>
|
||
</div>
|
||
<div class="muted" style="margin-top:6px;">
|
||
Downloads are per-room. Checked download saves one file per checked room; Download Modified saves every room you've changed.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Tools</h3>
|
||
<div class="muted">
|
||
Tools are on the <b>icon toolbar</b> at the right edge of the canvas.
|
||
</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<label class="muted" style="display:flex; align-items:center; gap:8px;">
|
||
<input type="checkbox" id="snapChk" checked/>
|
||
Grid snap (20px) on placement / drag
|
||
</label>
|
||
</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<label class="muted" style="flex:1;">New platform type:</label>
|
||
<select id="platformGroup" style="flex:1; min-width:120px;">
|
||
<option value="ground">Ground</option>
|
||
<option value="floating">Floating</option>
|
||
<option value="hazard">Hazard (red - damage)</option>
|
||
<option value="moving">Moving (path)</option>
|
||
<option value="trigger">Trigger (name)</option>
|
||
<option value="dissolve">Dissolve (timed)</option>
|
||
</select>
|
||
</div>
|
||
<div class="muted" style="margin-top:8px;">
|
||
<b>Layers:</b> every object (platforms, houses, NPCs, enemies, props, doors, exits) has a <b>layer</b> in its Properties — lower = further back, higher = further forward. The canvas and game draw back-to-front by (layer, category), and the select tool respects layers.
|
||
</div>
|
||
<div class="muted" style="margin-top:8px;">
|
||
Shortcuts: <span class="kbd">Ctrl</span>+<span class="kbd">Z</span> undo,
|
||
<span class="kbd">Ctrl</span>+<span class="kbd">Y</span> redo,
|
||
<span class="kbd">Ctrl</span>+<span class="kbd">C</span>/<span class="kbd">V</span> copy/paste,
|
||
<span class="kbd">Del</span> delete, <span class="kbd">Ctrl</span>+<span class="kbd">D</span> duplicate.
|
||
</div>
|
||
|
||
<div class="hr"></div>
|
||
<h3 style="margin-top:10px;">Enemy Types</h3>
|
||
<div class="muted">Load an enemy types JSON (keyed by enemy id). Built-ins (enemy/miniboss/boss) are always available; importing overrides them.</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<input id="enemyTypesInput" type="file" accept=".json,application/json" />
|
||
</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<select id="enemyTypeSelect" style="flex:1; min-width:200px;"></select>
|
||
</div>
|
||
<label class="muted" style="display:flex; align-items:center; gap:8px; margin-top:8px;">
|
||
<input type="checkbox" id="showAggroChk"/>
|
||
Draw enemy aggro ranges
|
||
</label>
|
||
<div class="hr"></div>
|
||
<h3 style="margin-top:10px;">NPC Types</h3>
|
||
<div class="muted">Pick which NPC the <b>NPC Spawn</b> tool places. You can also change an NPC's type in Properties after placing it.</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<select id="npcTypeSelect" style="flex:1; min-width:200px;">
|
||
<option value="vendor">vendor</option>
|
||
<option value="upgrader">upgrader</option>
|
||
<option value="necklace_vendor">necklace_vendor</option>
|
||
<option value="combat_master">combat_master</option>
|
||
</select>
|
||
</div>
|
||
<div class="hr"></div>
|
||
<h3 style="margin-top:10px;">Objects Catalog</h3>
|
||
<div class="muted">Load objects.json to get default properties. Built-ins are always available: <b>flow_butterfly</b> (light), <b>lamp</b> (save point), <b>flow_butterfly_jar</b> (collectible), <b>house</b>, <b>diary</b>, <b>player</b> (size reference).</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<input id="objectsInput" type="file" accept=".json,application/json" />
|
||
</div>
|
||
<div class="muted" id="objectsStatus" style="margin-top:6px;">No objects loaded.</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<select id="objectSelect" style="flex:1; min-width:200px;"></select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Multi-Room Linking</h3>
|
||
<div class="muted">
|
||
1) Select an <b>Exit</b> in the active room<br/>
|
||
2) Choose target room + spawn (entry spawn)<br/>
|
||
3) Click <b>Link</b>
|
||
</div>
|
||
<div class="hr"></div>
|
||
<div class="row" style="margin-bottom:8px;">
|
||
<button id="toggleLinkModeBtn" class="warn">Enable Link Mode</button>
|
||
</div> <div class="row" style="margin-bottom:8px;">
|
||
<label class="muted" style="display:flex; align-items:center; gap:8px;">
|
||
<input type="checkbox" id="showLinksChk"/>
|
||
Draw link lines
|
||
</label>
|
||
</div>
|
||
|
||
<div class="muted" style="margin-top:-2px;">
|
||
<b>Link Mode:</b> select an Exit in the active room, then click an entry spawn marker in the compare canvas to auto-link.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Compare View</h3>
|
||
<div class="row">
|
||
<button id="toggleCompareBtn">Enable Compare</button>
|
||
<select id="compareRoomSelect"></select>
|
||
</div>
|
||
<div class="muted" style="margin-top:8px;">
|
||
Compare shows a second room canvas (read-only) to visually match door positions.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Properties</h3>
|
||
<div id="props" class="muted">Select something to edit properties.</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="right">
|
||
<div class="topbar">
|
||
<div id="activeRoomLabel">Active: (none)</div>
|
||
<div class="status" id="status">—</div>
|
||
</div>
|
||
|
||
<div class="canvasRow" id="canvasRow" style="grid-template-columns: 1fr;">
|
||
<div class="canvasWrap">
|
||
<div class="overlayLabel" id="labelA">Active room</div>
|
||
<canvas id="canvasA"></canvas>
|
||
</div>
|
||
<div class="canvasWrap" id="compareWrap" style="display:none;">
|
||
<div class="overlayLabel" id="labelB">Compare room</div>
|
||
<canvas id="canvasB"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Floating tool palette (right edge) -->
|
||
<div id="toolbar" title="Tools">
|
||
<button class="toolBtn toolIconBtn active" data-tool="select" title="Select / Move (V)">
|
||
<svg viewBox="0 0 24 24"><path d="M5 3 L5 17 L9.5 14 L12 20 L14.5 19 L12 13 L17 12 Z"/></svg>
|
||
</button>
|
||
<button class="toolBtn toolIconBtn" data-tool="platform" title="Platform (P)">
|
||
<svg viewBox="0 0 24 24"><rect x="3" y="8" width="18" height="9" rx="1"/></svg>
|
||
</button>
|
||
<button class="toolBtn toolIconBtn" data-tool="enemy_spawn" title="Enemy Spawn (E)">
|
||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"/><path d="M8 12 H16 M12 8 V16"/></svg>
|
||
</button>
|
||
<button class="toolBtn toolIconBtn" data-tool="npc_spawn" title="NPC Spawn (N)">
|
||
<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="3"/><path d="M6 20 C6 16 8.5 14 12 14 C15.5 14 18 16 18 20 Z"/></svg>
|
||
</button>
|
||
<button class="toolBtn toolIconBtn" data-tool="player_spawn" title="Player Spawn">
|
||
<svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3"/><path d="M5 20 C5 16 8 14 12 14 C16 14 19 16 19 20 Z"/><path d="M12 2 L14 5 M12 2 L10 5 M12 2 V5"/></svg>
|
||
</button>
|
||
<button class="toolBtn toolIconBtn" data-tool="entry_spawn" title="Entry Spawn">
|
||
<svg viewBox="0 0 24 24"><path d="M6 4 L18 4 L15 9 L18 14 L6 14 Z"/><path d="M6 4 V20"/></svg>
|
||
</button>
|
||
<button class="toolBtn toolIconBtn" data-tool="exit" title="Exit / Doorway">
|
||
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="1"/><path d="M10 9 L14 12 L10 15"/><path d="M14 12 H6"/></svg>
|
||
</button>
|
||
<button class="toolBtn toolIconBtn" data-tool="ability_door" title="Ability Door">
|
||
<svg viewBox="0 0 24 24"><rect x="6" y="3" width="12" height="18" rx="1"/><circle cx="15" cy="12" r="1.5"/></svg>
|
||
</button>
|
||
<button class="toolBtn toolIconBtn" data-tool="object_spawn" title="Object Spawn (O)">
|
||
<svg viewBox="0 0 24 24"><path d="M12 3 L21 12 L12 21 L3 12 Z"/><circle cx="12" cy="12" r="2"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(() => {
|
||
// ---------- Utilities ----------
|
||
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
|
||
const deepClone = (obj)=>JSON.parse(JSON.stringify(obj));
|
||
const nowId = ()=>Math.random().toString(16).slice(2) + Date.now().toString(16);
|
||
const objectCatalog = { byType: {}, byId: {} };
|
||
|
||
// Built-in object catalog so the new assets (butterfly light, lantern save point,
|
||
// butterfly-in-jar collectible) are always placeable even before loading objects.json.
|
||
const DEFAULT_OBJECT_CATALOG = {
|
||
flow_butterfly: { type: "light_source", charge_regen_range: 180 },
|
||
lamp: { type: "save_point", charge_regen_range: 90 },
|
||
flow_butterfly_jar: { type: "butterfly_jar" },
|
||
house: { type: "house" },
|
||
diary: { type: "diary" },
|
||
player: { type: "player" },
|
||
quest_point: { type: "quest_giver" },
|
||
};
|
||
// Type fallbacks used when no object def provides a radius.
|
||
const TYPE_DEFAULT_RADII = { light_source: 180, save_point: 90 };
|
||
|
||
// Built-in enemy types (mirrors data/enemies/enemy_types.json) so the editor works
|
||
// immediately; importing an enemy types JSON overrides these.
|
||
const DEFAULT_ENEMY_TYPES = {
|
||
enemy: { rect: [40, 60], health: 3, speed: 2.2, air_speed: 2.0, detection_range: 240, contact_damage: 1, drop_profile: "normal" },
|
||
miniboss: { rect: [70, 95], health: 18, speed: 1.8, air_speed: 1.6, detection_range: 360, contact_damage: 2, drop_profile: "miniboss" },
|
||
boss: {
|
||
rect: [110, 120], health: 10, speed: 1.6, air_speed: 1.4, detection_range: 500, contact_damage: 3, drop_profile: "boss", behavior: "shockwave",
|
||
charger: {
|
||
retreat_dist: 150, retreat_jump_vel: -14.0, retreat_speed_mult: 1.2, charge_speed_mult: 4.0,
|
||
charge_duration: 1.1, dash_min_dist: 160, dash_max_dist: 420, dash_speed_mult: 4.2,
|
||
dash_duration: 0.7, attack_cooldown: 1.8, above_y: 40
|
||
}
|
||
},
|
||
boss_flyer: {
|
||
rect: [120, 90], health: 26, speed: 3.0, air_speed: 3.0, detection_range: 620, contact_damage: 3, drop_profile: "boss", behavior: "flyer",
|
||
flyer: {
|
||
flight_height: 220, bob_amplitude: 20, bob_speed: 2.2, hover_speed: 2.6,
|
||
slam_cd: 6.0, slam_dive_vel: 16.0, ground_time: 2.0,
|
||
summon_cd: 8.0, summon_max: 3, summon_type: "boss_minion", summon_life: 6.0, summon_heal: 4, summon_damage: 3,
|
||
aoe_cd: 9.0, aoe_radius: 300, aoe_damage: 1, aoe_duration: 0.9,
|
||
shot_cd: 5.0, shot_damage: 5, shot_speed: 300, shot_lifetime: 2.2, shot_homing: 1.8
|
||
}
|
||
},
|
||
boss_minion: { rect: [34, 50], health: 4, speed: 2.4, air_speed: 2.2, detection_range: 320, contact_damage: 1, drop_profile: "normal" },
|
||
};
|
||
|
||
function seedDefaultObjectCatalog(){
|
||
objectCatalog.byType = {};
|
||
objectCatalog.byId = {};
|
||
for(const [id, def] of Object.entries(DEFAULT_OBJECT_CATALOG)){
|
||
objectCatalog.byId[id] = def;
|
||
if(def.type) objectCatalog.byType[def.type] = def;
|
||
}
|
||
}
|
||
seedDefaultObjectCatalog();
|
||
|
||
const snap = (v)=> state.snapEnabled ? Math.round(v / state.gridSize) * state.gridSize : Math.round(v);
|
||
const snapPt = (x,y)=> [snap(x), snap(y)];
|
||
|
||
function downloadText(filename, text){
|
||
const blob = new Blob([text], {type:"application/json"});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(()=>URL.revokeObjectURL(url), 1000);
|
||
}
|
||
|
||
function getObjectDefByType(type){
|
||
if(!type) return null;
|
||
return objectCatalog.byType[type] || null;
|
||
}
|
||
|
||
function getObjectDefById(id){
|
||
if(!id) return null;
|
||
return objectCatalog.byId[id] || null;
|
||
}
|
||
|
||
function getDefaultRadiusForType(type, objectId){
|
||
const def = getObjectDefById(objectId) || getObjectDefByType(type);
|
||
const val = def && Number.isFinite(Number(def.charge_regen_range)) ? Number(def.charge_regen_range) : 0;
|
||
if(val > 0) return val;
|
||
return TYPE_DEFAULT_RADII[type] || 180;
|
||
}
|
||
|
||
function roomTemplate(name="room_new"){
|
||
return {
|
||
name,
|
||
room_w: 1600,
|
||
room_h: 900,
|
||
player_spawn: [100, 300],
|
||
entry_spawns: {}, // {spawn_id: [x,y]}
|
||
platforms: [],
|
||
enemies: [],
|
||
npcs: [],
|
||
exits: [],
|
||
ability_doors: [],
|
||
props: []
|
||
};
|
||
}
|
||
|
||
function normPlatformEntry(rect, group){
|
||
if(Array.isArray(rect)){
|
||
return { x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group, layer: rect.length>=5 ? (+rect[4]||0) : 0 };
|
||
}
|
||
if(rect && typeof rect === "object"){
|
||
const out = { x:+rect.x||0, y:+rect.y||0, w:+rect.w||0, h:+rect.h||0, group: rect.group || group || "ground", layer: +(rect.layer||0) };
|
||
if(Array.isArray(rect.path)) out.path = deepClone(rect.path);
|
||
if(rect.speed !== undefined) out.speed = +rect.speed;
|
||
if(rect.name) out.name = String(rect.name);
|
||
if(rect.quest) out.quest = String(rect.quest);
|
||
if(rect.quest_complete) out.quest_complete = String(rect.quest_complete);
|
||
if(rect.quest_turned_in) out.quest_turned_in = String(rect.quest_turned_in);
|
||
if(rect.lock_when_pass) out.lock_when_pass = true;
|
||
if(rect.open_on_miniboss_dead) out.open_on_miniboss_dead = true;
|
||
if(rect.open_on_boss_dead) out.open_on_boss_dead = true;
|
||
if(Array.isArray(rect.respawn)) out.respawn = deepClone(rect.respawn);
|
||
if(rect.stand_time !== undefined) out.stand_time = +rect.stand_time;
|
||
if(rect.dissolve_time !== undefined) out.dissolve_time = +rect.dissolve_time;
|
||
return out;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeRoom(json, fallbackName, filename){
|
||
// Supports both:
|
||
// A) editor-native rooms (name, platforms:[{x,y,w,h}], exits:[{x,y,w,h}...])
|
||
// B) your game rooms (meta, platforms:{ground:[[x,y,w,h],...], ...}, exits:[{rect:[...]}], enemies:[{pos:[x,y]}])
|
||
const src = json || {};
|
||
const r = {};
|
||
|
||
// ----- format detection -----
|
||
const isGameFormat = (src && typeof src === "object" && src.meta && (src.platforms && !Array.isArray(src.platforms)));
|
||
r._format = isGameFormat ? "game_v1" : "editor_v1";
|
||
|
||
// ----- naming / meta -----
|
||
if(isGameFormat){
|
||
r.meta = deepClone(src.meta || {});
|
||
const id = (r.meta && r.meta.id) ? r.meta.id : (fallbackName || "room");
|
||
// internally we use r.name as the room id (so exits use stable ids)
|
||
r.name = id;
|
||
// keep display name too
|
||
r.display_name = (r.meta && r.meta.name) ? r.meta.name : id;
|
||
r.bounds = deepClone(src.bounds || {});
|
||
} else {
|
||
r.name = src.name || fallbackName || "room";
|
||
r.display_name = src.display_name || r.name;
|
||
r.meta = deepClone(src.meta || { id: r.name, name: r.display_name });
|
||
r.bounds = deepClone(src.bounds || {});
|
||
}
|
||
|
||
// ----- spawns -----
|
||
r.player_spawn = Array.isArray(src.player_spawn) ? [Number(src.player_spawn[0])||0, Number(src.player_spawn[1])||0] : [100,300];
|
||
r.entry_spawns = (src.entry_spawns && typeof src.entry_spawns === "object") ? deepClone(src.entry_spawns) : {};
|
||
|
||
// ----- platforms -----
|
||
r.platforms = [];
|
||
if(isGameFormat){
|
||
const platObj = (src.platforms && typeof src.platforms === "object") ? src.platforms : {};
|
||
for(const group of Object.keys(platObj)){
|
||
const arr = platObj[group];
|
||
if(!Array.isArray(arr)) continue;
|
||
for(const rect of arr){
|
||
const np = normPlatformEntry(rect, group);
|
||
if(np) r.platforms.push(np);
|
||
}
|
||
}
|
||
// if nothing came through, try also accepting editor-native arrays inside game rooms
|
||
if(Array.isArray(src.platforms)){
|
||
for(const p of src.platforms){
|
||
const np = normPlatformEntry(p, p && p.group ? p.group : "ground");
|
||
if(np) r.platforms.push(np);
|
||
}
|
||
}
|
||
} else {
|
||
// editor-native
|
||
if(Array.isArray(src.platforms)){
|
||
for(const p of src.platforms){
|
||
const np = normPlatformEntry(p, p && p.group ? p.group : "ground");
|
||
if(np) r.platforms.push(np);
|
||
}
|
||
} else if(src.platforms && typeof src.platforms === "object"){
|
||
// allow grouped platforms even in editor rooms
|
||
for(const group of Object.keys(src.platforms)){
|
||
const arr = src.platforms[group];
|
||
if(!Array.isArray(arr)) continue;
|
||
for(const rect of arr){
|
||
const np = normPlatformEntry(rect, group);
|
||
if(np) r.platforms.push(np);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----- enemies -----
|
||
r.enemies = [];
|
||
if(Array.isArray(src.enemies)){
|
||
for(const e of src.enemies){
|
||
if(e && typeof e === "object"){
|
||
if(Array.isArray(e.pos)){
|
||
r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy", layer: +(e.layer||0), behavior: e.behavior || "" });
|
||
} else if(typeof e.x === "number" || typeof e.y === "number"){
|
||
r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy", layer: +(e.layer||0), behavior: e.behavior || "" });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----- npcs -----
|
||
r.npcs = [];
|
||
if(Array.isArray(src.npcs)){
|
||
for(const n of src.npcs){
|
||
if(n && typeof n === "object"){
|
||
if(Array.isArray(n.pos)){
|
||
r.npcs.push({ x:+n.pos[0]||0, y:+n.pos[1]||0, type: n.type || "vendor", layer: +(n.layer||0) });
|
||
} else if(typeof n.x === "number" || typeof n.y === "number"){
|
||
r.npcs.push({ x:+n.x||0, y:+n.y||0, type: n.type || "vendor", layer: +(n.layer||0) });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----- props -----
|
||
r.props = [];
|
||
if(Array.isArray(src.props)){
|
||
for(const p of src.props){
|
||
if(!p || typeof p !== "object") continue;
|
||
let x = 0, y = 0;
|
||
if(Array.isArray(p.pos)){
|
||
x = +p.pos[0] || 0; y = +p.pos[1] || 0;
|
||
} else {
|
||
x = +p.x || 0; y = +p.y || 0;
|
||
}
|
||
const type = p.type || p.kind || "light_source";
|
||
const object_id = p.object_id || p.objectId || null;
|
||
const radius = +p.radius || getDefaultRadiusForType(type, object_id);
|
||
r.props.push({ x, y, type, radius, object_id, layer: +(p.layer||0), quest_id: p.quest_id || p.questId || "", coins: Array.isArray(p.coins) ? deepClone(p.coins) : undefined });
|
||
}
|
||
}
|
||
|
||
// ----- exits -----
|
||
r.exits = [];
|
||
if(Array.isArray(src.exits)){
|
||
for(const ex of src.exits){
|
||
if(!ex || typeof ex !== "object") continue;
|
||
if(Array.isArray(ex.rect)){
|
||
r.exits.push({
|
||
x:+ex.rect[0]||0, y:+ex.rect[1]||0, w:+ex.rect[2]||0, h:+ex.rect[3]||0,
|
||
to: ex.to || "",
|
||
spawn_id: ex.spawn_id || "",
|
||
spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null,
|
||
hint: ex.hint || "",
|
||
layer: +(ex.layer||0)
|
||
});
|
||
} else {
|
||
// editor-native exit
|
||
r.exits.push({
|
||
x:+ex.x||0, y:+ex.y||0, w:+ex.w||0, h:+ex.h||0,
|
||
to: ex.to || "",
|
||
spawn_id: ex.spawn_id || "",
|
||
spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null,
|
||
hint: ex.hint || "",
|
||
layer: +(ex.layer||0)
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----- ability doors -----
|
||
r.ability_doors = [];
|
||
if(Array.isArray(src.ability_doors)){
|
||
for(const d of src.ability_doors){
|
||
if(!d) continue;
|
||
const kind = (typeof d === "object" && d.kind === "shade") ? "shade" : "ranged";
|
||
if(Array.isArray(d) && d.length >= 4){
|
||
r.ability_doors.push({ x:+d[0]||0, y:+d[1]||0, w:+d[2]||0, h:+d[3]||0, layer: d.length>=5 ? (+d[4]||0) : 0, kind });
|
||
} else if(typeof d === "object"){
|
||
if(Array.isArray(d.rect)){
|
||
r.ability_doors.push({ x:+d.rect[0]||0, y:+d.rect[1]||0, w:+d.rect[2]||0, h:+d.rect[3]||0, layer: +(d.layer||0), kind });
|
||
} else {
|
||
r.ability_doors.push({ x:+d.x||0, y:+d.y||0, w:+d.w||0, h:+d.h||0, layer: +(d.layer||0), kind });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----- keep other top-level fields (future-proof) -----
|
||
r._extras = {};
|
||
if(src && typeof src === "object"){
|
||
const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","npcs","exits","ability_doors","entry_spawns","props","name","display_name","room_w","room_h","mist"]);
|
||
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;
|
||
r.mist = !!(src && src.mist);
|
||
|
||
return r;
|
||
}
|
||
|
||
// ---------- App State ----------
|
||
const state = {
|
||
rooms: new Map(), // id -> {id, filename, data, dirty, history, future, checked}
|
||
activeId: null,
|
||
compareId: null,
|
||
compareEnabled: false,
|
||
linkMode: false,
|
||
|
||
tool: "select",
|
||
platformGroup: "ground",
|
||
gridSize: 20,
|
||
snapEnabled: true,
|
||
selection: null, // {kind, index, roomId}
|
||
dragging: null,
|
||
|
||
// per-canvas cameras (so compare can pan/zoom independently)
|
||
viewA: { zoom: 1.0, panX: 0, panY: 0 },
|
||
viewB: { zoom: 1.0, panX: 0, panY: 0 },
|
||
|
||
clipboard: null,
|
||
|
||
enemyTypes: deepClone(DEFAULT_ENEMY_TYPES),
|
||
enemyTypeKeys: Object.keys(DEFAULT_ENEMY_TYPES),
|
||
enemyPlaceType: "enemy",
|
||
npcPlaceType: "vendor",
|
||
showAggro: false,
|
||
objectPlaceId: null,
|
||
waitingPathPt: null
|
||
};
|
||
|
||
const els = {
|
||
fileInput: document.getElementById("fileInput"),
|
||
roomsList: document.getElementById("roomsList"),
|
||
newRoomBtn: document.getElementById("newRoomBtn"),
|
||
downloadActiveBtn: document.getElementById("downloadActiveBtn"),
|
||
downloadCheckedBtn: document.getElementById("downloadCheckedBtn"),
|
||
downloadAllBtn: document.getElementById("downloadAllBtn"),
|
||
activeRoomLabel: document.getElementById("activeRoomLabel"),
|
||
status: document.getElementById("status"),
|
||
|
||
linkTargetRoom: document.getElementById("linkTargetRoom"),
|
||
linkTargetSpawn: document.getElementById("linkTargetSpawn"),
|
||
linkApplyBtn: document.getElementById("linkApplyBtn"),
|
||
openTargetBtn: document.getElementById("openTargetBtn"),
|
||
toggleLinkModeBtn: document.getElementById("toggleLinkModeBtn"),
|
||
|
||
toggleCompareBtn: document.getElementById("toggleCompareBtn"),
|
||
compareWrap: document.getElementById("compareWrap"),
|
||
canvasRow: document.getElementById("canvasRow"),
|
||
compareRoomSelect: document.getElementById("compareRoomSelect"),
|
||
labelA: document.getElementById("labelA"),
|
||
labelB: document.getElementById("labelB"),
|
||
showLinksChk: document.getElementById("showLinksChk"),
|
||
|
||
props: document.getElementById("props"),
|
||
canvasA: document.getElementById("canvasA"),
|
||
canvasB: document.getElementById("canvasB"),
|
||
platformGroup: document.getElementById("platformGroup"),
|
||
snapChk: document.getElementById("snapChk"),
|
||
enemyTypesInput: document.getElementById("enemyTypesInput"),
|
||
enemyTypeSelect: document.getElementById("enemyTypeSelect"),
|
||
npcTypeSelect: document.getElementById("npcTypeSelect"),
|
||
showAggroChk: document.getElementById("showAggroChk"),
|
||
objectsInput: document.getElementById("objectsInput"),
|
||
objectsStatus: document.getElementById("objectsStatus"),
|
||
objectSelect: document.getElementById("objectSelect")
|
||
};
|
||
|
||
const ctxA = els.canvasA.getContext("2d");
|
||
const ctxB = els.canvasB.getContext("2d");
|
||
|
||
// Ensure pointer interactions behave consistently
|
||
[els.canvasA, els.canvasB].forEach(c=>{
|
||
c.style.touchAction = "none";
|
||
c.addEventListener("contextmenu", (e)=>e.preventDefault());
|
||
});
|
||
|
||
function getActiveRoom(){
|
||
return state.activeId ? state.rooms.get(state.activeId) : null;
|
||
}
|
||
function getCompareRoom(){
|
||
return state.compareId ? state.rooms.get(state.compareId) : null;
|
||
}
|
||
|
||
function markDirty(roomId, dirty=true){
|
||
const r = state.rooms.get(roomId);
|
||
if(!r) return;
|
||
r.dirty = dirty;
|
||
}
|
||
|
||
// ---------- History (per-room) ----------
|
||
function pushHistory(roomId){
|
||
const r = state.rooms.get(roomId);
|
||
if(!r) return;
|
||
r.history.push(deepClone(r.data));
|
||
if(r.history.length > 80) r.history.shift();
|
||
r.future = [];
|
||
markDirty(roomId, true);
|
||
}
|
||
function undo(){
|
||
const r = getActiveRoom(); if(!r) return;
|
||
if(!r.history.length) return;
|
||
r.future.push(deepClone(r.data));
|
||
r.data = r.history.pop();
|
||
state.selection = null;
|
||
markDirty(r.id, true);
|
||
refreshUI();
|
||
}
|
||
function redo(){
|
||
const r = getActiveRoom(); if(!r) return;
|
||
if(!r.future.length) return;
|
||
r.history.push(deepClone(r.data));
|
||
r.data = r.future.pop();
|
||
state.selection = null;
|
||
markDirty(r.id, true);
|
||
refreshUI();
|
||
}
|
||
|
||
// ---------- Rooms UI ----------
|
||
function addRoomFromJSON(json, filenameHint){
|
||
const id = nowId();
|
||
const baseName = (json && json.name) ? json.name : (filenameHint ? filenameHint.replace(/\.json$/i,"") : "room");
|
||
const data = normalizeRoom(json, baseName);
|
||
const filename = (filenameHint && filenameHint.endsWith(".json")) ? filenameHint : (data.name + ".json");
|
||
state.rooms.set(id, {id, filename, data, dirty:false, history:[], future:[], checked:false});
|
||
if(!state.activeId) state.activeId = id;
|
||
if(!state.compareId) state.compareId = id;
|
||
refreshUI();
|
||
}
|
||
|
||
function setActiveRoom(id){
|
||
if(!state.rooms.has(id)) return;
|
||
|
||
const prevActive = state.activeId;
|
||
const prevCompare = state.compareId;
|
||
|
||
state.activeId = id;
|
||
state.selection = null;
|
||
|
||
if(state.compareEnabled){
|
||
const keys = [...state.rooms.keys()];
|
||
const idx = keys.indexOf(state.activeId);
|
||
|
||
const pickNextOther = ()=>{
|
||
if(keys.length <= 1) return null;
|
||
for(let step=1; step<keys.length; step++){
|
||
const cand = keys[(idx + step) % keys.length];
|
||
if(cand !== state.activeId) return cand;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// If we promoted the compare room to active, advance compare to the next room (e.g. room_03)
|
||
if(prevCompare === state.activeId){
|
||
state.compareId = pickNextOther();
|
||
} else if(!state.compareId || !state.rooms.has(state.compareId) || state.compareId === state.activeId){
|
||
state.compareId = pickNextOther();
|
||
}
|
||
}
|
||
|
||
fitViewToRoom(getActiveRoom(),'A');
|
||
if(state.compareEnabled) fitViewToRoom(getCompareRoom(),'B');
|
||
refreshUI();
|
||
}
|
||
|
||
function toggleRoomChecked(id){
|
||
const r = state.rooms.get(id);
|
||
if(!r) return;
|
||
r.checked = !r.checked;
|
||
refreshRoomsList();
|
||
}
|
||
|
||
function removeRoom(id){
|
||
if(!state.rooms.has(id)) return;
|
||
state.rooms.delete(id);
|
||
if(state.activeId === id){
|
||
state.activeId = [...state.rooms.keys()][0] || null;
|
||
state.selection = null;
|
||
}
|
||
if(state.compareId === id){
|
||
state.compareId = [...state.rooms.keys()][0] || null;
|
||
}
|
||
refreshUI();
|
||
}
|
||
|
||
function sanitizeRoomName(name){
|
||
return String(name||"").trim()
|
||
.replace(/\s+/g, "_")
|
||
.replace(/[^A-Za-z0-9_\-]/g, "_")
|
||
.replace(/_+/g, "_");
|
||
}
|
||
|
||
function renameRoom(roomId, newName){
|
||
const r = state.rooms.get(roomId);
|
||
if(!r) return;
|
||
|
||
const oldName = r.data && r.data.name ? r.data.name : "";
|
||
const clean = sanitizeRoomName(newName);
|
||
if(!clean){ setStatus("Rename canceled (empty name)."); return; }
|
||
|
||
// prevent duplicates
|
||
for(const [oid, other] of state.rooms.entries()){
|
||
if(oid !== roomId && other && other.data && other.data.name === clean){
|
||
alert('A room named "' + clean + '" already exists.');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// record history for this room
|
||
pushHistory(roomId);
|
||
|
||
r.data.name = clean;
|
||
r.data.display_name = clean;
|
||
r.data.meta = r.data.meta || {};
|
||
r.data.meta.id = clean;
|
||
r.data.meta.name = clean;
|
||
|
||
// update filename if it was generic or name-based
|
||
if(r.filename === "room_new.json" || r.filename === (oldName + ".json")){
|
||
r.filename = clean + ".json";
|
||
}
|
||
|
||
// update cross-room exit links that point to the old name
|
||
for(const [oid, wrap] of state.rooms.entries()){
|
||
if(!wrap || !wrap.data || !Array.isArray(wrap.data.exits)) continue;
|
||
let changed = false;
|
||
for(const ex of wrap.data.exits){
|
||
if(ex && ex.to === oldName){
|
||
if(!changed){
|
||
pushHistory(oid);
|
||
changed = true;
|
||
}
|
||
ex.to = clean;
|
||
}
|
||
}
|
||
if(changed){
|
||
markDirty(oid, true);
|
||
}
|
||
}
|
||
|
||
refreshUI();
|
||
setStatus('Renamed "' + oldName + '" -> "' + clean + '"');
|
||
}
|
||
|
||
function refreshRoomsList(){
|
||
els.roomsList.innerHTML = "";
|
||
for(const [id, r] of state.rooms.entries()){
|
||
const row = document.createElement("div");
|
||
row.className = "roomItem";
|
||
|
||
const cb = document.createElement("input");
|
||
cb.type = "checkbox";
|
||
cb.checked = !!r.checked;
|
||
cb.addEventListener("change", ()=>toggleRoomChecked(id));
|
||
|
||
const btn = document.createElement("button");
|
||
btn.className = "roomBtn " + (id === state.activeId ? "primary" : "");
|
||
btn.textContent = r.data.name;
|
||
btn.addEventListener("click", ()=>setActiveRoom(id));
|
||
|
||
const rn = document.createElement("button");
|
||
rn.className = "warn";
|
||
rn.textContent = "Rename";
|
||
rn.title = "Rename this room";
|
||
rn.addEventListener("click", (e)=>{
|
||
e.stopPropagation();
|
||
const cur = (r && r.data && r.data.name) ? r.data.name : "";
|
||
const next = window.prompt("Rename room:", cur);
|
||
if(next === null) return;
|
||
renameRoom(id, next);
|
||
});
|
||
|
||
const badges = document.createElement("div");
|
||
badges.style.display = "flex";
|
||
badges.style.gap = "6px";
|
||
const b1 = document.createElement("span");
|
||
b1.className = "badge" + (r.dirty ? " dirty" : "");
|
||
b1.textContent = r.dirty ? "modified" : "saved";
|
||
badges.appendChild(b1);
|
||
|
||
const rm = document.createElement("button");
|
||
rm.className = "danger";
|
||
rm.textContent = "×";
|
||
rm.title = "Remove from workspace (does not delete your file)";
|
||
rm.addEventListener("click", (e)=>{
|
||
e.stopPropagation();
|
||
const rr = state.rooms.get(id);
|
||
if(rr && rr.dirty){
|
||
// Ask to save before removing
|
||
const save = window.confirm(`"${rr.data.name}" has unsaved changes.
|
||
|
||
OK = Download (save) then remove
|
||
Cancel = Choose whether to remove without saving`);
|
||
if(save){
|
||
downloadText(rr.filename || (rr.data.name + ".json"), serializeRoom(rr));
|
||
rr.dirty = false;
|
||
removeRoom(id);
|
||
return;
|
||
} else {
|
||
const discard = window.confirm(`Remove "${rr.data.name}" without saving?`);
|
||
if(!discard) return; // abort
|
||
removeRoom(id);
|
||
return;
|
||
}
|
||
}
|
||
removeRoom(id);
|
||
});
|
||
|
||
// compose
|
||
const wrapper = document.createElement("div");
|
||
wrapper.style.display="flex";
|
||
wrapper.style.flex="1";
|
||
wrapper.style.gap="6px";
|
||
wrapper.appendChild(btn);
|
||
wrapper.appendChild(badges);
|
||
|
||
row.appendChild(cb);
|
||
row.appendChild(wrapper);
|
||
row.appendChild(rn);
|
||
row.appendChild(rm);
|
||
els.roomsList.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function refreshRoomSelectors(){
|
||
const rooms = [...state.rooms.entries()].map(([id,r])=>({id, name:r.data.name}));
|
||
const activeId = state.activeId;
|
||
|
||
function fill(select, filterFn, selectedId){
|
||
if(!select) return;
|
||
const prev = select.value;
|
||
select.innerHTML = "";
|
||
for(const it of rooms.filter(filterFn)){
|
||
const opt = document.createElement("option");
|
||
opt.value = it.id;
|
||
opt.textContent = it.name;
|
||
select.appendChild(opt);
|
||
}
|
||
// keep selection if possible
|
||
const wanted = selectedId || prev;
|
||
if([...select.options].some(o=>o.value===wanted)) select.value = wanted;
|
||
else if(select.options.length) select.value = select.options[0].value;
|
||
}
|
||
|
||
fill(els.linkTargetRoom, it => it.id !== activeId, state.compareEnabled ? null : null);
|
||
fill(els.compareRoomSelect, it => it.id !== activeId, state.compareId);
|
||
|
||
// maintain compareId if enabled
|
||
if(state.compareEnabled){
|
||
const v = els.compareRoomSelect.value;
|
||
state.compareId = v || null;
|
||
}
|
||
|
||
updateLinkSpawnList();
|
||
}
|
||
|
||
function updateLinkSpawnList(){
|
||
if(!els.linkTargetRoom || !els.linkTargetSpawn) return;
|
||
const targetId = els.linkTargetRoom.value;
|
||
const targetRoom = state.rooms.get(targetId);
|
||
els.linkTargetSpawn.innerHTML = "";
|
||
if(!targetRoom){
|
||
const opt = document.createElement("option");
|
||
opt.value = "";
|
||
opt.textContent = "(no target room)";
|
||
els.linkTargetSpawn.appendChild(opt);
|
||
return;
|
||
}
|
||
const keys = Object.keys(targetRoom.data.entry_spawns || {}).sort();
|
||
if(keys.length === 0){
|
||
const opt = document.createElement("option");
|
||
opt.value = "";
|
||
opt.textContent = "(no entry spawns)";
|
||
els.linkTargetSpawn.appendChild(opt);
|
||
return;
|
||
}
|
||
for(const k of keys){
|
||
const opt = document.createElement("option");
|
||
opt.value = k;
|
||
opt.textContent = k;
|
||
els.linkTargetSpawn.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
// ---------- Link Mode (click-to-link via compare canvas) ----------
|
||
function setLinkMode(on){
|
||
state.linkMode = !!on;
|
||
if(state.linkMode){
|
||
// Safety: switch to Select/Move so linking misclicks never place new objects
|
||
state.tool = "select";
|
||
// compare view is required to click a target spawn
|
||
if(!state.compareEnabled){
|
||
state.compareEnabled = true;
|
||
const other = [...state.rooms.keys()].find(k => k !== state.activeId);
|
||
state.compareId = other || state.activeId;
|
||
}
|
||
setStatus("Link Mode: select an Exit, then click a spawn in the compare canvas");
|
||
} else {
|
||
setStatus("Link Mode disabled");
|
||
}
|
||
refreshUI();
|
||
}
|
||
|
||
function hitTestSpawnNode(roomWrap, wx, wy){
|
||
if(!roomWrap) return null;
|
||
const room = roomWrap.data;
|
||
|
||
// We choose the closest node within a radius, so overlapping nodes don't pick the "first" key.
|
||
let best = null;
|
||
let bestD2 = Infinity;
|
||
const R = 14;
|
||
|
||
// player spawn (single)
|
||
if(room.player_spawn){
|
||
const dx = wx - room.player_spawn[0];
|
||
const dy = wy - room.player_spawn[1];
|
||
const d2 = dx*dx + dy*dy;
|
||
if(d2 <= R*R){
|
||
best = {kind:"player_spawn", key:"__player_spawn__", roomId: roomWrap.id};
|
||
bestD2 = d2;
|
||
}
|
||
}
|
||
|
||
// entry spawns (named)
|
||
const entries = room.entry_spawns || {};
|
||
for(const k of Object.keys(entries)){
|
||
const [ex,ey] = entries[k];
|
||
const dx = wx - ex;
|
||
const dy = wy - ey;
|
||
const d2 = dx*dx + dy*dy;
|
||
if(d2 <= R*R && d2 < bestD2){
|
||
best = {kind:"entry_spawn", key:k, roomId: roomWrap.id};
|
||
bestD2 = d2;
|
||
}
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
function onComparePointerDown(ev){
|
||
if(!state.compareEnabled) return;
|
||
const roomWrap = getCompareRoom(); if(!roomWrap) return;
|
||
|
||
const rect = els.canvasB.getBoundingClientRect();
|
||
const sx = ev.clientX - rect.left;
|
||
const sy = ev.clientY - rect.top;
|
||
const [wx,wy] = screenToWorld(sx,sy,els.canvasB);
|
||
|
||
const room = roomWrap.data;
|
||
|
||
// Adding a path point to a selected moving platform?
|
||
if(state.waitingPathPt){
|
||
if(ev.button === 0){
|
||
ev.preventDefault();
|
||
const wp = state.waitingPathPt;
|
||
const wrap = state.rooms.get(wp.roomId);
|
||
const plat = wrap && wrap.data.platforms && wrap.data.platforms[wp.index];
|
||
if(plat){
|
||
if(!Array.isArray(plat.path)) plat.path = [[plat.x, plat.y]];
|
||
plat.path.push(snapPt(wx, wy));
|
||
}
|
||
state.waitingPathPt = null;
|
||
refreshUI();
|
||
setStatus("Path point added.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
if(ev.button === 1){
|
||
// middle mouse pan handled by mousedown
|
||
return;
|
||
}
|
||
if(ev.button === 0 && ev.altKey){
|
||
ev.preventDefault();
|
||
state.dragging = {mode:"pan", canvas:"B", startX:sx, startY:sy, startPanX:state.viewB.panX, startPanY:state.viewB.panY, startZoom:state.viewB.zoom};
|
||
return;
|
||
}
|
||
|
||
// LINK MODE: pick spawn node in compare room by clicking it
|
||
if(state.linkMode){
|
||
const activeRoomWrap = getActiveRoom();
|
||
const sel = state.selection;
|
||
if(!activeRoomWrap || !sel || sel.kind !== "exit" || sel.roomId !== activeRoomWrap.id){
|
||
setStatus("Link Mode: select an Exit in the active room first");
|
||
return;
|
||
}
|
||
const hitNode = hitTestSpawnNode(roomWrap, wx, wy);
|
||
if(!hitNode){
|
||
setStatus("Link Mode: click a spawn node (Player Spawn or Entry Spawn) in the compare room");
|
||
return;
|
||
}
|
||
|
||
const exit = activeRoomWrap.data.exits[sel.index];
|
||
if(!exit) return;
|
||
const spawnId = hitNode.key;
|
||
const pos = (spawnId==="__player_spawn__") ? roomWrap.data.player_spawn : roomWrap.data.entry_spawns[spawnId];
|
||
|
||
pushHistory(activeRoomWrap.id);
|
||
exit.to = roomWrap.data.name;
|
||
exit.spawn_id = spawnId;
|
||
exit.spawn = [pos[0], pos[1]];
|
||
|
||
if(els.linkTargetRoom && els.linkTargetSpawn){
|
||
els.linkTargetRoom.value = roomWrap.id;
|
||
updateLinkSpawnList();
|
||
els.linkTargetSpawn.value = spawnId;
|
||
}
|
||
|
||
refreshUI();
|
||
setStatus(`Linked exit → ${exit.to}:${spawnId}`);
|
||
// auto-disable after one link
|
||
setLinkMode(false);
|
||
return;
|
||
}
|
||
|
||
// Normal editing on compare room
|
||
if(state.tool === "select"){
|
||
const hit = hitTest(roomWrap, wx, wy, els.canvasB);
|
||
state.selection = hit;
|
||
refreshProps();
|
||
if(hit){
|
||
if(hit.kind==="platform" && hit.edge){
|
||
const p = room.platforms[hit.index];
|
||
if(p){
|
||
pushHistory(roomWrap.id);
|
||
state.dragging = {
|
||
mode:"resizePlatformEdge",
|
||
canvas:"B",
|
||
roomId: roomWrap.id,
|
||
index: hit.index,
|
||
edge: hit.edge,
|
||
base: {x:p.x,y:p.y,w:p.w,h:p.h},
|
||
startWX: wx,
|
||
startWY: wy
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
if(hit.kind==="exit" && hit.edge){
|
||
const ex = room.exits[hit.index];
|
||
if(ex){
|
||
pushHistory(roomWrap.id);
|
||
state.dragging = {
|
||
mode:"resizeExitEdge",
|
||
canvas:"B",
|
||
roomId: roomWrap.id,
|
||
index: hit.index,
|
||
edge: hit.edge,
|
||
base: {x:ex.x,y:ex.y,w:ex.w,h:ex.h},
|
||
startWX: wx,
|
||
startWY: wy
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
if(hit.kind==="ability_door" && hit.edge){
|
||
const d = room.ability_doors && room.ability_doors[hit.index];
|
||
if(d){
|
||
pushHistory(roomWrap.id);
|
||
state.dragging = {
|
||
mode:"resizeAbilityDoorEdge",
|
||
canvas:"B",
|
||
roomId: roomWrap.id,
|
||
index: hit.index,
|
||
edge: hit.edge,
|
||
base: {x:d.x,y:d.y,w:d.w,h:d.h},
|
||
startWX: wx,
|
||
startWY: wy
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
|
||
state.dragging = {mode:"move", canvas:"B", roomId: roomWrap.id, sel:deepClone(hit), startWX:wx, startWY:wy, base:null};
|
||
} else {
|
||
state.dragging = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// creation tools apply to compare room too
|
||
if(state.tool === "platform"){
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
|
||
// Start as a 1x1 rect at the anchor; resizing can go in any direction.
|
||
const p = {x: sx1, y: sy1, w: 1, h: 1, group: state.platformGroup || "ground"};
|
||
if(p.group === "moving") p.path = [[sx1, sy1]];
|
||
room.platforms.push(p);
|
||
|
||
state.selection = {kind:"platform", index: room.platforms.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
|
||
state.dragging = {
|
||
mode:"resizePlatform",
|
||
canvas:"B",
|
||
roomId: roomWrap.id,
|
||
index: room.platforms.length-1,
|
||
startWX: wx,
|
||
startWY: wy,
|
||
anchorX: sx1,
|
||
anchorY: sy1
|
||
};
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "enemy_spawn"){
|
||
if(!state.enemyTypes || !state.enemyTypeKeys || state.enemyTypeKeys.length === 0){
|
||
setStatus("Please import an enemy types JSON first.");
|
||
return;
|
||
}
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
room.enemies.push({x: sx1, y: sy1, type: (state.enemyPlaceType || "enemy")});
|
||
state.selection = {kind:"enemy", index:room.enemies.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "npc_spawn"){
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
room.npcs = room.npcs || [];
|
||
room.npcs.push({x: sx1, y: sy1, type: (state.npcPlaceType || "vendor")});
|
||
state.selection = {kind:"npc", index:room.npcs.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "player_spawn"){
|
||
pushHistory(roomWrap.id);
|
||
room.player_spawn = snapPt(wx,wy);
|
||
state.selection = {kind:"player_spawn", index:0, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "entry_spawn"){
|
||
pushHistory(roomWrap.id);
|
||
let base = "entry_";
|
||
let n = 1;
|
||
while(room.entry_spawns[base+n]) n++;
|
||
const key = base+n;
|
||
room.entry_spawns[key] = snapPt(wx,wy);
|
||
state.selection = {kind:"entry_spawn", key, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "exit"){
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
|
||
room.exits.push({x: sx1, y: sy1, w: 10, h: 10, to:"", spawn_id:"", spawn:[sx1,sy1]});
|
||
state.selection = {kind:"exit", index:room.exits.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
|
||
state.dragging = {
|
||
mode:"resizeExit",
|
||
canvas:"B",
|
||
roomId: roomWrap.id,
|
||
index: room.exits.length-1,
|
||
startWX: wx,
|
||
startWY: wy,
|
||
anchorX: sx1,
|
||
anchorY: sy1
|
||
};
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "ability_door"){
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
|
||
room.ability_doors.push({x: sx1, y: sy1, w: 10, h: 10});
|
||
state.selection = {kind:"ability_door", index:room.ability_doors.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
|
||
state.dragging = {
|
||
mode:"resizeAbilityDoor",
|
||
canvas:"B",
|
||
roomId: roomWrap.id,
|
||
index: room.ability_doors.length-1,
|
||
startWX: wx,
|
||
startWY: wy,
|
||
anchorX: sx1,
|
||
anchorY: sy1
|
||
};
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "object_spawn"){
|
||
if(!objectCatalog.byId || Object.keys(objectCatalog.byId).length === 0){
|
||
setStatus("Please import an objects JSON first.");
|
||
return;
|
||
}
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
const props = room.props || (room.props = []);
|
||
const objectId = state.objectPlaceId || null;
|
||
const def = getObjectDefById(objectId) || getObjectDefByType("light_source");
|
||
const type = (def && def.type) ? def.type : "light_source";
|
||
const radius = getDefaultRadiusForType(type, objectId);
|
||
props.push({x:sx1, y:sy1, type, radius, object_id: objectId});
|
||
state.selection = {kind:"prop", index:props.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
}
|
||
|
||
|
||
function fitViewToRoom(roomWrap, canvasKey){
|
||
if(!roomWrap) return;
|
||
const room = roomWrap.data;
|
||
const canvas = (canvasKey==="B") ? els.canvasB : els.canvasA;
|
||
const view = (canvasKey==="B") ? state.viewB : state.viewA;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const w = rect.width || canvas.clientWidth || 1;
|
||
const h = rect.height || canvas.clientHeight || 1;
|
||
|
||
// compute bounds from content, include room border
|
||
let minX = 0, minY = 0, maxX = room.room_w || 1600, maxY = room.room_h || 900;
|
||
|
||
for(const p of (room.platforms||[])){
|
||
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
|
||
maxX = Math.max(maxX, p.x+p.w); maxY = Math.max(maxY, p.y+p.h);
|
||
}
|
||
for(const ex of (room.exits||[])){
|
||
minX = Math.min(minX, ex.x); minY = Math.min(minY, ex.y);
|
||
maxX = Math.max(maxX, ex.x+ex.w); maxY = Math.max(maxY, ex.y+ex.h);
|
||
}
|
||
for(const ad of (room.ability_doors||[])){
|
||
minX = Math.min(minX, ad.x); minY = Math.min(minY, ad.y);
|
||
maxX = Math.max(maxX, ad.x+ad.w); maxY = Math.max(maxY, ad.y+ad.h);
|
||
}
|
||
if(Array.isArray(room.player_spawn)){
|
||
minX = Math.min(minX, room.player_spawn[0]-50);
|
||
minY = Math.min(minY, room.player_spawn[1]-50);
|
||
maxX = Math.max(maxX, room.player_spawn[0]+50);
|
||
maxY = Math.max(maxY, room.player_spawn[1]+50);
|
||
}
|
||
const entries = room.entry_spawns||{};
|
||
for(const k of Object.keys(entries)){
|
||
const [x,y]=entries[k];
|
||
minX = Math.min(minX, x-50); minY = Math.min(minY, y-50);
|
||
maxX = Math.max(maxX, x+50); maxY = Math.max(maxY, y+50);
|
||
}
|
||
for(const e of (room.enemies||[])){
|
||
minX = Math.min(minX, e.x-50); minY = Math.min(minY, e.y-50);
|
||
maxX = Math.max(maxX, e.x+50); maxY = Math.max(maxY, e.y+50);
|
||
}
|
||
for(const n of (room.npcs||[])){
|
||
minX = Math.min(minX, n.x-60); minY = Math.min(minY, n.y-60);
|
||
maxX = Math.max(maxX, n.x+60); maxY = Math.max(maxY, n.y+60);
|
||
}
|
||
|
||
const pad = 60;
|
||
minX -= pad; minY -= pad; maxX += pad; maxY += pad;
|
||
|
||
const contentW = Math.max(1, maxX - minX);
|
||
const contentH = Math.max(1, maxY - minY);
|
||
const dpr = window.devicePixelRatio || 1;
|
||
|
||
// pick zoom that fits
|
||
const zx = (w) / contentW;
|
||
const zy = (h) / contentH;
|
||
const targetZoom = clamp(Math.min(zx, zy), 0.2, 3.0);
|
||
view.zoom = targetZoom;
|
||
|
||
// center
|
||
const cx = (minX + maxX)/2;
|
||
const cy = (minY + maxY)/2;
|
||
view.panX = (w/(2*view.zoom)) - cx;
|
||
view.panY = (h/(2*view.zoom)) - cy;
|
||
}
|
||
// ---------- Drawing ----------
|
||
function resizeCanvasToDisplaySize(canvas){
|
||
let w = canvas.clientWidth, h = canvas.clientHeight;
|
||
if((!w || !h) && canvas.getBoundingClientRect){ const br = canvas.getBoundingClientRect(); w = br.width; h = br.height; }
|
||
// last resort: try parent size
|
||
if((!w || !h) && canvas.parentElement){ const pr = canvas.parentElement.getBoundingClientRect(); w = pr.width; h = pr.height; }
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const nw = Math.max(1, Math.floor(w*dpr));
|
||
const nh = Math.max(1, Math.floor(h*dpr));
|
||
if(canvas.width !== nw || canvas.height !== nh){
|
||
canvas.width = nw; canvas.height = nh;
|
||
}
|
||
return dpr;
|
||
}
|
||
|
||
|
||
function getViewForCanvas(canvas){
|
||
return (canvas === els.canvasB) ? state.viewB : state.viewA;
|
||
}
|
||
|
||
function worldToScreen(x,y, canvas){
|
||
const v = getViewForCanvas(canvas);
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const cx = (x + v.panX) * v.zoom;
|
||
const cy = (y + v.panY) * v.zoom;
|
||
return [cx*dpr, cy*dpr];
|
||
}
|
||
|
||
function screenToWorld(sx,sy, canvas){
|
||
const v = getViewForCanvas(canvas);
|
||
// sx/sy come from mouse/touch events in CSS pixels (clientX/clientY).
|
||
// Do NOT divide by devicePixelRatio here; worldToScreen already accounts for DPR.
|
||
const x = (sx)/v.zoom - v.panX;
|
||
const y = (sy)/v.zoom - v.panY;
|
||
return [x,y];
|
||
}
|
||
|
||
// Replicates the game's procedural vendor house backdrop (drawn in world units).
|
||
function drawHouseShape(ctx, centerX, bottomY, dpr, canvas){
|
||
const house_w = 140, house_h = 90;
|
||
const bx = centerX - house_w/2;
|
||
const by = bottomY - house_h;
|
||
|
||
const wallA = worldToScreen(bx, by + 30, canvas);
|
||
const wallB = worldToScreen(bx + house_w, by + house_h, canvas);
|
||
const roofA = worldToScreen(bx - 6, by + 34, canvas);
|
||
const roofB = worldToScreen(bx + house_w/2, by - 8, canvas);
|
||
const roofC = worldToScreen(bx + house_w + 6, by + 34, canvas);
|
||
const doorA = worldToScreen(bx + house_w/2 - 13, by + house_h - 34, canvas);
|
||
const doorB = worldToScreen(bx + house_w/2 + 13, by + house_h, canvas);
|
||
|
||
// wall
|
||
ctx.fillStyle = "rgba(180,170,150,0.80)";
|
||
ctx.fillRect(wallA[0], wallA[1], wallB[0]-wallA[0], wallB[1]-wallA[1]);
|
||
ctx.strokeStyle = "rgba(120,110,95,0.9)";
|
||
ctx.lineWidth = 1.5*dpr;
|
||
ctx.strokeRect(wallA[0], wallA[1], wallB[0]-wallA[0], wallB[1]-wallA[1]);
|
||
|
||
// door
|
||
ctx.fillStyle = "rgba(150,170,195,0.85)";
|
||
ctx.fillRect(doorA[0], doorA[1], doorB[0]-doorA[0], doorB[1]-doorA[1]);
|
||
ctx.strokeStyle = "rgba(95,110,130,0.9)";
|
||
ctx.strokeRect(doorA[0], doorA[1], doorB[0]-doorA[0], doorB[1]-doorA[1]);
|
||
|
||
// roof
|
||
ctx.beginPath();
|
||
ctx.moveTo(roofA[0], roofA[1]);
|
||
ctx.lineTo(roofB[0], roofB[1]);
|
||
ctx.lineTo(roofC[0], roofC[1]);
|
||
ctx.closePath();
|
||
ctx.fillStyle = "rgba(210,170,110,0.9)";
|
||
ctx.fill();
|
||
ctx.strokeStyle = "rgba(135,100,65,0.9)";
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Replicates the game's DiaryObject: a 40x50 book with a center spine (top-left anchored).
|
||
function drawBookShape(ctx, x, y, dpr, canvas){
|
||
const a = worldToScreen(x, y, canvas);
|
||
const b = worldToScreen(x + 40, y + 50, canvas);
|
||
const w = b[0]-a[0], h = b[1]-a[1];
|
||
ctx.fillStyle = "rgba(150,110,70,0.9)";
|
||
ctx.fillRect(a[0], a[1], w, h);
|
||
ctx.strokeStyle = "rgba(90,60,30,0.9)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(a[0], a[1], w, h);
|
||
ctx.beginPath();
|
||
ctx.moveTo(a[0]+w/2, a[1]);
|
||
ctx.lineTo(a[0]+w/2, a[1]+h);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Clickable body for props with a real in-world shape (null = small point marker).
|
||
function getPropHitRect(p){
|
||
if(!p) return null;
|
||
const t = p.type || "light_source";
|
||
if(t === "house") return { x: p.x - 70, y: p.y - 90, w: 140, h: 90 }; // bottom-center
|
||
if(t === "diary") return { x: p.x, y: p.y, w: 40, h: 50 }; // top-left book
|
||
if(t === "player") return { x: p.x, y: p.y, w: 48, h: 72 }; // top-left hitbox
|
||
return null;
|
||
}
|
||
|
||
// Corner/edge handle detection for resizing (platforms, doors, exits).
|
||
function getResizeEdge(r, wx, wy, tol2){
|
||
const x0=r.x, x1=r.x+r.w, y0=r.y, y1=r.y+r.h;
|
||
const xm=(x0+x1)/2, ym=(y0+y1)/2;
|
||
const handles = [
|
||
[x0,y0,'nw'], [xm,y0,'n'], [x1,y0,'ne'],
|
||
[x1,ym,'e'], [x1,y1,'se'], [xm,y1,'s'],
|
||
[x0,y1,'sw'], [x0,ym,'w']
|
||
];
|
||
const order = ['nw','ne','se','sw','n','e','s','w'];
|
||
for(const tag of order){
|
||
const h = handles.find(h=>h[2]===tag);
|
||
const dx=wx-h[0], dy=wy-h[1];
|
||
if(dx*dx+dy*dy <= tol2) return tag;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Draws a single prop (house/diary/player get their real shape; others get a marker).
|
||
function drawPropMarker(ctx, p, dpr, canvas){
|
||
const PROP_STYLES = {
|
||
light_source: { ring: "rgba(250,204,21,0.25)", fill: "rgba(250,204,21,0.85)", label: "Butterfly" },
|
||
save_point: { ring: "rgba(56,189,248,0.30)", fill: "rgba(56,189,248,0.9)", label: "Lantern" },
|
||
butterfly_jar: { ring: "rgba(168,85,247,0.22)", fill: "rgba(168,85,247,0.9)", label: "Jar" },
|
||
diary: { ring: "rgba(150,110,70,0.25)", fill: "rgba(150,110,70,0.95)", label: "Diary" },
|
||
reward_chest: { ring: "rgba(180,120,60,0.30)", fill: "rgba(190,125,70,0.95)", label: "Chest" },
|
||
ability_pickup:{ ring: "rgba(200,230,255,0.30)", fill: "rgba(200,230,255,0.95)", label: "Ability" },
|
||
};
|
||
const ptype = p.type || "light_source";
|
||
const st = PROP_STYLES[ptype] || { ring: "rgba(148,163,184,0.25)", fill: "rgba(148,163,184,0.9)", label: ptype };
|
||
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
|
||
const v = getViewForCanvas(canvas);
|
||
const r = Math.max(20, +p.radius || getDefaultRadiusForType(ptype, p.object_id || p.objectId || null));
|
||
|
||
if(ptype === "house"){
|
||
drawHouseShape(ctx, p.x, p.y, dpr, canvas);
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText("House", sx+10*dpr, sy+4*dpr);
|
||
return;
|
||
}
|
||
if(ptype === "diary"){
|
||
drawBookShape(ctx, p.x, p.y, dpr, canvas);
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText("Diary", sx+10*dpr, sy-4*dpr);
|
||
return;
|
||
}
|
||
if(ptype === "player"){
|
||
const a = worldToScreen(p.x, p.y, canvas);
|
||
const b = worldToScreen(p.x+48, p.y+72, canvas);
|
||
ctx.fillStyle = "rgba(200,200,220,0.15)";
|
||
ctx.fillRect(a[0], a[1], b[0]-a[0], b[1]-a[1]);
|
||
ctx.strokeStyle = "rgba(200,200,220,0.8)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(a[0], a[1], b[0]-a[0], b[1]-a[1]);
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText("Player", a[0], a[1]-4*dpr);
|
||
return;
|
||
}
|
||
|
||
// radius ring (only for types that actually use it in-game)
|
||
if(ptype === "light_source" || ptype === "save_point"){
|
||
const rPx = r * (v.zoom || 1) * dpr;
|
||
ctx.strokeStyle = st.ring;
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.beginPath(); ctx.arc(sx, sy, rPx, 0, Math.PI*2); ctx.stroke();
|
||
}
|
||
// marker
|
||
ctx.fillStyle = st.fill;
|
||
if(ptype === "save_point"){
|
||
ctx.beginPath();
|
||
ctx.moveTo(sx, sy-7*dpr);
|
||
ctx.lineTo(sx+7*dpr, sy);
|
||
ctx.lineTo(sx, sy+7*dpr);
|
||
ctx.lineTo(sx-7*dpr, sy);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
} else if(ptype === "butterfly_jar"){
|
||
ctx.fillRect(sx-6*dpr, sy-6*dpr, 12*dpr, 12*dpr);
|
||
} else {
|
||
ctx.beginPath(); ctx.arc(sx, sy, 6*dpr, 0, Math.PI*2); ctx.fill();
|
||
}
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText(st.label, sx+10*dpr, sy+4*dpr);
|
||
}
|
||
|
||
// House backdrops are explicit house props now; this shape helper is used for them.
|
||
|
||
function drawRoom(ctx, canvas, roomWrap, labelEl, isCompare=false){
|
||
const dpr = resizeCanvasToDisplaySize(canvas);
|
||
ctx.setTransform(1,0,0,1,0,0);
|
||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||
|
||
if(!roomWrap){
|
||
labelEl.innerHTML = (isCompare ? "Compare room" : "Active room") + "<br/><small>(none)</small>";
|
||
return;
|
||
}
|
||
|
||
const room = roomWrap.data;
|
||
labelEl.innerHTML = (isCompare ? "Compare room" : "Active room") + "<br/><small>" + room.name + "</small>";
|
||
|
||
const v = getViewForCanvas(canvas);
|
||
|
||
// background
|
||
ctx.fillStyle = "#05070a";
|
||
ctx.fillRect(0,0,canvas.width,canvas.height);
|
||
|
||
// room border
|
||
const [x0,y0] = worldToScreen(0,0,canvas);
|
||
const [x1,y1] = worldToScreen(room.room_w, room.room_h, canvas);
|
||
ctx.strokeStyle = "rgba(148,163,184,0.35)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(x0,y0, x1-x0, y1-y0);
|
||
|
||
// layerable objects: props/houses (0), platforms (1), ability doors (2), exits (3),
|
||
// enemies (4), NPCs (5) drawn back-to-front by (layer, category) so you can push
|
||
// any of them into the fore- or background.
|
||
const NPC_STYLES = {
|
||
vendor: { body: "rgba(220,220,190,0.95)", border: "rgba(140,140,110,0.9)" },
|
||
upgrader: { body: "rgba(140,140,150,0.95)", border: "rgba(100,100,110,0.9)" },
|
||
necklace_vendor: { body: "rgba(190,160,230,0.95)", border: "rgba(130,100,170,0.9)" },
|
||
combat_master: { body: "rgba(200,120,110,0.95)", border: "rgba(150,80,70,0.9)" },
|
||
};
|
||
const layerable = [];
|
||
for(const p of (room.props || [])) if(p) layerable.push({ z: +(p.layer||0), cat: 0, kind: "prop", p });
|
||
for(const p of (room.platforms || [])){
|
||
if(p && (p.group === "moving")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "moving", p });
|
||
else if(p && (p.group === "trigger")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "trigger", p });
|
||
else if(p && (p.group === "hazard")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "hazard", p });
|
||
else if(p && (p.group === "dissolve")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "dissolve", p });
|
||
else layerable.push({ z: +(p.layer||0), cat: 1, kind: "platform", p });
|
||
}
|
||
for(const d of (room.ability_doors || [])) if(d) layerable.push({ z: +(d.layer||0), cat: 2, kind: "door", d });
|
||
for(const ex of (room.exits || [])) if(ex) layerable.push({ z: +(ex.layer||0), cat: 3, kind: "exit", ex });
|
||
for(const e of (room.enemies || [])) if(e) layerable.push({ z: +(e.layer||0), cat: 4, kind: "enemy", e });
|
||
for(const n of (room.npcs || [])) if(n) layerable.push({ z: +(n.layer||0), cat: 5, kind: "npc", n });
|
||
layerable.sort((a,b)=> (a.z - b.z) || (a.cat - b.cat));
|
||
for(const it of layerable){
|
||
if(it.kind === "prop"){
|
||
drawPropMarker(ctx, it.p, dpr, canvas);
|
||
} else if(it.kind === "platform"){
|
||
const p = it.p;
|
||
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
|
||
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
|
||
ctx.fillStyle = "rgba(96,165,250,0.25)";
|
||
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.strokeStyle = "rgba(96,165,250,0.75)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
} else if(it.kind === "moving"){
|
||
const p = it.p;
|
||
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
|
||
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
|
||
ctx.fillStyle = "rgba(80,180,220,0.30)";
|
||
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.strokeStyle = "rgba(150,220,255,0.9)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
// draw the movement path (dashed line + waypoint markers)
|
||
const path = p.path || [];
|
||
if(path.length >= 2){
|
||
ctx.strokeStyle = "rgba(150,220,255,0.5)";
|
||
ctx.lineWidth = 1.5*dpr;
|
||
ctx.setLineDash([4*dpr, 4*dpr]);
|
||
ctx.beginPath();
|
||
path.forEach((pt, pi)=>{
|
||
const [xx,yy] = worldToScreen(pt[0], pt[1], canvas);
|
||
if(pi===0) ctx.moveTo(xx,yy); else ctx.lineTo(xx,yy);
|
||
});
|
||
// loop back to start
|
||
const [exx,eyy] = worldToScreen(path[0][0], path[0][1], canvas);
|
||
ctx.lineTo(exx, eyy);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
for(const pt of path){
|
||
const [xx,yy] = worldToScreen(pt[0], pt[1], canvas);
|
||
ctx.fillStyle = "rgba(150,220,255,0.9)";
|
||
ctx.beginPath(); ctx.arc(xx, yy, 3*dpr, 0, Math.PI*2); ctx.fill();
|
||
}
|
||
// ghost outline of the platform at its final waypoint
|
||
const lastPt = path[path.length-1];
|
||
if(lastPt){
|
||
const [gx,gy] = worldToScreen(lastPt[0], lastPt[1], canvas);
|
||
const gw = sx2 - sx, gh = sy2 - sy;
|
||
ctx.strokeStyle = "rgba(150,220,255,0.45)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.setLineDash([5*dpr, 4*dpr]);
|
||
ctx.strokeRect(gx, gy, gw, gh);
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
} else if(it.kind === "trigger"){
|
||
const p = it.p;
|
||
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
|
||
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
|
||
ctx.fillStyle = "rgba(220,190,60,0.30)";
|
||
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.strokeStyle = "rgba(255,230,120,0.9)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
const label = p.name ? ("Trigger: " + p.name) : "Trigger";
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText(label, sx+4*dpr, sy-4*dpr);
|
||
} else if(it.kind === "hazard"){
|
||
const p = it.p;
|
||
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
|
||
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
|
||
ctx.fillStyle = "rgba(239,68,68,0.35)";
|
||
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.strokeStyle = "rgba(239,68,68,0.9)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText("Hazard", sx+4*dpr, sy-4*dpr);
|
||
// linked respawn marker
|
||
if(Array.isArray(p.respawn) && p.respawn.length>=2){
|
||
const [rx,ry] = worldToScreen(p.respawn[0], p.respawn[1], canvas);
|
||
ctx.strokeStyle = "rgba(52,211,153,0.9)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.beginPath(); ctx.arc(rx, ry, 6*dpr, 0, Math.PI*2); ctx.stroke();
|
||
ctx.fillStyle = "rgba(52,211,153,0.9)";
|
||
ctx.beginPath(); ctx.arc(rx, ry, 3*dpr, 0, Math.PI*2); ctx.fill();
|
||
ctx.strokeStyle = "rgba(52,211,153,0.5)";
|
||
ctx.setLineDash([3*dpr,3*dpr]);
|
||
ctx.beginPath();
|
||
ctx.moveTo((sx+sx2)/2, (sy+sy2)/2);
|
||
ctx.lineTo(rx, ry);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
} else if(it.kind === "dissolve"){
|
||
const p = it.p;
|
||
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
|
||
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
|
||
ctx.fillStyle = "rgba(96,165,250,0.15)";
|
||
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.strokeStyle = "rgba(150,210,255,0.9)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText("Dissolve", sx+4*dpr, sy-4*dpr);
|
||
} else if(it.kind === "door"){
|
||
const d = it.d;
|
||
const [sx,sy] = worldToScreen(d.x, d.y, canvas);
|
||
const [sx2,sy2] = worldToScreen(d.x+d.w, d.y+d.h, canvas);
|
||
if(d.kind === "shade"){
|
||
ctx.fillStyle = "rgba(129,110,190,0.20)";
|
||
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.strokeStyle = "rgba(129,110,190,0.80)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
} else {
|
||
ctx.fillStyle = "rgba(16,185,129,0.18)";
|
||
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.strokeStyle = "rgba(16,185,129,0.75)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
}
|
||
} else if(it.kind === "exit"){
|
||
const ex = it.ex;
|
||
const [sx,sy] = worldToScreen(ex.x, ex.y, canvas);
|
||
const [sx2,sy2] = worldToScreen(ex.x+ex.w, ex.y+ex.h, canvas);
|
||
ctx.fillStyle = "rgba(167,139,250,0.18)";
|
||
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
|
||
ctx.strokeStyle = "rgba(167,139,250,0.75)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
const label = `${ex.to || "?"}${ex.spawn_id ? " → " + ex.spawn_id : ""}`;
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${12*dpr}px system-ui`;
|
||
ctx.fillText(label, sx+6*dpr, sy-6*dpr);
|
||
} else if(it.kind === "enemy"){
|
||
const e = it.e;
|
||
const typeId = (e.type || "enemy");
|
||
const def = state.enemyTypes && state.enemyTypes[typeId] ? state.enemyTypes[typeId] : null;
|
||
const w = def && Array.isArray(def.rect) ? (+def.rect[0]||0) : 0;
|
||
const h = def && Array.isArray(def.rect) ? (+def.rect[1]||0) : 0;
|
||
if(w>0 && h>0){
|
||
const hx = e.x - w/2, hy = e.y - h/2;
|
||
const [sx1,sy1] = worldToScreen(hx, hy, canvas);
|
||
const [sx2,sy2] = worldToScreen(hx+w, hy+h, canvas);
|
||
ctx.fillStyle = "rgba(239,68,68,0.12)";
|
||
ctx.fillRect(sx1,sy1, sx2-sx1, sy2-sy1);
|
||
ctx.strokeStyle = "rgba(239,68,68,0.35)";
|
||
ctx.lineWidth = 1.5*dpr;
|
||
ctx.strokeRect(sx1,sy1, sx2-sx1, sy2-sy1);
|
||
}
|
||
if(state.showAggro && def && typeof def.detection_range === "number"){
|
||
const r = +def.detection_range || 0;
|
||
if(r>0){
|
||
const [sx,sy] = worldToScreen(e.x, e.y, canvas);
|
||
ctx.strokeStyle = "rgba(239,68,68,0.18)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.setLineDash([6*dpr, 6*dpr]);
|
||
ctx.beginPath();
|
||
ctx.arc(sx,sy, r*state[canvas===els.canvasA?"viewA":"viewB"].zoom*dpr, 0, Math.PI*2);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
const [sx,sy] = worldToScreen(e.x, e.y, canvas);
|
||
ctx.fillStyle = "rgba(239,68,68,0.95)";
|
||
ctx.fillRect(sx-5*dpr, sy-5*dpr, 10*dpr, 10*dpr);
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText(typeId, sx+10*dpr, sy+4*dpr);
|
||
} else if(it.kind === "npc"){
|
||
const n = it.n;
|
||
const ntype = n.type || "vendor";
|
||
const st = NPC_STYLES[ntype] || { body: "rgba(150,150,160,0.95)", border: "rgba(110,110,120,0.9)" };
|
||
const [sx1,sy1] = worldToScreen(n.x, n.y, canvas);
|
||
const [sx2,sy2] = worldToScreen(n.x+36, n.y+54, canvas);
|
||
ctx.fillStyle = st.body;
|
||
ctx.fillRect(sx1, sy1, sx2-sx1, sy2-sy1);
|
||
ctx.strokeStyle = st.border;
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.strokeRect(sx1, sy1, sx2-sx1, sy2-sy1);
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText(ntype, sx1 + 10*dpr, sy1 - 4*dpr);
|
||
}
|
||
}
|
||
|
||
// grid overlay (always covers the full canvas)
|
||
const grid = state.gridSize;
|
||
ctx.strokeStyle = "rgba(148,163,184,0.16)";
|
||
ctx.lineWidth = 1*dpr;
|
||
|
||
// Visible world bounds for the current view (independent of room size)
|
||
const viewLeft = (0 / dpr) / v.zoom - v.panX;
|
||
const viewTop = (0 / dpr) / v.zoom - v.panY;
|
||
const viewRight = (canvas.width / dpr) / v.zoom - v.panX;
|
||
const viewBottom = (canvas.height / dpr) / v.zoom - v.panY;
|
||
|
||
const gx0 = Math.floor(Math.min(viewLeft, viewRight) / grid) * grid;
|
||
const gx1 = Math.ceil(Math.max(viewLeft, viewRight) / grid) * grid;
|
||
const gy0 = Math.floor(Math.min(viewTop, viewBottom) / grid) * grid;
|
||
const gy1 = Math.ceil(Math.max(viewTop, viewBottom) / grid) * grid;
|
||
|
||
for(let gx = gx0; gx <= gx1; gx += grid){
|
||
const [sx,sy] = worldToScreen(gx, gy0, canvas);
|
||
const [sx2,sy2] = worldToScreen(gx, gy1, canvas);
|
||
ctx.beginPath();
|
||
ctx.moveTo(sx, sy);
|
||
ctx.lineTo(sx2, sy2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
for(let gy = gy0; gy <= gy1; gy += grid){
|
||
const [sx,sy] = worldToScreen(gx0, gy, canvas);
|
||
const [sx2,sy2] = worldToScreen(gx1, gy, canvas);
|
||
ctx.beginPath();
|
||
ctx.moveTo(sx, sy);
|
||
ctx.lineTo(sx2, sy2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
|
||
// player spawn
|
||
{
|
||
const [px,py] = room.player_spawn;
|
||
const [sx,sy] = worldToScreen(px,py,canvas);
|
||
ctx.fillStyle = "rgba(52,211,153,0.95)";
|
||
ctx.beginPath(); ctx.arc(sx,sy, 6*dpr, 0, Math.PI*2); ctx.fill();
|
||
ctx.fillStyle = "rgba(52,211,153,0.55)";
|
||
ctx.fillRect(sx-12*dpr, sy-3*dpr, 24*dpr, 6*dpr);
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${12*dpr}px system-ui`;
|
||
ctx.fillText("P", sx+10*dpr, sy-8*dpr);
|
||
}
|
||
|
||
// entry spawns
|
||
const entries = room.entry_spawns || {};
|
||
const entryKeys = Object.keys(entries);
|
||
for(const k of entryKeys){
|
||
const [ex,ey] = entries[k];
|
||
const [sx,sy] = worldToScreen(ex,ey,canvas);
|
||
ctx.fillStyle = "rgba(245,158,11,0.95)";
|
||
ctx.beginPath(); ctx.arc(sx,sy, 6*dpr, 0, Math.PI*2); ctx.fill();
|
||
ctx.strokeStyle = "rgba(245,158,11,0.65)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.beginPath(); ctx.moveTo(sx-10*dpr, sy); ctx.lineTo(sx+10*dpr, sy); ctx.stroke();
|
||
ctx.fillStyle = "rgba(229,231,235,0.9)";
|
||
ctx.font = `${11*dpr}px system-ui`;
|
||
ctx.fillText(k, sx+10*dpr, sy+4*dpr);
|
||
}
|
||
|
||
// enemies are drawn in the layerable pass above (layer support)
|
||
// NPCs are drawn in the layerable pass above (layer support)
|
||
// props are drawn in the layerable pass above (layer support)
|
||
|
||
// selection highlight
|
||
if(state.selection && state.selection.roomId === roomWrap.id){
|
||
ctx.save();
|
||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.setLineDash([6*dpr, 4*dpr]);
|
||
const sel = state.selection;
|
||
if(sel.kind==="platform"){
|
||
const p = room.platforms[sel.index];
|
||
if(p){
|
||
const [sx,sy] = worldToScreen(p.x,p.y,canvas);
|
||
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
|
||
// resize handles
|
||
const hs = 6*dpr;
|
||
const hx0 = sx, hx1 = sx2, hy0 = sy, hy1 = sy2;
|
||
const hxm = (hx0+hx1)/2, hym = (hy0+hy1)/2;
|
||
const handles = [
|
||
[hx0,hy0],[hxm,hy0],[hx1,hy0],
|
||
[hx1,hym],[hx1,hy1],[hxm,hy1],
|
||
[hx0,hy1],[hx0,hym]
|
||
];
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "rgba(255,255,255,0.95)";
|
||
ctx.strokeStyle = "rgba(0,0,0,0.55)";
|
||
ctx.lineWidth = 1*dpr;
|
||
for(const [hx,hy] of handles){
|
||
ctx.beginPath();
|
||
ctx.rect(hx-hs, hy-hs, hs*2, hs*2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
}
|
||
|
||
}
|
||
} else if(sel.kind==="exit"){
|
||
const ex = room.exits[sel.index];
|
||
if(ex){
|
||
const [sx,sy] = worldToScreen(ex.x,ex.y,canvas);
|
||
const [sx2,sy2] = worldToScreen(ex.x+ex.w, ex.y+ex.h, canvas);
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
|
||
// resize handles (same as platforms)
|
||
const hs = 6*dpr;
|
||
const hx0 = sx, hx1 = sx2, hy0 = sy, hy1 = sy2;
|
||
const hxm = (hx0+hx1)/2, hym = (hy0+hy1)/2;
|
||
const handles = [
|
||
[hx0,hy0],[hxm,hy0],[hx1,hy0],
|
||
[hx1,hym],[hx1,hy1],[hxm,hy1],
|
||
[hx0,hy1],[hx0,hym]
|
||
];
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "rgba(255,255,255,0.95)";
|
||
ctx.strokeStyle = "rgba(0,0,0,0.55)";
|
||
ctx.lineWidth = 1*dpr;
|
||
for(const [hx,hy] of handles){
|
||
ctx.beginPath();
|
||
ctx.rect(hx-hs, hy-hs, hs*2, hs*2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
} else if(sel.kind==="ability_door"){
|
||
const d = room.ability_doors && room.ability_doors[sel.index];
|
||
if(d){
|
||
const [sx,sy] = worldToScreen(d.x,d.y,canvas);
|
||
const [sx2,sy2] = worldToScreen(d.x+d.w, d.y+d.h, canvas);
|
||
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
|
||
|
||
// resize handles (same as platforms)
|
||
const hs = 6*dpr;
|
||
const hx0 = sx, hx1 = sx2, hy0 = sy, hy1 = sy2;
|
||
const hxm = (hx0+hx1)/2, hym = (hy0+hy1)/2;
|
||
const handles = [
|
||
[hx0,hy0],[hxm,hy0],[hx1,hy0],
|
||
[hx1,hym],[hx1,hy1],[hxm,hy1],
|
||
[hx0,hy1],[hx0,hym]
|
||
];
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "rgba(255,255,255,0.95)";
|
||
ctx.strokeStyle = "rgba(0,0,0,0.55)";
|
||
ctx.lineWidth = 1*dpr;
|
||
for(const [hx,hy] of handles){
|
||
ctx.beginPath();
|
||
ctx.rect(hx-hs, hy-hs, hs*2, hs*2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
} else if(sel.kind==="enemy"){
|
||
const e = room.enemies[sel.index];
|
||
if(e){
|
||
const [sx,sy] = worldToScreen(e.x,e.y,canvas);
|
||
ctx.strokeRect(sx-8*dpr, sy-8*dpr, 16*dpr, 16*dpr);
|
||
}
|
||
} else if(sel.kind==="npc"){
|
||
const n = room.npcs && room.npcs[sel.index];
|
||
if(n){
|
||
const [sx1,sy1] = worldToScreen(n.x,n.y,canvas);
|
||
const [sx2,sy2] = worldToScreen(n.x+36,n.y+54,canvas);
|
||
ctx.strokeRect(sx1, sy1, sx2-sx1, sy2-sy1);
|
||
}
|
||
} else if(sel.kind==="prop"){
|
||
const p = room.props && room.props[sel.index];
|
||
if(p){
|
||
const ptype = p.type || "light_source";
|
||
const hr = getPropHitRect(p);
|
||
if(hr){
|
||
const a = worldToScreen(hr.x, hr.y, canvas);
|
||
const b = worldToScreen(hr.x+hr.w, hr.y+hr.h, canvas);
|
||
ctx.strokeRect(a[0], a[1], b[0]-a[0], b[1]-a[1]);
|
||
} else {
|
||
const [sx,sy] = worldToScreen(p.x,p.y,canvas);
|
||
ctx.strokeRect(sx-10*dpr, sy-10*dpr, 20*dpr, 20*dpr);
|
||
}
|
||
}
|
||
} else if(sel.kind==="player_spawn"){
|
||
const [px,py] = room.player_spawn;
|
||
const [sx,sy] = worldToScreen(px,py,canvas);
|
||
ctx.strokeRect(sx-10*dpr, sy-10*dpr, 20*dpr, 20*dpr);
|
||
} else if(sel.kind==="entry_spawn"){
|
||
const k = sel.key;
|
||
const pos = room.entry_spawns && room.entry_spawns[k];
|
||
if(pos){
|
||
const [sx,sy] = worldToScreen(pos[0],pos[1],canvas);
|
||
ctx.strokeRect(sx-10*dpr, sy-10*dpr, 20*dpr, 20*dpr);
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// link lines: draw LAST (on top of this canvas's content). Each canvas draws its
|
||
// own clipped half of the line, so it's always visible regardless of z-index.
|
||
drawLinkLinesForCanvas(ctx, canvas, isCompare);
|
||
}
|
||
|
||
// Draws the active-room->compare-room link lines on the given canvas, converting
|
||
// both endpoints into this canvas's local device coordinates (canvas clips the rest).
|
||
function drawLinkLinesForCanvas(ctx, canvas, isCompare){
|
||
if(!els.showLinksChk || !els.showLinksChk.checked) return;
|
||
if(!state.compareEnabled) return;
|
||
const srcWrap = getActiveRoom();
|
||
const dstWrap = getCompareRoom();
|
||
if(!srcWrap || !dstWrap || srcWrap === dstWrap) return;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const aRect = els.canvasA.getBoundingClientRect();
|
||
const bRect = els.canvasB.getBoundingClientRect();
|
||
const thisRect = (canvas === els.canvasA) ? aRect : bRect;
|
||
const thisLeft = thisRect.left * dpr, thisTop = thisRect.top * dpr;
|
||
|
||
const exits = srcWrap.data.exits || [];
|
||
for(let i=0;i<exits.length;i++){
|
||
const ex = exits[i];
|
||
if(!ex || ex.to !== dstWrap.data.name) continue;
|
||
|
||
// End the line at the ENTRY point in the compare room (spawn / entry spawn),
|
||
// i.e. exit -> entry, not exit -> exit.
|
||
const sid = ex.spawn_id || null;
|
||
let pos = null;
|
||
if(sid === "__player_spawn__"){
|
||
pos = dstWrap.data.player_spawn || null;
|
||
} else if(sid){
|
||
pos = (dstWrap.data.entry_spawns && dstWrap.data.entry_spawns[sid]) ? dstWrap.data.entry_spawns[sid] : null;
|
||
}
|
||
// Only fall back to the raw spawn coordinate when the exit has NO spawn_id.
|
||
// A stale spawn_id (e.g. from a target room that doesn't define it) would
|
||
// point the line at an arbitrary spot, so skip those broken links instead.
|
||
if(!pos && !sid && Array.isArray(ex.spawn) && ex.spawn.length >= 2){
|
||
pos = ex.spawn;
|
||
}
|
||
if(!pos) continue;
|
||
|
||
// viewport device coords of both endpoints
|
||
const [sx,sy] = worldToScreen(ex.x + ex.w/2, ex.y + ex.h/2, els.canvasA);
|
||
const [tx,ty] = worldToScreen(pos[0], pos[1], els.canvasB);
|
||
const p1x = aRect.left*dpr + sx, p1y = aRect.top*dpr + sy;
|
||
const p2x = bRect.left*dpr + tx, p2y = bRect.top*dpr + ty;
|
||
|
||
// to this canvas's local device coords
|
||
const lx1 = p1x - thisLeft, ly1 = p1y - thisTop;
|
||
const lx2 = p2x - thisLeft, ly2 = p2y - thisTop;
|
||
|
||
ctx.save();
|
||
ctx.strokeStyle = "rgba(167,139,250,0.9)";
|
||
ctx.lineWidth = 2*dpr;
|
||
ctx.setLineDash([6*dpr, 6*dpr]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(lx1, ly1);
|
||
ctx.lineTo(lx2, ly2);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
function render(){
|
||
drawRoom(ctxA, els.canvasA, getActiveRoom(), els.labelA, false);
|
||
if(state.compareEnabled){
|
||
drawRoom(ctxB, els.canvasB, getCompareRoom(), els.labelB, true);
|
||
}
|
||
requestAnimationFrame(render);
|
||
}
|
||
|
||
|
||
// Compatibility: older code paths call redraw(), but we run a continuous render loop.
|
||
function redraw(){
|
||
// no-op on purpose
|
||
}
|
||
|
||
|
||
|
||
|
||
// ---------- Hit Testing ----------
|
||
function hitTest(roomWrap, wx, wy, canvas){
|
||
if(!roomWrap) return null;
|
||
const room = roomWrap.data;
|
||
|
||
// exits, ability doors and enemies are resolved in the layerable pass below (layer-aware)
|
||
// player spawn
|
||
{
|
||
const [px,py]=room.player_spawn;
|
||
if(Math.abs(wx-px)<12 && Math.abs(wy-py)<12){
|
||
return {kind:"player_spawn", index:0, roomId: roomWrap.id};
|
||
}
|
||
}
|
||
// entry spawns
|
||
const entries = room.entry_spawns || {};
|
||
for(const k of Object.keys(entries)){
|
||
const [ex,ey]=entries[k];
|
||
if(Math.abs(wx-ex)<12 && Math.abs(wy-ey)<12){
|
||
return {kind:"entry_spawn", key:k, roomId: roomWrap.id};
|
||
}
|
||
}
|
||
|
||
// layerable objects (props, platforms, doors, exits, enemies, NPCs): pick the
|
||
// TOPMOST by layer, so an object in a higher layer wins over one behind it.
|
||
const vL = canvas ? getViewForCanvas(canvas) : {zoom:1};
|
||
const tolL = (8 / (vL.zoom||1));
|
||
const tol2L = tolL*tolL;
|
||
let best = null; // {key, kind, index, edge?}
|
||
const consider = (key, kind, index, edge)=>{
|
||
if(!best || key > best.key){
|
||
best = {key, kind, index, roomId: roomWrap.id, edge};
|
||
}
|
||
};
|
||
// props (category 0): house/diary/player use their shape, others a point marker
|
||
for(let i=(room.props||[]).length-1;i>=0;i--){
|
||
const p = room.props[i];
|
||
if(!p) continue;
|
||
const hr = getPropHitRect(p);
|
||
if(hr){
|
||
if(wx>=hr.x && wx<=hr.x+hr.w && wy>=hr.y && wy<=hr.y+hr.h){
|
||
consider((p.layer||0)*10 + 0, "prop", i, null);
|
||
}
|
||
} else {
|
||
const dx = wx - p.x, dy = wy - p.y;
|
||
if((dx*dx + dy*dy) <= 100){
|
||
consider((p.layer||0)*10 + 0, "prop", i, null);
|
||
}
|
||
}
|
||
}
|
||
// platforms (category 1) with edge detection for resize
|
||
for(let i=room.platforms.length-1;i>=0;i--){
|
||
const p=room.platforms[i];
|
||
if(wx>=p.x && wx<=p.x+p.w && wy>=p.y && wy<=p.y+p.h){
|
||
const edge = getResizeEdge(p, wx, wy, tol2L);
|
||
consider((p.layer||0)*10 + 1, "platform", i, edge);
|
||
}
|
||
}
|
||
// ability doors (category 2) with edge detection
|
||
for(let i=(room.ability_doors||[]).length-1;i>=0;i--){
|
||
const d=room.ability_doors[i];
|
||
if(d && wx>=d.x && wx<=d.x+d.w && wy>=d.y && wy<=d.y+d.h){
|
||
const edge = getResizeEdge(d, wx, wy, tol2L);
|
||
consider((d.layer||0)*10 + 2, "ability_door", i, edge);
|
||
}
|
||
}
|
||
// exits (category 3) with edge detection
|
||
for(let i=(room.exits||[]).length-1;i>=0;i--){
|
||
const ex=room.exits[i];
|
||
if(ex && wx>=ex.x && wx<=ex.x+ex.w && wy>=ex.y && wy<=ex.y+ex.h){
|
||
const edge = getResizeEdge(ex, wx, wy, tol2L);
|
||
consider((ex.layer||0)*10 + 3, "exit", i, edge);
|
||
}
|
||
}
|
||
// enemies (category 4)
|
||
for(let i=(room.enemies||[]).length-1;i>=0;i--){
|
||
const e=room.enemies[i];
|
||
if(e && Math.abs(wx-e.x)<10 && Math.abs(wy-e.y)<10){
|
||
consider((e.layer||0)*10 + 4, "enemy", i, null);
|
||
}
|
||
}
|
||
// npcs (category 5)
|
||
for(let i=(room.npcs||[]).length-1;i>=0;i--){
|
||
const n=room.npcs[i];
|
||
if(!n) continue;
|
||
if(wx>=n.x && wx<=n.x+36 && wy>=n.y && wy<=n.y+54){
|
||
consider((n.layer||0)*10 + 5, "npc", i, null);
|
||
}
|
||
}
|
||
if(best) return best;
|
||
return null;
|
||
}
|
||
|
||
// ---------- Editing ----------
|
||
function setTool(tool){
|
||
state.tool = tool;
|
||
document.querySelectorAll(".toolBtn").forEach(b=>{
|
||
const isActive = b.dataset.tool === tool;
|
||
b.classList.toggle("primary", isActive && tool!=="select");
|
||
b.classList.toggle("warn", isActive && tool==="select");
|
||
b.classList.toggle("active", isActive);
|
||
});
|
||
setStatus(`Tool: ${tool}`);
|
||
}
|
||
|
||
function setStatus(msg){
|
||
els.status.textContent = msg;
|
||
}
|
||
|
||
function propsHTMLForSelection(){
|
||
const sel = state.selection;
|
||
const roomWrap = sel ? state.rooms.get(sel.roomId) : getActiveRoom();
|
||
if(!roomWrap) return "<div class='muted'>No room loaded.</div>";
|
||
const room = roomWrap.data;
|
||
if(!sel){
|
||
return `
|
||
<div><b>Room Settings</b></div>
|
||
<div class="hr"></div>
|
||
<div style="margin-top:8px;">
|
||
<label><input id="roommist" type="checkbox" ${room.mist?"checked":""}/> Mist (purple haze - shade form costs no charge)</label>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if(sel.kind==="platform"){
|
||
const p=room.platforms[sel.index];
|
||
if(!p) return "<div class='muted'>Selection missing.</div>";
|
||
return `
|
||
<div><b>Platform</b> #${sel.index}</div>
|
||
<div class="hr"></div>
|
||
<div class="grid2">
|
||
<div><div class="mini">x</div><input id="px" type="number" value="${p.x}"/></div>
|
||
<div><div class="mini">y</div><input id="py" type="number" value="${p.y}"/></div>
|
||
<div><div class="mini">w</div><input id="pw" type="number" value="${p.w}"/></div>
|
||
<div><div class="mini">h</div><input id="ph" type="number" value="${p.h}"/></div>
|
||
<div><div class="mini">layer (lower = back)</div><input id="pl" type="number" value="${p.layer||0}"/></div>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">type</div>
|
||
<select id="pg">
|
||
<option value="ground" ${p.group==="ground"||!p.group?"selected":""}>Ground</option>
|
||
<option value="floating" ${p.group==="floating"?"selected":""}>Floating</option>
|
||
<option value="hazard" ${p.group==="hazard"?"selected":""}>Hazard (red - damage)</option>
|
||
<option value="moving" ${p.group==="moving"?"selected":""}>Moving (path)</option>
|
||
<option value="trigger" ${p.group==="trigger"?"selected":""}>Trigger (name)</option>
|
||
<option value="dissolve" ${p.group==="dissolve"?"selected":""}>Dissolve (timed)</option>
|
||
</select>
|
||
</div>
|
||
${(p.group||"ground") === "trigger" ? `
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">name</div>
|
||
<input id="ptname" type="text" value="${p.name||""}" placeholder="e.g. opens_bridge"/>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">unlock when quest accepted (id)</div>
|
||
<input id="ptquest" type="text" value="${p.quest||""}" placeholder="e.g. quest_first_kills"/>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">unlock when quest complete (id)</div>
|
||
<input id="ptqcomplete" type="text" value="${p.quest_complete||""}" placeholder="e.g. quest_first_kills"/>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">unlock when quest turned in (id)</div>
|
||
<input id="ptqturnedin" type="text" value="${p.quest_turned_in||""}" placeholder="e.g. quest_first_kills"/>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<label><input id="ptlockpass" type="checkbox" ${p.lock_when_pass?"checked":""}/> Appear once the player walks through (locks them in)</label>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<label><input id="ptminibossdead" type="checkbox" ${p.open_on_miniboss_dead?"checked":""}/> Present until the room's miniboss is slain</label>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<label><input id="ptbossdead" type="checkbox" ${p.open_on_boss_dead?"checked":""}/> Present until the room's boss is slain</label>
|
||
</div>` : ""}
|
||
${(p.group||"ground") === "hazard" ? `
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">respawn point (hazard teleports here)</div>
|
||
<select id="hzrespawn">
|
||
<option value="">(none - room default)</option>
|
||
${Object.keys(room.entry_spawns||{}).map(k=>`<option value="${k}">Entry: ${k}</option>`).join("")}
|
||
</select>
|
||
</div>` : ""}
|
||
${(p.group||"ground") === "moving" ? `
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">path (waypoints - click Add, then the canvas)</div>
|
||
<button id="addPathPt" class="primary" style="width:100%;">Add path point</button>
|
||
<div id="pathList" style="margin-top:6px; max-height:120px; overflow:auto;"></div>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">speed (px/s)</div>
|
||
<input id="ptspeed" type="number" value="${p.speed||120}"/>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<label><input id="pmbossdead" type="checkbox" ${p.open_on_boss_dead?"checked":""}/> Hidden until the room's boss is slain</label>
|
||
</div>` : ""}
|
||
${(p.group||"ground") === "dissolve" ? `
|
||
<div class="grid2" style="margin-top:8px;">
|
||
<div><div class="mini">stand time (s)</div><input id="ptstand" type="number" step="0.1" min="0.1" value="${p.stand_time!==undefined?p.stand_time:1.0}"/></div>
|
||
<div><div class="mini">dissolve time (s)</div><input id="ptdissolve" type="number" step="0.1" min="0.1" value="${p.dissolve_time!==undefined?p.dissolve_time:2.0}"/></div>
|
||
</div>
|
||
<div class="muted" style="margin-top:6px;">While the player stands on it, after the stand time it becomes translucent (no hitbox) for the dissolve time, then reforms.</div>` : ""}
|
||
<div class="row" style="margin-top:8px;">
|
||
<button id="delSel" class="danger">Delete</button>
|
||
</div>
|
||
`;
|
||
}
|
||
if(sel.kind==="exit"){
|
||
const ex=room.exits[sel.index];
|
||
if(!ex) return "<div class='muted'>Selection missing.</div>";
|
||
return `
|
||
<div><b>Exit</b> #${sel.index}</div>
|
||
<div class="hr"></div>
|
||
<div class="grid2">
|
||
<div><div class="mini">x</div><input id="exx" type="number" value="${ex.x}"/></div>
|
||
<div><div class="mini">y</div><input id="exy" type="number" value="${ex.y}"/></div>
|
||
<div><div class="mini">w</div><input id="exw" type="number" value="${ex.w}"/></div>
|
||
<div><div class="mini">h</div><input id="exh" type="number" value="${ex.h}"/></div>
|
||
<div><div class="mini">layer (lower = back)</div><input id="exlayer" type="number" value="${ex.layer||0}"/></div>
|
||
</div>
|
||
<div class="hr"></div>
|
||
<div class="grid2">
|
||
<div><div class="mini">to</div><input id="exto" type="text" value="${ex.to||""}" placeholder="room_02"/></div>
|
||
<div><div class="mini">spawn_id</div><input id="exsid" type="text" value="${ex.spawn_id||""}" placeholder="from_room_01"/></div>
|
||
</div>
|
||
<div class="muted" style="margin-top:8px;">Tip: use the Linking panel to set these safely.</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<button id="delSel" class="danger">Delete</button>
|
||
</div>
|
||
`;
|
||
}
|
||
if(sel.kind==="ability_door"){
|
||
const d = room.ability_doors && room.ability_doors[sel.index];
|
||
if(!d) return "<div class='muted'>Selection missing.</div>";
|
||
return `
|
||
<div><b>Ability Door</b> #${sel.index}</div>
|
||
<div class="hr"></div>
|
||
<div class="grid2">
|
||
<div><div class="mini">x</div><input id="adx" type="number" value="${d.x}"/></div>
|
||
<div><div class="mini">y</div><input id="ady" type="number" value="${d.y}"/></div>
|
||
<div><div class="mini">w</div><input id="adw" type="number" value="${d.w}"/></div>
|
||
<div><div class="mini">h</div><input id="adh" type="number" value="${d.h}"/></div>
|
||
<div><div class="mini">layer (lower = back)</div><input id="adlayer" type="number" value="${d.layer||0}"/></div>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">type</div>
|
||
<select id="adkind">
|
||
<option value="ranged" ${(d.kind||"ranged")==="ranged"?"selected":""}>Ranged door (broken by the Ranged bolt)</option>
|
||
<option value="shade" ${d.kind==="shade"?"selected":""}>Shade door (passable in shade form, solid in light)</option>
|
||
</select>
|
||
</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<button id="delSel" class="danger">Delete</button>
|
||
</div>
|
||
`;
|
||
}
|
||
if(sel.kind==="enemy"){
|
||
const e=room.enemies[sel.index];
|
||
if(!e) return "<div class='muted'>Selection missing.</div>";
|
||
const eType = e.type || "enemy";
|
||
const eDef = state.enemyTypes && state.enemyTypes[eType] ? state.enemyTypes[eType] : null;
|
||
const eProf = (eDef && eDef.drop_profile) ? eDef.drop_profile : "normal";
|
||
const beh = e.behavior || ((eDef && eDef.behavior) ? eDef.behavior : "chaser");
|
||
const bossBehavior = (eProf === "boss");
|
||
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>
|
||
${bossBehavior ? `
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">boss AI</div>
|
||
<select id="enbeh">
|
||
<option value="" ${beh===""?"selected":""}>default (from enemy type)</option>
|
||
<option value="shockwave" ${beh==="shockwave"?"selected":""}>shockwave (slam + shockwave)</option>
|
||
<option value="charger" ${beh==="charger"?"selected":""}>charger (retreat-charge + dash)</option>
|
||
<option value="flyer" ${beh==="flyer"?"selected":""}>flyer (air boss: slam, minions, aoe, homing shot)</option>
|
||
</select>
|
||
</div>
|
||
` : ""}
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">layer (lower = back)</div>
|
||
<input id="enlayer" type="number" value="${e.layer||0}"/>
|
||
</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<button id="delSel" class="danger">Delete</button>
|
||
</div>
|
||
`;
|
||
}
|
||
if(sel.kind==="npc"){
|
||
const n=room.npcs && room.npcs[sel.index];
|
||
if(!n) return "<div class='muted'>Selection missing.</div>";
|
||
return `
|
||
<div><b>NPC</b> #${sel.index}</div>
|
||
<div class="hr"></div>
|
||
<div class="grid2">
|
||
<div><div class="mini">x</div><input id="npx" type="number" value="${n.x}"/></div>
|
||
<div><div class="mini">y</div><input id="npy" type="number" value="${n.y}"/></div>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">type / name</div>
|
||
<input id="npt" type="text" value="${n.type||"vendor"}"/>
|
||
</div>
|
||
<div class="muted" style="margin-top:6px;">Any name works (e.g. quest_giver_gwen). vendor / upgrader / necklace_vendor / combat_master have game behavior; custom names are inert until coded.</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">layer (lower = back)</div>
|
||
<input id="npl" type="number" value="${n.layer||0}"/>
|
||
</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<button id="delSel" class="danger">Delete</button>
|
||
</div>
|
||
`;
|
||
}
|
||
if(sel.kind==="prop"){
|
||
const p=room.props && room.props[sel.index];
|
||
if(!p) return "<div class='muted'>Selection missing.</div>";
|
||
const prDefault = getDefaultRadiusForType(p.type || "light_source", p.object_id || p.objectId || null);
|
||
const objId = p.object_id || p.objectId || "";
|
||
const hint = ({light_source:"Butterfly (charge regen 2x)", save_point:"Lantern (save point - press F to save)", butterfly_jar:"Butterfly-in-jar (collectible)", diary:"Boss diary (interact)", house:"Standalone house", player:"Player size reference (no in-game effect)", quest_giver:"Quest point (name it below)"})[p.type||"light_source"] || "";
|
||
const isQuest = (p.type||"") === "quest_giver";
|
||
return `
|
||
<div><b>Prop</b> #${sel.index}${hint ? ` <span class="mini">· ${hint}</span>` : ""}</div>
|
||
<div class="hr"></div>
|
||
<div class="grid2">
|
||
<div><div class="mini">x</div><input id="prx" type="number" value="${p.x}"/></div>
|
||
<div><div class="mini">y</div><input id="pry" type="number" value="${p.y}"/></div>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">type</div>
|
||
<input id="prtype" type="text" value="${p.type||"light_source"}"/>
|
||
</div>
|
||
${isQuest ? `
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">quest id (name)</div>
|
||
<input id="prqid" type="text" value="${p.quest_id||""}" placeholder="e.g. quest_save_the_well"/>
|
||
</div>` : ""}
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">object id</div>
|
||
<input id="probjid" type="text" value="${objId}" placeholder="(e.g. flow_butterfly, lamp, flow_butterfly_jar)"/>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">radius</div>
|
||
<input id="prrad" type="number" value="${p.radius||prDefault}"/>
|
||
</div>
|
||
<div style="margin-top:8px;">
|
||
<div class="mini">layer (lower = back; houses use it)</div>
|
||
<input id="prlayer" type="number" value="${p.layer||0}"/>
|
||
</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<button id="delSel" class="danger">Delete</button>
|
||
</div>
|
||
`;
|
||
}
|
||
if(sel.kind==="player_spawn"){
|
||
const [x,y]=room.player_spawn;
|
||
return `
|
||
<div><b>Player Spawn</b></div>
|
||
<div class="hr"></div>
|
||
<div class="grid2">
|
||
<div><div class="mini">x</div><input id="psx" type="number" value="${x}"/></div>
|
||
<div><div class="mini">y</div><input id="psy" type="number" value="${y}"/></div>
|
||
</div>
|
||
`;
|
||
}
|
||
if(sel.kind==="entry_spawn"){
|
||
const k=sel.key;
|
||
const pos = room.entry_spawns[k];
|
||
const [x,y]=pos||[0,0];
|
||
return `
|
||
<div><b>Entry Spawn</b></div>
|
||
<div class="hr"></div>
|
||
<div style="margin-bottom:8px;">
|
||
<div class="mini">id</div>
|
||
<input id="esid" type="text" value="${k}"/>
|
||
</div>
|
||
<div class="grid2">
|
||
<div><div class="mini">x</div><input id="esx" type="number" value="${x}"/></div>
|
||
<div><div class="mini">y</div><input id="esy" type="number" value="${y}"/></div>
|
||
</div>
|
||
<div class="row" style="margin-top:8px;">
|
||
<button id="delEntry" class="danger">Delete</button>
|
||
</div>
|
||
`;
|
||
}
|
||
return "<div class='muted'>Select something to edit properties.</div>";
|
||
}
|
||
|
||
function wireProps(){
|
||
const roomWrap = getActiveRoom();
|
||
if(!roomWrap) return;
|
||
const room = roomWrap.data;
|
||
const sel = state.selection;
|
||
const q = (id)=>document.getElementById(id);
|
||
|
||
function num(v){ const n = Number(v); return Number.isFinite(n) ? n : 0; }
|
||
|
||
const pushAnd = (fn)=>{
|
||
pushHistory(roomWrap.id);
|
||
fn();
|
||
refreshUI();
|
||
};
|
||
|
||
if(!sel){
|
||
const rm = q("roommist");
|
||
if(rm) rm.addEventListener("change", ()=>pushAnd(()=>{ room.mist = rm.checked; }));
|
||
return;
|
||
}
|
||
|
||
if(sel.kind==="platform"){
|
||
const p = room.platforms[sel.index]; if(!p) return;
|
||
["px","py","pw","ph"].forEach(id=>{
|
||
const el=q(id); if(!el) return;
|
||
el.addEventListener("change", ()=>pushAnd(()=>{
|
||
if(id==="px") p.x = num(el.value);
|
||
if(id==="py") p.y = num(el.value);
|
||
if(id==="pw") p.w = Math.max(1,num(el.value));
|
||
if(id==="ph") p.h = Math.max(1,num(el.value));
|
||
}));
|
||
});
|
||
const pl=q("pl"); if(pl) pl.addEventListener("change", ()=>pushAnd(()=>{ p.layer = Math.round(num(pl.value)); }));
|
||
const pg=q("pg"); if(pg) pg.addEventListener("change", ()=>pushAnd(()=>{
|
||
p.group = pg.value || "ground";
|
||
if(p.group === "moving" && !Array.isArray(p.path)) p.path = [[p.x, p.y]];
|
||
if(p.group === "dissolve"){
|
||
if(p.stand_time === undefined) p.stand_time = 1.0;
|
||
if(p.dissolve_time === undefined) p.dissolve_time = 2.0;
|
||
}
|
||
if(p.group !== "trigger" && p.name !== undefined && p.name !== "") delete p.name;
|
||
}));
|
||
const ptname=q("ptname"); if(ptname) ptname.addEventListener("change", ()=>pushAnd(()=>{
|
||
const v = ptname.value.trim();
|
||
if(v){ p.name = v; } else { delete p.name; }
|
||
}));
|
||
const ptquest=q("ptquest"); if(ptquest) ptquest.addEventListener("change", ()=>pushAnd(()=>{
|
||
const v = ptquest.value.trim();
|
||
if(v){ p.quest = v; } else { delete p.quest; }
|
||
}));
|
||
const ptqcomplete=q("ptqcomplete"); if(ptqcomplete) ptqcomplete.addEventListener("change", ()=>pushAnd(()=>{
|
||
const v = ptqcomplete.value.trim();
|
||
if(v){ p.quest_complete = v; } else { delete p.quest_complete; }
|
||
}));
|
||
const ptqturnedin=q("ptqturnedin"); if(ptqturnedin) ptqturnedin.addEventListener("change", ()=>pushAnd(()=>{
|
||
const v = ptqturnedin.value.trim();
|
||
if(v){ p.quest_turned_in = v; } else { delete p.quest_turned_in; }
|
||
}));
|
||
const ptlockpass=q("ptlockpass"); if(ptlockpass) ptlockpass.addEventListener("change", ()=>pushAnd(()=>{
|
||
if(ptlockpass.checked){ p.lock_when_pass = true; } else { delete p.lock_when_pass; }
|
||
}));
|
||
const ptminibossdead=q("ptminibossdead"); if(ptminibossdead) ptminibossdead.addEventListener("change", ()=>pushAnd(()=>{
|
||
if(ptminibossdead.checked){ p.open_on_miniboss_dead = true; } else { delete p.open_on_miniboss_dead; }
|
||
}));
|
||
const ptbossdead=q("ptbossdead"); if(ptbossdead) ptbossdead.addEventListener("change", ()=>pushAnd(()=>{
|
||
if(ptbossdead.checked){ p.open_on_boss_dead = true; } else { delete p.open_on_boss_dead; }
|
||
}));
|
||
const ptspeed=q("ptspeed"); if(ptspeed) ptspeed.addEventListener("change", ()=>pushAnd(()=>{ p.speed = Math.max(1, num(ptspeed.value)); }));
|
||
const pmbossdead=q("pmbossdead"); if(pmbossdead) pmbossdead.addEventListener("change", ()=>pushAnd(()=>{
|
||
if(pmbossdead.checked){ p.open_on_boss_dead = true; } else { delete p.open_on_boss_dead; }
|
||
}));
|
||
const ptstand=q("ptstand"); if(ptstand) ptstand.addEventListener("change", ()=>pushAnd(()=>{ p.stand_time = Math.max(0.1, num(ptstand.value)); }));
|
||
const ptdissolve=q("ptdissolve"); if(ptdissolve) ptdissolve.addEventListener("change", ()=>pushAnd(()=>{ p.dissolve_time = Math.max(0.1, num(ptdissolve.value)); }));
|
||
const hzrespawn=q("hzrespawn");
|
||
if(hzrespawn){
|
||
// preselect current respawn if it matches an entry spawn
|
||
let cur = "";
|
||
if(Array.isArray(p.respawn)){
|
||
const es = room.entry_spawns || {};
|
||
for(const k of Object.keys(es)){
|
||
const pos = es[k];
|
||
if(Array.isArray(pos) && pos.length>=2 && Math.round(pos[0])===Math.round(p.respawn[0]) && Math.round(pos[1])===Math.round(p.respawn[1])){
|
||
cur = k; break;
|
||
}
|
||
}
|
||
}
|
||
hzrespawn.value = cur;
|
||
hzrespawn.addEventListener("change", ()=>pushAnd(()=>{
|
||
const v = hzrespawn.value;
|
||
if(v === ""){ delete p.respawn; }
|
||
else {
|
||
const pos = (room.entry_spawns||{})[v];
|
||
if(Array.isArray(pos) && pos.length>=2) p.respawn = [Math.round(pos[0]), Math.round(pos[1])];
|
||
}
|
||
}));
|
||
}
|
||
const addPathPt=q("addPathPt"); if(addPathPt) addPathPt.addEventListener("click", ()=>{
|
||
state.waitingPathPt = { roomId: roomWrap.id, index: sel.index };
|
||
setStatus("Click on the canvas to add a path point to this moving platform.");
|
||
refreshProps();
|
||
});
|
||
const pathList=q("pathList");
|
||
if(pathList && Array.isArray(p.path)){
|
||
pathList.innerHTML = "";
|
||
p.path.forEach((pt, pi)=>{
|
||
const row = document.createElement("div");
|
||
row.style.cssText = "display:flex; align-items:center; gap:6px; margin-bottom:4px;";
|
||
row.innerHTML = `<span style="flex:1; font-size:12px;">${pi+1}. (${Math.round(pt[0])}, ${Math.round(pt[1])})</span>`;
|
||
const delBtn = document.createElement("button");
|
||
delBtn.textContent = "x";
|
||
delBtn.style.cssText = "padding:2px 8px;";
|
||
delBtn.addEventListener("click", ()=>pushAnd(()=>{
|
||
p.path.splice(pi,1);
|
||
}));
|
||
row.appendChild(delBtn);
|
||
pathList.appendChild(row);
|
||
});
|
||
if(state.waitingPathPt && state.waitingPathPt.index === sel.index){
|
||
const hint = document.createElement("div");
|
||
hint.className = "muted";
|
||
hint.textContent = "Click the canvas to add point " + (p.path.length + 1);
|
||
pathList.appendChild(hint);
|
||
}
|
||
}
|
||
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
|
||
room.platforms.splice(sel.index,1);
|
||
state.selection=null;
|
||
}));
|
||
} else if(sel.kind==="exit"){
|
||
const ex = room.exits[sel.index]; if(!ex) return;
|
||
["exx","exy","exw","exh"].forEach(id=>{
|
||
const el=q(id); if(!el) return;
|
||
el.addEventListener("change", ()=>pushAnd(()=>{
|
||
if(id==="exx") ex.x = num(el.value);
|
||
if(id==="exy") ex.y = num(el.value);
|
||
if(id==="exw") ex.w = Math.max(1,num(el.value));
|
||
if(id==="exh") ex.h = Math.max(1,num(el.value));
|
||
}));
|
||
});
|
||
const exto=q("exto"); if(exto) exto.addEventListener("change", ()=>pushAnd(()=>{ ex.to = exto.value.trim(); }));
|
||
const exsid=q("exsid"); if(exsid) exsid.addEventListener("change", ()=>pushAnd(()=>{ ex.spawn_id = exsid.value.trim(); }));
|
||
const exlayer=q("exlayer"); if(exlayer) exlayer.addEventListener("change", ()=>pushAnd(()=>{ ex.layer = Math.round(num(exlayer.value)); }));
|
||
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
|
||
room.exits.splice(sel.index,1);
|
||
state.selection=null;
|
||
}));
|
||
} else if(sel.kind==="ability_door"){
|
||
const d = room.ability_doors && room.ability_doors[sel.index]; if(!d) return;
|
||
["adx","ady","adw","adh"].forEach(id=>{
|
||
const el=q(id); if(!el) return;
|
||
el.addEventListener("change", ()=>pushAnd(()=>{
|
||
if(id==="adx") d.x = num(el.value);
|
||
if(id==="ady") d.y = num(el.value);
|
||
if(id==="adw") d.w = Math.max(1,num(el.value));
|
||
if(id==="adh") d.h = Math.max(1,num(el.value));
|
||
}));
|
||
});
|
||
const adlayer=q("adlayer"); if(adlayer) adlayer.addEventListener("change", ()=>pushAnd(()=>{ d.layer = Math.round(num(adlayer.value)); }));
|
||
const adkind=q("adkind"); if(adkind) adkind.addEventListener("change", ()=>pushAnd(()=>{ d.kind = adkind.value === "shade" ? "shade" : "ranged"; }));
|
||
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
|
||
room.ability_doors.splice(sel.index,1);
|
||
state.selection=null;
|
||
}));
|
||
} else if(sel.kind==="enemy"){
|
||
const e = room.enemies[sel.index]; if(!e) return;
|
||
const enx=q("enx"), eny=q("eny"), ent=q("ent"), enlayer=q("enlayer"), enbeh=q("enbeh");
|
||
if(enx) enx.addEventListener("change", ()=>pushAnd(()=>{ e.x=num(enx.value); }));
|
||
if(eny) eny.addEventListener("change", ()=>pushAnd(()=>{ e.y=num(eny.value); }));
|
||
if(ent) ent.addEventListener("change", ()=>pushAnd(()=>{ e.type=ent.value.trim()||"enemy"; }));
|
||
if(enlayer) enlayer.addEventListener("change", ()=>pushAnd(()=>{ e.layer = Math.round(num(enlayer.value)); }));
|
||
if(enbeh) enbeh.addEventListener("change", ()=>pushAnd(()=>{
|
||
const v = enbeh.value;
|
||
if(v) e.behavior = v; else delete e.behavior;
|
||
}));
|
||
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
|
||
room.enemies.splice(sel.index,1);
|
||
state.selection=null;
|
||
}));
|
||
} else if(sel.kind==="npc"){
|
||
const n = room.npcs && room.npcs[sel.index]; if(!n) return;
|
||
const npx=q("npx"), npy=q("npy"), npt=q("npt"), npl=q("npl");
|
||
if(npx) npx.addEventListener("change", ()=>pushAnd(()=>{ n.x=num(npx.value); }));
|
||
if(npy) npy.addEventListener("change", ()=>pushAnd(()=>{ n.y=num(npy.value); }));
|
||
if(npt) npt.addEventListener("change", ()=>pushAnd(()=>{ n.type=npt.value.trim()||"vendor"; }));
|
||
if(npl) npl.addEventListener("change", ()=>pushAnd(()=>{ n.layer = Math.round(num(npl.value)); }));
|
||
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
|
||
room.npcs.splice(sel.index,1);
|
||
state.selection=null;
|
||
}));
|
||
} else if(sel.kind==="prop"){
|
||
const p = room.props && room.props[sel.index]; if(!p) return;
|
||
const prx=q("prx"), pry=q("pry"), prtype=q("prtype"), probjid=q("probjid"), prrad=q("prrad"), prlayer=q("prlayer"), prqid=q("prqid");
|
||
if(prx) prx.addEventListener("change", ()=>pushAnd(()=>{ p.x=num(prx.value); }));
|
||
if(pry) pry.addEventListener("change", ()=>pushAnd(()=>{ p.y=num(pry.value); }));
|
||
if(prlayer) prlayer.addEventListener("change", ()=>pushAnd(()=>{ p.layer = Math.round(num(prlayer.value)); }));
|
||
if(prqid) prqid.addEventListener("change", ()=>pushAnd(()=>{
|
||
const v = prqid.value.trim();
|
||
if(v){ p.quest_id = v; } else { delete p.quest_id; }
|
||
}));
|
||
if(probjid) probjid.addEventListener("change", ()=>pushAnd(()=>{
|
||
const v = probjid.value.trim();
|
||
if(v){ p.object_id = v; } else { delete p.object_id; }
|
||
}));
|
||
if(prtype) prtype.addEventListener("change", ()=>pushAnd(()=>{
|
||
p.type=prtype.value.trim()||"light_source";
|
||
if(!(Number.isFinite(Number(p.radius)) && Number(p.radius) > 0)){
|
||
p.radius = getDefaultRadiusForType(p.type, p.object_id || p.objectId || null);
|
||
if(prrad) prrad.value = String(p.radius);
|
||
}
|
||
}));
|
||
if(prrad) prrad.addEventListener("change", ()=>pushAnd(()=>{ p.radius=Math.max(0,num(prrad.value)); }));
|
||
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
|
||
room.props.splice(sel.index,1);
|
||
state.selection=null;
|
||
}));
|
||
} else if(sel.kind==="player_spawn"){
|
||
const psx=q("psx"), psy=q("psy");
|
||
if(psx) psx.addEventListener("change", ()=>pushAnd(()=>{ room.player_spawn[0]=num(psx.value); }));
|
||
if(psy) psy.addEventListener("change", ()=>pushAnd(()=>{ room.player_spawn[1]=num(psy.value); }));
|
||
} else if(sel.kind==="entry_spawn"){
|
||
const oldKey = sel.key;
|
||
const esid=q("esid"), esx=q("esx"), esy=q("esy"), del=q("delEntry");
|
||
if(esid) esid.addEventListener("change", ()=>pushAnd(()=>{
|
||
const newKey = esid.value.trim() || oldKey;
|
||
if(newKey !== oldKey){
|
||
room.entry_spawns[newKey] = room.entry_spawns[oldKey];
|
||
delete room.entry_spawns[oldKey];
|
||
state.selection = {kind:"entry_spawn", key:newKey, roomId: roomWrap.id};
|
||
}
|
||
}));
|
||
if(esx) esx.addEventListener("change", ()=>pushAnd(()=>{ room.entry_spawns[state.selection.key][0]=num(esx.value); }));
|
||
if(esy) esy.addEventListener("change", ()=>pushAnd(()=>{ room.entry_spawns[state.selection.key][1]=num(esy.value); }));
|
||
if(del) del.addEventListener("click", ()=>pushAnd(()=>{
|
||
delete room.entry_spawns[state.selection.key];
|
||
state.selection=null;
|
||
}));
|
||
}
|
||
}
|
||
|
||
function refreshProps(){
|
||
els.props.innerHTML = propsHTMLForSelection();
|
||
wireProps();
|
||
}
|
||
|
||
// ---------- Main Interaction (Active Canvas) ----------
|
||
function onPointerDown(ev){
|
||
const roomWrap = getActiveRoom(); if(!roomWrap) return;
|
||
const rect = els.canvasA.getBoundingClientRect();
|
||
const sx = ev.clientX - rect.left;
|
||
const sy = ev.clientY - rect.top;
|
||
const [wx,wy] = screenToWorld(sx,sy,els.canvasA);
|
||
|
||
const room = roomWrap.data;
|
||
|
||
// Adding a path point to a selected moving platform?
|
||
if(state.waitingPathPt){
|
||
if(ev.button === 0){
|
||
ev.preventDefault();
|
||
const wp = state.waitingPathPt;
|
||
const wrap = state.rooms.get(wp.roomId);
|
||
const plat = wrap && wrap.data.platforms && wrap.data.platforms[wp.index];
|
||
if(plat){
|
||
if(!Array.isArray(plat.path)) plat.path = [[plat.x, plat.y]];
|
||
plat.path.push(snapPt(wx, wy));
|
||
}
|
||
state.waitingPathPt = null;
|
||
refreshUI();
|
||
setStatus("Path point added.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
if(ev.button === 1){
|
||
// middle mouse pan is handled by the mousedown listener (better cross-browser)
|
||
return;
|
||
}
|
||
if(ev.button === 0 && ev.altKey){
|
||
// alt-drag pan
|
||
ev.preventDefault();
|
||
state.dragging = {mode:"pan", canvas:"A", startX:sx, startY:sy, startPanX:state.viewA.panX, startPanY:state.viewA.panY, startZoom:state.viewA.zoom};
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "select"){
|
||
const hit = hitTest(roomWrap, wx, wy, els.canvasA);
|
||
state.selection = hit;
|
||
refreshProps();
|
||
if(hit){
|
||
if(hit.kind==="platform" && hit.edge){
|
||
const p = room.platforms[hit.index];
|
||
if(p){
|
||
pushHistory(roomWrap.id);
|
||
state.dragging = {
|
||
mode:"resizePlatformEdge",
|
||
canvas:"A",
|
||
roomId: roomWrap.id,
|
||
index: hit.index,
|
||
edge: hit.edge,
|
||
base: {x:p.x,y:p.y,w:p.w,h:p.h},
|
||
startWX: wx,
|
||
startWY: wy
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
if(hit.kind==="exit" && hit.edge){
|
||
const ex = room.exits[hit.index];
|
||
if(ex){
|
||
pushHistory(roomWrap.id);
|
||
state.dragging = {
|
||
mode:"resizeExitEdge",
|
||
canvas:"A",
|
||
roomId: roomWrap.id,
|
||
index: hit.index,
|
||
edge: hit.edge,
|
||
base: {x:ex.x,y:ex.y,w:ex.w,h:ex.h},
|
||
startWX: wx,
|
||
startWY: wy
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
if(hit.kind==="ability_door" && hit.edge){
|
||
const d = room.ability_doors && room.ability_doors[hit.index];
|
||
if(d){
|
||
pushHistory(roomWrap.id);
|
||
state.dragging = {
|
||
mode:"resizeAbilityDoorEdge",
|
||
canvas:"A",
|
||
roomId: roomWrap.id,
|
||
index: hit.index,
|
||
edge: hit.edge,
|
||
base: {x:d.x,y:d.y,w:d.w,h:d.h},
|
||
startWX: wx,
|
||
startWY: wy
|
||
};
|
||
return;
|
||
}
|
||
}
|
||
|
||
state.dragging = {mode:"move", canvas:"A", roomId: roomWrap.id, sel:deepClone(hit), startWX:wx, startWY:wy, base:null};
|
||
} else {
|
||
state.dragging = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// creation tools
|
||
if(state.tool === "platform"){
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
|
||
// Start as a 1x1 rect at the anchor; resizing can go in any direction.
|
||
const p = {x: sx1, y: sy1, w: 1, h: 1, group: state.platformGroup || "ground"};
|
||
if(p.group === "moving") p.path = [[sx1, sy1]];
|
||
room.platforms.push(p);
|
||
|
||
state.selection = {kind:"platform", index: room.platforms.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
|
||
state.dragging = {
|
||
mode:"resizePlatform",
|
||
canvas:"A",
|
||
roomId: roomWrap.id,
|
||
index: room.platforms.length-1,
|
||
startWX: wx,
|
||
startWY: wy,
|
||
anchorX: sx1,
|
||
anchorY: sy1
|
||
};
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "enemy_spawn"){
|
||
if(!state.enemyTypes || !state.enemyTypeKeys || state.enemyTypeKeys.length === 0){
|
||
setStatus("Please import an enemy types JSON first.");
|
||
return;
|
||
}
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
room.enemies.push({x: sx1, y: sy1, type: (state.enemyPlaceType || "enemy")});
|
||
state.selection = {kind:"enemy", index:room.enemies.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "npc_spawn"){
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
room.npcs = room.npcs || [];
|
||
room.npcs.push({x: sx1, y: sy1, type: (state.npcPlaceType || "vendor")});
|
||
state.selection = {kind:"npc", index:room.npcs.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "player_spawn"){
|
||
pushHistory(roomWrap.id);
|
||
room.player_spawn = snapPt(wx,wy);
|
||
state.selection = {kind:"player_spawn", index:0, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "entry_spawn"){
|
||
pushHistory(roomWrap.id);
|
||
// auto-name
|
||
let base = "entry_";
|
||
let n = 1;
|
||
while(room.entry_spawns[base+n]) n++;
|
||
const key = base+n;
|
||
room.entry_spawns[key] = snapPt(wx,wy);
|
||
state.selection = {kind:"entry_spawn", key, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "exit"){
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
|
||
room.exits.push({x: sx1, y: sy1, w: 10, h: 10, to:"", spawn_id:"", spawn:[sx1,sy1]});
|
||
state.selection = {kind:"exit", index:room.exits.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
|
||
state.dragging = {
|
||
mode:"resizeExit",
|
||
canvas:"A",
|
||
roomId: roomWrap.id,
|
||
index: room.exits.length-1,
|
||
startWX: wx,
|
||
startWY: wy,
|
||
anchorX: sx1,
|
||
anchorY: sy1
|
||
};
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "ability_door"){
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
|
||
room.ability_doors.push({x: sx1, y: sy1, w: 10, h: 10});
|
||
state.selection = {kind:"ability_door", index:room.ability_doors.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
|
||
state.dragging = {
|
||
mode:"resizeAbilityDoor",
|
||
canvas:"A",
|
||
roomId: roomWrap.id,
|
||
index: room.ability_doors.length-1,
|
||
startWX: wx,
|
||
startWY: wy,
|
||
anchorX: sx1,
|
||
anchorY: sy1
|
||
};
|
||
return;
|
||
}
|
||
|
||
if(state.tool === "object_spawn"){
|
||
if(!objectCatalog.byId || Object.keys(objectCatalog.byId).length === 0){
|
||
setStatus("Please import an objects JSON first.");
|
||
return;
|
||
}
|
||
pushHistory(roomWrap.id);
|
||
const [sx1,sy1]=snapPt(wx,wy);
|
||
const props = room.props || (room.props = []);
|
||
const objectId = state.objectPlaceId || null;
|
||
const def = getObjectDefById(objectId) || getObjectDefByType("light_source");
|
||
const type = (def && def.type) ? def.type : "light_source";
|
||
const radius = getDefaultRadiusForType(type, objectId);
|
||
props.push({x:sx1, y:sy1, type, radius, object_id: objectId});
|
||
state.selection = {kind:"prop", index:props.length-1, roomId: roomWrap.id};
|
||
refreshUI();
|
||
return;
|
||
}
|
||
}
|
||
|
||
function onPointerMove(ev){
|
||
if(!state.dragging) return;
|
||
|
||
// pan (works for both canvases)
|
||
if(state.dragging.mode === "pan"){
|
||
const canvas = (state.dragging.canvas === "B") ? els.canvasB : els.canvasA;
|
||
const v = (state.dragging.canvas === "B") ? state.viewB : state.viewA;
|
||
const r2 = canvas.getBoundingClientRect();
|
||
const sx2 = ev.clientX - r2.left;
|
||
const sy2 = ev.clientY - r2.top;
|
||
const dx = (sx2 - state.dragging.startX) / v.zoom;
|
||
const dy = (sy2 - state.dragging.startY) / v.zoom;
|
||
v.panX = state.dragging.startPanX + dx;
|
||
v.panY = state.dragging.startPanY + dy;
|
||
return;
|
||
}
|
||
|
||
const canvas = (state.dragging.canvas === "B") ? els.canvasB : els.canvasA;
|
||
const r = canvas.getBoundingClientRect();
|
||
const sx = ev.clientX - r.left;
|
||
const sy = ev.clientY - r.top;
|
||
const [wx,wy] = screenToWorld(sx,sy,canvas);
|
||
|
||
const roomId = state.dragging.roomId || (state.activeId || null);
|
||
const roomWrap = state.rooms.get(roomId);
|
||
if(!roomWrap) return;
|
||
const room = roomWrap.data;
|
||
|
||
if(state.dragging.mode === "move" && state.selection){
|
||
const sel = state.dragging.sel;
|
||
if(sel.roomId !== roomWrap.id) return;
|
||
|
||
// first movement triggers history push once (but only for moves, not edge-resize which already pushed)
|
||
if(!state.dragging.didPush){
|
||
pushHistory(roomWrap.id);
|
||
state.dragging.didPush = true;
|
||
}
|
||
|
||
// initialize base positions once
|
||
if(!state.dragging.base){
|
||
const base = { kind: sel.kind };
|
||
if(sel.kind==="platform"){
|
||
const p=room.platforms[sel.index]; if(p) base.p = {x:p.x,y:p.y};
|
||
} else if(sel.kind==="exit"){
|
||
const ex=room.exits[sel.index]; if(ex) base.ex = {x:ex.x,y:ex.y};
|
||
} else if(sel.kind==="ability_door"){
|
||
const d=room.ability_doors && room.ability_doors[sel.index]; if(d) base.ad = {x:d.x,y:d.y};
|
||
} else if(sel.kind==="enemy"){
|
||
const e=room.enemies[sel.index]; if(e) base.e = {x:e.x,y:e.y};
|
||
} else if(sel.kind==="npc"){
|
||
const n=room.npcs && room.npcs[sel.index]; if(n) base.n = {x:n.x,y:n.y};
|
||
} else if(sel.kind==="prop"){
|
||
const p=room.props && room.props[sel.index]; if(p) base.pr = {x:p.x,y:p.y};
|
||
} else if(sel.kind==="player_spawn"){
|
||
base.ps = {x:room.player_spawn[0], y:room.player_spawn[1]};
|
||
} else if(sel.kind==="entry_spawn"){
|
||
const pos=room.entry_spawns[sel.key]; if(pos) base.es = {x:pos[0], y:pos[1]};
|
||
}
|
||
base.startWX = wx;
|
||
base.startWY = wy;
|
||
state.dragging.base = base;
|
||
}
|
||
|
||
const offX = wx - state.dragging.base.startWX;
|
||
const offY = wy - state.dragging.base.startWY;
|
||
|
||
if(sel.kind==="platform"){
|
||
const p=room.platforms[sel.index];
|
||
if(p && state.dragging.base.p){
|
||
p.x = snap(state.dragging.base.p.x + offX);
|
||
p.y = snap(state.dragging.base.p.y + offY);
|
||
}
|
||
} else if(sel.kind==="exit"){
|
||
const ex=room.exits[sel.index];
|
||
if(ex && state.dragging.base.ex){
|
||
ex.x = snap(state.dragging.base.ex.x + offX);
|
||
ex.y = snap(state.dragging.base.ex.y + offY);
|
||
}
|
||
} else if(sel.kind==="ability_door"){
|
||
const d=room.ability_doors && room.ability_doors[sel.index];
|
||
if(d && state.dragging.base.ad){
|
||
d.x = snap(state.dragging.base.ad.x + offX);
|
||
d.y = snap(state.dragging.base.ad.y + offY);
|
||
}
|
||
} else if(sel.kind==="enemy"){
|
||
const e=room.enemies[sel.index];
|
||
if(e && state.dragging.base.e){
|
||
e.x = snap(state.dragging.base.e.x + offX);
|
||
e.y = snap(state.dragging.base.e.y + offY);
|
||
}
|
||
} else if(sel.kind==="npc"){
|
||
const n=room.npcs && room.npcs[sel.index];
|
||
if(n && state.dragging.base.n){
|
||
n.x = snap(state.dragging.base.n.x + offX);
|
||
n.y = snap(state.dragging.base.n.y + offY);
|
||
}
|
||
} else if(sel.kind==="prop"){
|
||
const p=room.props && room.props[sel.index];
|
||
if(p && state.dragging.base.pr){
|
||
p.x = snap(state.dragging.base.pr.x + offX);
|
||
p.y = snap(state.dragging.base.pr.y + offY);
|
||
}
|
||
} else if(sel.kind==="player_spawn"){
|
||
if(state.dragging.base.ps){
|
||
room.player_spawn[0] = snap(state.dragging.base.ps.x + offX);
|
||
room.player_spawn[1] = snap(state.dragging.base.ps.y + offY);
|
||
}
|
||
} else if(sel.kind==="entry_spawn"){
|
||
const pos=room.entry_spawns[sel.key];
|
||
if(pos && state.dragging.base.es){
|
||
pos[0] = snap(state.dragging.base.es.x + offX);
|
||
pos[1] = snap(state.dragging.base.es.y + offY);
|
||
}
|
||
}
|
||
|
||
markDirty(roomWrap.id, true);
|
||
refreshProps();
|
||
return;
|
||
}
|
||
|
||
if(state.dragging.mode === "resizePlatform"){
|
||
const p = room.platforms[state.dragging.index]; if(!p) return;
|
||
|
||
// Allow resizing in all directions while creating:
|
||
// anchor is the initial click position (snapped). We expand towards the current mouse position.
|
||
const ax = (typeof state.dragging.anchorX === "number") ? state.dragging.anchorX : snap(state.dragging.startWX);
|
||
const ay = (typeof state.dragging.anchorY === "number") ? state.dragging.anchorY : snap(state.dragging.startWY);
|
||
const cx = snap(wx);
|
||
const cy = snap(wy);
|
||
|
||
const x0 = Math.min(ax, cx);
|
||
const y0 = Math.min(ay, cy);
|
||
const x1 = Math.max(ax, cx);
|
||
const y1 = Math.max(ay, cy);
|
||
|
||
p.x = x0;
|
||
p.y = y0;
|
||
p.w = Math.max(1, x1 - x0);
|
||
p.h = Math.max(1, y1 - y0);
|
||
|
||
markDirty(roomWrap.id, true);
|
||
refreshProps();
|
||
return;
|
||
}
|
||
|
||
if(state.dragging.mode === "resizeExit"){
|
||
const ex = room.exits[state.dragging.index]; if(!ex) return;
|
||
|
||
const ax = (typeof state.dragging.anchorX === "number") ? state.dragging.anchorX : snap(state.dragging.startWX);
|
||
const ay = (typeof state.dragging.anchorY === "number") ? state.dragging.anchorY : snap(state.dragging.startWY);
|
||
const cx = snap(wx);
|
||
const cy = snap(wy);
|
||
|
||
const x0 = Math.min(ax, cx);
|
||
const y0 = Math.min(ay, cy);
|
||
const x1 = Math.max(ax, cx);
|
||
const y1 = Math.max(ay, cy);
|
||
|
||
ex.x = x0;
|
||
ex.y = y0;
|
||
ex.w = Math.max(10, x1 - x0);
|
||
ex.h = Math.max(10, y1 - y0);
|
||
|
||
markDirty(roomWrap.id, true);
|
||
refreshProps();
|
||
return;
|
||
}
|
||
if(state.dragging.mode === "resizeAbilityDoor"){
|
||
const d = room.ability_doors && room.ability_doors[state.dragging.index]; if(!d) return;
|
||
|
||
const ax = (typeof state.dragging.anchorX === "number") ? state.dragging.anchorX : snap(state.dragging.startWX);
|
||
const ay = (typeof state.dragging.anchorY === "number") ? state.dragging.anchorY : snap(state.dragging.startWY);
|
||
const cx = snap(wx);
|
||
const cy = snap(wy);
|
||
|
||
const x0 = Math.min(ax, cx);
|
||
const y0 = Math.min(ay, cy);
|
||
const x1 = Math.max(ax, cx);
|
||
const y1 = Math.max(ay, cy);
|
||
|
||
d.x = x0;
|
||
d.y = y0;
|
||
d.w = Math.max(10, x1 - x0);
|
||
d.h = Math.max(10, y1 - y0);
|
||
|
||
markDirty(roomWrap.id, true);
|
||
refreshProps();
|
||
return;
|
||
}
|
||
|
||
if(state.dragging.mode === "resizePlatformEdge"){
|
||
const p = room.platforms[state.dragging.index]; if(!p) return;
|
||
const b = state.dragging.base;
|
||
const dx = snap(wx) - snap(state.dragging.startWX);
|
||
const dy = snap(wy) - snap(state.dragging.startWY);
|
||
|
||
const edge = state.dragging.edge;
|
||
|
||
// horizontal
|
||
if(edge === 'l' || edge === 'w' || edge === 'nw' || edge === 'sw'){
|
||
const newX = b.x + dx;
|
||
const newW = (b.x + b.w) - newX;
|
||
p.x = newX;
|
||
p.w = Math.max(1, newW);
|
||
}
|
||
if(edge === 'r' || edge === 'e' || edge === 'ne' || edge === 'se'){
|
||
p.w = Math.max(1, b.w + dx);
|
||
}
|
||
|
||
// vertical
|
||
if(edge === 't' || edge === 'n' || edge === 'nw' || edge === 'ne'){
|
||
const newY = b.y + dy;
|
||
const newH = (b.y + b.h) - newY;
|
||
p.y = newY;
|
||
p.h = Math.max(1, newH);
|
||
}
|
||
if(edge === 'b' || edge === 's' || edge === 'sw' || edge === 'se'){
|
||
p.h = Math.max(1, b.h + dy);
|
||
}
|
||
|
||
// clamp if inverted due to dragging past opposite edge
|
||
if(p.w < 1){ p.w = 1; }
|
||
if(p.h < 1){ p.h = 1; }
|
||
|
||
markDirty(roomWrap.id, true);
|
||
refreshProps();
|
||
return;
|
||
}
|
||
if(state.dragging.mode === "resizeExitEdge"){
|
||
const ex = room.exits[state.dragging.index]; if(!ex) return;
|
||
const b = state.dragging.base;
|
||
const dx = snap(wx) - snap(state.dragging.startWX);
|
||
const dy = snap(wy) - snap(state.dragging.startWY);
|
||
const edge = state.dragging.edge;
|
||
|
||
// horizontal
|
||
if(edge === 'w' || edge === 'nw' || edge === 'sw'){
|
||
const newX = b.x + dx;
|
||
const newW = (b.x + b.w) - newX;
|
||
ex.x = newX;
|
||
ex.w = Math.max(10, newW);
|
||
}
|
||
if(edge === 'e' || edge === 'ne' || edge === 'se'){
|
||
ex.w = Math.max(10, b.w + dx);
|
||
}
|
||
|
||
// vertical
|
||
if(edge === 'n' || edge === 'nw' || edge === 'ne'){
|
||
const newY = b.y + dy;
|
||
const newH = (b.y + b.h) - newY;
|
||
ex.y = newY;
|
||
ex.h = Math.max(10, newH);
|
||
}
|
||
if(edge === 's' || edge === 'sw' || edge === 'se'){
|
||
ex.h = Math.max(10, b.h + dy);
|
||
}
|
||
|
||
markDirty(roomWrap.id, true);
|
||
refreshProps();
|
||
return;
|
||
}
|
||
if(state.dragging.mode === "resizeAbilityDoorEdge"){
|
||
const d = room.ability_doors && room.ability_doors[state.dragging.index]; if(!d) return;
|
||
const b = state.dragging.base;
|
||
const dx = snap(wx) - snap(state.dragging.startWX);
|
||
const dy = snap(wy) - snap(state.dragging.startWY);
|
||
const edge = state.dragging.edge;
|
||
|
||
if(edge === 'w' || edge === 'nw' || edge === 'sw'){
|
||
const newX = b.x + dx;
|
||
const newW = (b.x + b.w) - newX;
|
||
d.x = newX;
|
||
d.w = Math.max(10, newW);
|
||
}
|
||
if(edge === 'e' || edge === 'ne' || edge === 'se'){
|
||
d.w = Math.max(10, b.w + dx);
|
||
}
|
||
|
||
if(edge === 'n' || edge === 'nw' || edge === 'ne'){
|
||
const newY = b.y + dy;
|
||
const newH = (b.y + b.h) - newY;
|
||
d.y = newY;
|
||
d.h = Math.max(10, newH);
|
||
}
|
||
if(edge === 's' || edge === 'sw' || edge === 'se'){
|
||
d.h = Math.max(10, b.h + dy);
|
||
}
|
||
|
||
markDirty(roomWrap.id, true);
|
||
refreshProps();
|
||
return;
|
||
}
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
function onPointerUp(ev){
|
||
state.dragging = null;
|
||
}
|
||
|
||
function onWheel(ev){
|
||
// zoom around pointer
|
||
const v = state.viewA;
|
||
const rect = els.canvasA.getBoundingClientRect();
|
||
const sx = ev.clientX - rect.left;
|
||
const sy = ev.clientY - rect.top;
|
||
const [wx1,wy1] = screenToWorld(sx,sy,els.canvasA);
|
||
const delta = -ev.deltaY * 0.001;
|
||
const newZoom = clamp(v.zoom * (1+delta), 0.2, 4.0);
|
||
v.zoom = newZoom;
|
||
const [wx2,wy2] = screenToWorld(sx,sy,els.canvasA);
|
||
v.panX += (wx2 - wx1);
|
||
v.panY += (wy2 - wy1);
|
||
ev.preventDefault();
|
||
}
|
||
|
||
function onWheelB(ev){
|
||
const v = state.viewB;
|
||
const rect = els.canvasB.getBoundingClientRect();
|
||
const sx = ev.clientX - rect.left;
|
||
const sy = ev.clientY - rect.top;
|
||
const [wx1,wy1] = screenToWorld(sx,sy,els.canvasB);
|
||
const delta = -ev.deltaY * 0.001;
|
||
const newZoom = clamp(v.zoom * (1+delta), 0.2, 4.0);
|
||
v.zoom = newZoom;
|
||
const [wx2,wy2] = screenToWorld(sx,sy,els.canvasB);
|
||
v.panX += (wx2 - wx1);
|
||
v.panY += (wy2 - wy1);
|
||
ev.preventDefault();
|
||
}
|
||
|
||
// ---------- Clipboard ----------
|
||
function getSelectedObject(){
|
||
const roomWrap = getActiveRoom(); if(!roomWrap) return null;
|
||
const room = roomWrap.data;
|
||
const sel = state.selection;
|
||
if(!sel) return null;
|
||
if(sel.kind==="platform") return {kind:"platform", data: deepClone(room.platforms[sel.index])};
|
||
if(sel.kind==="exit") return {kind:"exit", data: deepClone(room.exits[sel.index])};
|
||
if(sel.kind==="ability_door") return {kind:"ability_door", data: deepClone(room.ability_doors[sel.index])};
|
||
if(sel.kind==="enemy") return {kind:"enemy", data: deepClone(room.enemies[sel.index])};
|
||
if(sel.kind==="npc") return {kind:"npc", data: deepClone(room.npcs[sel.index])};
|
||
if(sel.kind==="prop") return {kind:"prop", data: deepClone(room.props[sel.index])};
|
||
if(sel.kind==="entry_spawn"){
|
||
const pos = room.entry_spawns[sel.key];
|
||
return {kind:"entry_spawn", key: sel.key, data: deepClone(pos)};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function copySelection(){
|
||
const obj = getSelectedObject();
|
||
if(!obj) return;
|
||
state.clipboard = obj;
|
||
setStatus("Copied " + obj.kind);
|
||
}
|
||
|
||
function cutSelection(){
|
||
const roomWrap = getActiveRoom(); if(!roomWrap) return;
|
||
const room = roomWrap.data;
|
||
const sel = state.selection;
|
||
if(!sel) return;
|
||
copySelection();
|
||
pushHistory(roomWrap.id);
|
||
if(sel.kind==="platform") room.platforms.splice(sel.index,1);
|
||
else if(sel.kind==="exit") room.exits.splice(sel.index,1);
|
||
else if(sel.kind==="ability_door") room.ability_doors.splice(sel.index,1);
|
||
else if(sel.kind==="enemy") room.enemies.splice(sel.index,1);
|
||
else if(sel.kind==="npc") room.npcs.splice(sel.index,1);
|
||
else if(sel.kind==="prop") room.props.splice(sel.index,1);
|
||
else if(sel.kind==="entry_spawn") delete room.entry_spawns[sel.key];
|
||
state.selection=null;
|
||
refreshUI();
|
||
setStatus("Cut");
|
||
}
|
||
|
||
function pasteClipboard(){
|
||
const roomWrap = getActiveRoom(); if(!roomWrap || !state.clipboard) return;
|
||
const room = roomWrap.data;
|
||
pushHistory(roomWrap.id);
|
||
|
||
const c = deepClone(state.clipboard);
|
||
const offset = 12;
|
||
if(c.kind==="platform"){
|
||
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
|
||
room.platforms.push(c.data);
|
||
state.selection = {kind:"platform", index: room.platforms.length-1, roomId: roomWrap.id};
|
||
} else if(c.kind==="exit"){
|
||
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
|
||
room.exits.push(c.data);
|
||
state.selection = {kind:"exit", index: room.exits.length-1, roomId: roomWrap.id};
|
||
} else if(c.kind==="ability_door"){
|
||
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
|
||
room.ability_doors.push(c.data);
|
||
state.selection = {kind:"ability_door", index: room.ability_doors.length-1, roomId: roomWrap.id};
|
||
} else if(c.kind==="enemy"){
|
||
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
|
||
room.enemies.push(c.data);
|
||
state.selection = {kind:"enemy", index: room.enemies.length-1, roomId: roomWrap.id};
|
||
} else if(c.kind==="npc"){
|
||
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
|
||
room.npcs = room.npcs || [];
|
||
room.npcs.push(c.data);
|
||
state.selection = {kind:"npc", index: room.npcs.length-1, roomId: roomWrap.id};
|
||
} else if(c.kind==="prop"){
|
||
c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset);
|
||
(room.props || (room.props = [])).push(c.data);
|
||
state.selection = {kind:"prop", index: room.props.length-1, roomId: roomWrap.id};
|
||
} else if(c.kind==="entry_spawn"){
|
||
// ensure unique key
|
||
let key = c.key || "entry_1";
|
||
let n=1;
|
||
while(room.entry_spawns[key]){ key = (c.key||"entry_") + "_" + (n++); }
|
||
room.entry_spawns[key] = [snap(c.data[0]+offset), snap(c.data[1]+offset)];
|
||
state.selection = {kind:"entry_spawn", key, roomId: roomWrap.id};
|
||
}
|
||
refreshUI();
|
||
setStatus("Pasted");
|
||
}
|
||
|
||
function duplicateSelection(){
|
||
copySelection();
|
||
pasteClipboard();
|
||
}
|
||
|
||
function deleteSelection(){
|
||
const roomWrap = getActiveRoom(); if(!roomWrap) return;
|
||
const room = roomWrap.data;
|
||
const sel = state.selection;
|
||
if(!sel) return;
|
||
pushHistory(roomWrap.id);
|
||
if(sel.kind==="platform") room.platforms.splice(sel.index,1);
|
||
else if(sel.kind==="exit") room.exits.splice(sel.index,1);
|
||
else if(sel.kind==="ability_door") room.ability_doors.splice(sel.index,1);
|
||
else if(sel.kind==="enemy") room.enemies.splice(sel.index,1);
|
||
else if(sel.kind==="npc") room.npcs.splice(sel.index,1);
|
||
else if(sel.kind==="prop") room.props.splice(sel.index,1);
|
||
else if(sel.kind==="entry_spawn") delete room.entry_spawns[sel.key];
|
||
else if(sel.kind==="player_spawn") room.player_spawn = [100,300];
|
||
state.selection = null;
|
||
refreshUI();
|
||
setStatus("Deleted");
|
||
}
|
||
|
||
// ---------- Linking ----------
|
||
function applyLink(){
|
||
if(!els.linkTargetRoom || !els.linkTargetSpawn){
|
||
setStatus("Link Mode: use compare click linking.");
|
||
return;
|
||
}
|
||
const roomWrap = getActiveRoom(); if(!roomWrap) return;
|
||
const room = roomWrap.data;
|
||
const sel = state.selection;
|
||
if(!sel || sel.kind!=="exit") { setStatus("Select an Exit first."); return; }
|
||
|
||
const exit = room.exits[sel.index];
|
||
if(!exit) return;
|
||
|
||
const targetId = els.linkTargetRoom.value;
|
||
const targetRoomWrap = state.rooms.get(targetId);
|
||
if(!targetRoomWrap){ setStatus("Pick a target room."); return; }
|
||
|
||
const spawnId = els.linkTargetSpawn.value;
|
||
if(!spawnId){ setStatus("Pick a target spawn."); return; }
|
||
|
||
const pos = targetRoomWrap.data.entry_spawns && targetRoomWrap.data.entry_spawns[spawnId];
|
||
if(!pos){ setStatus("Target spawn not found."); return; }
|
||
|
||
pushHistory(roomWrap.id);
|
||
exit.to = targetRoomWrap.data.name;
|
||
exit.spawn_id = spawnId;
|
||
exit.spawn = [pos[0], pos[1]]; // legacy fallback
|
||
refreshUI();
|
||
setStatus(`Linked exit → ${exit.to}:${spawnId}`);
|
||
// auto-disable after one link
|
||
setLinkMode(false);
|
||
}
|
||
|
||
// ---------- Export ----------
|
||
function serializeRoom(roomWrap){
|
||
const d = deepClone(roomWrap.data || {});
|
||
const fmt = d._format || "editor_v1";
|
||
|
||
if(fmt === "game_v1"){
|
||
// rebuild grouped platforms (layer appended as 5th element when non-zero;
|
||
// moving/trigger platforms serialize as objects to keep their path/name/speed)
|
||
const platforms = {};
|
||
for(const p of (d.platforms || [])){
|
||
const group = p.group || "ground";
|
||
if(!platforms[group]) platforms[group] = [];
|
||
if(group === "moving" || group === "trigger" || group === "hazard" || group === "dissolve"){
|
||
const obj = { x: p.x, y: p.y, w: p.w, h: p.h };
|
||
if(p.layer) obj.layer = p.layer;
|
||
if(group === "moving"){
|
||
if(Array.isArray(p.path)) obj.path = p.path;
|
||
if(p.speed !== undefined) obj.speed = p.speed;
|
||
}
|
||
if(group === "dissolve"){
|
||
if(p.stand_time !== undefined) obj.stand_time = p.stand_time;
|
||
if(p.dissolve_time !== undefined) obj.dissolve_time = p.dissolve_time;
|
||
}
|
||
if(p.name) obj.name = p.name;
|
||
if(p.quest) obj.quest = p.quest;
|
||
if(p.quest_complete) obj.quest_complete = p.quest_complete;
|
||
if(p.quest_turned_in) obj.quest_turned_in = p.quest_turned_in;
|
||
if(p.lock_when_pass) obj.lock_when_pass = true;
|
||
if(p.open_on_miniboss_dead) obj.open_on_miniboss_dead = true;
|
||
if(p.open_on_boss_dead) obj.open_on_boss_dead = true;
|
||
if(Array.isArray(p.respawn)) obj.respawn = p.respawn;
|
||
platforms[group].push(obj);
|
||
} else {
|
||
const rect = [p.x, p.y, p.w, p.h];
|
||
if(p.layer) rect.push(p.layer);
|
||
platforms[group].push(rect);
|
||
}
|
||
}
|
||
|
||
// enemies back to {type, pos:[x,y]} (+ layer when set)
|
||
const enemies = (d.enemies || []).map(e => {
|
||
const out = { type: e.type || "enemy", pos: [e.x, e.y] };
|
||
if(e.layer) out.layer = e.layer;
|
||
if(e.behavior) out.behavior = e.behavior;
|
||
return out;
|
||
});
|
||
|
||
// npcs back to {type, pos:[x,y]} (+ layer when set)
|
||
const npcs = (d.npcs || []).map(n => {
|
||
const out = { type: n.type || "vendor", pos: [n.x, n.y] };
|
||
if(n.layer) out.layer = n.layer;
|
||
return out;
|
||
});
|
||
|
||
// exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint} (+ layer when set)
|
||
const exits = (d.exits || []).map(ex => {
|
||
const out = {
|
||
rect: [ex.x, ex.y, ex.w, ex.h],
|
||
to: ex.to || "",
|
||
spawn: Array.isArray(ex.spawn) ? [ex.spawn[0], ex.spawn[1]] : [0,0],
|
||
hint: ex.hint || ""
|
||
};
|
||
if(ex.spawn_id) out.spawn_id = ex.spawn_id;
|
||
if(ex.layer) out.layer = ex.layer;
|
||
return out;
|
||
});
|
||
|
||
// ability doors back to {rect:[x,y,w,h]} (+ layer/kind when set)
|
||
const ability_doors = (d.ability_doors || []).map(ad => {
|
||
const out = { rect: [ad.x, ad.y, ad.w, ad.h] };
|
||
if(ad.layer) out.layer = ad.layer;
|
||
if(ad.kind === "shade") out.kind = "shade";
|
||
return out;
|
||
});
|
||
|
||
const props = (d.props || []).map(p => {
|
||
const out = {
|
||
type: p.type || "light_source",
|
||
pos: [p.x, p.y],
|
||
radius: p.radius || 180
|
||
};
|
||
if(p.object_id) out.object_id = p.object_id;
|
||
if(p.layer) out.layer = p.layer;
|
||
if(p.quest_id) out.quest_id = p.quest_id;
|
||
if(Array.isArray(p.coins)) out.coins = p.coins;
|
||
return out;
|
||
});
|
||
|
||
const meta = d.meta && typeof d.meta === "object" ? d.meta : { id: d.name || "room", name: d.display_name || d.name || "room" };
|
||
if(!meta.id) meta.id = d.name || "room";
|
||
if(!meta.name) meta.name = d.display_name || meta.id;
|
||
|
||
const out = {
|
||
meta,
|
||
player_spawn: Array.isArray(d.player_spawn) ? d.player_spawn : [100,300],
|
||
bounds: d.bounds && typeof d.bounds === "object" ? d.bounds : {},
|
||
platforms,
|
||
enemies,
|
||
npcs,
|
||
exits,
|
||
ability_doors
|
||
};
|
||
|
||
// include entry spawns (new feature) for your spawn linking
|
||
if(d.entry_spawns && typeof d.entry_spawns === "object") out.entry_spawns = d.entry_spawns;
|
||
if(props.length) out.props = props;
|
||
if(d.mist) out.mist = true;
|
||
|
||
// carry over any extra keys that were in the original
|
||
if(d._extras && typeof d._extras === "object"){
|
||
for(const k of Object.keys(d._extras)){
|
||
out[k] = d._extras[k];
|
||
}
|
||
}
|
||
|
||
return JSON.stringify(out, null, 2);
|
||
}
|
||
|
||
// editor-native export (keeps internal structure)
|
||
const out = deepClone(d);
|
||
delete out._format;
|
||
delete out._extras;
|
||
return JSON.stringify(out, null, 2);
|
||
}
|
||
|
||
// ---------- UI Refresh ----------
|
||
function refreshUI(){
|
||
const __leftEl = document.querySelector(".left");
|
||
const __leftScroll = __leftEl ? __leftEl.scrollTop : 0;
|
||
refreshRoomsList();
|
||
refreshRoomSelectors();
|
||
|
||
const r = getActiveRoom();
|
||
els.activeRoomLabel.textContent = r ? `Active: ${r.data.name}` : "Active: (none)";
|
||
refreshProps();
|
||
|
||
// compare layout
|
||
if(state.compareEnabled){
|
||
els.compareWrap.style.display = "block";
|
||
els.canvasRow.style.gridTemplateColumns = "1fr 1fr";
|
||
els.toggleCompareBtn.textContent = "Disable Compare";
|
||
} else {
|
||
els.compareWrap.style.display = "none";
|
||
els.canvasRow.style.gridTemplateColumns = "1fr";
|
||
els.toggleCompareBtn.textContent = "Enable Compare";
|
||
}
|
||
|
||
// keep link spawns updated when switching rooms
|
||
updateLinkSpawnList();
|
||
|
||
// link mode button label
|
||
if(els.toggleLinkModeBtn){
|
||
els.toggleLinkModeBtn.textContent = state.linkMode ? "Disable Link Mode" : "Enable Link Mode";
|
||
}
|
||
|
||
// restore sidebar scroll (refreshRoomsList rebuilds DOM)
|
||
if(__leftEl) __leftEl.scrollTop = __leftScroll;
|
||
}
|
||
|
||
// ---------- Event Wiring ----------
|
||
document.querySelectorAll(".toolBtn").forEach(btn=>{
|
||
btn.addEventListener("click", ()=>setTool(btn.dataset.tool));
|
||
});
|
||
|
||
if(els.platformGroup){
|
||
els.platformGroup.value = state.platformGroup;
|
||
els.platformGroup.addEventListener("change", ()=>{
|
||
state.platformGroup = els.platformGroup.value || "ground";
|
||
setStatus(`Platform layer: ${state.platformGroup}`);
|
||
});
|
||
}
|
||
|
||
// Enemy types (optional)
|
||
function refreshEnemyTypeSelect(){
|
||
if(!els.enemyTypeSelect) return;
|
||
const sel = els.enemyTypeSelect;
|
||
sel.innerHTML = "";
|
||
const keys = state.enemyTypeKeys && state.enemyTypeKeys.length ? state.enemyTypeKeys : [];
|
||
if(keys.length === 0){
|
||
const opt = document.createElement("option");
|
||
opt.value = "";
|
||
opt.textContent = "(no enemy types loaded)";
|
||
sel.appendChild(opt);
|
||
state.enemyPlaceType = null;
|
||
return;
|
||
}
|
||
for(const k of keys){
|
||
const opt = document.createElement("option");
|
||
opt.value = k;
|
||
opt.textContent = k;
|
||
sel.appendChild(opt);
|
||
}
|
||
if(keys.includes(state.enemyPlaceType)) sel.value = state.enemyPlaceType;
|
||
else { state.enemyPlaceType = keys[0]; sel.value = keys[0]; }
|
||
}
|
||
|
||
if(els.enemyTypesInput){
|
||
els.enemyTypesInput.addEventListener("change", async ()=>{
|
||
const f = els.enemyTypesInput.files && els.enemyTypesInput.files[0];
|
||
if(!f) return;
|
||
try{
|
||
const txt = await f.text();
|
||
const data = JSON.parse(txt);
|
||
if(!data || typeof data !== "object" || Array.isArray(data)){
|
||
throw new Error("Enemy types JSON must be an object keyed by type id.");
|
||
}
|
||
state.enemyTypes = data;
|
||
state.enemyTypeKeys = Object.keys(data);
|
||
if(state.enemyTypeKeys.length===0){
|
||
state.enemyTypeKeys = ["enemy"];
|
||
state.enemyTypes = null;
|
||
}
|
||
state.enemyPlaceType = state.enemyTypeKeys[0] || "enemy";
|
||
refreshEnemyTypeSelect();
|
||
setStatus(`Loaded enemy types (${state.enemyTypeKeys.length}) from ${f.name}`);
|
||
redraw();
|
||
}catch(err){
|
||
console.error(err);
|
||
setStatus("Failed to load enemy types JSON. Check console.");
|
||
}finally{
|
||
els.enemyTypesInput.value = "";
|
||
}
|
||
});
|
||
}
|
||
|
||
function applyObjectDefaultsToRooms(){
|
||
let changedAny = false;
|
||
for(const [id, wrap] of state.rooms.entries()){
|
||
if(!wrap || !wrap.data || !Array.isArray(wrap.data.props)) continue;
|
||
let changed = false;
|
||
for(const p of wrap.data.props){
|
||
if(!p || typeof p !== "object") continue;
|
||
const type = p.type || "light_source";
|
||
if(!(Number.isFinite(Number(p.radius)) && Number(p.radius) > 0)){
|
||
p.radius = getDefaultRadiusForType(type);
|
||
changed = true;
|
||
}
|
||
}
|
||
if(changed){
|
||
markDirty(id, true);
|
||
changedAny = true;
|
||
}
|
||
}
|
||
if(changedAny) refreshUI();
|
||
}
|
||
|
||
function loadObjectCatalog(data, filename){
|
||
// Keep the built-in defaults so the core assets are always available; a loaded
|
||
// objects.json overrides individual definitions (same id) and adds new ones.
|
||
seedDefaultObjectCatalog();
|
||
|
||
if(data && typeof data === "object" && data.objects && typeof data.objects === "object"){
|
||
for(const [id, def] of Object.entries(data.objects)){
|
||
if(!def || typeof def !== "object") continue;
|
||
objectCatalog.byId[id] = def;
|
||
if(def.type){
|
||
objectCatalog.byType[def.type] = def;
|
||
}
|
||
}
|
||
}
|
||
|
||
const count = Object.keys(objectCatalog.byId).length;
|
||
if(els.objectsStatus){
|
||
els.objectsStatus.textContent = count ? `Loaded ${count} object(s) from ${filename}` : "No objects loaded.";
|
||
}
|
||
refreshObjectSelect();
|
||
applyObjectDefaultsToRooms();
|
||
}
|
||
|
||
if(els.objectsInput){
|
||
els.objectsInput.addEventListener("change", async ()=>{
|
||
const f = els.objectsInput.files && els.objectsInput.files[0];
|
||
if(!f) return;
|
||
try{
|
||
const txt = await f.text();
|
||
const data = JSON.parse(txt);
|
||
loadObjectCatalog(data, f.name);
|
||
setStatus(`Loaded objects catalog: ${f.name}`);
|
||
}catch(err){
|
||
console.error(err);
|
||
setStatus("Failed to load objects.json. Check console.");
|
||
}finally{
|
||
els.objectsInput.value = "";
|
||
}
|
||
});
|
||
}
|
||
|
||
if(els.objectSelect){
|
||
refreshObjectSelect();
|
||
els.objectSelect.addEventListener("change", ()=>{
|
||
state.objectPlaceId = els.objectSelect.value || null;
|
||
if(state.objectPlaceId){
|
||
setStatus(`Object placement: ${state.objectPlaceId}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
if(els.enemyTypeSelect){
|
||
refreshEnemyTypeSelect();
|
||
els.enemyTypeSelect.addEventListener("change", ()=>{
|
||
state.enemyPlaceType = els.enemyTypeSelect.value || "enemy";
|
||
setStatus(`Enemy placement type: ${state.enemyPlaceType}`);
|
||
});
|
||
}
|
||
|
||
if(els.npcTypeSelect){
|
||
els.npcTypeSelect.value = state.npcPlaceType || "vendor";
|
||
els.npcTypeSelect.addEventListener("change", ()=>{
|
||
state.npcPlaceType = els.npcTypeSelect.value || "vendor";
|
||
setStatus(`NPC placement type: ${state.npcPlaceType}`);
|
||
});
|
||
}
|
||
|
||
function refreshObjectSelect(){
|
||
if(!els.objectSelect) return;
|
||
const sel = els.objectSelect;
|
||
sel.innerHTML = "";
|
||
const ids = Object.keys(objectCatalog.byId);
|
||
if(ids.length === 0){
|
||
const opt = document.createElement("option");
|
||
opt.value = "";
|
||
opt.textContent = "(no objects loaded)";
|
||
sel.appendChild(opt);
|
||
state.objectPlaceId = null;
|
||
sel.value = "";
|
||
return;
|
||
}
|
||
for(const id of ids){
|
||
const def = objectCatalog.byId[id];
|
||
const label = def && def.type ? `${id} (${def.type})` : id;
|
||
const opt = document.createElement("option");
|
||
opt.value = id;
|
||
opt.textContent = label;
|
||
sel.appendChild(opt);
|
||
}
|
||
if(state.objectPlaceId && ids.includes(state.objectPlaceId)){
|
||
sel.value = state.objectPlaceId;
|
||
} else {
|
||
state.objectPlaceId = ids[0];
|
||
sel.value = ids[0];
|
||
}
|
||
}
|
||
|
||
if(els.showAggroChk){
|
||
els.showAggroChk.checked = !!state.showAggro;
|
||
els.showAggroChk.addEventListener("change", ()=>{
|
||
state.showAggro = !!els.showAggroChk.checked;
|
||
redraw();
|
||
});
|
||
}
|
||
|
||
if(els.snapChk){
|
||
els.snapChk.checked = !!state.snapEnabled;
|
||
els.snapChk.addEventListener("change", ()=>{
|
||
state.snapEnabled = !!els.snapChk.checked;
|
||
setStatus(state.snapEnabled ? "Grid snap ON" : "Grid snap OFF (free movement)");
|
||
});
|
||
}
|
||
|
||
|
||
|
||
if(els.linkTargetRoom){
|
||
els.linkTargetRoom.addEventListener("change", ()=>{
|
||
updateLinkSpawnList();
|
||
});
|
||
}
|
||
if(els.linkApplyBtn){
|
||
els.linkApplyBtn.addEventListener("click", applyLink);
|
||
}
|
||
if(els.openTargetBtn){
|
||
els.openTargetBtn.addEventListener("click", ()=>{
|
||
const targetId = els.linkTargetRoom.value;
|
||
if(targetId) setActiveRoom(targetId);
|
||
});
|
||
}
|
||
|
||
els.toggleCompareBtn.addEventListener("click", ()=>{
|
||
state.compareEnabled = !state.compareEnabled;
|
||
|
||
if(state.compareEnabled){
|
||
// Prefer the user-selected compare room (dropdown), then the previous compareId, otherwise pick any other room.
|
||
const selected = els.compareRoomSelect && els.compareRoomSelect.value ? els.compareRoomSelect.value : null;
|
||
|
||
const isValidOther = (id)=> !!id && state.rooms.has(id) && id !== state.activeId;
|
||
|
||
if(isValidOther(selected)){
|
||
state.compareId = selected;
|
||
} else if(isValidOther(state.compareId)){
|
||
// keep previous compareId
|
||
} else {
|
||
const other = [...state.rooms.keys()].find(k => k !== state.activeId);
|
||
state.compareId = other || state.activeId;
|
||
}
|
||
}
|
||
|
||
refreshUI();
|
||
});
|
||
|
||
els.showLinksChk.checked = false; // always start unchecked (no auto Compare)
|
||
els.showLinksChk.addEventListener("change", ()=>{
|
||
// Lines only draw while Compare is on too (see drawLinkLinesForCanvas).
|
||
// 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).`);
|
||
});
|
||
|
||
els.downloadAllBtn.addEventListener("click", async ()=>{
|
||
const rooms = [...state.rooms.values()].filter(r=>r.dirty);
|
||
if(!rooms.length){ setStatus("No modified rooms."); return; }
|
||
let i=0;
|
||
for(const r of rooms){
|
||
downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
|
||
r.dirty = false;
|
||
i++;
|
||
await new Promise(res=>setTimeout(res, 250));
|
||
}
|
||
refreshRoomsList();
|
||
setStatus(`Downloaded ${i} modified room(s).`);
|
||
});
|
||
|
||
// canvas pointer handlers
|
||
// middle-mouse pan (and prevent browser autoscroll / sidebar scrolling)
|
||
function startMousePan(e, canvasKey){
|
||
e.preventDefault();
|
||
const rect = (canvasKey==="A"? els.canvasA : els.canvasB).getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
state.dragging = {mode:"pan", canvas:canvasKey, startX:sx, startY:sy, startPanX:(canvasKey==="A"? state.viewA.panX:state.viewB.panX), startPanY:(canvasKey==="A"? state.viewA.panY:state.viewB.panY)};
|
||
}
|
||
|
||
[els.canvasA, els.canvasB].forEach((c, idx)=>{
|
||
const key = idx===0 ? "A" : "B";
|
||
c.addEventListener("mousedown", (e)=>{
|
||
if(e.button===1){
|
||
startMousePan(e, key);
|
||
}
|
||
}, {passive:false});
|
||
// firefox sometimes triggers auxclick without pointer events for middle button
|
||
c.addEventListener("auxclick", (e)=>{ if(e.button===1) e.preventDefault(); }, {passive:false});
|
||
});
|
||
|
||
window.addEventListener("mousemove", (e)=>{
|
||
if(!state.dragging || state.dragging.mode!=="pan") return;
|
||
const key = state.dragging.canvas;
|
||
const rect = (key==="A"? els.canvasA : els.canvasB).getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
const view = key==="A" ? state.viewA : state.viewB;
|
||
const dx = (sx - state.dragging.startX) / view.zoom;
|
||
const dy = (sy - state.dragging.startY) / view.zoom;
|
||
view.panX = state.dragging.startPanX + dx;
|
||
view.panY = state.dragging.startPanY + dy;
|
||
}, {passive:false});
|
||
|
||
window.addEventListener("mouseup", (e)=>{
|
||
if(state.dragging && state.dragging.mode==="pan"){
|
||
state.dragging = null;
|
||
}
|
||
});
|
||
els.canvasA.addEventListener("pointerdown", onPointerDown);
|
||
els.canvasB.addEventListener("pointerdown", onComparePointerDown);
|
||
// (Removed mousedown fallback that called onPointerDown/onComparePointerDown.
|
||
// It caused double placement because both pointerdown and mousedown fired on left-click.)
|
||
window.addEventListener("pointermove", onPointerMove);
|
||
window.addEventListener("pointerup", onPointerUp);
|
||
els.canvasA.addEventListener("wheel", onWheel, {passive:false});
|
||
els.canvasB.addEventListener("wheel", onWheelB, {passive:false});
|
||
|
||
|
||
// hover cursor (resize/move) on both canvases
|
||
function cursorForEdge(edge){
|
||
if(!edge) return "move";
|
||
if(edge==="n"||edge==="s") return "ns-resize";
|
||
if(edge==="e"||edge==="w") return "ew-resize";
|
||
if(edge==="nw"||edge==="se") return "nwse-resize";
|
||
if(edge==="ne"||edge==="sw") return "nesw-resize";
|
||
return "move";
|
||
}
|
||
|
||
function updateHover(ev, canvasKey){
|
||
if(state.dragging) return;
|
||
// Only show smart cursors in select tool
|
||
if(state.tool !== "select"){
|
||
const c = canvasKey==="A" ? els.canvasA : els.canvasB;
|
||
c.style.cursor = "crosshair"; // placement feel
|
||
return;
|
||
}
|
||
const c = canvasKey==="A" ? els.canvasA : els.canvasB;
|
||
const roomWrap = (canvasKey==="A") ? getActiveRoom() : (state.compareEnabled ? getCompareRoom() : null);
|
||
if(!roomWrap){ c.style.cursor = "default"; return; }
|
||
|
||
const r = c.getBoundingClientRect();
|
||
const sx = ev.clientX - r.left;
|
||
const sy = ev.clientY - r.top;
|
||
const [wx,wy] = screenToWorld(sx,sy,c);
|
||
|
||
// prefer platform handles/edges
|
||
const hit = hitTest(roomWrap, wx, wy, c);
|
||
if(hit && hit.kind==="platform"){
|
||
c.style.cursor = cursorForEdge(hit.edge);
|
||
return;
|
||
}
|
||
if(hit && (hit.kind==="exit" || hit.kind==="ability_door")){
|
||
c.style.cursor = cursorForEdge(hit.edge);
|
||
return;
|
||
}
|
||
if(hit && (hit.kind==="enemy"||hit.kind==="player_spawn"||hit.kind==="entry_spawn")){
|
||
c.style.cursor = "move";
|
||
return;
|
||
}
|
||
c.style.cursor = "default";
|
||
}
|
||
|
||
els.canvasA.addEventListener("pointermove", (e)=>updateHover(e,"A"));
|
||
els.canvasB.addEventListener("pointermove", (e)=>updateHover(e,"B"));
|
||
|
||
|
||
// keyboard shortcuts
|
||
window.addEventListener("keydown", (e)=>{
|
||
const isMac = navigator.platform.toLowerCase().includes("mac");
|
||
const ctrl = isMac ? e.metaKey : e.ctrlKey;
|
||
|
||
const tgt = e.target;
|
||
const tag = tgt && tgt.tagName ? tgt.tagName.toUpperCase() : "";
|
||
const typing = tgt && (tag==="INPUT" || tag==="TEXTAREA" || tag==="SELECT" || tgt.isContentEditable);
|
||
if(typing){
|
||
// don't steal keys while user is typing in a field (e.g. 'F' should type, not Fit View)
|
||
return;
|
||
}
|
||
|
||
if(ctrl && e.key.toLowerCase()==="z"){
|
||
e.preventDefault();
|
||
if(e.shiftKey) redo(); else undo();
|
||
return;
|
||
}
|
||
if(ctrl && e.key.toLowerCase()==="y"){
|
||
e.preventDefault();
|
||
redo(); return;
|
||
}
|
||
if(ctrl && e.key.toLowerCase()==="c"){
|
||
e.preventDefault();
|
||
copySelection(); return;
|
||
}
|
||
if(ctrl && e.key.toLowerCase()==="x"){
|
||
e.preventDefault();
|
||
cutSelection(); return;
|
||
}
|
||
if(ctrl && e.key.toLowerCase()==="v"){
|
||
e.preventDefault();
|
||
pasteClipboard(); return;
|
||
}
|
||
if(ctrl && e.key.toLowerCase()==="d"){
|
||
e.preventDefault();
|
||
duplicateSelection(); return;
|
||
}
|
||
if(e.key==="Delete" || e.key==="Backspace"){
|
||
// avoid backspace navigating back
|
||
e.preventDefault();
|
||
deleteSelection(); return;
|
||
}
|
||
// Tool switching shortcuts (match the toolbar button titles).
|
||
const toolByKey = { v:"select", p:"platform", e:"enemy_spawn", n:"npc_spawn", o:"object_spawn" };
|
||
const toolKey = toolByKey[e.key.toLowerCase()];
|
||
if(toolKey){
|
||
e.preventDefault();
|
||
setTool(toolKey);
|
||
return;
|
||
}
|
||
if(e.key.toLowerCase()==='f'){
|
||
e.preventDefault();
|
||
fitViewToRoom(getActiveRoom(),'A');
|
||
if(state.compareEnabled) fitViewToRoom(getCompareRoom(),'B');
|
||
return;
|
||
}
|
||
if(e.key==="Escape"){
|
||
state.selection=null;
|
||
refreshUI();
|
||
return;
|
||
}
|
||
});
|
||
|
||
// tool init
|
||
setTool("select");
|
||
|
||
// Boot with an empty room so canvas isn't blank
|
||
addRoomFromJSON(roomTemplate("room_01"), "room_01.json");
|
||
fitViewToRoom(getActiveRoom(), 'A');
|
||
|
||
// If user already has room_01/02 files locally: they can load both via file input.
|
||
refreshUI();
|
||
render();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|