| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <title>Scene Map Editor β Midnight Mystery</title> |
| <style> |
| :root { --bg:#15171c; --panel:#1d2129; --ink:#e6e8ec; --muted:#9aa3b2; --accent:#c9a14a; --block:rgba(220,60,60,.45); } |
| * { box-sizing:border-box; } |
| body { margin:0; font:14px/1.4 system-ui,sans-serif; background:var(--bg); color:var(--ink); } |
| header { padding:10px 16px; background:var(--panel); border-bottom:1px solid #2a2f3a; display:flex; gap:14px; align-items:center; flex-wrap:wrap; } |
| header h1 { font-size:15px; margin:0; color:var(--accent); letter-spacing:.5px; } |
| .wrap { display:flex; gap:14px; padding:14px; align-items:flex-start; } |
| .panel { background:var(--panel); border:1px solid #2a2f3a; border-radius:8px; padding:12px; width:300px; flex:0 0 auto; } |
| .panel h2 { font-size:12px; text-transform:uppercase; letter-spacing:.7px; color:var(--muted); margin:14px 0 6px; } |
| .panel h2:first-child { margin-top:0; } |
| label { display:block; font-size:12px; color:var(--muted); margin:6px 0 2px; } |
| input[type=number], input[type=text], select { width:100%; background:#11141a; border:1px solid #2a2f3a; color:var(--ink); border-radius:5px; padding:5px 7px; } |
| .row { display:flex; gap:8px; } .row > * { flex:1; } |
| button { background:#2a2f3a; color:var(--ink); border:1px solid #3a4150; border-radius:5px; padding:7px 10px; cursor:pointer; font-size:13px; } |
| button:hover { background:#343b48; } |
| button.primary { background:var(--accent); color:#1a1205; border-color:var(--accent); font-weight:600; } |
| .modes button { width:100%; text-align:left; margin-bottom:4px; } |
| .modes button.active { outline:2px solid var(--accent); } |
| canvas { border:1px solid #2a2f3a; border-radius:6px; cursor:crosshair; image-rendering:pixelated; } |
| .canvas-host { flex:1; min-width:0; overflow:auto; max-height:calc(100vh - 90px); } |
| .hint { font-size:11px; color:var(--muted); margin-top:6px; } |
| .zonelist { max-height:160px; overflow:auto; border:1px solid #2a2f3a; border-radius:5px; } |
| .zonelist div { padding:4px 7px; cursor:pointer; display:flex; justify-content:space-between; } |
| .zonelist div.sel { background:#343b48; } |
| .zonelist .pts { color:var(--muted); font-size:11px; } |
| .pill { display:inline-block; padding:1px 6px; border-radius:10px; font-size:11px; background:#11141a; color:var(--muted); } |
| textarea { width:100%; height:120px; background:#11141a; color:var(--ink); border:1px solid #2a2f3a; border-radius:5px; font:11px monospace; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <h1>SCENE MAP EDITOR</h1> |
| <span class="pill" id="status">no scene loaded</span> |
| <div style="margin-left:auto; display:flex; gap:8px;"> |
| <button id="loadExisting">Load existing manifest</button> |
| <button class="primary" id="export">Export manifest.json</button> |
| </div> |
| </header> |
|
|
| <div class="wrap"> |
| |
| <div class="panel"> |
| <h2>Scene</h2> |
| <select id="scene"> |
| <option value="avorio_f1">avorio_f1 β Avorio Mansion</option> |
| <option value="haunted_mansion">haunted_mansion β Haunted Mansion</option> |
| <option value="mental_asylum">mental_asylum β Mental Asylum</option> |
| </select> |
| <button id="loadBg" style="width:100%; margin-top:8px;">Load background</button> |
|
|
| <h2>Grid</h2> |
| <div class="row"> |
| <div><label>tile_px</label><input type="number" id="tilePx" value="70" /></div> |
| <div><label>display %</label><input type="number" id="disp" value="20" /></div> |
| </div> |
| <div class="row"> |
| <div><label>offset x</label><input type="number" id="offX" value="0" /></div> |
| <div><label>offset y</label><input type="number" id="offY" value="0" /></div> |
| </div> |
| <div class="row"> |
| <div><label>grid_w</label><input type="number" id="gridW" value="0" readonly /></div> |
| <div><label>grid_h</label><input type="number" id="gridH" value="0" readonly /></div> |
| </div> |
| <button id="applyGrid" style="width:100%; margin-top:8px;">Apply grid</button> |
| <div class="hint">Nudge tile_px / offset until the white grid lines sit on the map's walls.</div> |
|
|
| <h2>Collision auto-seed</h2> |
| <label>darkness threshold <span id="thVal">70</span></label> |
| <input type="range" id="thresh" min="0" max="200" value="70" style="width:100%" /> |
| <button id="seed" style="width:100%; margin-top:6px;">Auto-seed walls β blocked</button> |
| <div class="row" style="margin-top:6px;"> |
| <button id="clearCol">Clear all</button> |
| <button id="fillCol">Block all</button> |
| </div> |
| <div class="hint">Seeds blocked tiles from dark wall pixels. Then hand-correct in Collision mode.</div> |
| </div> |
|
|
| |
| <div class="canvas-host"> |
| <canvas id="cv"></canvas> |
| </div> |
|
|
| |
| <div class="panel"> |
| <h2>Mode</h2> |
| <div class="modes"> |
| <button data-mode="collision" class="active">π§± Collision (toggle tiles)</button> |
| <button data-mode="zone">β Zone (drag a rectangle)</button> |
| <button data-mode="spawn">π§ Spawn point (click)</button> |
| <button data-mode="anchor">π Clue anchor (click)</button> |
| </div> |
| <div class="hint" id="modeHint">Click or drag to toggle walls. First tile sets paint value.</div> |
|
|
| <h2>Zones</h2> |
| <div class="zonelist" id="zonelist"></div> |
| <div class="hint">Selected zone receives spawn/anchor clicks. Click a zone to select; click again to delete its last point.</div> |
| <button id="delZone" style="width:100%; margin-top:6px;">Delete selected zone</button> |
|
|
| <h2>Overlays</h2> |
| <label><input type="checkbox" id="showCol" checked /> collision</label> |
| <label><input type="checkbox" id="showGrid" checked /> grid</label> |
| <label><input type="checkbox" id="showZones" checked /> zones / points</label> |
|
|
| <h2>Manifest preview</h2> |
| <textarea id="preview" readonly></textarea> |
| </div> |
| </div> |
|
|
| <script> |
| const ZONE_COLORS = ["#4da3ff","#c9a14a","#5fbf72","#cf5fb0","#d98841","#7a86ff","#46b6b6","#c95f5f"]; |
| const state = { |
| scene:"avorio_f1", img:null, tilePx:70, offX:0, offY:0, gridW:0, gridH:0, disp:0.2, |
| collision:[], zones:[], doors:[], mode:"collision", selZone:-1, |
| paintVal:1, dragging:false, dragStart:null, |
| }; |
| const $ = id => document.getElementById(id); |
| const cv = $("cv"), ctx = cv.getContext("2d"); |
| |
| const full = document.createElement("canvas"), fctx = full.getContext("2d", {willReadFrequently:true}); |
| |
| |
| |
| const SCENE_DEFAULTS = { |
| avorio_f1: { ext: "jpg", tilePx: 70 }, |
| haunted_mansion:{ ext: "png", tilePx: 70 }, |
| mental_asylum: { ext: "jpg", tilePx: 70 }, |
| }; |
| function sceneExt(scene){ return (SCENE_DEFAULTS[scene] || {}).ext || "jpg"; } |
| function sceneTilePx(scene){ return (SCENE_DEFAULTS[scene] || {}).tilePx || 70; } |
| function bgPath(scene){ return `/game-assets/assets/maps/${scene}/background.${sceneExt(scene)}`; } |
| |
| $("loadBg").onclick = () => { |
| state.scene = $("scene").value; |
| const img = new Image(); |
| img.onload = () => { |
| state.img = img; |
| full.width = img.width; full.height = img.height; |
| fctx.drawImage(img,0,0); |
| applyGrid(true); |
| $("status").textContent = `${state.scene} β ${img.width}Γ${img.height}px`; |
| }; |
| img.onerror = () => $("status").textContent = "failed to load background (run via the app, not file://)"; |
| img.src = bgPath(state.scene); |
| }; |
| |
| function applyGrid(resetCollision){ |
| if(!state.img) return; |
| state.tilePx = +$("tilePx").value; state.offX = +$("offX").value; state.offY = +$("offY").value; |
| state.disp = (+$("disp").value)/100; |
| state.gridW = Math.floor((state.img.width - state.offX) / state.tilePx); |
| state.gridH = Math.floor((state.img.height - state.offY) / state.tilePx); |
| $("gridW").value = state.gridW; $("gridH").value = state.gridH; |
| if(resetCollision || state.collision.length !== state.gridH){ |
| state.collision = Array.from({length:state.gridH}, ()=>Array(state.gridW).fill(0)); |
| } |
| cv.width = Math.round(state.img.width * state.disp); |
| cv.height = Math.round(state.img.height * state.disp); |
| draw(); |
| } |
| $("applyGrid").onclick = () => applyGrid(false); |
| $("disp").onchange = () => applyGrid(false); |
| |
| |
| function tpx(){ return state.tilePx * state.disp; } |
| function draw(){ |
| if(!state.img) return; |
| ctx.clearRect(0,0,cv.width,cv.height); |
| ctx.drawImage(state.img,0,0,cv.width,cv.height); |
| const t = tpx(), ox = state.offX*state.disp, oy = state.offY*state.disp; |
| |
| if($("showCol").checked){ |
| ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--block"); |
| for(let y=0;y<state.gridH;y++) for(let x=0;x<state.gridW;x++) |
| if(state.collision[y][x]) ctx.fillRect(ox+x*t, oy+y*t, t, t); |
| } |
| if($("showGrid").checked){ |
| ctx.strokeStyle="rgba(255,255,255,.18)"; ctx.lineWidth=1; ctx.beginPath(); |
| for(let x=0;x<=state.gridW;x++){ ctx.moveTo(ox+x*t,oy); ctx.lineTo(ox+x*t,oy+state.gridH*t); } |
| for(let y=0;y<=state.gridH;y++){ ctx.moveTo(ox,oy+y*t); ctx.lineTo(ox+state.gridW*t,oy+y*t); } |
| ctx.stroke(); |
| } |
| if($("showZones").checked){ |
| state.zones.forEach((z,i)=>{ |
| const c = ZONE_COLORS[i%ZONE_COLORS.length], [zx,zy,zw,zh]=z.rect; |
| ctx.strokeStyle=c; ctx.lineWidth=i===state.selZone?3:1.5; |
| ctx.strokeRect(ox+zx*t, oy+zy*t, zw*t, zh*t); |
| ctx.fillStyle=c; ctx.font="11px sans-serif"; |
| ctx.fillText(z.label||z.id, ox+zx*t+3, oy+zy*t+12); |
| ctx.fillStyle=c; |
| z.spawn_points.forEach(([px,py])=>{ ctx.beginPath(); ctx.arc(ox+(px+.5)*t,oy+(py+.5)*t,t*.18,0,7); ctx.fill(); }); |
| ctx.strokeStyle=c; |
| z.clue_anchors.forEach(([px,py])=>{ ctx.beginPath(); ctx.arc(ox+(px+.5)*t,oy+(py+.5)*t,t*.22,0,7); ctx.lineWidth=2; ctx.stroke(); }); |
| }); |
| } |
| refreshZoneList(); refreshPreview(); |
| } |
| ["showCol","showGrid","showZones"].forEach(id=>$(id).onchange=draw); |
| |
| |
| function evTile(e){ |
| const r = cv.getBoundingClientRect(); |
| const px = (e.clientX - r.left), py = (e.clientY - r.top); |
| const t = tpx(), ox = state.offX*state.disp, oy = state.offY*state.disp; |
| return { x: Math.floor((px-ox)/t), y: Math.floor((py-oy)/t) }; |
| } |
| function inBounds(x,y){ return x>=0&&x<state.gridW&&y>=0&&y<state.gridH; } |
| |
| cv.addEventListener("mousedown", e=>{ |
| const {x,y}=evTile(e); if(!inBounds(x,y)) return; |
| state.dragging=true; state.dragStart={x,y}; |
| if(state.mode==="collision"){ state.paintVal = state.collision[y][x]?0:1; state.collision[y][x]=state.paintVal; draw(); } |
| else if(state.mode==="spawn"||state.mode==="anchor"){ addPoint(state.mode,x,y); } |
| }); |
| cv.addEventListener("mousemove", e=>{ |
| if(!state.dragging) return; |
| const {x,y}=evTile(e); if(!inBounds(x,y)) return; |
| if(state.mode==="collision"){ state.collision[y][x]=state.paintVal; draw(); } |
| else if(state.mode==="zone"){ drawZonePreview(state.dragStart,{x,y}); } |
| }); |
| window.addEventListener("mouseup", e=>{ |
| if(state.dragging && state.mode==="zone" && state.dragStart){ |
| const {x,y}=evTile(e); commitZone(state.dragStart,{x,y}); |
| } |
| state.dragging=false; state.dragStart=null; |
| }); |
| |
| function drawZonePreview(a,b){ |
| draw(); |
| const t=tpx(), ox=state.offX*state.disp, oy=state.offY*state.disp; |
| const x=Math.min(a.x,b.x), y=Math.min(a.y,b.y), w=Math.abs(b.x-a.x)+1, h=Math.abs(b.y-a.y)+1; |
| ctx.strokeStyle="#fff"; ctx.lineWidth=1.5; ctx.setLineDash([5,4]); |
| ctx.strokeRect(ox+x*t,oy+y*t,w*t,h*t); ctx.setLineDash([]); |
| } |
| function commitZone(a,b){ |
| const x=Math.min(a.x,b.x), y=Math.min(a.y,b.y), w=Math.abs(b.x-a.x)+1, h=Math.abs(b.y-a.y)+1; |
| if(w<1||h<1) return; |
| const id = prompt("Zone id (kebab-case, e.g. kitchen):"); if(!id) { draw(); return; } |
| const label = prompt("Zone label (display name):", id.replace(/_/g," ").replace(/\b\w/g,c=>c.toUpperCase())) || id; |
| state.zones.push({id:id.trim(), label:label.trim(), rect:[x,y,w,h], spawn_points:[], clue_anchors:[]}); |
| state.selZone = state.zones.length-1; draw(); |
| } |
| function addPoint(kind,x,y){ |
| if(state.selZone<0){ alert("Select a zone first (click one in the Zones list)."); return; } |
| const z = state.zones[state.selZone]; |
| const arr = kind==="spawn"?z.spawn_points:z.clue_anchors; |
| arr.push([x,y]); draw(); |
| } |
| |
| |
| function refreshZoneList(){ |
| const host=$("zonelist"); host.innerHTML=""; |
| state.zones.forEach((z,i)=>{ |
| const d=document.createElement("div"); if(i===state.selZone) d.className="sel"; |
| d.innerHTML = `<span style="color:${ZONE_COLORS[i%ZONE_COLORS.length]}">${z.id}</span>`+ |
| `<span class="pts">${z.spawn_points.length}π§ ${z.clue_anchors.length}π</span>`; |
| d.onclick = ()=>{ if(state.selZone===i){ |
| const z2=state.zones[i]; (z2.clue_anchors.pop()||z2.spawn_points.pop()); } |
| state.selZone=i; draw(); }; |
| host.appendChild(d); |
| }); |
| } |
| $("delZone").onclick=()=>{ if(state.selZone>=0){ state.zones.splice(state.selZone,1); state.selZone=-1; draw(); } }; |
| |
| |
| document.querySelectorAll(".modes button").forEach(b=>b.onclick=()=>{ |
| document.querySelectorAll(".modes button").forEach(x=>x.classList.remove("active")); |
| b.classList.add("active"); state.mode=b.dataset.mode; |
| const hints={collision:"Click or drag to toggle walls. First tile sets paint value.", |
| zone:"Drag a rectangle, then name the zone.", spawn:"Click a tile to add a spawn to the selected zone.", |
| anchor:"Click a tile to add a clue anchor to the selected zone."}; |
| $("modeHint").textContent=hints[state.mode]; |
| }); |
| |
| |
| $("thresh").oninput = e => $("thVal").textContent = e.target.value; |
| $("seed").onclick = () => { |
| if(!state.img){ alert("Load a background first."); return; } |
| const th = +$("thresh").value, tp=state.tilePx; |
| for(let y=0;y<state.gridH;y++) for(let x=0;x<state.gridW;x++){ |
| const sx=state.offX+x*tp, sy=state.offY+y*tp; |
| const data = fctx.getImageData(sx,sy,tp,tp).data; |
| let dark=0, n=0; |
| for(let i=0;i<data.length;i+=4*7){ |
| const lum=0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]; |
| if(lum<th) dark++; n++; |
| } |
| state.collision[y][x] = (dark/n > 0.33) ? 1 : 0; |
| } |
| draw(); |
| }; |
| $("clearCol").onclick=()=>{ state.collision=state.collision.map(r=>r.map(()=>0)); draw(); }; |
| $("fillCol").onclick=()=>{ state.collision=state.collision.map(r=>r.map(()=>1)); draw(); }; |
| |
| |
| function buildManifest(){ |
| return { |
| scene_id: state.scene, |
| background: `assets/maps/${state.scene}/background.${sceneExt(state.scene)}`, |
| tile_px: state.tilePx, grid_w: state.gridW, grid_h: state.gridH, |
| zones: state.zones.map(z=>({id:z.id,label:z.label,rect:z.rect,spawn_points:z.spawn_points,clue_anchors:z.clue_anchors})), |
| doors: state.doors, |
| collision: state.collision, |
| }; |
| } |
| function refreshPreview(){ |
| const m=buildManifest(); |
| $("preview").value = JSON.stringify({...m, collision:`[${m.grid_h}Γ${m.grid_w} grid omitted]`}, null, 1); |
| } |
| $("export").onclick = () => { |
| const blob = new Blob([JSON.stringify(buildManifest())], {type:"application/json"}); |
| const a=document.createElement("a"); a.href=URL.createObjectURL(blob); |
| a.download="manifest.json"; a.click(); |
| }; |
| |
| const fileInput = document.createElement("input"); |
| fileInput.type = "file"; fileInput.accept = "application/json,.json"; fileInput.style.display = "none"; |
| document.body.appendChild(fileInput); |
| |
| $("loadExisting").onclick = () => { fileInput.value = ""; fileInput.click(); }; |
| |
| fileInput.onchange = () => { |
| const f = fileInput.files[0]; if(!f) return; |
| const reader = new FileReader(); |
| reader.onload = () => { |
| let m; |
| try { m = JSON.parse(reader.result); } |
| catch(e){ alert("That file isn't valid JSON:\n"+e); return; } |
| if(!m.collision || !Array.isArray(m.collision)){ alert("Not a scene manifest (no collision grid)."); return; } |
| |
| state.scene = m.scene_id || $("scene").value; |
| state.tilePx = m.tile_px; state.gridW = m.grid_w; state.gridH = m.grid_h; |
| state.zones = (m.zones||[]).map(z=>({spawn_points:[],clue_anchors:[],...z})); |
| state.doors = m.doors || []; state.collision = m.collision; state.selZone = -1; |
| $("tilePx").value = m.tile_px; $("gridW").value = m.grid_w; $("gridH").value = m.grid_h; |
| |
| if([...$("scene").options].some(o=>o.value===state.scene)) $("scene").value = state.scene; |
| $("loadBg").onclick(); |
| setTimeout(draw, 300); |
| $("status").textContent = `loaded ${f.name} β ${state.scene}`; |
| }; |
| reader.readAsText(f); |
| }; |
| |
| $("scene").onchange = () => $("tilePx").value = sceneTilePx($("scene").value); |
| </script> |
| </body> |
| </html> |
|
|