Files
mplus-routes/web/app.js
T
florian.berthold 18c7792935 switch to keystone.guru tiles + enemy data
Replaces the upreza-derived 4K dungeon textures + AtlasLoot boss-coord
overlay (which had a consistent positional offset against texture skulls)
with keystone.guru's z=4 tile pyramid stitched to 6144x4096 WebP per floor.

kg's split_floors.js gives per-dungeon enemies, packs (polygons), patrols
(polylines), and map icons calibrated to those tiles, so overlays align
pixel-perfectly. 27/29 classic dungeons now have full enemy/pack data;
ZG + Sunken Temple have maps only.

Pipeline: tools/kg_fetch.py -> tools/kg_stitch.py -> tools/kg_build_data.py.
2026-04-25 22:11:17 +02:00

630 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ascension M+ Route Planner — vanilla JS, single-file SPA.
// Renders keystone.guru-derived data (enemies, packs, patrols, icons) on
// stitched z=4 dungeon tiles. All overlays live in image-pixel space; the
// canvas-stage CSS transform handles pan/zoom uniformly.
const SVG_NS = "http://www.w3.org/2000/svg";
const state = {
dungeons: [],
current: null,
floorIndex: 0,
tool: "route", // "route" | "pull"
routes: {}, // { key: [{x,y}] } image-pixel coords
pulls: {}, // { key: [{x,y}] }
history: [],
drag: null,
view: { scale: 1, tx: 0, ty: 0 },
pan: null,
show: { enemies: true, packs: true, patrols: true, icons: true },
};
const $ = (id) => document.getElementById(id);
async function init() {
const r = await fetch("assets/dungeons.json", { cache: "no-cache" });
state.dungeons = (await r.json()).dungeons;
populateExpansionFilter();
renderDungeonList();
hookEvents();
loadFromHash();
if (!state.current && state.dungeons.length) {
selectDungeon(state.dungeons[0].id);
}
}
function populateExpansionFilter() {
const sel = $("expansion-filter");
sel.innerHTML = '<option value="">All</option>';
// Classic-only deployment — keep the dropdown as a no-op for now.
}
function renderDungeonList() {
const ul = $("dungeon-list");
const q = $("search").value.trim().toLowerCase();
ul.innerHTML = "";
for (const d of state.dungeons.filter((d) =>
!q || d.name.toLowerCase().includes(q) || (d.acronym || "").toLowerCase().includes(q)
)) {
const li = document.createElement("li");
li.dataset.id = d.id;
if (state.current && d.id === state.current.id) li.classList.add("active");
const name = document.createElement("span");
name.textContent = d.name;
const acr = document.createElement("span");
acr.className = "acronym";
acr.textContent = d.acronym || "";
li.append(name, acr);
li.onclick = () => selectDungeon(d.id);
ul.appendChild(li);
}
}
function selectDungeon(id) {
const d = state.dungeons.find((x) => x.id === id);
if (!d) return;
state.current = d;
state.floorIndex = 0;
renderDungeonList();
renderViewer();
updateHash();
}
function currentKey() {
return state.current ? `${state.current.id}::${state.floorIndex}` : null;
}
function currentMap() {
return state.current?.maps[state.floorIndex] || null;
}
function renderViewer() {
const d = state.current;
if (!d) return;
$("dungeon-name").textContent = d.name;
const m = currentMap();
const meta = [
d.acronym, d.levelRange && `Lv ${d.levelRange}`,
m && `${m.enemies?.length ?? 0} enemies`,
m && `${m.packs?.length ?? 0} packs`,
].filter(Boolean).join(" · ");
$("dungeon-meta").textContent = meta;
const tabs = $("floor-tabs");
tabs.innerHTML = "";
if (d.maps.length > 1) {
d.maps.forEach((mp, i) => {
const b = document.createElement("button");
b.textContent = humanizeLabel(mp.label);
if (i === state.floorIndex) b.classList.add("active");
b.onclick = () => { state.floorIndex = i; renderViewer(); updateHash(); };
tabs.appendChild(b);
});
}
const img = $("map-img");
if (m) {
img.src = "assets/" + m.image;
img.width = m.width;
img.height = m.height;
const svg = $("overlay");
svg.setAttribute("width", m.width);
svg.setAttribute("height", m.height);
img.onload = () => { fitToScreen(); renderOverlay(); };
if (img.complete) fitToScreen();
} else {
img.removeAttribute("src");
}
renderOverlay();
renderInfoPane();
}
function humanizeLabel(s) {
return s
.replace(/_floor(\d+)$/, " · floor $1")
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/* --- overlay rendering --------------------------------------------------- */
function renderOverlay() {
const svg = $("overlay");
svg.innerHTML = "";
const m = currentMap();
if (!m) return;
svg.setAttribute("viewBox", `0 0 ${m.width} ${m.height}`);
svg.setAttribute("preserveAspectRatio", "none");
const key = currentKey();
const wps = state.routes[key] || [];
const pulls = state.pulls[key] || [];
// Pack polygons (under enemies)
if (state.show.packs && m.packs) {
for (const p of m.packs) {
if (p.vertices.length < 3) continue;
const poly = document.createElementNS(SVG_NS, "polygon");
poly.setAttribute("class", "pack");
poly.setAttribute("points", p.vertices.map(([x, y]) => `${x},${y}`).join(" "));
poly.setAttribute("fill", p.color);
poly.setAttribute("fill-opacity", "0.18");
poly.setAttribute("stroke", p.color);
poly.setAttribute("stroke-width", "4");
poly.setAttribute("stroke-opacity", "0.65");
svg.appendChild(poly);
}
}
// Patrol polylines
if (state.show.patrols && m.patrols) {
for (const pa of m.patrols) {
if (pa.vertices.length < 2) continue;
const line = document.createElementNS(SVG_NS, "polyline");
line.setAttribute("class", "patrol");
line.setAttribute("points", pa.vertices.map(([x, y]) => `${x},${y}`).join(" "));
line.setAttribute("fill", "none");
line.setAttribute("stroke", pa.color);
line.setAttribute("stroke-width", String((pa.weight || 3) * 4));
line.setAttribute("stroke-dasharray", "12 8");
line.setAttribute("opacity", "0.7");
svg.appendChild(line);
}
}
// Enemies (smallest pins first; bosses on top)
if (state.show.enemies && m.enemies) {
const ranked = [...m.enemies].sort(
(a, b) => (a.classification || 0) - (b.classification || 0)
);
for (const e of ranked) {
svg.appendChild(makeEnemyPin(e));
}
}
// Map icons (skull, comment, door, etc.)
if (state.show.icons && m.icons) {
for (const ic of m.icons) {
svg.appendChild(makeIconMarker(ic));
}
}
// Route polyline
if (wps.length > 1) {
const path = document.createElementNS(SVG_NS, "polyline");
path.setAttribute("class", "route-line");
path.setAttribute("points", wps.map((p) => `${p.x},${p.y}`).join(" "));
svg.appendChild(path);
}
// Pull markers + waypoints (above everything)
pulls.forEach((p, i) => svg.appendChild(makeUserPin(p.x, p.y, i + 1, "pull", "pull", i)));
wps.forEach((p, i) => svg.appendChild(makeUserPin(p.x, p.y, i + 1, "waypoint", "route", i)));
svg.onclick = onCanvasClick;
}
const CLASS_RADIUS = { 1: 14, 2: 18, 3: 28, 4: 38 };
const CLASS_FILL = { 1: "#9aa1aa", 2: "#d6d6dc", 3: "#d63b3b", 4: "#ffd83a" };
function makeEnemyPin(e) {
const r = CLASS_RADIUS[e.classification || 1] ?? 14;
const fill = CLASS_FILL[e.classification || 1] ?? "#9aa1aa";
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", `enemy enemy-c${e.classification || 1}`);
g.setAttribute("transform", `translate(${e.pos[0]},${e.pos[1]})`);
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", r);
c.setAttribute("fill", fill);
c.setAttribute("stroke", "#000");
c.setAttribute("stroke-width", "3");
g.appendChild(c);
if ((e.classification || 1) >= 3) {
// Boss — overlay a skull glyph
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", r * 0.35);
t.setAttribute("font-size", String(Math.round(r * 1.6)));
t.setAttribute("text-anchor", "middle");
t.setAttribute("fill", "#1a0000");
t.setAttribute("font-weight", "900");
t.textContent = "☠";
g.appendChild(t);
}
const title = document.createElementNS(SVG_NS, "title");
title.textContent = e.name + (e.skippable ? " (skippable)" : "");
g.appendChild(title);
return g;
}
function makeIconMarker(ic) {
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", `icon icon-${ic.type}`);
g.setAttribute("transform", `translate(${ic.pos[0]},${ic.pos[1]})`);
if (ic.type === "comment") {
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 14);
c.setAttribute("fill", "#5993D2");
c.setAttribute("stroke", "#000");
c.setAttribute("stroke-width", "2");
g.appendChild(c);
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", 6);
t.setAttribute("font-size", "20");
t.setAttribute("text-anchor", "middle");
t.setAttribute("fill", "#fff");
t.setAttribute("font-weight", "700");
t.textContent = "i";
g.appendChild(t);
} else if (ic.type === "door") {
const r = document.createElementNS(SVG_NS, "rect");
r.setAttribute("x", -10); r.setAttribute("y", -14);
r.setAttribute("width", 20); r.setAttribute("height", 28);
r.setAttribute("fill", "#B58A3F");
r.setAttribute("stroke", "#000");
r.setAttribute("stroke-width", "2");
g.appendChild(r);
}
if (ic.comment) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = ic.comment;
g.appendChild(title);
}
return g;
}
function makeUserPin(x, y, label, cssClass, kind, idx) {
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", cssClass);
g.setAttribute("transform", `translate(${x},${y})`);
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 26);
g.appendChild(c);
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", 9);
t.setAttribute("font-size", "26");
t.textContent = label;
g.appendChild(t);
g.addEventListener("pointerdown", (e) => {
e.stopPropagation();
state.drag = { kind, idx };
g.classList.add("dragging");
g.setPointerCapture(e.pointerId);
});
g.addEventListener("pointermove", (e) => {
if (!state.drag || state.drag.idx !== idx || state.drag.kind !== kind) return;
const pt = svgPointFromEvent(e);
const arr = kind === "route" ? state.routes[currentKey()] : state.pulls[currentKey()];
arr[idx] = { x: pt.x, y: pt.y };
g.setAttribute("transform", `translate(${pt.x},${pt.y})`);
if (kind === "route") {
const polyline = document.querySelector("#overlay .route-line");
if (polyline) {
polyline.setAttribute(
"points",
state.routes[currentKey()].map((p) => `${p.x},${p.y}`).join(" ")
);
}
}
});
g.addEventListener("pointerup", () => {
if (!state.drag) return;
state.drag = null;
g.classList.remove("dragging");
pushHistory();
renderInfoPane();
updateHash();
});
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
removePoint(kind, idx);
});
return g;
}
function onCanvasClick(e) {
if (state.drag) return;
const pt = svgPointFromEvent(e);
const key = currentKey();
if (state.tool === "route") {
state.routes[key] = state.routes[key] || [];
state.routes[key].push(pt);
} else {
state.pulls[key] = state.pulls[key] || [];
state.pulls[key].push(pt);
}
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
function svgPointFromEvent(e) {
// Convert client coords → image-pixel coords (the SVG's own coord system,
// which matches the underlying webp's natural dimensions).
const m = currentMap();
const svg = $("overlay");
const rect = svg.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * m.width;
const y = ((e.clientY - rect.top) / rect.height) * m.height;
return { x: +x.toFixed(1), y: +y.toFixed(1) };
}
function removePoint(kind, idx) {
const key = currentKey();
const arr = kind === "route" ? state.routes[key] : state.pulls[key];
if (!arr) return;
arr.splice(idx, 1);
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
/* --- info pane ----------------------------------------------------------- */
function renderInfoPane() {
const m = currentMap();
const bossUl = $("boss-list");
bossUl.innerHTML = "";
if (m) {
const bosses = (m.enemies || []).filter((e) => (e.classification || 0) >= 3);
bosses.sort((a, b) => (b.classification || 0) - (a.classification || 0));
for (const e of bosses) {
const li = document.createElement("li");
li.innerHTML = `<span class="swatch boss"></span><span>${escapeHtml(e.name)}</span>`;
bossUl.appendChild(li);
}
if (!bosses.length) {
const li = document.createElement("li");
li.innerHTML = `<span style="color:var(--text-dim)">no boss data</span>`;
bossUl.appendChild(li);
}
}
const wpUl = $("waypoint-list");
wpUl.innerHTML = "";
const wps = state.routes[currentKey()] || [];
wps.forEach((p, i) => {
const li = document.createElement("li");
li.innerHTML = `
<span class="swatch"></span>
<span>#${i + 1} (${Math.round(p.x)}, ${Math.round(p.y)})</span>
<button title="Remove">×</button>
`;
li.querySelector("button").onclick = () => removePoint("route", i);
wpUl.appendChild(li);
});
if (!wps.length) {
const li = document.createElement("li");
li.innerHTML = `<span style="color:var(--text-dim)">click map to add</span>`;
wpUl.appendChild(li);
}
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
/* --- pan / zoom ---------------------------------------------------------- */
function applyView() {
$("canvas-stage").style.transform =
`translate(${state.view.tx}px, ${state.view.ty}px) scale(${state.view.scale})`;
}
function fitToScreen() {
const m = currentMap();
const host = $("canvas-host");
if (!m || !host) return;
const pad = 24;
const sx = (host.clientWidth - pad * 2) / m.width;
const sy = (host.clientHeight - pad * 2) / m.height;
const s = Math.min(sx, sy);
state.view.scale = s;
state.view.tx = -(m.width * s) / 2;
state.view.ty = -(m.height * s) / 2;
applyView();
}
function zoomBy(factor, anchorX, anchorY) {
const host = $("canvas-host");
const rect = host.getBoundingClientRect();
const ax = anchorX ?? rect.width / 2;
const ay = anchorY ?? rect.height / 2;
const cx = rect.width / 2 + state.view.tx;
const cy = rect.height / 2 + state.view.ty;
const dx = ax - cx; const dy = ay - cy;
const before = state.view.scale;
const after = Math.max(0.05, Math.min(20, before * factor));
const ratio = after / before;
state.view.scale = after;
state.view.tx -= dx * (ratio - 1);
state.view.ty -= dy * (ratio - 1);
applyView();
}
function hookCanvasPanZoom() {
const host = $("canvas-host");
host.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = host.getBoundingClientRect();
zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.clientX - rect.left, e.clientY - rect.top);
}, { passive: false });
host.addEventListener("pointerdown", (e) => {
if (e.target.closest("g.waypoint, g.pull")) return;
const onSvg = e.target.closest("#overlay");
const isPanIntent = e.button === 1 || e.shiftKey || e.button === 2 || !onSvg;
if (!isPanIntent) return;
e.preventDefault();
state.pan = { x0: e.clientX, y0: e.clientY, tx0: state.view.tx, ty0: state.view.ty };
host.classList.add("panning");
host.setPointerCapture(e.pointerId);
});
host.addEventListener("pointermove", (e) => {
if (!state.pan) return;
state.view.tx = state.pan.tx0 + (e.clientX - state.pan.x0);
state.view.ty = state.pan.ty0 + (e.clientY - state.pan.y0);
applyView();
});
host.addEventListener("pointerup", () => {
state.pan = null;
host.classList.remove("panning");
});
host.addEventListener("contextmenu", (e) => {
if (state.pan) e.preventDefault();
});
$("zoom-in").addEventListener("click", () => zoomBy(1.25));
$("zoom-out").addEventListener("click", () => zoomBy(1 / 1.25));
$("zoom-reset").addEventListener("click", fitToScreen);
window.addEventListener("resize", () => fitToScreen());
}
/* --- history / share / export ------------------------------------------- */
function pushHistory() {
state.history.push(JSON.stringify({ routes: state.routes, pulls: state.pulls }));
if (state.history.length > 200) state.history.shift();
}
function undo() {
state.history.pop();
const last = state.history[state.history.length - 1];
if (last) {
const s = JSON.parse(last);
state.routes = s.routes;
state.pulls = s.pulls;
} else {
state.routes = {};
state.pulls = {};
}
renderOverlay();
renderInfoPane();
updateHash();
}
function clearCurrent() {
const key = currentKey();
delete state.routes[key];
delete state.pulls[key];
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
function updateHash() {
if (!state.current) return;
const payload = { d: state.current.id, f: state.floorIndex, r: state.routes, p: state.pulls };
const hash = "#" + compress(JSON.stringify(payload));
if (location.hash !== hash) {
history.replaceState(null, "", location.pathname + location.search + hash);
}
}
function loadFromHash() {
const h = location.hash.slice(1);
if (!h) return;
try {
const p = JSON.parse(decompress(h));
if (p.d) {
const d = state.dungeons.find((x) => x.id === p.d);
if (d) {
state.current = d;
state.floorIndex = p.f || 0;
state.routes = p.r || {};
state.pulls = p.p || {};
renderViewer();
}
}
} catch (e) {
console.warn("hash parse failed", e);
}
}
function compress(s) {
return btoa(unescape(encodeURIComponent(s))).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function decompress(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
return decodeURIComponent(escape(atob(s)));
}
function shareUrl() {
updateHash();
navigator.clipboard?.writeText(location.href);
toast("Link copied");
}
function exportJson() {
const d = state.current;
if (!d) return;
const payload = {
dungeon: { id: d.id, name: d.name },
floors: d.maps.map((m, i) => ({
label: m.label,
route: state.routes[`${d.id}::${i}`] || [],
pulls: state.pulls[`${d.id}::${i}`] || [],
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${d.id}-route.json`;
a.click();
URL.revokeObjectURL(url);
}
function toast(msg) {
let t = document.querySelector(".toast");
if (!t) {
t = document.createElement("div");
t.className = "toast";
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add("show");
clearTimeout(toast._h);
toast._h = setTimeout(() => t.classList.remove("show"), 1500);
}
function setTool(tool) {
state.tool = tool;
$("tool-route").classList.toggle("active", tool === "route");
$("tool-pull").classList.toggle("active", tool === "pull");
}
function hookEvents() {
$("search").addEventListener("input", renderDungeonList);
const ef = $("expansion-filter"); if (ef) ef.addEventListener("change", renderDungeonList);
$("undo").addEventListener("click", undo);
$("clear").addEventListener("click", clearCurrent);
$("share").addEventListener("click", shareUrl);
$("export").addEventListener("click", exportJson);
$("tool-route").addEventListener("click", () => setTool("route"));
$("tool-pull").addEventListener("click", () => setTool("pull"));
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
e.preventDefault();
undo();
}
});
window.addEventListener("hashchange", loadFromHash);
hookCanvasPanZoom();
}
init().catch((e) => {
console.error(e);
document.body.innerHTML = `<pre style="color:#d63b3b;padding:20px">init failed: ${e.message}</pre>`;
});