Files
losadev-tools/app/public/legacy/js/mapedit.js
T

2218 lines
95 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.
"use strict";
// Map editor (Map tab): the mode map configs (rs.iop config/mode/<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 <name>_x / <name>_z (+ _y) is a point, any <name>min /
// <name>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 <name>_x / <name>_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/<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 = <Imageset>#<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(`<Imageset\\b[^>]*Name="${esc(setName)}"[^>]*>([\\s\\S]*?)</Imageset>`, "i").exec(imagesetCache);
const file = block && /File="([^"]+)"/.exec(block[0].slice(0, block[0].indexOf(">")))?.[1];
const img = block && new RegExp(`<Image\\b[^>]*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 });
})();