<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>TMBuilder - "The Map" Customizer</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">
<style>
html,body{min-height:100vh;margin:0;padding:0;background:linear-gradient(180deg,#07101a 0%,#0b0f14 100%);background-attachment:fixed;color:#e6eef6;font-family:Inter, "Segoe UI", Roboto, system-ui}
.bar{display:flex;gap:12px;align-items:center;padding:14px;background:rgba(255,255,255,0.02);border-bottom:1px solid rgba(255,255,255,0.01)}
h1{margin:0;font-size:18px;font-weight:900;letter-spacing:0.2px}
.spacer{flex:1}
.btn{background:#ff7a18;border:0;color:#071014;padding:11px 14px;border-radius:10px;cursor:pointer;font-weight:900;font-size:15px;box-shadow:0 6px 12px rgba(255,122,24,0.08);touch-action:manipulation}
.btn.ghost{background:transparent;color:#9aa7b2;border:1px solid rgba(255,255,255,0.03);box-shadow:none;font-weight:700}
.container{display:flex;justify-content:center;padding:16px}
.main{width:1100px;max-width:calc(100% - 32px)}
.world{background:#0f1720;border:1px solid rgba(255,255,255,0.02);padding:12px;border-radius:10px;margin-bottom:12px}
.hdr{display:flex;justify-content:space-between;gap:8px;align-items:center}
.hdr .left{display:flex;gap:12px;align-items:center}
.drag{cursor:grab;display:inline-block;width:18px;height:18px;line-height:18px;text-align:center;user-select:none}
label{display:block;margin-top:10px;margin-bottom:6px;color:#9aa7b2;font-size:13px}
input[type="text"],input[type="number"],input[type="file"],textarea{width:100%;padding:10px;border-radius:8px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.02);color:inherit;font-size:14px;box-sizing:border-box;outline:none}
input[type="number"]{-moz-appearance:textfield}input[type="number"]::-webkit-outer-spin-button,input[type="number"]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
.file-name{margin-top:6px;color:#cfe8ff;font-size:13px}
.file-name .missing{color:#ffb8a0}
table.levels{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px}
table.levels th,table.levels td{padding:8px 6px;border-bottom:1px dashed rgba(255,255,255,0.02);text-align:left;vertical-align:middle}
.ye{width:100%;padding:6px 8px;border-radius:6px;background:transparent;border:1px solid rgba(255,255,255,0.02);color:inherit;font-size:13px;box-sizing:border-box}
.kill-world{background:#e04b3b;color:#fff;border:0;padding:8px 10px;border-radius:8px;cursor:pointer;font-weight:700}
.kill-lvl{background:transparent;color:#ff8a7a;border:0;cursor:pointer;font-size:16px}
@media (max-width:720px){.main{width:calc(100% - 32px)}.btn{padding:12px 16px;font-size:16px}input[type="text"],input[type="number"]{padding:12px}}
</style>
</head>
<body>
<div class="bar">
<h1>TMBuilder - "The Map" Customizer Tool</h1>
<div class="spacer"></div>
<button id="importJSON" class="btn ghost">Import JSON</button>
<input id="thejson" type="file" accept="application/json" style="display:none">
<button id="plusWorld" class="btn">+ World</button>
<button id="zipitup" class="btn">Export .ZIP</button>
</div>
<div class="container">
<div id="main" class="main"></div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script>
$(function(){
const $main = $('#main'), $thejson = $('#thejson');
let worlds = [];
const uid = ()=> 'w-'+Math.random().toString(36).slice(2,9);
const esc = s => String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
const sanitize = s => String(s||'').replace(/[<>:"\/\\|?*\x00-\x1F]/g,'_').replace(/\s+/g,'_');
const parseCoord = v=>{
if (v==null) return 0;
const s=String(v).trim(); if(!s) return 0;
if(s.endsWith('%')){ const n=parseFloat(s.slice(0,-1)); return isNaN(n)?0:n/100; }
const n=parseFloat(s); return isNaN(n)?0:n;
};
function readTbody($tbody){
const arr=[];
$tbody.children('tr').each(function(){
const $r=$(this);
arr.push({
levelID: Number($r.find('.lv-id').val())||0,
x: $r.find('.lv-x').val(),
y: $r.find('.lv-y').val(),
authorName: $r.find('.lv-author').val()||'',
accountID: String($r.find('.lv-account').val())||0,
userID: String($r.find('.lv-user').val())||0
});
});
return arr;
}
function AMAZING(){
$main.empty();
worlds.forEach((w,i)=>{
const rows = (w.levels||[]).map(lv=>`
<tr>
<td class="drag">≡</td>
<td><input class="ye lv-id" type="number" value="${lv.levelID||0}"></td>
<td><input class="ye lv-x" type="text" value="${esc(lv.x||0)}"></td>
<td><input class="ye lv-y" type="text" value="${esc(lv.y||0)}"></td>
<td><input class="ye lv-author" type="text" value="${esc(lv.authorName||'')}"></td>
<td><input class="ye lv-account" type="number" value="${lv.accountID||0}"></td>
<td><input class="ye lv-user" type="number" value="${lv.userID||0}"></td>
<td><button class="kill-lvl">✕</button></td>
</tr>`).join('');
const card = `
<div class="world" data-id="${w.id}">
<div class="hdr">
<div class="left"><span class="drag">≡</span><strong>World ${i+1}</strong></div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn add-level">+ Level</button>
<button class="kill-world">Remove</button>
</div>
</div>
<label>Title (required)</label>
<input class="title" type="text" value="${esc(w.title)}" placeholder="Title">
<label>Short text (optional)</label>
<input class="shortText" type="text" value="${esc(w.shortText)}" placeholder="">
<label>Background (PNG — required)</label>
<input class="bgInput" type="file" accept="image/png">
<div class="file-name bg-name">${w.backgroundFile ? esc(w.backgroundFile.name) : (w.bgFile ? esc(w.bgFile) : '<span class="missing">no background</span>')}</div>
<label>Music (MP3 — required)</label>
<input class="mp3Input" type="file" accept="audio/mpeg">
<div class="file-name music-name">${w.musicFile ? esc(w.musicFile.name) : (w.musicName ? esc(w.musicName) : '<span class="missing">no music</span>')}</div>
<div style="margin-top:10px;font-weight:600">Levels</div>
<table class="levels">
<thead><tr><th></th><th>levelID</th><th>x</th><th>y</th><th>author</th><th>accountID</th><th>userID</th><th></th></tr></thead>
<tbody class="levels-body">${rows}</tbody>
</table>
</div>`;
$main.append(card);
});
$main.find('.levels-body').each(function(){
const $tb=$(this);
new Sortable(this,{handle:'.drag',animation:150,onEnd:()=>{ const id=$tb.closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w) w.levels = readTbody($tb); AMAZING(); }});
});
new Sortable($main[0],{handle:'.drag',animation:150,onEnd:()=>{
const ids=$main.children().map(function(){return $(this).attr('data-id');}).get();
worlds = ids.map(id=>worlds.find(w=>w.id===id));
AMAZING();
}});
}
function bobTheBuilder(){ return worlds.map(w=>({
title: w.title||'',
shortText: w.shortText||'',
background: w.bgFile||'',
music: w.musicName||'',
levels: (w.levels||[]).map((lv,idx)=>({
levelID: Number(lv.levelID)||0,
x: parseCoord(lv.x),
y: parseCoord(lv.y),
order: idx+1,
authorName: lv.authorName||'',
accountID: String(lv.accountID)||0,
userID: String(lv.userID)||0
}))
})); }
async function exportZip(){
if(!Array.isArray(worlds)||!worlds.length){ alert('Nothing to export.'); return; }
for(let i=0;i<worlds.length;i++){
const w=worlds[i];
if(!w.title||!w.title.trim()){ alert('World '+(i+1)+' title is required.'); return; }
if(!w.backgroundFile){ if(w.bgFile) alert('World '+(i+1)+' expecting background file: '+w.bgFile+'.'); else alert('World '+(i+1)+' background PNG is required.'); return; }
if(!/\.png$/i.test(w.backgroundFile.name)){ alert('World '+(i+1)+': background must be a PNG file, Geometry Dash does not support other image formats.'); return; }
if(!w.musicFile){ if(w.musicName) alert('World '+(i+1)+' expecting music file: '+w.musicName+'.'); else alert('World '+(i+1)+' music MP3 is required.'); return; }
if(!/\.mp3$/i.test(w.musicFile.name)){ alert('World '+(i+1)+': music must be an MP3 file, Geometry Dash does not support any other audio formats.'); return; }
}
const zip = new JSZip();
const map = bobTheBuilder();
const e = {};
for(let i=0;i<worlds.length;i++){
const w=worlds[i];
let b = sanitize(w.backgroundFile.name), m = sanitize(w.musicFile.name);
if(e[b]){ e[b]++; b=b.replace(/(\.[^.]+)?$/,'_'+e[b]+'$1'); } else e[b]=1;
if(e[m]){ e[m]++; m=m.replace(/(\.[^.]+)?$/,'_'+e[m]+'$1'); } else e[m]=1;
zip.folder('backgrounds').file(b, w.backgroundFile);
zip.folder('music').file(m, w.musicFile);
map[i].background = b; map[i].music = m;
}
zip.file('map.json', JSON.stringify(map,null,2));
const blob = await zip.generateAsync({type:'blob'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'custom.zip'; document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
function importJson(file){
const r = new FileReader();
r.onload = ()=> {
try{
const parsed = JSON.parse(r.result);
if(!Array.isArray(parsed)){ alert('Invalid JSON: expected an array of worlds.'); return; }
parsed.forEach(obj=>{
const w = { id: uid(), title: obj.title?String(obj.title):'', shortText: obj.shortText?String(obj.shortText):'', backgroundFile:null, bgFile: obj.background?String(obj.background):'', musicFile:null, musicName: obj.music?String(obj.music):'', levels:[] };
if(Array.isArray(obj.levels)) obj.levels.forEach(lv=> w.levels.push({
levelID: Number(lv.levelID)||0, x: lv.x===undefined?0:lv.x, y: lv.y===undefined?0:lv.y,
authorName: lv.authorName?String(lv.authorName):'', accountID: String(lv.accountID)||0, userID: String(lv.userID)||0
}));
worlds.push(w);
});
AMAZING();
alert('Imported '+parsed.length+' world(s)! But... You need to reupload backgrounds and music for all worlds.');
}catch(e){ alert('Failed to parse JSON: '+(e&&e.message?e.message:e)); }
};
r.onerror = ()=> alert('Failed to read file.');
r.readAsText(file);
}
$main.on('input','input.title',function(){ const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w) w.title=$(this).val(); });
$main.on('input','input.shortText',function(){ const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w) w.shortText=$(this).val(); });
$main.on('input','.levels-body input',function(){ const $tb=$(this).closest('.levels-body'); const id=$tb.closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w) w.levels = readTbody($tb); });
$main.on('change','.bgInput',function(){ const f=this.files&&this.files[0]?this.files[0]:null; const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(!w) return; if(f){ w.backgroundFile=f; w.bgFile=sanitize(f.name); $(this).siblings('.bg-name').text(f.name); } else { w.backgroundFile=null; } });
$main.on('change','.mp3Input',function(){ const f=this.files&&this.files[0]?this.files[0]:null; const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(!w) return; if(f){ w.musicFile=f; w.musicName=sanitize(f.name); $(this).siblings('.music-name').text(f.name); } else { w.musicFile=null; } });
$main.on('click','.add-level',function(){ const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w){ if(!Array.isArray(w.levels)) w.levels=[]; w.levels.push({levelID:0,x:0,y:0,authorName:'',accountID:0,userID:0}); AMAZING(); } });
$main.on('click','.kill-world',function(){ if(!confirm('Remove this world?')) return; const id=$(this).closest('.world').attr('data-id'); const idx=worlds.findIndex(x=>x.id===id); if(idx>=0){ worlds.splice(idx,1); AMAZING(); } });
$main.on('click','.kill-lvl',function(){ const $tr=$(this).closest('tr'); const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(!w) return; const idx=$tr.index(); if(idx>=0){ w.levels.splice(idx,1); AMAZING(); } });
$('#plusWorld').on('click',function(){ worlds.push({ id: uid(), title:'', shortText:'', backgroundFile:null, bgFile:'', musicFile:null, musicName:'', levels:[] }); AMAZING(); setTimeout(()=>window.scrollTo({top:document.body.scrollHeight,behavior:'smooth'}),80); });
$('#zipitup').on('click',()=> exportZip().catch(err=>alert('Export failed: '+err)));
$('#importJSON').on('click',()=>{ $thejson.val(null); $thejson.trigger('click'); });
$thejson.on('change',e=>{ const f=e.target.files&&e.target.files[0]; if(f) importJson(f); });
AMAZING();
});
</script>
</body>
</html>