room overhaul

more editor changes, new rooms
This commit is contained in:
2026-09-29 18:55:29 +02:00
parent f676e59008
commit a9e999b49e
14 changed files with 1110 additions and 1288 deletions
+306 -47
View File
@@ -9,9 +9,9 @@
--bg:#0b0f14; --panel:#111827; --panel2:#0f172a; --fg:#e5e7eb; --muted:#9ca3af;
--accent:#60a5fa; --accent2:#34d399; --warn:#f59e0b; --danger:#ef4444; --border:#1f2937;
}
html,body{height:100%; margin:0; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;}
.wrap{display:grid; grid-template-columns: 360px 1fr; height:100vh; min-height:100vh;}
.left{background:var(--panel); border-right:1px solid var(--border); padding:12px; overflow:auto;}
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;}
@@ -32,7 +32,7 @@
.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%;}
.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;
@@ -105,6 +105,16 @@
Grid snap (20px) on placement / drag
</label>
</div>
<div class="row" style="margin-top:8px;">
<label class="muted" style="flex:1;">New platform type:</label>
<select id="platformGroup" style="flex:1; min-width:120px;">
<option value="ground">Ground</option>
<option value="floating">Floating</option>
<option value="hazard">Hazard (red - damage)</option>
<option value="moving">Moving (path)</option>
<option value="trigger">Trigger (name)</option>
</select>
</div>
<div class="muted" style="margin-top:8px;">
<b>Layers:</b> every object (platforms, houses, NPCs, enemies, props, doors, exits) has a <b>layer</b> in its Properties — lower = further back, higher = further forward. The canvas and game draw back-to-front by (layer, category), and the select tool respects layers.
</div>
@@ -258,6 +268,7 @@
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 };
@@ -328,7 +339,22 @@
};
}
function normalizeRoom(json, fallbackName, filename){
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(Array.isArray(rect.respawn)) out.respawn = deepClone(rect.respawn);
return out;
}
return null;
}
function normalizeRoom(json, fallbackName, filename){
// Supports both:
// A) editor-native rooms (name, platforms:[{x,y,w,h}], exits:[{x,y,w,h}...])
// B) your game rooms (meta, platforms:{ground:[[x,y,w,h],...], ...}, exits:[{rect:[...]}], enemies:[{pos:[x,y]}])
@@ -367,27 +393,23 @@
const arr = platObj[group];
if(!Array.isArray(arr)) continue;
for(const rect of arr){
if(!Array.isArray(rect) || rect.length < 4) continue;
r.platforms.push({ x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group, layer: rect.length>=5 ? (+rect[4]||0) : 0 });
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){
if(p && typeof p === "object"){
r.platforms.push({ x:+p.x||0, y:+p.y||0, w:+p.w||0, h:+p.h||0, group: p.group || "ground", layer: +(p.layer||0) });
}
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){
if(p && typeof p === "object"){
r.platforms.push({ x:+p.x||0, y:+p.y||0, w:+p.w||0, h:+p.h||0, group: p.group || "ground", layer: +(p.layer||0) });
} else if(Array.isArray(p) && p.length>=4){
r.platforms.push({ x:+p[0]||0, y:+p[1]||0, w:+p[2]||0, h:+p[3]||0, group:"ground", layer: p.length>=5 ? (+p[4]||0) : 0 });
}
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
@@ -395,8 +417,8 @@
const arr = src.platforms[group];
if(!Array.isArray(arr)) continue;
for(const rect of arr){
if(!Array.isArray(rect) || rect.length < 4) continue;
r.platforms.push({ x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group, layer: rect.length>=5 ? (+rect[4]||0) : 0 });
const np = normPlatformEntry(rect, group);
if(np) r.platforms.push(np);
}
}
}
@@ -444,7 +466,7 @@
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) });
r.props.push({ x, y, type, radius, object_id, layer: +(p.layer||0), quest_id: p.quest_id || p.questId || "" });
}
}
@@ -535,7 +557,8 @@
enemyPlaceType: "enemy",
npcPlaceType: "vendor",
showAggro: false,
objectPlaceId: null
objectPlaceId: null,
waitingPathPt: null
};
const els = {
@@ -957,6 +980,24 @@ Cancel = Choose whether to remove without saving`);
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;
@@ -1075,7 +1116,8 @@ Cancel = Choose whether to remove without saving`);
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};
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};
@@ -1491,7 +1533,12 @@ Cancel = Choose whether to remove without saving`);
};
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 || [])) layerable.push({ z: +(p.layer||0), cat: 1, kind: "platform", p });
for(const p of (room.platforms || [])){
if(p && (p.group === "moving")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "moving", p });
else if(p && (p.group === "trigger")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "trigger", p });
else if(p && (p.group === "hazard")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "hazard", p });
else layerable.push({ z: +(p.layer||0), cat: 1, kind: "platform", p });
}
for(const d of (room.ability_doors || [])) if(d) layerable.push({ z: +(d.layer||0), cat: 2, kind: "door", d });
for(const ex of (room.exits || [])) if(ex) layerable.push({ z: +(ex.layer||0), cat: 3, kind: "exit", ex });
for(const e of (room.enemies || [])) if(e) layerable.push({ z: +(e.layer||0), cat: 4, kind: "enemy", e });
@@ -1509,6 +1556,89 @@ Cancel = Choose whether to remove without saving`);
ctx.strokeStyle = "rgba(96,165,250,0.75)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
} else if(it.kind === "moving"){
const p = it.p;
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
ctx.fillStyle = "rgba(80,180,220,0.30)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(150,220,255,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
// draw the movement path (dashed line + waypoint markers)
const path = p.path || [];
if(path.length >= 2){
ctx.strokeStyle = "rgba(150,220,255,0.5)";
ctx.lineWidth = 1.5*dpr;
ctx.setLineDash([4*dpr, 4*dpr]);
ctx.beginPath();
path.forEach((pt, pi)=>{
const [xx,yy] = worldToScreen(pt[0], pt[1], canvas);
if(pi===0) ctx.moveTo(xx,yy); else ctx.lineTo(xx,yy);
});
// loop back to start
const [exx,eyy] = worldToScreen(path[0][0], path[0][1], canvas);
ctx.lineTo(exx, eyy);
ctx.stroke();
ctx.setLineDash([]);
for(const pt of path){
const [xx,yy] = worldToScreen(pt[0], pt[1], canvas);
ctx.fillStyle = "rgba(150,220,255,0.9)";
ctx.beginPath(); ctx.arc(xx, yy, 3*dpr, 0, Math.PI*2); ctx.fill();
}
// ghost outline of the platform at its final waypoint
const lastPt = path[path.length-1];
if(lastPt){
const [gx,gy] = worldToScreen(lastPt[0], lastPt[1], canvas);
const gw = sx2 - sx, gh = sy2 - sy;
ctx.strokeStyle = "rgba(150,220,255,0.45)";
ctx.lineWidth = 2*dpr;
ctx.setLineDash([5*dpr, 4*dpr]);
ctx.strokeRect(gx, gy, gw, gh);
ctx.setLineDash([]);
}
}
} else if(it.kind === "trigger"){
const p = it.p;
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
ctx.fillStyle = "rgba(220,190,60,0.30)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(255,230,120,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
const label = p.name ? ("Trigger: " + p.name) : "Trigger";
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText(label, sx+4*dpr, sy-4*dpr);
} else if(it.kind === "hazard"){
const p = it.p;
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
ctx.fillStyle = "rgba(239,68,68,0.35)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(239,68,68,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText("Hazard", sx+4*dpr, sy-4*dpr);
// linked respawn marker
if(Array.isArray(p.respawn) && p.respawn.length>=2){
const [rx,ry] = worldToScreen(p.respawn[0], p.respawn[1], canvas);
ctx.strokeStyle = "rgba(52,211,153,0.9)";
ctx.lineWidth = 2*dpr;
ctx.beginPath(); ctx.arc(rx, ry, 6*dpr, 0, Math.PI*2); ctx.stroke();
ctx.fillStyle = "rgba(52,211,153,0.9)";
ctx.beginPath(); ctx.arc(rx, ry, 3*dpr, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = "rgba(52,211,153,0.5)";
ctx.setLineDash([3*dpr,3*dpr]);
ctx.beginPath();
ctx.moveTo((sx+sx2)/2, (sy+sy2)/2);
ctx.lineTo(rx, ry);
ctx.stroke();
ctx.setLineDash([]);
}
} else if(it.kind === "door"){
const d = it.d;
const [sx,sy] = worldToScreen(d.x, d.y, canvas);
@@ -1807,26 +1937,17 @@ Cancel = Choose whether to remove without saving`);
const ex = exits[i];
if(!ex || ex.to !== dstWrap.data.name) continue;
// End the line at the corresponding DOOR in the compare room (the exit that
// leads back to this room), so it lands accurately on the doorway.
// 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;
for(const dex of (dstWrap.data.exits || [])){
if(dex && dex.to === srcWrap.data.name){
pos = [dex.x + dex.w/2, dex.y + dex.h/2];
break;
}
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;
}
// Fallback: the exit's spawn / entry spawn in the compare room.
if(!pos){
const sid = ex.spawn_id || 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;
}
if(!pos && Array.isArray(ex.spawn) && ex.spawn.length >= 2){
pos = ex.spawn;
}
if(!pos && Array.isArray(ex.spawn) && ex.spawn.length >= 2){
pos = ex.spawn;
}
if(!pos) continue;
@@ -1998,6 +2119,39 @@ Cancel = Choose whether to remove without saving`);
<div><div class="mini">h</div><input id="ph" type="number" value="${p.h}"/></div>
<div><div class="mini">layer (lower = back)</div><input id="pl" type="number" value="${p.layer||0}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type</div>
<select id="pg">
<option value="ground" ${p.group==="ground"||!p.group?"selected":""}>Ground</option>
<option value="floating" ${p.group==="floating"?"selected":""}>Floating</option>
<option value="hazard" ${p.group==="hazard"?"selected":""}>Hazard (red - damage)</option>
<option value="moving" ${p.group==="moving"?"selected":""}>Moving (path)</option>
<option value="trigger" ${p.group==="trigger"?"selected":""}>Trigger (name)</option>
</select>
</div>
${(p.group||"ground") === "trigger" ? `
<div style="margin-top:8px;">
<div class="mini">name</div>
<input id="ptname" type="text" value="${p.name||""}" placeholder="e.g. opens_bridge"/>
</div>` : ""}
${(p.group||"ground") === "hazard" ? `
<div style="margin-top:8px;">
<div class="mini">respawn point (hazard teleports here)</div>
<select id="hzrespawn">
<option value="">(none - room default)</option>
${Object.keys(room.entry_spawns||{}).map(k=>`<option value="${k}">Entry: ${k}</option>`).join("")}
</select>
</div>` : ""}
${(p.group||"ground") === "moving" ? `
<div style="margin-top:8px;">
<div class="mini">path (waypoints - click Add, then the canvas)</div>
<button id="addPathPt" class="primary" style="width:100%;">Add path point</button>
<div id="pathList" style="margin-top:6px; max-height:120px; overflow:auto;"></div>
</div>
<div style="margin-top:8px;">
<div class="mini">speed (px/s)</div>
<input id="ptspeed" type="number" value="${p.speed||120}"/>
</div>` : ""}
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
@@ -2079,9 +2233,10 @@ Cancel = Choose whether to remove without saving`);
<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</div>
<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}"/>
@@ -2096,7 +2251,8 @@ Cancel = Choose whether to remove without saving`);
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)"})[p.type||"light_source"] || "";
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>
@@ -2108,6 +2264,11 @@ Cancel = Choose whether to remove without saving`);
<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)"/>
@@ -2188,6 +2349,67 @@ Cancel = Choose whether to remove without saving`);
}));
});
const pl=q("pl"); if(pl) pl.addEventListener("change", ()=>pushAnd(()=>{ p.layer = Math.round(num(pl.value)); }));
const pg=q("pg"); if(pg) pg.addEventListener("change", ()=>pushAnd(()=>{
p.group = pg.value || "ground";
if(p.group === "moving" && !Array.isArray(p.path)) p.path = [[p.x, p.y]];
if(p.group !== "trigger" && p.name !== undefined && p.name !== "") delete p.name;
}));
const ptname=q("ptname"); if(ptname) ptname.addEventListener("change", ()=>pushAnd(()=>{
const v = ptname.value.trim();
if(v){ p.name = v; } else { delete p.name; }
}));
const ptspeed=q("ptspeed"); if(ptspeed) ptspeed.addEventListener("change", ()=>pushAnd(()=>{ p.speed = Math.max(1, num(ptspeed.value)); }));
const hzrespawn=q("hzrespawn");
if(hzrespawn){
// preselect current respawn if it matches an entry spawn
let cur = "";
if(Array.isArray(p.respawn)){
const es = room.entry_spawns || {};
for(const k of Object.keys(es)){
const pos = es[k];
if(Array.isArray(pos) && pos.length>=2 && Math.round(pos[0])===Math.round(p.respawn[0]) && Math.round(pos[1])===Math.round(p.respawn[1])){
cur = k; break;
}
}
}
hzrespawn.value = cur;
hzrespawn.addEventListener("change", ()=>pushAnd(()=>{
const v = hzrespawn.value;
if(v === ""){ delete p.respawn; }
else {
const pos = (room.entry_spawns||{})[v];
if(Array.isArray(pos) && pos.length>=2) p.respawn = [Math.round(pos[0]), Math.round(pos[1])];
}
}));
}
const addPathPt=q("addPathPt"); if(addPathPt) addPathPt.addEventListener("click", ()=>{
state.waitingPathPt = { roomId: roomWrap.id, index: sel.index };
setStatus("Click on the canvas to add a path point to this moving platform.");
refreshProps();
});
const pathList=q("pathList");
if(pathList && Array.isArray(p.path)){
pathList.innerHTML = "";
p.path.forEach((pt, pi)=>{
const row = document.createElement("div");
row.style.cssText = "display:flex; align-items:center; gap:6px; margin-bottom:4px;";
row.innerHTML = `<span style="flex:1; font-size:12px;">${pi+1}. (${Math.round(pt[0])}, ${Math.round(pt[1])})</span>`;
const delBtn = document.createElement("button");
delBtn.textContent = "x";
delBtn.style.cssText = "padding:2px 8px;";
delBtn.addEventListener("click", ()=>pushAnd(()=>{
p.path.splice(pi,1);
}));
row.appendChild(delBtn);
pathList.appendChild(row);
});
if(state.waitingPathPt && state.waitingPathPt.index === sel.index){
const hint = document.createElement("div");
hint.className = "muted";
hint.textContent = "Click the canvas to add point " + (p.path.length + 1);
pathList.appendChild(hint);
}
}
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.platforms.splice(sel.index,1);
state.selection=null;
@@ -2250,10 +2472,14 @@ Cancel = Choose whether to remove without saving`);
}));
} 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");
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; }
@@ -2309,6 +2535,24 @@ Cancel = Choose whether to remove without saving`);
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;
@@ -2390,7 +2634,8 @@ Cancel = Choose whether to remove without saving`);
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};
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};
@@ -3000,14 +3245,27 @@ Cancel = Choose whether to remove without saving`);
const fmt = d._format || "editor_v1";
if(fmt === "game_v1"){
// rebuild grouped platforms (layer appended as 5th element when non-zero)
// 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] = [];
const rect = [p.x, p.y, p.w, p.h];
if(p.layer) rect.push(p.layer);
platforms[group].push(rect);
if(group === "moving" || group === "trigger" || group === "hazard"){
const obj = { x: p.x, y: p.y, w: p.w, h: p.h };
if(p.layer) obj.layer = p.layer;
if(group === "moving"){
if(Array.isArray(p.path)) obj.path = p.path;
if(p.speed !== undefined) obj.speed = p.speed;
}
if(p.name) obj.name = p.name;
if(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)
@@ -3052,6 +3310,7 @@ Cancel = Choose whether to remove without saving`);
};
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;
return out;
});