"use strict"; // Map editor (Map tab): the mode map configs (rs.iop config/mode//*.ini). Shows the map // model, its collision and outline, the objects, and every position in the file as a marker that // can be dragged in 3D: any key pair _x / _z (+ _y) is a point, any min / // max pair a box — so start / revival points, item spawns, objects, effects, zones, flags, // camera and boost limits of every mode are covered without a list per mode. Every section can be // edited as key = value; numbered sections (object1, effect2…) and numbered points (pos3_x…) // can be added, copied and removed with their counts kept right. Lines that weren't edited are // written back byte for byte (CP949 comments included). (() => { const MAP = { src: null, rows: [], // [key, name, mode, mesh, path, pack] mode: "", doc: null, // { lines: [raw], eol, sections: [...], byName } where: null, title: "", dirty: false, undo: [], redo: [], world: null, // { name, cfg, meshes, col, edg } objects: new Map(), // section lname → { model, meshes, pending } generatedModels: new Map(), // generated one-mesh staticmodel configs for this editor session previewRot: new Map(), // editor-only rotation persisted in an ignored INI comment transformMode: "position", moveAxis: null, markers: [], boxes: [], sel: null, // { sec, prefix } of the selected marker secSel: null, // selected section lname hover: null, hidden: new Set(), // hidden layers snap: 0, }; window.MAP = MAP; let view = null; let kit = null; // ---------- small helpers ---------- const toLatin1 = (u8) => { let s = ""; for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192)); return s; }; const fromLatin1 = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255); const cp949 = (s) => LSF.decode(fromLatin1(s)); // latin1 string → readable text const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const baseName = (p) => p.replace(/\\/g, "/").split("/").pop(); const toggled = (id) => $(id).classList.contains("on"); function ensureView() { if (view) return view; try { view = new GL3D.View($("map-canvas")); } catch (err) { toast(`3D view unavailable: ${err.message}`, true); return null; } kit = MeshKit.create(view, () => MAP.src); view.onPointerDown = pointerDown; view.onCamera = () => { edgeDirty = true; requestAnimationFrame(drawLabels); }; view.beforeDraw = () => { if (edgeDirty) updateOutline(); }; MAP.view = view; return view; } // ===================================================================== // INI document (line-preserving) // ===================================================================== function parseIni(text) { const lines = []; const re = /[^\r\n]*(\r\n|\r|\n|$)/g; let m; while ((m = re.exec(text)) && m.index < text.length) { lines.push(m[0]); if (!m[1]) break; } const crlf = (text.match(/\r\n/g) || []).length; const doc = { lines, eol: crlf || !text.includes("\n") ? "\r\n" : "\n" }; return reindex(doc); } function reindex(doc = MAP.doc) { doc.sections = []; let cur = null; doc.lines.forEach((raw, i) => { const text = raw.replace(/[\r\n]+$/, ""); const t = text.trim(); const h = /^\[([^\]]*)\]/.exec(t); if (h) { cur = { name: h[1].trim(), lname: h[1].trim().toLowerCase(), head: i, keys: [] }; doc.sections.push(cur); return; } if (!cur || !t || t.startsWith(";") || t.startsWith("//") || t.startsWith("#")) return; const eq = text.indexOf("="); if (eq < 0) return; const key = text.slice(0, eq).trim(); if (!key) return; let vs = eq + 1; while (vs < text.length && /\s/.test(text[vs])) vs++; let ve = text.length; while (ve > vs && /\s/.test(text[ve - 1])) ve--; cur.keys.push({ key, lkey: key.toLowerCase(), line: i, vs, ve, value: text.slice(vs, ve) }); }); doc.byName = new Map(); for (const s of doc.sections) if (!doc.byName.has(s.lname)) doc.byName.set(s.lname, s); // the first one wins, like the client return doc; } const serialize = () => MAP.doc.lines.join(""); // undo snapshots hold the map config and (PvE maps) its navigation box graph const NAVI_SEP = "\u0001\u0001NAVI\u0001\u0001"; const state = () => serialize() + (MAP.navi ? NAVI_SEP + MAP.navi.doc.src : ""); const sec = (name) => MAP.doc?.byName.get(name.toLowerCase()) || null; const keyIn = (s, k) => s?.keys.find((x) => x.lkey === k.toLowerCase()) || null; const val = (s, k) => keyIn(s, k)?.value; const num = (s, k, def = 0) => { const v = parseFloat(val(s, k)); return isFinite(v) ? v : def; }; const lastLine = (s) => (s.keys.length ? s.keys[s.keys.length - 1].line : s.head); const EDITOR_ROTATION = /^\s*;\s*LS_TOOL_ROTATION\s*=\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*$/i; function loadPreviewRotations() { MAP.previewRot.clear(); if (!MAP.doc) return; for (const s of MAP.doc.sections) { if (!/^(?:object|costom_object|function_object|object_camera_fixed)\d+$/i.test(s.name)) continue; const end = MAP.doc.sections.find((next) => next.head > s.head)?.head ?? MAP.doc.lines.length; for (let i = s.head + 1; i < end; i++) { const match = EDITOR_ROTATION.exec(MAP.doc.lines[i].replace(/[\r\n]+$/, "")); if (match) { MAP.previewRot.set(s.lname, match.slice(1).map(Number)); break; } } } } function writePreviewRotation(section, rotation) { const s = sec(section); if (!s) return false; const line = `; LS_TOOL_ROTATION=${rotation.map((v) => Number(v.toFixed(3))).join(",")}`; const end = MAP.doc.sections.find((next) => next.head > s.head)?.head ?? MAP.doc.lines.length; for (let i = s.head + 1; i < end; i++) { if (EDITOR_ROTATION.test(MAP.doc.lines[i].replace(/[\r\n]+$/, ""))) { if (MAP.doc.lines[i].replace(/[\r\n]+$/, "") === line) return false; const eol = /[\r\n]*$/.exec(MAP.doc.lines[i])[0]; MAP.doc.lines[i] = line + eol; reindex(); return true; } } MAP.doc.lines.splice(s.head + 1, 0, line + MAP.doc.eol); reindex(); return true; } /** A number written like the value it replaces (decimals, trailing "f"). */ function fmtNum(old, v) { const m = old != null ? /^\s*-?\d*(?:\.(\d+))?(f?)\s*$/i.exec(old) : null; if (m) { const dec = m[1] ? m[1].length : Number.isInteger(v) ? 0 : 1; return v.toFixed(dec) + (m[2] || ""); } return (Number.isInteger(v) ? v.toFixed(1) : String(+v.toFixed(2))) + "f"; } function insertLines(at, texts) { const L = MAP.doc.lines; if (at > 0 && !/[\r\n]$/.test(L[at - 1])) L[at - 1] += MAP.doc.eol; L.splice(at, 0, ...texts.map((t) => t + MAP.doc.eol)); } /** Sets (or adds) key = value in a section; the section is created when missing. */ function setVal(secName, k, str) { let s = sec(secName); if (!s) { insertLines(MAP.doc.lines.length, ["", `[${secName}]`]); reindex(); s = sec(secName); } const kk = keyIn(s, k); if (kk) { const raw = MAP.doc.lines[kk.line]; const eol = /[\r\n]*$/.exec(raw)[0]; const text = raw.slice(0, raw.length - eol.length); MAP.doc.lines[kk.line] = text.slice(0, kk.vs) + str + text.slice(kk.ve) + eol; } else insertLines(lastLine(s) + 1, [`${k} = ${str}`]); reindex(); } const setNum = (secName, k, v) => setVal(secName, k, fmtNum(val(sec(secName), k), v)); function removeKey(secName, k) { const kk = keyIn(sec(secName), k); if (!kk) return; MAP.doc.lines.splice(kk.line, 1); reindex(); } function renameKey(secName, from, to) { const kk = keyIn(sec(secName), from); if (!kk) return; const raw = MAP.doc.lines[kk.line]; const i = raw.indexOf(kk.key); MAP.doc.lines[kk.line] = raw.slice(0, i) + to + raw.slice(i + kk.key.length); reindex(); } function removeSection(s) { const L = MAP.doc.lines; let end = lastLine(s) + 1; while (end < L.length && !L[end].trim()) end++; L.splice(s.head, end - s.head); reindex(); } function renameSection(s, name) { const raw = MAP.doc.lines[s.head]; MAP.doc.lines[s.head] = raw.replace(/\[[^\]]*\]/, `[${name}]`); reindex(); } // ---------- numbered sections: object1…N with object_common.total_count ---------- const FAMILIES = { object: ["object_common", "total_count"], effect: ["effect_common", "total_count"], costom_object: ["costom_object_common", "total_count"], costom_effect: ["costom_effect_common", "total_count"], object_camera_fixed: ["object_camera_fixed", "total_count"], function_object: ["function_object_info", "count"], symbol: ["symbol_common_info", "symbol_cnt"], }; function familyOf(lname) { const m = /^(.*?)(\d+)$/.exec(lname); if (!m || !FAMILIES[m[1]]) return null; return { base: m[1], n: Number(m[2]), common: FAMILIES[m[1]][0], countKey: FAMILIES[m[1]][1] }; } const familyCount = (f) => num(sec(f.common), f.countKey, 0); /** Adds [base N+1] after the last one (count + 1). → new section name */ function addNumbered(base, keyvals) { const [common, countKey] = FAMILIES[base]; const n = num(sec(common), countKey, 0); const name = `${base}${n + 1}`; const after = sec(`${base}${n}`) || sec(common); const at = after ? lastLine(after) + 1 : MAP.doc.lines.length; insertLines(at, ["", `[${name}]`, ...keyvals.map(([k, v]) => `${k} = ${v}`)]); reindex(); setVal(common, countKey, String(n + 1)); return name; } /** Removes [base n] and renumbers the ones after it (count − 1). */ function removeNumbered(lname) { const f = familyOf(lname); const count = familyCount(f); removeSection(sec(lname)); for (let k = f.n + 1; k <= count; k++) { const s = sec(`${f.base}${k}`); if (s) renameSection(s, s.name.replace(/\d+$/, String(k - 1))); } setVal(f.common, f.countKey, String(Math.max(0, count - 1))); } // ---------- numbered points inside a section: pos1_x…, zone2_pos_x…, with a count key ---------- function pointFamily(s, prefix) { const m = /^(.*?)(\d+)(.*)$/.exec(prefix); if (!m) return null; const [, head, idxS] = m; const idx = Number(idxS); const re = new RegExp(`^${esc(head)}(\\d+)(_|$)`); let max = 0; for (const k of s.keys) { const km = re.exec(k.lkey); if (km) max = Math.max(max, Number(km[1])); } // the count key: a cnt / count / max key holding the highest index, preferably naming the list const cands = s.keys.filter((k) => !re.test(k.lkey) && /(cnt|count|max)/.test(k.lkey) && Number(k.value) === max); const count = cands.find((k) => k.lkey.includes(head.replace(/_$/, ""))) || cands[0] || null; return { head, idx, max, count: count?.key || null }; } const memberKeys = (s, head, idx) => s.keys.filter((k) => new RegExp(`^${esc(head)}${idx}(_|$)`).test(k.lkey)); function addPointMember(secName, prefix) { const s = sec(secName); const pf = pointFamily(s, prefix); if (!pf) return null; const n = pf.max + 1; const src = memberKeys(s, pf.head, pf.idx); const last = Math.max(...s.keys.filter((k) => new RegExp(`^${esc(pf.head)}\\d+(_|$)`).test(k.lkey)).map((k) => k.line)); const lines = src.map((k) => { const nk = pf.head + n + k.key.slice(pf.head.length + String(pf.idx).length); let v = k.value; if (/_x$/i.test(k.key)) v = fmtNum(v, (parseFloat(v) || 0) + 150); return `${nk} = ${v}`; }); insertLines(last + 1, lines); reindex(); if (pf.count) setVal(secName, pf.count, String(n)); return prefix.replace(new RegExp(`^${esc(pf.head)}${pf.idx}`), pf.head + n); } function removePointMember(secName, prefix) { const pf = pointFamily(sec(secName), prefix); if (!pf) return false; for (const k of memberKeys(sec(secName), pf.head, pf.idx).reverse()) removeKey(secName, k.key); for (let j = pf.idx + 1; j <= pf.max; j++) { for (const k of memberKeys(sec(secName), pf.head, j)) renameKey(secName, k.key, pf.head + (j - 1) + k.key.slice(pf.head.length + String(j).length)); } if (pf.count) setVal(secName, pf.count, String(Math.max(0, pf.max - 1))); return true; } // ===================================================================== // markers (every _x / _z in the file) // ===================================================================== const NOT_POSITIONS = /^(main_light|shadow|minimap|minimapextra|glow|outline|clear_color|ambient|fog|omni_light|alarm_ment|highturn\d*|symbol_common_info)$/; const NOT_POINT_PREFIX = /^(scale|move_dir|target|timeicon|maptitle|mapdifficulty|timenum|timecolon|timedot)/; const COLORS = { blue: [0.35, 0.6, 1, 1], red: [1, 0.35, 0.3, 1], white: [0.92, 0.92, 0.95, 1], item: [1, 0.85, 0.25, 1], object: [1, 0.6, 0.2, 1], effect: [0.95, 0.4, 0.95, 1], camera: [0.35, 0.9, 1, 1], boost: [0.4, 1, 0.5, 1], other: [0.6, 1, 0.75, 1], axis: [0.7, 0.7, 0.75, 1], navi: [0.3, 0.95, 0.85, 1], }; const layerOf = (lname) => lname.replace(/\d+$/, ""); function colorOf(lname, p) { const L = layerOf(lname); if (/red/.test(p) || /^red/.test(L) || (L === "team_start" && p === "start2")) return "red"; if (/blue/.test(p) || /^blue/.test(L) || (L === "team_start" && p === "start1")) return "blue"; if (/(start|revival|waiting)$/.test(L)) return "white"; if (/generate/.test(L)) return "item"; if (/object/.test(L)) return "object"; if (/effect/.test(L)) return "effect"; if (L === "map_camera" || /event_scene|camera/.test(L)) return "camera"; if (L === "boost_limit") return "boost"; if (L === "axis_limit") return "axis"; return "other"; } function buildMarkers() { MAP.markers = []; MAP.boxes = []; if (!MAP.doc) return; for (const b of naviBoxes()) { const cx = (b.min[0] + b.max[0]) / 2, cz = (b.min[2] + b.max[2]) / 2; MAP.markers.push({ sec: "navigation", secName: "navigation", prefix: b.name, hasY: true, x: cx, y: b.min[1], z: cz, gy: b.min[1], layer: "navigation", color: "navi", navi: b }); } for (const s of MAP.doc.sections) { if (NOT_POSITIONS.test(s.lname)) continue; const keys = new Set(s.keys.map((k) => k.lkey)); const prefixes = []; for (const k of s.keys) { const m = /^(.*)_x$/.exec(k.lkey); if (m && keys.has(`${m[1]}_z`) && !NOT_POINT_PREFIX.test(m[1]) && !prefixes.includes(m[1])) prefixes.push(m[1]); } for (const p of prefixes) { const hasY = keys.has(`${p}_y`); const x = num(s, `${p}_x`); const z = num(s, `${p}_z`); const gy = groundAt(x, z) ?? 0; MAP.markers.push({ sec: s.lname, secName: s.name, prefix: p, hasY, x, z, y: hasY ? num(s, `${p}_y`) : gy, gy, layer: layerOf(s.lname), color: colorOf(s.lname, p) }); } for (const p of prefixes) { if (!/(^|_)min$/.test(p)) continue; const q = p.replace(/min$/, "max"); if (!prefixes.includes(q)) continue; MAP.boxes.push({ sec: s.lname, min: p, max: q, layer: layerOf(s.lname), color: colorOf(s.lname, p), y0: keys.has(`${p}_y`) ? num(s, `${p}_y`) : null, y1: keys.has(`${q}_y`) ? num(s, `${q}_y`) : null }); } } } const markerOf = (sel) => sel && MAP.markers.find((m) => m.sec === sel.sec && m.prefix === sel.prefix); const layerVisible = (layer) => !MAP.hidden.has(layer); function drawMarkers() { if (!view) return; const pts = []; const sel = []; const lines = []; const push = (a, p, c) => a.push(p[0], p[1], p[2], c[0], c[1], c[2], c[3]); const s = markerOf(MAP.sel); for (const m of MAP.markers) { if (!layerVisible(m.layer)) continue; const c = COLORS[m.color]; push(pts, [m.x, m.y + 4, m.z], c); // stem down to the floor for points in the air if (m.hasY && Math.abs(m.y - m.gy) > 20) { push(lines, [m.x, m.gy, m.z], [c[0], c[1], c[2], 0.35]); push(lines, [m.x, m.y, m.z], [c[0], c[1], c[2], 0.35]); } } if (s) push(sel, [s.x, s.y + 4, s.z], [1, 1, 1, 1]); for (const b of MAP.boxes) { if (!layerVisible(b.layer)) continue; const S = sec(b.sec); const x0 = num(S, `${b.min}_x`), z0 = num(S, `${b.min}_z`), x1 = num(S, `${b.max}_x`), z1 = num(S, `${b.max}_z`); const g = groundAt((x0 + x1) / 2, (z0 + z1) / 2) ?? 0; const y0 = b.y0 ?? g, y1 = b.y1 ?? g + 400; const lo = [Math.min(x0, x1), Math.min(y0, y1), Math.min(z0, z1)]; const hi = [Math.max(x0, x1), Math.max(y0, y1), Math.max(z0, z1)]; const selected = MAP.secSel === b.sec; const c = COLORS[b.color]; const col = [c[0], c[1], c[2], selected ? 0.95 : 0.45]; const k = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => [i & 1 ? hi[0] : lo[0], i & 2 ? hi[1] : lo[1], i & 4 ? hi[2] : lo[2]]); for (const [a, e] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) { push(lines, k[a], col); push(lines, k[e], col); } } // PvE navigation: boxes (walkable areas for the monsters' A*) and their links if (layerVisible("navigation")) { const boxes = naviBoxes(); const byName = new Map(boxes.map((b) => [b.name, b])); for (const b of boxes) { const sel = MAP.sel?.sec === "navigation" && MAP.sel.prefix === b.name; const col = [0.3, 0.95, 0.85, sel ? 1 : 0.5]; const lo = b.min.map((v, i) => Math.min(v, b.max[i])), hi = b.min.map((v, i) => Math.max(v, b.max[i])); const k = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => [i & 1 ? hi[0] : lo[0], i & 2 ? hi[1] : lo[1], i & 4 ? hi[2] : lo[2]]); for (const [a, e] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) { push(lines, k[a], col); push(lines, k[e], col); } } const ctr = (b) => [(b.min[0] + b.max[0]) / 2, Math.max(b.min[1], b.max[1]), (b.min[2] + b.max[2]) / 2]; for (const l of naviLinks()) { const a = byName.get(l.from), b = byName.get(l.to); if (!a || !b) continue; push(lines, ctr(a), [1, 0.9, 0.4, 0.7]); push(lines, ctr(b), [1, 0.9, 0.4, 0.7]); } } // elevator paths (function_object entity_type 6: pos → end_pos) for (const s of MAP.doc.sections) { if (!/^function_object\d+$/.test(s.lname) || num(s, "entity_type", -1) !== 6 || !keyIn(s, "end_pos_x") || !layerVisible(layerOf(s.lname))) continue; const c = [1, 0.75, 0.2, MAP.secSel === s.lname ? 1 : 0.6]; push(lines, [num(s, "pos_x"), num(s, "pos_y"), num(s, "pos_z")], c); push(lines, [num(s, "end_pos_x"), num(s, "end_pos_y"), num(s, "end_pos_z")], c); } view.setOverlay("markers", pts.length ? new Float32Array(pts) : null, { mode: "points", depth: false, point: 11 }); view.setOverlay("sel", sel.length ? new Float32Array(sel) : null, { mode: "points", depth: false, point: 18 }); view.setOverlay("boxes", lines.length ? new Float32Array(lines) : null, { depth: true }); drawLabels(); } function markerLabel(m) { const s = sec(m.sec); const file = val(s, "model_file") || val(s, "effect_file"); const name = m.prefix === "pos" ? m.secName : `${m.secName} · ${m.prefix}`; return file && m.prefix === "pos" ? `${name} · ${baseName(file)}` : name; } function drawLabels() { const box = $("map-labels"); if (!view || !MAP.doc) return box.replaceChildren(); const all = toggled("map-t-labels"); const show = MAP.markers.filter((m) => layerVisible(m.layer) && (all || (MAP.sel && m.sec === MAP.sel.sec && m.prefix === MAP.sel.prefix) || m === MAP.hover)); const out = []; for (const m of show.slice(0, 400)) { const p = view.project([m.x, m.y + 4, m.z]); if (!p) continue; const c = COLORS[m.color]; out.push(el("span", { class: `map-tag${m === markerOf(MAP.sel) ? " on" : ""}`, text: markerLabel(m), style: `left:${p[0]}px;top:${p[1]}px;border-color:rgb(${c.slice(0, 3).map((v) => Math.round(v * 255)).join(",")})` })); } box.replaceChildren(...out); } // ===================================================================== // collision: floor height and picking // ===================================================================== let ground = null; // { pos, idx, grid, cell, minX, minZ, nx, nz } function buildGround(c) { const P = c.pos; const I = c.indices; let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity; for (let i = 0; i < P.length; i += 3) { minX = Math.min(minX, P[i]); maxX = Math.max(maxX, P[i]); minZ = Math.min(minZ, P[i + 2]); maxZ = Math.max(maxZ, P[i + 2]); } const cell = Math.max(50, Math.max(maxX - minX, maxZ - minZ) / 128); const nx = Math.ceil((maxX - minX) / cell) + 1; const nz = Math.ceil((maxZ - minZ) / cell) + 1; const grid = new Map(); for (let t = 0; t < I.length; t += 3) { const xs = [P[I[t] * 3], P[I[t + 1] * 3], P[I[t + 2] * 3]]; const zs = [P[I[t] * 3 + 2], P[I[t + 1] * 3 + 2], P[I[t + 2] * 3 + 2]]; const x0 = Math.floor((Math.min(...xs) - minX) / cell), x1 = Math.floor((Math.max(...xs) - minX) / cell); const z0 = Math.floor((Math.min(...zs) - minZ) / cell), z1 = Math.floor((Math.max(...zs) - minZ) / cell); for (let gx = x0; gx <= x1; gx++) for (let gz = z0; gz <= z1; gz++) { const k = gx * nz + gz; if (!grid.has(k)) grid.set(k, []); grid.get(k).push(t); } } ground = { pos: P, idx: I, grid, cell, minX, minZ, nz }; } /** Highest collision surface at (x, z), or null. */ function groundAt(x, z) { if (!ground) return null; const { pos: P, idx: I, grid, cell, minX, minZ, nz } = ground; const list = grid.get(Math.floor((x - minX) / cell) * nz + Math.floor((z - minZ) / cell)); if (!list) return null; let best = null; for (const t of list) { const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3; const d = (P[b + 2] - P[c + 2]) * (P[a] - P[c]) + (P[c] - P[b]) * (P[a + 2] - P[c + 2]); if (Math.abs(d) < 1e-9) continue; const l1 = ((P[b + 2] - P[c + 2]) * (x - P[c]) + (P[c] - P[b]) * (z - P[c + 2])) / d; const l2 = ((P[c + 2] - P[a + 2]) * (x - P[c]) + (P[a] - P[c]) * (z - P[c + 2])) / d; const l3 = 1 - l1 - l2; if (l1 < -1e-4 || l2 < -1e-4 || l3 < -1e-4) continue; const y = l1 * P[a + 1] + l2 * P[b + 1] + l3 * P[c + 1]; if (best === null || y > best) best = y; } return best; } /** Nearest collision hit of a ray (Möller–Trumbore), or null. */ function raycast(o, d) { if (!ground) return null; const { pos: P, idx: I } = ground; let best = Infinity; for (let t = 0; t < I.length; t += 3) { const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3; const e1 = [P[b] - P[a], P[b + 1] - P[a + 1], P[b + 2] - P[a + 2]]; const e2 = [P[c] - P[a], P[c + 1] - P[a + 1], P[c + 2] - P[a + 2]]; const p = [d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]]; const det = e1[0] * p[0] + e1[1] * p[1] + e1[2] * p[2]; if (Math.abs(det) < 1e-9) continue; const inv = 1 / det; const s = [o[0] - P[a], o[1] - P[a + 1], o[2] - P[a + 2]]; const u = (s[0] * p[0] + s[1] * p[1] + s[2] * p[2]) * inv; if (u < 0 || u > 1) continue; const q = [s[1] * e1[2] - s[2] * e1[1], s[2] * e1[0] - s[0] * e1[2], s[0] * e1[1] - s[1] * e1[0]]; const v = (d[0] * q[0] + d[1] * q[1] + d[2] * q[2]) * inv; if (v < 0 || u + v > 1) continue; const dist = (e2[0] * q[0] + e2[1] * q[1] + e2[2] * q[2]) * inv; if (dist > 0.01 && dist < best) best = dist; } return best < Infinity ? [o[0] + d[0] * best, o[1] + d[1] * best, o[2] + d[2] * best] : null; } // ---------- outline (.edg): silhouette + open edges, re-found when the camera moves ---------- let edgeDirty = true; function updateOutline() { edgeDirty = false; const w = MAP.world; if (!w?.edg || !toggled("map-t-outline")) return view.setOverlay("outline", null); const e = w.edg; const eyeGl = view.eye(); const eye = [eyeGl[0], eyeGl[1], -eyeGl[2]]; const P = e.pos, FN = e.faceNormals, E = e.edges; const c = w.cfg.outline?.color?.length === 3 ? w.cfg.outline.color.map((v) => v / 255) : [0, 0, 0]; const out = []; const front = (f, a) => FN[f * 3] * (eye[0] - P[a]) + FN[f * 3 + 1] * (eye[1] - P[a + 1]) + FN[f * 3 + 2] * (eye[2] - P[a + 2]) > 0; for (let i = 0; i < E.length; i += 4) { const a = E[i] * 3, b = E[i + 1] * 3, fa = E[i + 2], fb = E[i + 3]; const open = fb === 0xffff || fa === 0xffff; if (!open && front(fa, a) === front(fb, a)) continue; for (const v of [a, b]) { // pulled a little toward the camera so the line isn't hidden by its own surface const dx = eye[0] - P[v], dy = eye[1] - P[v + 1], dz = eye[2] - P[v + 2]; const l = Math.hypot(dx, dy, dz) || 1; const k = Math.min(3, l * 0.002) / l; out.push(P[v] + dx * k, P[v + 1] + dy * k, P[v + 2] + dz * k, c[0], c[1], c[2], 0.9); } } view.setOverlay("outline", out.length ? new Float32Array(out) : null, { depth: true }); } // ===================================================================== // loading a map // ===================================================================== async function setSource(src, { quiet = false } = {}) { try { const d = await call("mdl:describe", src); MAP.src = { ...src, label: d.label }; } catch (err) { return toast(err.message, true); } $("map-src-label").textContent = MAP.src.kind === "dir" ? "Extracted folder" : MAP.src.kind === "packs" ? "Pack folder" : `Packs next to ${MAP.src.name}`; $("map-src").title = `Reading from ${MAP.src.label}`; kit?.clearTextures(); modelBrowser?.kit?.clearTextures(); nameLists = {}; imagesetCache = null; await loadList(); if (!quiet) toast(`Reading maps from ${MAP.src.label}.`); } async function chooseSource() { const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); let choice = null; const options = [ ...profileSourceOption(), ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })), { value: "packs", title: "A folder with .iop packs…", sub: "the client folder: rs.iop (map configs), map.iop / map2.iop (meshes, textures, collision)" }, { value: "dir", title: "A folder of extracted files…", sub: "config/mode/…, resource/mesh, resource/texture… extracted from the packs" }, ]; const list = el( "div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [ el("input", { type: "radio", name: "mapsrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]), ]), ), ); choice = options[0].value; if (!(await modal("Where are the maps?", ["Map configs are in rs.iop (config/mode//…); the map meshes, textures and collision in map.iop / map2.iop. Pick the folder with all of them.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false; let src = choice; if (choice === "packs" || choice === "dir") { const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files"); if (!folder) return false; src = { kind: choice, folder }; } await setSource(src); return true; } $("map-src").onclick = () => chooseSource(); const list = new VTable( $("map-list"), [ { key: 1, label: "Map", width: 190, grow: true, title: true }, { key: 2, label: "Mode", width: 110, cls: "mono dim" }, ], { multi: false, id: "map" }, ); list.keyOf = (r) => r[0]; list.onOpen = (r) => openRow(r); async function loadList() { if (!MAP.src) return; loading(true, "Reading the map configs…"); try { const rows = await call("map:list", MAP.src); MAP.rows = rows.map(([key, path, mode, mesh, pack]) => [key, baseName(path).replace(/\.ini$/i, "").replace(/^sp2_/, ""), mode, mesh, path, pack]); } catch (err) { toast(err.message, true); MAP.rows = []; } finally { loading(false); } const modes = [...new Set(MAP.rows.map((r) => r[2]))].sort(); $("map-mode").replaceChildren(el("option", { value: "", text: `All modes (${MAP.rows.length})` }), ...modes.map((m) => el("option", { value: m, text: `${m} (${MAP.rows.filter((r) => r[2] === m).length})` }))); filterList(); } function filterList() { const q = $("map-search").value.trim().toLowerCase(); const mode = $("map-mode").value; const rows = MAP.rows.filter((r) => (!mode || r[2] === mode) && (!q || r[1].toLowerCase().includes(q) || r[3].toLowerCase().includes(q))); list.setRows(rows); $("map-count").textContent = rows.length.toLocaleString(); } $("map-search").addEventListener("input", filterList); $("map-mode").addEventListener("change", filterList); function loading(on, text = "Loading…") { $("map-loading").hidden = !on; $("map-loading").querySelector("span").textContent = text; } async function confirmDiscard() { if (!MAP.dirty) return true; return confirmBox("Unsaved changes", "This map has changes that aren't saved. Discard them?", "Discard", "danger"); } async function openRow(r) { if (!r || !(await confirmDiscard())) return; try { const hit = await call("mdl:readKey", MAP.src, r[0]); if (!hit) throw new Error("Not found in the packs any more."); await openDoc(toLatin1(hit.bytes), hit.where, r[1], hit.source); } catch (err) { toast(err.message, true); } finally { loading(false); } } async function importMapFromDisk() { if (!(await confirmDiscard())) return; const file = await call("dialog:file", "Map config (.ini)"); if (!file) return; try { const hit = await call("mdl:readFile", file); await openDoc(toLatin1(hit.bytes), hit.where, baseName(file), file); } catch (err) { toast(err.message, true); } finally { loading(false); } } $("map-open-file").onclick = importMapFromDisk; // ---------- PvE navigation ([common] navigation_map = xml/pve_navi_*.xml: a box graph) ---------- async function loadNavi() { MAP.navi = null; const path = val(sec("common"), "navigation_map"); if (!path || !MAP.src) return; const h = await call("mdl:readPath", MAP.src, path).catch(() => null); if (!h) return toast(`Navigation ${path} isn't in these packs.`, true); try { MAP.navi = { path, where: h.where, doc: XmlDoc.parse(toLatin1(h.bytes)), dirty: false }; } catch (err) { toast(`${path}: ${err.message}`, true); } } /** Boxes in game coordinates (the file stores x, z, y — ioTowerDefMode swaps y / z). */ function naviBoxes() { if (!MAP.navi) return []; const v = (s) => (s || "0,0,0").split(",").map(Number); return MAP.navi.doc.elements .filter((e) => e.tag.toLowerCase() === "box") .map((e) => { const a = v(XmlDoc.attr(e, "min")), b = v(XmlDoc.attr(e, "max")); return { el: e, name: XmlDoc.attr(e, "name") || "", min: [a[0], a[2], a[1]], max: [b[0], b[2], b[1]] }; }); } const naviLinks = () => (MAP.navi ? MAP.navi.doc.elements.filter((e) => e.tag.toLowerCase() === "link").map((e) => ({ from: XmlDoc.attr(e, "from"), to: XmlDoc.attr(e, "to"), type: XmlDoc.attr(e, "type") })) : []); function moveNaviBox(name, dx, dy, dz) { const box = naviBoxes().find((b) => b.name === name); if (!box) return; const fmt = (p) => `${+p[0].toFixed(3)},${+p[2].toFixed(3)},${+p[1].toFixed(3)}`; const mn = [box.min[0] + dx, box.min[1] + dy, box.min[2] + dz]; const mx = [box.max[0] + dx, box.max[1] + dy, box.max[2] + dz]; let d = MAP.navi.doc; let src = XmlDoc.setAttr(d, box.el, "min", fmt(mn)); d = XmlDoc.parse(src); src = XmlDoc.setAttr(d, d.elements.find((e) => e.tag.toLowerCase() === "box" && XmlDoc.attr(e, "name") === name), "max", fmt(mx)); MAP.navi.doc = XmlDoc.parse(src); MAP.navi.dirty = true; } async function openDoc(text, where, title, source) { if (!ensureView()) return; MAP.doc = parseIni(text); if (!sec("map")) throw new Error("This file has no [map] section — not a map config."); MAP.where = where; MAP.title = title; MAP.source = source; MAP.dirty = false; MAP.undo = []; MAP.redo = []; loadPreviewRotations(); MAP.transformMode = "position"; MAP.sel = null; MAP.secSel = "map"; $("map-title").textContent = source || title; $("map-dirty").hidden = true; $("map-empty").hidden = true; $("map-side").hidden = false; await loadWorld(); await loadNavi(); buildMarkers(); frameMap(); applyEnv(); drawMarkers(); renderSide(); syncObjects(); FX.inst.clear(); view.setParticles([]); syncEffects(); updateSave(); } /** The map model ([map] mesh_name): its meshes, collision (#COLLISION_MESH) and outline (#OUTLINE). */ async function loadWorld() { view.clear(); for (const o of MAP.objects.values()) o.dead = true; MAP.objects.clear(); ground = null; const name = val(sec("map"), "mesh_name") || val(sec("map"), "mesh_name1") || ""; MAP.world = { name, cfg: null, meshes: [], missing: [] }; if (!name || !MAP.src) return; loading(true, "Loading the map model…"); const model = await kit.readModel(name); if (!model) { MAP.world.missing.push(name); return toast(`${name} isn't in these packs.`, true); } MAP.world.cfg = model.cfg; for (const n of model.cfg.meshes) { const h = await kit.readNamed(`${n}.msh`, "mesh"); if (!h) { MAP.world.missing.push(`${n}.msh`); continue; } try { const m = await kit.loadMesh(h, n); MAP.world.meshes.push(m); kit.loadTextures(m); } catch (err) { MAP.world.missing.push(`${n}.msh (${err.message})`); } } if (model.cfg.collision) { const h = await kit.readNamed(model.cfg.collision, "mesh"); try { if (!h) throw new Error("not found"); const c = LSF.parseCms(h.bytes); MAP.world.col = c; buildGround(c); view.addMesh("col", { pos: c.pos, indices: new Uint32Array(c.indices), groups: [{ start: 0, count: c.indices.length, color: [0.25, 0.95, 0.55, 0.2] }] }, { wireColor: [0.35, 1, 0.6, 0.4] }); view.setVisible("col", toggled("map-t-col")); } catch (err) { MAP.world.missing.push(`${model.cfg.collision} (${err.message})`); } } if (model.cfg.outline?.file) { const h = await kit.readNamed(model.cfg.outline.file, "mesh"); try { if (h) MAP.world.edg = LSF.parseEdg(h.bytes); } catch (err) { MAP.world.missing.push(`${model.cfg.outline.file} (${err.message})`); } edgeDirty = true; } drawGrid(); loading(false); } function worldBounds() { const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity]; const add = (p) => { for (let i = 0; i < 3; i++) { min[i] = Math.min(min[i], p[i]); max[i] = Math.max(max[i], p[i]); } }; const aabb = MAP.world?.cfg?.aabb; if (aabb?.[0]?.length === 3) { add(aabb[0]); add(aabb[1]); } else for (const m of MAP.world?.meshes || []) (add(m.data.aabbMin), add(m.data.aabbMax)); if (!isFinite(min[0])) for (const m of MAP.markers) add([m.x, m.y, m.z]); if (!isFinite(min[0])) return [[-1000, 0, -1000], [1000, 500, 1000]]; return [min, max]; } function frameMap() { const [min, max] = worldBounds(); view.cam.pitch = 0.62; view.cam.yaw = 0; view.frame(min, max); view.cam.dist *= 0.8; view.home = { ...view.cam, target: view.cam.target.slice() }; } function drawGrid() { const [min, max] = worldBounds(); const g = []; const r = Math.max(max[0] - min[0], max[2] - min[2], 1000); const step = 10 ** Math.floor(Math.log10(r / 6)); const cx = Math.round((min[0] + max[0]) / 2 / step) * step, cz = Math.round((min[2] + max[2]) / 2 / step) * step; const half = Math.ceil(r / 2 / step + 1) * step; for (let v = -half; v <= half + 1e-6; v += step) { const c = Math.abs(v) < step / 2 ? [1, 1, 1, 0.22] : [1, 1, 1, 0.07]; g.push(cx + v, 0, cz - half, ...c, cx + v, 0, cz + half, ...c, cx - half, 0, cz + v, ...c, cx + half, 0, cz + v, ...c); } view.setOverlay("grid", new Float32Array(g)); view.showOverlay("grid", toggled("map-t-grid")); } // ---------- objects placed by the config ---------- const OBJECT_SECTIONS = /^(object|costom_object|function_object|object_camera_fixed)\d+$/; function syncObjects() { if (!MAP.doc || !view) return; const want = new Map(); if (toggled("map-t-objects")) { for (const s of MAP.doc.sections) { if (!OBJECT_SECTIONS.test(s.lname) || !layerVisible(layerOf(s.lname))) continue; const model = val(s, "model_file"); if (!model || model.toLowerCase() === "none") continue; const elev = num(s, "entity_type", -1) === 6 && keyIn(s, "end_pos_x") ? { end: [num(s, "end_pos_x"), num(s, "end_pos_y"), num(s, "end_pos_z")], speed: num(s, "move_speed", 0), wait: num(s, "wait_time", 0), first: num(s, "first_wait_add_time", 0) } : null; want.set(s.lname, { model, pos: [num(s, "pos_x"), num(s, "pos_y"), num(s, "pos_z")], scale: [num(s, "scale_x", 1) || 1, num(s, "scale_y", 1) || 1, num(s, "scale_z", 1) || 1], rotation: MAP.previewRot.get(s.lname) || [0, 0, 0], animIdx: num(s, "animation_idx", -1), elev }); } } for (const [k, o] of MAP.objects) { const w = want.get(k); if (!w || w.model.toLowerCase() !== o.model.toLowerCase()) { o.dead = true; for (const m of o.meshes) view.removeMesh(m.id); MAP.objects.delete(k); } } for (const [k, w] of want) { const o = MAP.objects.get(k); if (o) { o.pos = w.pos; o.scale = w.scale; o.rotation = w.rotation; o.elev = w.elev; if (o.animIdx !== w.animIdx) { o.animIdx = w.animIdx; if (o.skin) pickObjectAnimation(o); } if (!ANIM.on || !o.elev) for (const m of o.meshes) view.setTransform(m.id, w.pos, w.scale, w.rotation); continue; } const obj = { model: w.model, pos: w.pos, scale: w.scale, rotation: w.rotation, animIdx: w.animIdx, elev: w.elev, meshes: [], minY: Infinity, dead: false }; MAP.objects.set(k, obj); loadObject(obj); } for (const [k, o] of MAP.objects) for (const m of o.meshes) view.setHighlight(m.id, MAP.secSel === k); } async function loadObject(obj) { const model = MAP.generatedModels.get(obj.model.toLowerCase()) || await kit.readModel(obj.model); if (!model || obj.dead) return; obj.cfg = model.cfg; // animated models (animodel/…): skeleton + the animation_idx-th animation of the config if (model.cfg.skeleton) { const h = await kit.readNamed(model.cfg.skeleton, "skeleton"); if (h) { try { const sk = SkinKit.prepare(LSF.parseSkl(h.bytes)); if (sk.linked) { const l = await kit.readNamed(sk.linked, "skeleton"); if (l) SkinKit.link(sk, LSF.parseSkl(l.bytes)); } obj.skin = { sk, ani: null, map: null }; await pickObjectAnimation(obj); } catch (err) { console.warn("[map] skeleton", model.cfg.skeleton, err); } } } for (const n of model.cfg.meshes) { const h = await kit.readNamed(`${n}.msh`, "mesh"); if (!h || obj.dead) continue; try { const m = await kit.loadMesh(h, n, { pos: obj.pos, scale: obj.scale }); if (obj.dead) { view.removeMesh(m.id); continue; } for (let i = 1; i < m.data.pos.length; i += 3) obj.minY = Math.min(obj.minY, m.data.pos[i]); view.setTransform(m.id, obj.pos, obj.scale, obj.rotation); // may have moved meanwhile if (obj.skin && m.data.boneNames?.length) m.pal = SkinKit.palette(obj.skin.sk, m.data); obj.meshes.push(m); kit.loadTextures(m); } catch {} } } // ---------- map animation: texture effects, animated objects, elevators ---------- const ANIM = { on: true, raf: 0, t0: performance.now() }; async function pickObjectAnimation(obj) { const name = obj.animIdx >= 0 ? obj.cfg?.animations?.[obj.animIdx] : null; obj.skin.ani = null; obj.skin.map = null; if (!name) return; const h = await kit.readNamed(name, "animation"); if (!h) return; try { obj.skin.ani = LSF.parseAni(h.bytes); obj.skin.map = SkinKit.mapTracks(obj.skin.sk, obj.skin.ani); obj.skin.aniName = name; } catch (err) { console.warn("[map] animation", name, err); } } // ---------- effects ([effectN] / [costom_effectN] effect_file at pos; "none" = effect_common's common_file) ---------- const FX = { defs: new Map(), inst: new Map(), tex: new Map() }; function syncEffects() { if (!MAP.doc) return; const want = new Map(); const common = val(sec("effect_common"), "common_file"); for (const s of MAP.doc.sections) { if (!/^(effect|costom_effect)\d+$/.test(s.lname) || !layerVisible(layerOf(s.lname))) continue; let file = val(s, "effect_file") || "none"; if (file.toLowerCase() === "none") file = common || ""; if (!file || file.toLowerCase() === "none") continue; want.set(s.lname, { file, pos: [num(s, "pos_x"), num(s, "pos_y"), num(s, "pos_z")] }); } for (const k of [...FX.inst.keys()]) if (!want.has(k) || want.get(k).file.toLowerCase() !== FX.inst.get(k).file.toLowerCase()) FX.inst.delete(k); for (const [k, w] of want) { const cur = FX.inst.get(k); if (cur) { cur.pos = w.pos; if (cur.inst) cur.inst.origin = w.pos; continue; } const entry = { file: w.file, pos: w.pos, inst: null }; FX.inst.set(k, entry); effectDef(w.file).then((def) => { if (def && FX.inst.get(k) === entry) entry.inst = Effects.instance(def, entry.pos); }); } } function effectDef(file) { const key = file.toLowerCase(); if (!FX.defs.has(key)) { FX.defs.set( key, (async () => { const h = await kit.readNamed(file, "effect"); if (!h) return null; try { return Effects.parse(toLatin1(h.bytes)); } catch (err) { console.warn("[map] effect", file, err); return null; } })(), ); } return FX.defs.get(key); } /** A particle texture (clamped like the client); null until loaded. */ function fxTexture(name) { if (!name) return null; const key = name.toLowerCase(); if (!FX.tex.has(key)) { FX.tex.set(key, null); kit.texture(name).then((t) => { if (!t.tex) return; const gl = view.gl; gl.bindTexture(gl.TEXTURE_2D, t.tex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); FX.tex.set(key, t.tex); }); } return FX.tex.get(key); } function stepEffects(dt) { const cam = view.cameraAxes(); const out = []; for (const e of FX.inst.values()) { if (!e.inst) continue; Effects.update(e.inst, dt); for (const b of Effects.build(e.inst, cam)) { b.tex = fxTexture(b.texture); out.push(b); } } view.setParticles(out); return out.length > 0; } /** Elevator (function_object entity_type 6): wait, pos → end_pos at move_speed, wait, back. */ function elevatorAt(o, tMs) { const e = o.elev; const s = o.pos; const d = e.end; const dist = Math.hypot(d[0] - s[0], d[1] - s[1], d[2] - s[2]); if (!dist || e.speed <= 0) return s; const move = (dist / e.speed) * 1000; const w = Math.max(0, e.wait); let tt = tMs - Math.max(0, e.first); if (tt < 0) return s; const p = tt % (2 * (w + move)); const f = p < w ? 0 : p < w + move ? (p - w) / move : p < 2 * w + move ? 1 : 1 - (p - 2 * w - move) / move; return [s[0] + (d[0] - s[0]) * f, s[1] + (d[1] - s[1]) * f, s[2] + (d[2] - s[2]) * f]; } function animTick(now) { if (!ANIM.on) return; if (view && MAP.doc && $("view-map").classList.contains("on")) { const t = (now - ANIM.t0) / 1000; const dt = Math.min(100, Math.max(0, now - (ANIM.last || now))); ANIM.last = now; let any = FX.inst.size > 0 && (stepEffects(dt) || true); for (const m of MAP.world?.meshes || []) any = kit.animate(m, t) || any; for (const o of MAP.objects.values()) { for (const m of o.meshes) any = kit.animate(m, t) || any; if (o.skin?.ani) { const ps = SkinKit.pose(o.skin.sk, o.skin.ani, o.skin.map, (t * 1000) % Math.max(1, o.skin.ani.total)); for (const m of o.meshes) { if (!m.pal || !m.skinPos) continue; SkinKit.skin(m, m.pal, ps); view.updateMesh(m.id, m.skinPos, m.skinNrm); } any = true; } if (o.elev) { const p = elevatorAt(o, t * 1000); for (const m of o.meshes) view.setTransform(m.id, p, o.scale, o.rotation); any = true; } } if (any) view.dirty = true; } ANIM.raf = requestAnimationFrame(animTick); } /** Animation off: everything back to its rest state (the "static" look of the map). */ function resetAnim() { if (!view) return; view.setParticles([]); ANIM.last = 0; // effects start again from nothing next time for (const e of FX.inst.values()) if (e.inst) e.inst = Effects.instance(e.inst.def, e.pos); for (const m of MAP.world?.meshes || []) kit.animate(m, null); for (const o of MAP.objects.values()) { for (const m of o.meshes) { kit.animate(m, null); if (m.pal && m.skinPos) view.updateMesh(m.id, m.data.pos, m.data.normal); view.setTransform(m.id, o.pos, o.scale, o.rotation); } } view.dirty = true; } function setAnim(on) { ANIM.on = on; cancelAnimationFrame(ANIM.raf); if (on) { ANIM.t0 = performance.now(); ANIM.raf = requestAnimationFrame(animTick); } else resetAnim(); } ANIM.raf = requestAnimationFrame(animTick); // ---------- lighting preview ---------- function applyEnv() { if (!view) return; if (!MAP.doc || !toggled("map-t-light")) { view.env = null; view.dirty = true; return; } const rgb = (s, def = 127, keys = ["red", "green", "blue"]) => keys.map((k) => num(s, k, def) / 255); const light = sec("main_light"); const fog = sec("fog"); view.env = { ambient: rgb(sec("ambient"), 127), lightColor: rgb(light, 160), lightDir: [num(light, "target_x") - num(light, "pos_x", 10), num(light, "target_y") - num(light, "pos_y", 10), num(light, "target_z") - num(light, "pos_z", -5)], fog: fog && num(fog, "enable") ? { color: rgb(fog), start: num(fog, "start", 1000), end: num(fog, "end", 8000) } : null, clear: sec("clear_color") ? rgb(sec("clear_color"), 0) : null, }; view.dirty = true; } // ===================================================================== // picking + dragging // ===================================================================== function pickMarker(cx, cy) { const b = $("map-canvas").getBoundingClientRect(); let best = null; let bd = 13; for (const m of MAP.markers) { if (!layerVisible(m.layer)) continue; const p = view.project([m.x, m.y + 4, m.z]); if (!p) continue; const d = Math.hypot(p[0] + b.left - cx, p[1] + b.top - cy); if (d < bd) { bd = d; best = m; } } return best; } let drag = null; let down = null; function setTransformMode(mode) { MAP.transformMode = mode; for (const [id, name] of [["map-tool-position", "position"], ["map-tool-rotate", "rotate"], ["map-tool-scale", "scale"]]) $(id)?.classList.toggle("on", mode === name); const labels = { position: `G · Move${MAP.moveAxis ? ` ${MAP.moveAxis.toUpperCase()} axis` : ""}`, rotate: "R · Rotate preview", scale: "S · Scale" }; const status = $("map-tool-status"); if (status) status.textContent = labels[mode] || labels.position; } function setMoveAxis(axis) { MAP.moveAxis = axis; setTransformMode("position"); } const isObjectSection = (name) => OBJECT_SECTIONS.test(name || ""); function pointerDown(e) { if (!MAP.doc) return false; down = { x: e.clientX, y: e.clientY }; if (e.button !== 0 || e.shiftKey) return false; const m = pickMarker(e.clientX, e.clientY); if (!m) return false; select({ sec: m.sec, prefix: m.prefix }); const object = isObjectSection(m.sec); const s = sec(m.sec); drag = { m, before: state(), moved: false, alt: e.altKey, mode: object ? MAP.transformMode : "position", startX: e.clientX, startY: e.clientY, startScale: [num(s, "scale_x", 1) || 1, num(s, "scale_y", 1) || 1, num(s, "scale_z", 1) || 1], startRotation: [...(MAP.previewRot.get(m.sec) || [0, 0, 0])], }; return true; } window.addEventListener("mousemove", (e) => { if (!$("view-map").classList.contains("on") || !MAP.doc || !view) return; if (!drag) { if (e.target !== $("map-canvas") || e.buttons) return; const h = pickMarker(e.clientX, e.clientY); if (h !== MAP.hover) { MAP.hover = h; $("map-canvas").style.cursor = h ? "move" : ""; drawLabels(); } return; } const m = drag.m; if (drag.mode === "scale" && isObjectSection(m.sec)) { const factor = Math.max(0.05, Math.min(20, Math.pow(2, (drag.startY - e.clientY) / 150))); if (Math.abs(factor - 1) < 0.001) return; const keys = ["scale_x", "scale_y", "scale_z"]; for (let i = 0; i < 3; i++) setVal(m.sec, keys[i], `${Math.max(0.05, Math.min(100, drag.startScale[i] * factor)).toFixed(3)}f`); drag.moved = true; refreshScene(); $("map-hover").textContent = `${m.sec} · scale ${factor.toFixed(2)}×`; return; } if (drag.mode === "rotate" && isObjectSection(m.sec)) { const sensitivity = e.shiftKey ? 0.2 : 0.6; MAP.previewRot.set(m.sec, [drag.startRotation[0] + (drag.startY - e.clientY) * sensitivity, drag.startRotation[1] + (e.clientX - drag.startX) * sensitivity, drag.startRotation[2]]); drag.moved = true; refreshScene(); const r = MAP.previewRot.get(m.sec); $("map-hover").textContent = `${m.sec} · preview rotation X ${Math.round(r[0])}° Y ${Math.round(r[1])}°`; return; } const { o, d } = view.ray(e.clientX, e.clientY); let x = m.x, y = m.y, z = m.z; if (drag.alt && m.hasY) { // Alt: straight up / down, on the plane facing the camera const n = [d[0], 0, d[2]]; const l = Math.hypot(n[0], n[2]) || 1; n[0] /= l; n[2] /= l; const den = d[0] * n[0] + d[2] * n[2]; if (Math.abs(den) > 1e-6) { const t = ((m.x - o[0]) * n[0] + (m.z - o[2]) * n[2]) / den; y = o[1] + d[1] * t; } } else { let p = !m.hasY ? raycast(o, d) : null; if (!p) { const y0 = m.y; const t = Math.abs(d[1]) > 1e-6 ? (y0 - o[1]) / d[1] : -1; if (t > 0) p = [o[0] + d[0] * t, y0, o[2] + d[2] * t]; } if (!p) return; [x, , z] = p; if (MAP.snap) { x = Math.round(x / MAP.snap) * MAP.snap; z = Math.round(z / MAP.snap) * MAP.snap; } else { x = Math.round(x); z = Math.round(z); } } if (MAP.snap && drag.alt) y = Math.round(y / MAP.snap) * MAP.snap; else y = Math.round(y); if (x === m.x && y === m.y && z === m.z) return; drag.moved = true; if (m.navi) moveNaviBox(m.prefix, x - m.x, m.hasY && drag.alt ? y - m.y : 0, z - m.z); else { setNum(m.sec, `${m.prefix}_x`, x); setNum(m.sec, `${m.prefix}_z`, z); if (m.hasY && drag.alt) setNum(m.sec, `${m.prefix}_y`, y); } refreshScene(); drag.m = markerOf({ sec: m.sec, prefix: m.prefix }) || m; $("map-hover").textContent = `${markerLabel(drag.m)} X ${x} Y ${Math.round(drag.m.y)} Z ${z}`; }); window.addEventListener("mouseup", (e) => { if (drag) { const d = drag; drag = null; if (d.moved && d.mode !== "rotate") { pushUndo(d.before); markDirty(); renderSide(); } else if (d.moved) { const rotation = MAP.previewRot.get(d.m.sec) || [0, 0, 0]; if (writePreviewRotation(d.m.sec, rotation)) { pushUndo(d.before); markDirty(); refreshScene(); } renderSide(); } } else if (down && e.target === $("map-canvas") && Math.hypot(e.clientX - down.x, e.clientY - down.y) < 3 && e.button === 0) { // plain click on empty space: deselect the point if (MAP.sel) { MAP.sel = null; drawMarkers(); renderSide(); } } down = null; }); function select(s) { MAP.sel = s; if (s) { MAP.secSel = s.sec; if (isObjectSection(s.sec)) setTransformMode(MAP.transformMode || "position"); } drawMarkers(); syncObjects(); renderSide(); const row = $("map-sections").querySelector(".on"); row?.scrollIntoView({ block: "nearest" }); } function focusSel() { const m = markerOf(MAP.sel); if (!m) return; view.cam.target = [m.x, m.y, -m.z]; view.cam.dist = Math.min(view.cam.dist, 1800); view.moved(); } // ===================================================================== // edits, undo, save // ===================================================================== function refreshScene() { buildMarkers(); drawMarkers(); syncObjects(); syncEffects(); applyEnv(); } function markDirty() { MAP.dirty = true; $("map-dirty").hidden = false; updateSave(); } function pushUndo(before = state()) { MAP.undo.push(before); if (MAP.undo.length > 200) MAP.undo.shift(); MAP.redo = []; } /** Runs an edit with an undo step. */ function edit(fn) { const before = state(); fn(); if (state() === before) return; pushUndo(before); markDirty(); refreshScene(); renderSide(); } async function restore(from, to) { const text = from.pop(); if (text === undefined) return; to.push(state()); const meshBefore = val(sec("map"), "mesh_name"); const [ini, navi] = text.split(NAVI_SEP); MAP.doc = parseIni(ini); loadPreviewRotations(); if (MAP.navi && navi !== undefined && navi !== MAP.navi.doc.src) { MAP.navi.doc = XmlDoc.parse(navi); MAP.navi.dirty = true; } if (!markerOf(MAP.sel)) MAP.sel = null; if (!sec(MAP.secSel || "")) MAP.secSel = "map"; markDirty(); if (val(sec("map"), "mesh_name") !== meshBefore) await loadWorld(); refreshScene(); renderSide(); } function updateSave() { $("map-save").disabled = false; $("map-undo").disabled = !MAP.undo.length; $("map-redo").disabled = !MAP.redo.length; } $("map-undo").onclick = () => restore(MAP.undo, MAP.redo); $("map-redo").onclick = () => restore(MAP.redo, MAP.undo); async function save(as = false) { if (!MAP.doc) return; let where = MAP.where; if (as || !where) { const file = await call("dialog:saveAs", baseName(MAP.source || MAP.title || "map.ini").replace(/^(?!.*\.ini$)(.*)$/i, "$1.ini"), "ini"); if (!file) return; where = { kind: "file", file }; } else if (where.kind === "pack" && !(await confirmBox("Save into the pack", `Write ${where.entry} into ${baseName(where.file)}? The new copy replaces the old one (IOP Manager's Optimize frees the old space).`, "Save"))) return; try { await call("mdl:writeBack", where, fromLatin1(serialize())); if (MAP.navi?.dirty) { const nw = where.kind === "file" ? { kind: "file", file: where.file.replace(/[^\\/]+$/, "") + baseName(MAP.navi.path) } : MAP.navi.where; await call("mdl:writeBack", nw, fromLatin1(MAP.navi.doc.src)); MAP.navi.dirty = false; toast(`Saved the navigation ${baseName(MAP.navi.path)} too.`); } MAP.where = where; MAP.dirty = false; $("map-dirty").hidden = true; if (where.kind === "pack") kit.clearTextures(); toast(`Saved ${where.kind === "pack" ? `${where.entry} into ${baseName(where.file)}` : where.file}.`); } catch (err) { toast(err.message, true); } } $("map-save").onclick = (e) => showMenu( [ { label: MAP.where?.kind === "pack" ? `Save into ${baseName(MAP.where.file)}` : "Save", icon: MAP.where?.kind === "pack" ? "box" : "save", hint: "Ctrl+S", disabled: !MAP.doc, run: () => save() }, { label: "Save as file…", icon: "download", disabled: !MAP.doc, run: () => save(true) }, { label: "Import map from disk…", icon: "import", run: importMapFromDisk }, "-", { label: "Screenshot (PNG)…", icon: "camera", disabled: !MAP.doc, run: screenshot }, ], { anchor: e.currentTarget }, ); async function screenshot() { const file = await call("dialog:savePng", `${MAP.title || "map"}.png`); if (!file) return; view.draw(); const src = $("map-canvas"); const c = document.createElement("canvas"); c.width = src.width; c.height = src.height; const g = c.getContext("2d"); g.fillStyle = "#101014"; g.fillRect(0, 0, c.width, c.height); g.drawImage(src, 0, 0); await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]); toast(`Saved ${file}.`); } // ---------- adding things ---------- let nameLists = {}; async function names(folder) { if (!nameLists[folder] && MAP.src) nameLists[folder] = await call("mdl:names", MAP.src, folder).catch(() => []); return nameLists[folder] || []; } async function askName(title, label, folder, value = "") { const id = `map-dl-${folder}`; const dl = el("datalist", { id }, (await names(folder)).map((n) => el("option", { value: n }))); const input = el("input", { type: "text", value, list: id, spellcheck: "false" }); input.addEventListener("keydown", (e) => e.key === "Enter" && $("modal-actions").lastChild.click()); const ok = await modal(title, [el("label", { text: label }), input, dl], [{ label: "Cancel", value: false }, { label: "Add", kind: "primary", value: true }]); return ok ? input.value.trim() : null; } let modelBrowser = null; let meshBrowser = null; async function chooseStaticModel() { const all = (await names("model")).filter((n) => /^(?:staticmodel|animodel)\/.+\.txt$/i.test(n)); if (!all.length) return toast("No static or animated model configs were found in the selected source.", true), null; if (!modelBrowser) { const search = el("input", { type: "search", placeholder: "Search static and animated models…", spellcheck: "false" }); const list = el("div", { class: "map-model-list" }); const canvas = el("canvas", { class: "map-model-canvas" }); const status = el("div", { class: "map-model-status", text: "Choose a model to preview it." }); const preview = el("div", { class: "map-model-preview" }, [canvas, status]); const body = el("div", { class: "map-model-browser" }, [el("div", { class: "map-model-browser-list" }, [search, list]), preview]); modelBrowser = { body, search, list, canvas, status, preview, view: null, kit: null, selected: "", request: 0, meshes: [] }; } const b = modelBrowser; b.selected = ""; b.search.value = ""; b.status.textContent = "Choose a model to preview it."; if (!b.view) { try { b.view = new GL3D.View(b.canvas); b.kit = MeshKit.create(b.view, () => MAP.src); } catch (err) { return toast(`3D preview unavailable: ${err.message}`, true), null; } } const render = () => { const q = b.search.value.trim().toLowerCase(); const hits = all.filter((n) => n.toLowerCase().includes(q)); b.list.replaceChildren(...hits.map((n) => el("button", { class: `map-model-option${b.selected === n ? " on" : ""}`, text: n, title: n, onclick: () => { b.selected = n; render(); previewModel(n); }, }))); }; const previewModel = async (name) => { const request = ++b.request; b.view.clear(); b.status.textContent = `Loading ${name}…`; const cfg = await b.kit.readModel(name).catch(() => null); if (request !== b.request) return; if (!cfg) return (b.status.textContent = "Model config was not found."); const loaded = []; for (const n of cfg.cfg.meshes) { const hit = await b.kit.readNamed(`${n}.msh`, "mesh").catch(() => null); if (request !== b.request) return; if (!hit) continue; try { const m = await b.kit.loadMesh(hit, n); loaded.push(m); } catch {} } if (request !== b.request) return; if (!loaded.length) return (b.status.textContent = "This model has no readable meshes."); b.meshes = loaded; await Promise.all(loaded.map((m) => b.kit.loadTextures(m))); if (request !== b.request) return; const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity]; for (const m of loaded) for (let i = 0; i < m.data.pos.length; i += 3) for (let a = 0; a < 3; a++) { lo[a] = Math.min(lo[a], m.data.pos[i + a]); hi[a] = Math.max(hi[a], m.data.pos[i + a]); } const center = lo.map((v, i) => (v + hi[i]) / 2); const radius = Math.max(1, ...hi.map((v, i) => v - lo[i])); b.view.cam.target = [center[0], center[1], -center[2]]; b.view.cam.dist = radius * 2.1; b.view.cam.radius = radius; b.view.cam.pitch = 0.38; b.view.cam.yaw = 0.72; b.view.moved(); b.status.textContent = `${name} · ${loaded.length} mesh(es)`; }; b.search.oninput = () => { const hits = all.filter((n) => n.toLowerCase().includes(b.search.value.trim().toLowerCase())); if (!hits.includes(b.selected)) { b.selected = hits[0] || ""; render(); if (b.selected) previewModel(b.selected); else { b.request++; b.view.clear(); b.status.textContent = "No models match this search."; } } else render(); }; b.search.onkeydown = (e) => { if (e.key === "Enter" && b.selected) $("modal-actions").lastChild.click(); }; render(); b.selected = all[0]; render(); previewModel(b.selected); const chosen = await modal("Model library", [b.body], [ { label: "Cancel", value: false }, { label: "Add selected", kind: "primary", value: true }, ]); const result = chosen ? b.selected : null; b.request++; b.view.clear(); b.status.textContent = "Choose a model to preview it."; return result; } async function chooseMeshFromLibrary() { const all = (await names("mesh")).filter((n) => /\.msh$/i.test(n)); if (!all.length) return toast("No mesh files were found in the selected resource source.", true), null; if (!meshBrowser) { const search = el("input", { type: "search", placeholder: "Search meshes…", spellcheck: "false" }); const list = el("div", { class: "map-model-list" }); const canvas = el("canvas", { class: "map-model-canvas" }); const status = el("div", { class: "map-model-status", text: "Choose a mesh to preview it." }); const preview = el("div", { class: "map-model-preview" }, [canvas, status]); const body = el("div", { class: "map-model-browser" }, [el("div", { class: "map-model-browser-list" }, [search, list]), preview]); meshBrowser = { body, search, list, canvas, status, view: null, kit: null, selected: "", request: 0 }; } const b = meshBrowser; b.selected = ""; b.search.value = ""; b.status.textContent = "Choose a mesh to preview it."; if (!b.view) { try { b.view = new GL3D.View(b.canvas); b.kit = MeshKit.create(b.view, () => MAP.src); } catch (err) { return toast(`3D preview unavailable: ${err.message}`, true), null; } } const render = () => { const q = b.search.value.trim().toLowerCase(); const hits = all.filter((n) => n.toLowerCase().includes(q)); b.list.replaceChildren(...hits.map((n) => el("button", { class: `map-model-option${b.selected === n ? " on" : ""}`, text: n, title: n, onclick: () => { b.selected = n; render(); previewMesh(n); }, }))); }; const previewMesh = async (file) => { const request = ++b.request; b.view.clear(); b.status.textContent = `Loading ${file}…`; const stem = file.replace(/\.msh$/i, ""); const hit = await b.kit.readNamed(file, "mesh", true).catch(() => null); if (request !== b.request) return; if (!hit) return (b.status.textContent = "Mesh file was not found in the selected source."); let mesh; try { mesh = await b.kit.loadMesh(hit, stem); } catch (err) { return (b.status.textContent = `Couldn't load mesh: ${err.message}`); } if (request !== b.request) return; await b.kit.loadTextures(mesh); if (request !== b.request) return; const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity]; for (let i = 0; i < mesh.data.pos.length; i += 3) for (let a = 0; a < 3; a++) { lo[a] = Math.min(lo[a], mesh.data.pos[i + a]); hi[a] = Math.max(hi[a], mesh.data.pos[i + a]); } const center = lo.map((v, i) => (v + hi[i]) / 2); const radius = Math.max(1, ...hi.map((v, i) => v - lo[i])); b.view.cam.target = [center[0], center[1], -center[2]]; b.view.cam.dist = radius * 2.1; b.view.cam.radius = radius; b.view.cam.pitch = 0.38; b.view.cam.yaw = 0.72; b.view.moved(); b.status.textContent = `${file} · ${mesh.data.faceCount} triangle(s)`; }; b.search.oninput = () => { const hits = all.filter((n) => n.toLowerCase().includes(b.search.value.trim().toLowerCase())); if (!hits.includes(b.selected)) { b.selected = hits[0] || ""; render(); if (b.selected) previewMesh(b.selected); else { b.request++; b.view.clear(); b.status.textContent = "No meshes match this search."; } } else render(); }; b.search.onkeydown = (e) => { if (e.key === "Enter" && b.selected) $("modal-actions").lastChild.click(); }; render(); b.selected = all[0]; render(); previewMesh(b.selected); const chosen = await modal("Mesh library", [b.body], [ { label: "Cancel", value: false }, { label: "Add selected", kind: "primary", value: true }, ]); const result = chosen ? b.selected : null; b.request++; b.view.clear(); b.status.textContent = "Choose a mesh to preview it."; return result; } /** Where new things go: the selected point, else the floor under the view centre. */ function dropPoint() { const m = markerOf(MAP.sel); if (m) return [m.x + 150, m.y, m.z]; const t = view.cam.target; const x = Math.round(t[0]), z = Math.round(-t[2]); return [x, groundAt(x, z) ?? 0, z]; } const f1 = (v) => fmtNum(null, Math.round(v)); function snapObjectToFloor(section) { const m = markerOf({ sec: section, prefix: "pos" }); const o = MAP.objects.get(section); if (!m) return toast("This object has no position marker.", true); const floor = groundAt(m.x, m.z); if (floor === null) return toast("The map has no collision surface under this object.", true); if (!o || !Number.isFinite(o.minY)) return toast("Wait for the object preview to finish loading, then snap it again.", true); const bottom = o.minY * (num(sec(section), "scale_y", 1) || 1); edit(() => setNum(section, "pos_y", floor - bottom)); } function objectInput(section, key, label, fallback = 0) { const input = el("input", { type: "number", step: "any", value: val(sec(section), key) ?? String(fallback) }); input.addEventListener("change", () => { const n = Number(input.value); if (!Number.isFinite(n)) return; edit(() => setVal(section, key, `${n}${/[f]$/i.test(val(sec(section), key) || "") ? "f" : ""}`)); }); return el("label", { class: "fld" }, [el("span", { text: label }), input]); } function objectTransformCard(section) { const rot = MAP.previewRot.get(section) || [0, 0, 0]; const modeButtons = [ ["map-tool-position", "G", "Move (G, then X/Y/Z to constrain axis)", "position"], ["map-tool-rotate", "R", "Rotate preview (R)", "rotate"], ["map-tool-scale", "S", "Scale (S)", "scale"], ].map(([id, text, title, mode]) => el("button", { id, class: `iconbtn${MAP.transformMode === mode ? " on" : ""}`, title, text, onclick: () => setTransformMode(mode) })); return el("div", { class: "map-object-transform" }, [ el("div", { class: "map-object-mode" }, [...modeButtons, el("span", { id: "map-tool-status", class: "muted small", text: `${MAP.transformMode === "rotate" ? "R · Rotate preview" : MAP.transformMode === "scale" ? "S · Scale" : `G · Move${MAP.moveAxis ? ` ${MAP.moveAxis.toUpperCase()} axis` : ""}`}` })]), el("div", { class: "map-object-fields" }, [ objectInput(section, "pos_x", "X"), objectInput(section, "pos_y", "Y"), objectInput(section, "pos_z", "Z"), objectInput(section, "scale_x", "Scale X", 1), objectInput(section, "scale_y", "Scale Y", 1), objectInput(section, "scale_z", "Scale Z", 1), ]), el("div", { class: "map-object-mode" }, [ el("button", { class: "btn", text: "Snap base to map floor", onclick: () => snapObjectToFloor(section) }), el("span", { class: "muted small", text: `Editor rotation: ${Math.round(rot[0])}°, ${Math.round(rot[1])}°, ${Math.round(rot[2])}° · saved as editor metadata; game ignores it` }), ]), ]); } async function addObject(base = "object") { const model = await chooseStaticModel(); if (!model || !/^(?:staticmodel|animodel)\/.+\.txt$/i.test(model)) return model && toast("Pick a model config from staticmodel/ or animodel/.", true); const [x, y, z] = dropPoint(); let name; edit(() => { const entries = [["pos_x", f1(x)], ["pos_y", f1(y)], ["pos_z", f1(z)], ["model_file", model], ["shadow_cast", "0"], ["object_quality_level", "0"]]; if (/^animodel\//i.test(model)) entries.push(["animation_idx", "0"]); name = addNumbered(base, entries); }); select({ sec: name.toLowerCase(), prefix: "pos" }); } async function chooseSourceMapDoc(description) { const sourceSelect = el("select", {}, [ el("option", { value: "", text: "Choose a source map…" }), el("option", { value: "__disk__", text: "Browse map file from disk…" }), ...MAP.rows.map((row) => el("option", { value: row[0], text: `${row[1]} · ${row[2]}` })), ]); if (!(await modal("Choose the source map", [ el("label", { class: "fld" }, [el("span", { text: description }), sourceSelect]), ], [{ label: "Cancel", value: false }, { label: "Continue", kind: "primary", value: true }]))) return null; if (!sourceSelect.value) return toast("Choose a source map first.", true), null; let hit, title; if (sourceSelect.value === "__disk__") { const file = await call("dialog:file", "Source map config (.ini)"); if (!file) return null; hit = await call("mdl:readFile", file); title = baseName(file); } else { const row = MAP.rows.find((r) => r[0] === sourceSelect.value); hit = await call("mdl:readKey", MAP.src, sourceSelect.value); title = row?.[1] || sourceSelect.value; } if (!hit) throw new Error("Couldn't read the selected source map."); return { doc: parseIni(toLatin1(hit.bytes)), title }; } async function importMapMesh() { if (!MAP.doc) return; try { const source = await chooseSourceMapDoc("Map containing the mesh list"); if (!source) return; const sourceDoc = source.doc; const mapSection = sourceDoc.byName.get("map"); const modelName = keyIn(mapSection, "mesh_name")?.value || keyIn(mapSection, "mesh_name1")?.value; if (!modelName) throw new Error("The selected file has no map mesh_name."); const mapModel = await kit.readModel(modelName); if (!mapModel?.cfg.meshes?.length) throw new Error(`Map model ${modelName} wasn't found in the selected resource source.`); const meshSelect = el("select", {}, mapModel.cfg.meshes.map((mesh, index) => el("option", { value: mesh, text: `${index + 1}. ${mesh}` }))); if (!(await modal("Choose a mesh", [ el("div", { class: "muted small", text: `${source.title} · ${mapModel.cfg.meshes.length} meshes` }), el("label", { class: "fld" }, [el("span", { text: "Mesh to import" }), meshSelect]), el("div", { class: "muted small", text: "A one-mesh staticmodel config will be created for this object." }), ], [{ label: "Cancel", value: false }, { label: "Next", kind: "primary", value: true }]))) return; await addMeshAsObject(meshSelect.value); } catch (err) { toast(err.message, true); } } async function addMeshAsObject(mesh) { const folder = await call("dialog:folder", "Choose resource/model/staticmodel output folder"); if (!folder) return; const safe = mesh.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 44) || "mesh"; const fileName = `map_import_${safe}_${Date.now().toString(36)}.txt`; const modelFile = `staticmodel/${fileName}`; const config = `#MODEL_CFG\r\n#MESH_LIST\r\n${mesh}\r\n#MESH_LIST_END\r\n`; await call("mdl:writeFiles", folder, [{ name: fileName, data: config }]); MAP.generatedModels.set(modelFile.toLowerCase(), { cfg: { meshes: [mesh], skeleton: "", animations: [] } }); const [x, y, z] = dropPoint(); let name; edit(() => { name = addNumbered("object", [["pos_x", f1(x)], ["pos_y", f1(y)], ["pos_z", f1(z)], ["model_file", modelFile], ["shadow_cast", "0"], ["object_quality_level", "0"]]); }); select({ sec: name.toLowerCase(), prefix: "pos" }); toast(`Added mesh ${mesh} as ${name}. Add ${fileName} to the game's resource pack for it to load in-game.`); } async function addMeshFromLibrary() { if (!MAP.doc) return; const selected = await chooseMeshFromLibrary(); if (!selected) return; await addMeshAsObject(selected.replace(/\\/g, "/").replace(/\.msh$/i, "")); } async function importMapObject() { if (!MAP.doc) return; try { const source = await chooseSourceMapDoc("Map containing the object to copy"); if (!source) return; const objects = source.doc.sections.filter((s) => /^(?:object|costom_object)\d+$/i.test(s.name) && keyIn(s, "model_file")?.value && keyIn(s, "model_file").value.toLowerCase() !== "none", ); if (!objects.length) return toast("No placed objects were found in that map.", true); const objectSelect = el("select", {}, objects.map((s, i) => { const model = keyIn(s, "model_file").value; return el("option", { value: String(i), text: `${s.name} · ${model}` }); })); if (!(await modal("Choose an object", [ el("div", { class: "muted small", text: `${source.title} · ${objects.length} placed objects` }), el("label", { class: "fld" }, [el("span", { text: "Object to copy" }), objectSelect]), el("div", { class: "muted small", text: "The copied object will be placed at the current map view position." }), ], [{ label: "Cancel", value: false }, { label: "Add object", kind: "primary", value: true }]))) return; const src = objects[Number(objectSelect.value)]; const model = keyIn(src, "model_file").value; const [x, y, z] = dropPoint(); const entries = [["pos_x", f1(x)], ["pos_y", f1(y)], ["pos_z", f1(z)]]; for (const k of src.keys) if (!/^pos_[xyz]$/i.test(k.key)) entries.push([k.key, k.value]); const nextSection = source.doc.sections.find((s) => s.head > src.head); const sourceEnd = nextSection?.head ?? source.doc.lines.length; let rotation = null; for (let i = src.head + 1; i < sourceEnd; i++) { const match = EDITOR_ROTATION.exec(source.doc.lines[i].replace(/[\r\n]+$/, "")); if (match) { rotation = match.slice(1).map(Number); break; } } let name; edit(() => { name = addNumbered("object", entries); if (rotation) { MAP.previewRot.set(name.toLowerCase(), rotation); writePreviewRotation(name.toLowerCase(), rotation); } }); select({ sec: name.toLowerCase(), prefix: "pos" }); toast(`Copied ${src.name} (${model}) into ${name}.`); } catch (err) { toast(err.message, true); } } async function addEffect() { const file = await askName("New effect", "Effect file (resource/effect/…)", "effect"); if (!file) return; const [x, y, z] = dropPoint(); let name; edit(() => { name = addNumbered("effect", [["pos_x", f1(x)], ["pos_y", f1(y)], ["pos_z", f1(z)], ["effect_file", file]]); }); select({ sec: name.toLowerCase(), prefix: "pos" }); } function addItemPoint() { const s = sec("item_generate"); const [x, , z] = dropPoint(); let prefix = "pos1"; edit(() => { if (s && keyIn(s, "pos1_x")) prefix = addPointMember("item_generate", "pos1"); else { setVal("item_generate", "pos_cnt", "1"); setVal("item_generate", "pos1_x", f1(x)); setVal("item_generate", "pos1_z", f1(z)); } }); select({ sec: "item_generate", prefix }); } function duplicateSel() { const m = markerOf(MAP.sel); if (!m) return; const s = sec(m.sec); const fam = familyOf(m.sec); if (fam) { let name; edit(() => { name = addNumbered(fam.base, s.keys.map((k) => [k.key, /^pos_x$/i.test(k.key) ? fmtNum(k.value, (parseFloat(k.value) || 0) + 150) : k.value])); }); return select({ sec: name.toLowerCase(), prefix: m.prefix }); } if (pointFamily(s, m.prefix)) { let p; edit(() => (p = addPointMember(m.sec, m.prefix))); if (p) select({ sec: m.sec, prefix: p }); return; } toast("This point is a fixed part of its section; it can't be copied.", true); } function deleteSel() { const m = markerOf(MAP.sel); if (!m) return; if (familyOf(m.sec) && m.prefix === "pos") { edit(() => removeNumbered(m.sec)); MAP.sel = null; MAP.secSel = null; return (refreshScene(), renderSide(), toast(`Removed [${m.secName}]; the ones after it were renumbered.`)); } if (pointFamily(sec(m.sec), m.prefix)) { edit(() => removePointMember(m.sec, m.prefix)); MAP.sel = null; return (refreshScene(), renderSide(), toast(`Removed ${m.prefix}; the count and the numbers after it were updated.`)); } toast("This point is a fixed part of its section (edit its values instead).", true); } $("map-add").onclick = (e) => showMenu( [ { label: "Object…", icon: "cube", disabled: !MAP.doc, run: () => addObject() }, { label: "Object from another map…", icon: "import", disabled: !MAP.doc, run: importMapObject }, { label: "Mesh from library…", icon: "cube", disabled: !MAP.doc, run: addMeshFromLibrary }, { label: "Mesh from map file…", icon: "import", disabled: !MAP.doc, run: importMapMesh }, { label: "Effect…", icon: "zap", disabled: !MAP.doc, run: addEffect }, { label: "Item spawn point", icon: "target", disabled: !MAP.doc, run: addItemPoint }, "-", { label: "Copy of the selected point", icon: "copy", hint: "Ctrl+D", disabled: !MAP.sel, run: duplicateSel }, { label: "Remove the selected point", icon: "trash", hint: "Del", danger: true, disabled: !MAP.sel, run: deleteSel }, ], { anchor: e.currentTarget }, ); // ===================================================================== // side panel // ===================================================================== const LAYER_NAMES = { team_start: "Team start", team_revival: "Team revival", single_start: "Single start", single_revival: "Single revival", item_generate: "Item spawns", object: "Objects", effect: "Effects", function_object: "Moving objects", object_camera_fixed: "Camera-fixed objects", map_camera: "Camera limit", boost_limit: "Boost limit", event_scene: "Camera events", map: "Map centre", navigation: "PvE navigation boxes", }; function renderSide() { if (!MAP.doc) return; renderMapCard(); renderLayers(); renderObjectList(); renderSections(); renderSectionCard(); updateSave(); } function renderMapCard() { const m = sec("map"); const w = MAP.world || {}; const dl = el("dl", { class: "mdl-dl" }); const add = (k, v, cls) => dl.append(el("dt", { text: k }), el("dd", { text: v, class: cls || "" })); add("Model", w.name || "—"); add("Meshes", `${w.meshes?.length || 0}${w.missing?.length ? ` · missing: ${w.missing.join(", ")}` : ""}`, w.missing?.length ? "bad" : ""); add("Collision", w.col ? `${w.cfg.collision} · ${(w.col.indices.length / 3).toLocaleString()} triangles` : "none (points sit at height 0)"); add("Outline", w.edg ? `${w.cfg.outline.file} · ${(w.edg.edges.length / 4).toLocaleString()} edges` : "none"); if (val(m, "navmesh_name")) add("Navmesh", `${val(m, "navmesh_name")} (PathEngine, not shown)`); const mode = MAP.rows.find((r) => r[0] === MAP.where?.entry)?.[2] || (MAP.where?.entry || "").split("/")[2] || ""; if (/^(dungeon|monstersurvival|raid|towerdef|darkxmas|shuffle)/.test(mode)) dl.append(el("dt", { text: "Server" }), el("dd", { class: "small", text: "The game server reads its own copy for this mode (config/mode/map/…); change spawn / monster data there too." })); const quick = el("div", { class: "fld-grid" }, [ numField("map", "map_friction", "Friction"), numField("map", "map_gravity", "Gravity"), ]); const meshIn = el("input", { type: "text", value: val(m, "mesh_name") || "", spellcheck: "false", list: "map-dl-model" }); meshIn.addEventListener("change", async () => { edit(() => setVal("map", "mesh_name", meshIn.value.trim())); await loadWorld(); refreshScene(); renderSide(); }); names("model").then((n) => { if (!$("map-dl-model")) document.body.append(el("datalist", { id: "map-dl-model" }, n.map((x) => el("option", { value: x })))); }); $("map-info").replaceChildren(dl, el("label", { class: "fld" }, [el("span", { text: "Map model (mesh_name)" }), meshIn]), quick, minimapPreview()); } function numField(secName, key, label) { const s = sec(secName); const input = el("input", { type: "text", value: val(s, key) ?? "", spellcheck: "false" }); input.addEventListener("change", () => edit(() => setVal(secName, key, input.value.trim()))); return el("label", { class: "fld" }, [el("span", { text: label }), input]); } // minimap image: [minimap] image = # from xml/uiimageset.xml let imagesetCache = null; function minimapPreview() { const wrap = el("div", { class: "map-minimap" }); const ref = val(sec("minimap"), "image"); if (!ref || !ref.includes("#") || !MAP.src) return wrap; const [setName, imgName] = ref.split("#"); (async () => { if (!imagesetCache) { const h = await call("mdl:readPath", MAP.src, "xml/uiimageset.xml").catch(() => null); imagesetCache = h ? toLatin1(h.bytes) : ""; } const block = new RegExp(`]*Name="${esc(setName)}"[^>]*>([\\s\\S]*?)`, "i").exec(imagesetCache); const file = block && /File="([^"]+)"/.exec(block[0].slice(0, block[0].indexOf(">")))?.[1]; const img = block && new RegExp(`]*Name="${esc(imgName)}"[^>]*/>`, "i").exec(block[1])?.[0]; if (!file || !img) return wrap.append(el("em", { class: "muted small", text: `Minimap ${ref} isn't in uiimageset.xml` })); const n = (k) => Number(new RegExp(`\\b${k}="(-?\\d+)"`).exec(img)?.[1] || 0); const t = await kit.texture(file); if (!t.img) return wrap.append(el("em", { class: "muted small", text: `${file}: ${t.error}` })); const c = el("canvas"); c.width = n("Width"); c.height = n("Height"); const g = c.getContext("2d"); const bmp = t.img instanceof ImageData ? await createImageBitmap(t.img) : t.img; g.drawImage(bmp, n("X"), n("Y"), c.width, c.height, 0, 0, c.width, c.height); wrap.append(el("span", { class: "muted small", text: `Minimap ${ref}` }), c); })().catch(() => {}); return wrap; } function renderLayers() { const counts = new Map(); for (const m of MAP.markers) counts.set(m.layer, (counts.get(m.layer) || 0) + 1); const color = new Map(MAP.markers.map((m) => [m.layer, m.color])); $("map-layers").replaceChildren( ...[...counts].map(([layer, n]) => { const c = COLORS[color.get(layer)]; const cb = el("input", { type: "checkbox", checked: layerVisible(layer), onchange: () => { if (cb.checked) MAP.hidden.delete(layer); else MAP.hidden.add(layer); drawMarkers(); syncObjects(); }, }); const dot = el("i", { class: "map-dot", style: `background:rgb(${c.slice(0, 3).map((v) => Math.round(v * 255)).join(",")})` }); return el("label", { class: "map-layer", title: layer }, [cb, dot, el("span", { class: "grow", text: LAYER_NAMES[layer] || layer }), el("em", { text: String(n) })]); }), ); } function renderSections() { const q = $("map-sec-search").value.trim().toLowerCase(); $("map-sections").replaceChildren( ...MAP.doc.sections .filter((s) => !q || s.lname.includes(q) || s.keys.some((k) => k.value.toLowerCase().includes(q))) .map((s) => el("div", { class: `map-sec${s.lname === MAP.secSel ? " on" : ""}`, onclick: () => { MAP.secSel = s.lname; const first = MAP.markers.find((m) => m.sec === s.lname); MAP.sel = first ? { sec: first.sec, prefix: first.prefix } : null; drawMarkers(); syncObjects(); renderSide(); }, }, [el("b", { text: s.name }), el("em", { text: String(s.keys.length) })]), ), ); $("map-sec-count").textContent = String(MAP.doc.sections.length); } $("map-sec-search").addEventListener("input", () => MAP.doc && renderSections()); function renderObjectList() { const q = $("map-object-search").value.trim().toLowerCase(); const objects = MAP.doc.sections.filter((s) => isObjectSection(s.lname) && val(s, "model_file") && val(s, "model_file").toLowerCase() !== "none"); const shown = objects.filter((s) => !q || s.lname.includes(q) || val(s, "model_file").toLowerCase().includes(q)); $("map-object-count").textContent = String(objects.length); $("map-object-list").replaceChildren(...shown.map((s) => { const choose = () => { MAP.secSel = s.lname; MAP.sel = { sec: s.lname, prefix: "pos" }; setTransformMode(MAP.transformMode || "position"); drawMarkers(); syncObjects(); renderSide(); }; const row = el("button", { class: `map-object-row${MAP.secSel === s.lname ? " on" : ""}`, title: val(s, "model_file"), onclick: () => { choose(); focusSel(); }, }, [el("b", { text: s.name }), el("span", { text: val(s, "model_file") })]); const actions = el("div", { class: "map-object-actions" }, [ el("button", { class: "iconbtn only", title: `Duplicate ${s.name}`, onclick: (e) => { e.stopPropagation(); choose(); duplicateSel(); } }, [icon("copy")]), el("button", { class: "iconbtn only danger", title: `Delete ${s.name}`, onclick: (e) => { e.stopPropagation(); choose(); deleteSel(); } }, [icon("trash")]), ]); return el("div", { class: `map-object-item${MAP.secSel === s.lname ? " on" : ""}` }, [row, actions]); })); if (!shown.length) $("map-object-list").append(el("div", { class: "muted small", text: objects.length ? "No objects match this search." : "No objects in this map yet." })); } $("map-object-search").addEventListener("input", () => MAP.doc && renderObjectList()); /** Comment lines just above a key (CP949, shown as help). */ function commentsAbove(s, k) { const out = []; const prev = s.keys[s.keys.indexOf(k) - 1]; const from = prev ? prev.line + 1 : s.head + 1; for (let i = from; i < k.line; i++) { const t = MAP.doc.lines[i].trim(); if (/^(;|\/\/)/.test(t)) out.push(cp949(t.replace(/^(;+|\/\/+)\s*/, ""))); } return out.join("\n"); } function renderSectionCard() { const s = sec(MAP.secSel || ""); $("map-sec-card").hidden = !s; if (!s) return; const m = markerOf(MAP.sel); $("map-sec-name").textContent = `[${s.name}]`; const fam = familyOf(s.lname); const pf = m && m.sec === s.lname ? pointFamily(s, m.prefix) : null; const tools = []; if (m && m.sec === s.lname) tools.push( el("button", { class: "iconbtn", title: "Look at this point (F)", onclick: focusSel }, [icon("focus"), el("span", { text: m.prefix })]), el("span", { class: "muted small mono", text: `${Math.round(m.x)}, ${Math.round(m.y)}, ${Math.round(m.z)}` }), ); tools.push(el("span", { class: "spacer" })); if (fam || pf) tools.push(el("button", { class: "iconbtn only", title: fam ? `Copy this section as [${fam.base}${familyCount(fam) + 1}] (Ctrl+D)` : `Add ${pf.head}${pf.max + 1} (Ctrl+D)`, onclick: duplicateSel, disabled: !m }, [icon("copy")])); if ((fam && m?.prefix === "pos") || pf) tools.push(el("button", { class: "iconbtn only danger", title: fam ? "Remove this section (the numbers after it move down)" : `Remove ${m.prefix}`, onclick: deleteSel }, [icon("trash")])); const rows = s.keys.map((k) => { const help = commentsAbove(s, k); const input = el("input", { type: "text", value: k.value, spellcheck: "false" }); input.addEventListener("change", () => { const v = input.value; if (/[\r\n]/.test(v)) return; edit(() => setVal(s.name, k.key, v.trim())); if (s.lname === "map" && k.lkey === "mesh_name") loadWorld().then(() => (refreshScene(), renderSide())); }); input.addEventListener("keydown", (e) => e.key === "Enter" && input.blur()); const isPoint = m && m.sec === s.lname && k.lkey.startsWith(`${m.prefix}_`); return el("div", { class: `map-kv${isPoint ? " on" : ""}`, title: help }, [ el("span", { class: "k", text: k.key }), input, el("button", { class: "iconbtn only", title: "Remove this key", onclick: () => edit(() => removeKey(s.name, k.key)) }, [icon("minus")]), help ? el("i", { class: "map-help", text: help }) : null, ]); }); const nk = el("input", { type: "text", placeholder: "new_key", spellcheck: "false" }); const nv = el("input", { type: "text", placeholder: "value", spellcheck: "false" }); const addKey = () => { const k = nk.value.trim(); if (!/^[\w.#-]+$/.test(k)) return toast("Key names are letters, digits and _.", true); edit(() => setVal(s.name, k, nv.value.trim())); }; nv.addEventListener("keydown", (e) => e.key === "Enter" && addKey()); const content = [el("div", { class: "map-sec-tools" }, tools)]; if (isObjectSection(s.lname)) content.push(objectTransformCard(s.lname)); content.push( el("div", { class: "map-kvs" }, rows), el("div", { class: "map-kv add" }, [nk, nv, el("button", { class: "iconbtn only", title: "Add key", onclick: addKey }, [icon("plus")])]), ); $("map-sec").replaceChildren(...content); $("map-sec-card").querySelector(".map-kv.on input")?.scrollIntoView({ block: "nearest" }); } // ===================================================================== // toolbar + keys // ===================================================================== function setToggle(id, on) { $(id).classList.toggle("on", on); if (!view) return; view.opts.textures = toggled("map-t-tex"); view.opts.wire = toggled("map-t-wire"); view.setVisible("col", toggled("map-t-col")); view.showOverlay("grid", toggled("map-t-grid")); edgeDirty = true; applyEnv(); syncObjects(); drawLabels(); view.dirty = true; } for (const id of ["map-t-tex", "map-t-wire", "map-t-col", "map-t-outline", "map-t-light", "map-t-objects", "map-t-labels", "map-t-grid"]) $(id).onclick = () => setToggle(id, !toggled(id)); $("map-t-anim").onclick = () => { const on = !toggled("map-t-anim"); $("map-t-anim").classList.toggle("on", on); setAnim(on); }; $("map-top").onclick = () => view?.topView(); $("map-reset").onclick = () => view?.reset(); $("map-snap").onchange = () => (MAP.snap = Number($("map-snap").value) || 0); document.addEventListener("keydown", (e) => { if (!$("view-map").classList.contains("on") || !MAP.doc) return; if (e.target.matches?.("input, textarea, select")) return; const k = e.key.toLowerCase(); if (e.ctrlKey && k === "z") return e.preventDefault(), restore(MAP.undo, MAP.redo); if (e.ctrlKey && k === "y") return e.preventDefault(), restore(MAP.redo, MAP.undo); if (e.ctrlKey && k === "s") return e.preventDefault(), save(); if (e.ctrlKey && k === "d") return e.preventDefault(), duplicateSel(); if (e.key === "Delete") return deleteSel(); const selected = markerOf(MAP.sel); if (selected && isObjectSection(selected.sec) && ["g", "p", "r", "s"].includes(k)) { e.preventDefault(); if (k === "g" || k === "p") return setMoveAxis(null); return setTransformMode(k === "r" ? "rotate" : "scale"); } if (selected && isObjectSection(selected.sec) && MAP.transformMode === "position" && ["x", "y", "z"].includes(k)) { e.preventDefault(); return setMoveAxis(k); } if (k === "f") return focusSel(); if (k === "t") return view.topView(); const m = selected || markerOf(MAP.sel); const step = (e.shiftKey ? 100 : 10) * (MAP.snap ? MAP.snap / 10 : 1); const mv = MAP.moveAxis === "x" ? ({ arrowleft: [-step, 0, 0], arrowright: [step, 0, 0] }[k]) : MAP.moveAxis === "y" ? ({ arrowup: [0, step, 0], arrowdown: [0, -step, 0] }[k]) : MAP.moveAxis === "z" ? ({ arrowup: [0, 0, step], arrowdown: [0, 0, -step] }[k]) : ({ arrowleft: [-step, 0, 0], arrowright: [step, 0, 0], arrowup: [0, 0, step], arrowdown: [0, 0, -step] }[k]); if (m && mv) { e.preventDefault(); edit(() => { if (m.navi) return moveNaviBox(m.prefix, mv[0], mv[1], mv[2]); if (mv[0]) setNum(m.sec, `${m.prefix}_x`, m.x + mv[0]); if (mv[1]) setNum(m.sec, `${m.prefix}_y`, m.y + mv[1]); if (mv[2]) setNum(m.sec, `${m.prefix}_z`, m.z + mv[2]); }); } }); // first visit: the packs next to a file open in IOP Manager document.querySelector('[data-view="map"]').addEventListener("click", async () => { ensureView(); view?.resize(); if (MAP.src) return; if (profileSource()) return setSource(profileSource(), { quiet: true }); const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected); const s = sessions.find((x) => /^rs\.iop$/i.test(x.name)) || sessions[0]; if (s) await setSource({ kind: "session", sessionId: s.id, name: s.name }, { quiet: true }); }); Object.assign(MAP, { FX, ANIM, setAnim, elevatorAt, setSource, openRow, openDoc, serialize, sec, val, setVal, edit, restore, addNumbered, removeNumbered, addPointMember, removePointMember, select, save, groundAt, markerOf, pickMarker, rowsOf: () => list.view }); })();