midnight_mystery / app /static /map_editor.html
lcpizani's picture
Add full app: pages, assets, backend, config
811d960
Raw
History Blame Contribute Delete
17.2 kB
<!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">
<!-- LEFT: setup + grid -->
<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>
<!-- CENTER: canvas -->
<div class="canvas-host">
<canvas id="cv"></canvas>
</div>
<!-- RIGHT: modes + zones -->
<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");
// hidden full-res canvas for pixel sampling
const full = document.createElement("canvas"), fctx = full.getContext("2d", {willReadFrequently:true});
// app/static is served at /game-assets, and maps live under app/static/assets/maps
// per-scene authoring defaults: background file extension + tile_px (native-square aligned)
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);
// ---- drawing -----------------------------------------------------------
function tpx(){ return state.tilePx * state.disp; } // displayed tile size
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);
// ---- mouse β†’ tile ------------------------------------------------------
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();
}
// ---- zone list ---------------------------------------------------------
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){ /* second click removes last point */
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(); } };
// ---- modes -------------------------------------------------------------
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];
});
// ---- auto-seed ---------------------------------------------------------
$("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){ // sample every 7th pixel
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; // >33% dark => wall
}
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(); };
// ---- import / export ---------------------------------------------------
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();
};
// hidden file input β€” "Load existing manifest" opens the OS file picker
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; }
// adopt the manifest into editor state
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;
// sync the scene dropdown + reload its background, then redraw
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>