Add NPC/monster spawn browser with map and monster names to webtools

Map names now come from LIST_ZONE.STB (row index = map id, matching
CWorldServer::LoadZoneData). New NPCs tab lists all placed NPCs from
list_npcs with coordinates; new Monsters tab lists all 1,193 named montypes
from LIST_NPC.STB. Both are click-to-copy GM commands (/tele, /mon) for
quick GM navigation/spawning.
This commit is contained in:
Thomas committed 2026-07-10 14:20:12 +02:00
1 parent 16dccc94f2
commit e0d1f629db
4 files changed
+468 -36

No files matched your search

+135 -6
View File
@@ -175,6 +175,8 @@
<div class="tab active" data-view="commands"><span class="ico">⌘</span> GM Commands</div>
<div class="tab" data-view="items"><span class="ico">◆</span> Item Configurator</div>
<div class="tab" data-view="maps"><span class="ico">⌗</span> Maps</div>
<div class="tab" data-view="npcs"><span class="ico">☺</span> NPCs</div>
<div class="tab" data-view="monsters"><span class="ico">☠</span> Monsters</div>
<a class="tab-link" href="/admin/"><span class="ico">⚙</span> Admin Panel</a>
</nav>
@@ -200,6 +202,22 @@
</select>
</div>
<div class="side-panel" id="panel-npcs">
<div class="label">Map</div>
<select id="npc-map"></select>
<div class="label">Search by name</div>
<input id="q-npcs" type="text" placeholder="e.g. Lampa" autocomplete="off" />
</div>
<div class="side-panel" id="panel-monsters">
<div class="label">Spawn count</div>
<input id="mon-count" type="number" value="1" min="1" />
<div class="label">AI (0 = default)</div>
<input id="mon-ai" type="number" value="0" min="0" />
<div class="label">Search by name or id</div>
<input id="q-monsters" type="text" placeholder="e.g. jelly, or 210" autocomplete="off" />
</div>
<div class="legend" id="legend-commands">
<div class="row"><span class="pill pill-any">any</span> usable by any GM character</div>
<div class="row"><span class="pill pill-gm">gm 300+</span> accesslevel ≥ 300</div>
@@ -254,17 +272,39 @@
<section class="view" id="view-maps">
<div class="pagehead">
<h1>Every map with active spawn or NPC data</h1>
<p>Aggregated live from <code>list_special_spawns</code> and <code>list_npcs</code>.</p>
<p>Names pulled straight from <code>LIST_ZONE.STB</code> (row index = map id, same as <code>CWorldServer::LoadZoneData</code>
uses server-side); spawn/NPC counts aggregated live from <code>list_special_spawns</code> and <code>list_npcs</code>.</p>
</div>
<div class="map-empty-note">
No minimap images or display names are shown here — this repo only ships the server side of KTRose, not the
client's 3D/texture assets, and the localized zone-name strings aren't parsed by the server's simplified STB
loader either. What's below (spawn counts, monster totals, coordinate bounds) is real, queried straight from
the running database.
No minimap images are shipped here — this repo only ships the server side of KTRose, not the client's 3D/texture
assets. A handful of zone names are untranslated Korean (the dev-tool STB just was never localized for those rows).
</div>
<div class="map-grid" id="results-maps"></div>
</section>
<!-- ===================== NPCS ===================== -->
<section class="view" id="view-npcs">
<div class="pagehead">
<h1>Every placed NPC, with exact coordinates</h1>
<p>Straight out of <code>list_npcs</code>. Click a card to copy the matching <code>/tele</code> command
and warp straight to it as GM.</p>
</div>
<div class="resultmeta" id="resultmeta-npcs"></div>
<div class="item-grid" id="results-npcs"></div>
</section>
<!-- ===================== MONSTERS ===================== -->
<section class="view" id="view-monsters">
<div class="pagehead">
<h1>Every monster/NPC type, ready to spawn</h1>
<p>1,193 named entries straight out of <code>LIST_NPC.STB</code> (row index = <code>montype</code>, the same
id <code>/mon</code> and <code>/spawn</code> expect). Click one to copy a ready-to-paste <code>/mon</code>
command that drops it next to you.</p>
</div>
<div class="resultmeta" id="resultmeta-monsters"></div>
<div class="item-grid" id="results-monsters"></div>
</section>
</main>
</div>
@@ -456,7 +496,7 @@ function renderMaps(){
? `<div>X range</div><b>${m.bounds.minX} – ${m.bounds.maxX}</b><div>Y range</div><b>${m.bounds.minY} – ${m.bounds.maxY}</b>`
: `<div>Coordinates</div><b>no spawn data</b><div></div><b></b>`;
el.innerHTML = `
<h3>Map ${m.id}</h3>
<h3>${m.name || ("Map "+m.id)} <span style="color:var(--ink-faint); font-weight:400;">#${m.id}</span></h3>
${m.note ? `<div class="note">${m.note}</div>` : ""}
<div class="map-stats">
<div>Spawn points</div><b>${m.spawnPoints}</b>
@@ -469,6 +509,81 @@ function renderMaps(){
}
document.getElementById("map-sort").addEventListener("change", renderMaps);
/* ---------------- NPCS TAB ---------------- */
let NPCS = [];
function renderNpcs(){
const root = document.getElementById("results-npcs");
const meta = document.getElementById("resultmeta-npcs");
const map = document.getElementById("npc-map").value;
const q = document.getElementById("q-npcs").value.trim().toLowerCase();
root.innerHTML = "";
let filtered = NPCS;
if(map !== "__all__") filtered = filtered.filter(n => String(n.map) === map);
if(q) filtered = filtered.filter(n => n.name.toLowerCase().includes(q));
filtered.forEach(n=>{
const el = document.createElement("div");
el.className = "item-card";
const tele = `/tele ${n.map} ${n.x} ${n.y}`;
el.innerHTML = `<div class="nm">${n.name}</div><div class="id">map ${n.map} · x ${n.x} · y ${n.y}</div>`;
el.title = "Click to copy: " + tele;
el.addEventListener("click", ()=>{
copyText(tele);
const nm = el.querySelector(".nm");
const prev = nm.textContent;
nm.textContent = "Copied: " + tele;
setTimeout(()=>{ nm.textContent = prev; }, 1200);
});
root.appendChild(el);
});
if(filtered.length===0) root.innerHTML = `<div class="empty">No NPC matches.</div>`;
meta.textContent = `${filtered.length} of ${NPCS.length} NPCs`;
}
document.getElementById("q-npcs").addEventListener("input", renderNpcs);
document.getElementById("npc-map").addEventListener("change", renderNpcs);
/* ---------------- MONSTERS TAB ---------------- */
let MONSTERS = [];
function renderMonsters(){
const root = document.getElementById("results-monsters");
const meta = document.getElementById("resultmeta-monsters");
const q = document.getElementById("q-monsters").value.trim().toLowerCase();
root.innerHTML = "";
let filtered = MONSTERS;
if(q) filtered = filtered.filter(m => m.name.toLowerCase().includes(q) || String(m.id)===q);
const CAP = 180;
const total = filtered.length;
const shown = filtered.slice(0, CAP);
shown.forEach(m=>{
const el = document.createElement("div");
el.className = "item-card";
el.innerHTML = `<div class="nm">${m.name}</div><div class="id">montype ${m.id}</div>`;
el.addEventListener("click", ()=>{
const count = document.getElementById("mon-count").value || 1;
const ai = document.getElementById("mon-ai").value || 0;
const cmd = `/mon ${m.id} ${count} ${ai}`;
copyText(cmd);
const nm = el.querySelector(".nm");
const prev = nm.textContent;
nm.textContent = "Copied: " + cmd;
setTimeout(()=>{ nm.textContent = prev; }, 1200);
});
root.appendChild(el);
});
if(total===0) root.innerHTML = `<div class="empty">No monster matches.</div>`;
meta.textContent = total > CAP
? `Showing first ${CAP} of ${total} matches — narrow your search to see more.`
: `${total} of ${MONSTERS.length} monster types`;
}
document.getElementById("q-monsters").addEventListener("input", renderMonsters);
document.getElementById("mon-count").addEventListener("input", renderMonsters);
document.getElementById("mon-ai").addEventListener("input", renderMonsters);
/* ---------------- boot ---------------- */
buildCommandsNav();
renderCommands("");
@@ -485,6 +600,20 @@ fetch("maps.json").then(r=>r.json()).then(data=>{
MAPS = data;
renderMaps();
});
fetch("npcs.json").then(r=>r.json()).then(data=>{
NPCS = data;
const maps = [...new Set(data.map(n=>n.map))].sort((a,b)=>a-b);
const sel = document.getElementById("npc-map");
sel.innerHTML = `<option value="__all__">All maps</option>` +
maps.map(m=>`<option value="${m}">Map ${m}</option>`).join("");
renderNpcs();
});
fetch("monsters.json").then(r=>r.json()).then(data=>{
MONSTERS = data;
renderMonsters();
});
</script>
</body>
</html>