2218 lines
95 KiB
JavaScript
2218 lines
95 KiB
JavaScript
"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 });
|
||
})();
|