LosaDev Tools: Lost Saga modding tools in the browser

This commit is contained in:
LosaDev committed 2026-11-07 23:08:43 +07:00
commit c5e04472fb
105 files changed
+38541

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+8
View File
@@ -0,0 +1,8 @@
<div id="menu" class="menu" hidden></div>
<div id="modal" hidden><div class="dialog"><h3 id="modal-title"></h3><div id="modal-body"></div><div class="actions" id="modal-actions"></div></div></div>
<div id="toast"></div>
<div id="task" hidden>
<span id="task-text"></span>
<div class="bar-track"><i id="task-bar"></i></div>
<button id="task-cancel" class="iconbtn">Cancel</button>
</div>
+260
View File
@@ -0,0 +1,260 @@
"use strict";
// Generated by tools/extract-legacy.mjs: helpers copied from the desktop app.js + web additions.
// ---- web additions (src/legacy/host-web.js) ----
// The desktop app.js kept these as its own state; in the web version they come from the site.
// The active "profile": the game folder opened on the Game Folder page (mounted at /game in the
// worker). profileSource() (copied below) turns it into the tools' default asset source.
var WS = null;
/** Called by the site when the game folder changes. kind: "packs" (has .iop) or "dir" (unpacked). */
function setWebGameFolder(name, kind) {
WS = name ? { id: "web", name, gameDir: "/game", sourceKind: kind || "packs" } : null;
}
// IOP Manager is not part of the web tools yet: no open sessions.
var IOP = { files: [], active: null, rows: [], cwd: "", profiles: [], index: null, expanded: new Set(), mode: "folders" };
/** Turn <i data-icon="x"> placeholders into SVG icons and wire data-cmd buttons (app.js did this once at load). */
function hydrateLegacy(root) {
for (const n of root.querySelectorAll("[data-icon]")) n.replaceWith(Object.assign(icon(n.dataset.icon), { className: `ic ${n.className}`.trim() }));
for (const b of root.querySelectorAll("[data-cmd]")) b.addEventListener("click", () => $(b.dataset.cmd).onclick?.());
}
// ---- app.js 6-103 ----
const $ = (id) => document.getElementById(id);
const bridge = window.api; // (window.api itself is a read-only global from the preload)
const call = bridge.call;
const el = (tag, props = {}, children = []) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === "class") n.className = v;
else if (k === "text") n.textContent = v;
else if (k === "style") n.style.cssText = v || ""; // CSSOM: allowed by the page's CSP, style="" isn't
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
else if (v !== undefined && v !== null && v !== false) n.setAttribute(k, v === true ? "" : v);
}
for (const c of [].concat(children)) if (c != null) n.append(c);
return n;
};
const size = (n) => (n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`);
const hex = (n) => (n >>> 0).toString(16).padStart(8, "0");
const fmtDos = (date, time) => {
if (!date) return "";
const y = 1980 + (date >> 9), m = (date >> 5) & 15, d = date & 31;
const hh = time >> 11, mm = (time >> 5) & 63;
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
};
const MAX_ENTRIES = 65535; // client limit: ZipArchive without ZIP64, 16-bit entry index
// ---------- toast / modal / task ----------
function toast(msg, bad = false) {
const t = el("div", { class: `toast${bad ? " bad" : ""}`, text: msg, onclick: () => hide() });
const hide = () => {
t.classList.add("out");
setTimeout(() => t.remove(), 220);
};
$("toast").append(t);
setTimeout(hide, bad ? 8000 : 4500);
}
let modalSeq = 0;
function modal(title, body, buttons) {
return new Promise((resolve) => {
// a modal opened right after another closes: the old one's close timer must not hide it
const seq = ++modalSeq;
$("modal").classList.remove("closing");
$("modal-title").textContent = title;
$("modal-body").replaceChildren(...[].concat(body).map((b) => (typeof b === "string" ? el("p", { text: b }) : b)));
$("modal-actions").replaceChildren(
...buttons.map((b) =>
el("button", {
class: `btn ${b.kind || ""}`,
text: b.label,
onclick: () => {
$("modal").classList.add("closing");
setTimeout(() => {
if (seq !== modalSeq) return;
$("modal").hidden = true;
$("modal").classList.remove("closing");
}, 160);
resolve(b.value);
},
}),
),
);
$("modal").hidden = false;
$("modal-body").querySelector("input,select")?.focus();
});
}
const confirmBox = (title, text, ok = "OK", kind = "primary") => modal(title, text, [{ label: "Cancel", value: false }, { label: ok, kind, value: true }]);
async function prompt(title, label, value = "") {
const input = el("input", { type: "text", value, spellcheck: "false" });
input.addEventListener("keydown", (e) => e.key === "Enter" && $("modal-actions").lastChild.click());
const ok = await modal(title, [el("label", { text: label }), input], [{ label: "Cancel", value: false }, { label: "OK", kind: "primary", value: true }]);
return ok ? input.value.trim() : null;
}
let taskSeq = 0;
const taskHandlers = new Map();
bridge.onTask((p) => taskHandlers.get(p.taskId)?.(p));
/** Runs a main-process task with the progress bar; `label(p)` turns progress into text. */
async function runTask(channel, args, { label = () => "Working…", onEvent } = {}) {
const id = `t${++taskSeq}`;
$("task").hidden = false;
$("task-text").textContent = "Starting…";
$("task-bar").style.width = "0%";
$("task-cancel").onclick = () => call("task:cancel", id);
taskHandlers.set(id, (p) => {
onEvent?.(p);
if (p.type === "log") return;
$("task-text").textContent = label(p);
if (p.total) $("task-bar").style.width = `${Math.min(100, (100 * (p.bytesTotal ? p.bytes / p.bytesTotal : p.done / p.total)).toFixed(1))}%`;
});
try {
return await call(channel, id, ...args);
} finally {
taskHandlers.delete(id);
$("task").hidden = true;
}
}
// ---- app.js 162-162 ----
const baseName = (p) => p.replace(/\/$/, "").split("/").pop();
// ---- app.js 494-558 ----
const ICONS = {
box: '<path d="M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5z"/><path d="M3.5 7.5 12 12l8.5-4.5M12 12v9"/>',
pulse: '<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>',
power: '<path d="M12 3v8"/><path d="M6.4 7.2a7.5 7.5 0 1 0 11.2 0"/>',
upload: '<path d="M12 16V4M7 9l5-5 5 5"/><path d="M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4"/>',
gem: '<path d="M6 4h12l3 5-9 11L3 9z"/><path d="M3 9h18M9 4l3 16M15 4l-3 16"/>',
rocket: '<path d="M12 3c3 2 5 5.5 5 9.5L15 16H9l-2-3.5C7 8.5 9 5 12 3z"/><circle cx="12" cy="10" r="1.6"/><path d="M9 16l-2.5 3M15 16l2.5 3M12 17v4"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M16 7l3 3M14 9l2 2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
more: '<circle cx="5" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="19" cy="12" r="1.4"/>',
grid: '<rect x="4" y="4" width="7" height="7" rx="1"/><rect x="13" y="4" width="7" height="7" rx="1"/><rect x="4" y="13" width="7" height="7" rx="1"/><rect x="13" y="13" width="7" height="7" rx="1"/>',
list: '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".9"/><circle cx="4.5" cy="12" r=".9"/><circle cx="4.5" cy="18" r=".9"/>',
up: '<path d="M12 19V5M6 11l6-6 6 6"/>',
search: '<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>',
download: '<path d="M12 4v12M7 11l5 5 5-5"/><path d="M4 18v1a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-1"/>',
swap: '<path d="M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
fileplus: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M12 11v6M9 14h6"/>',
folderplus: '<path d="M3 6h6l2 2h10v11H3z"/><path d="M12 11v5M9.5 13.5h5"/>',
folder: '<path d="M3 6h6l2 2h10v11H3z"/>',
external: '<path d="M14 4h6v6M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>',
note: '<path d="M6 3h9l3 3v15H6z"/><path d="M9 9h6M9 13h6M9 17h4"/>',
apps: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M17 14v6M14 17h6"/>',
shield: '<path d="M12 3 5 6v6c0 4 3 7.5 7 9 4-1.5 7-5 7-9V6z"/><path d="m9 12 2 2 4-4"/>',
zap: '<path d="M13 3 5 13h6l-1 8 8-10h-6z"/>',
lock: '<rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/>',
newfile: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>',
import: '<path d="M12 4v11M7 10l5 5 5-5"/><path d="M5 20h14"/>',
server: '<rect x="4" y="4" width="16" height="7" rx="1.5"/><rect x="4" y="13" width="16" height="7" rx="1.5"/><path d="M8 7.5h.01M8 16.5h.01"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
image: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="m4 18 5-5 3 3 3-3 5 5"/>',
undo: '<path d="M9 7 4 12l5 5"/><path d="M4 12h10a6 6 0 0 1 0 12"/>',
redo: '<path d="m15 7 5 5-5 5"/><path d="M20 12H10a6 6 0 0 0 0 12"/>',
save: '<path d="M5 4h11l3 3v13H5z"/><path d="M8 4v5h7V4M8 20v-6h8v6"/>',
minus: '<path d="M5 12h14"/>',
fit: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
draw: '<rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 2.5"/><path d="M12 8v8M8 12h8"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="1.5"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>',
cube: '<path d="M12 3 4 7.5v9L12 21l8-4.5v-9z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/><path d="m8 5.3 8 4.5" opacity=".45"/>',
play: '<path d="M8 5v14l11-7z"/>',
pause: '<path d="M8 5v14M16 5v14"/>',
brush: '<path d="M14.5 4.5 19.5 9.5 11 18l-5-5z"/><path d="M6 13c-2 0-3 1.5-3 3.5S2 20 2 20s4 .5 5.5-1 1.5-3 1.5-3"/><path d="m16.5 2.5 5 5"/>',
shirt: '<path d="M8.5 3.5 4 6l-1.5 4.5 3 1.2V20.5h13V11.7l3-1.2L20 6l-4.5-2.5c-.6 1.6-2 2.5-3.5 2.5S9.1 5.1 8.5 3.5z"/>',
bone: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="m7.6 7.6 8.8 8.8"/>',
target: '<circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>',
floor: '<path d="M3 16 9 8h6l6 8z"/><path d="M12 8v8M6 12h12"/>',
focus: '<path d="M4 9V5a1 1 0 0 1 1-1h4M15 4h4a1 1 0 0 1 1 1v4M20 15v4a1 1 0 0 1-1 1h-4M9 20H5a1 1 0 0 1-1-1v-4"/><circle cx="12" cy="12" r="2.5"/>',
camera: '<path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/>',
layout: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M3.5 9h17M9 9v10.5"/>',
map: '<path d="M3 6.5 9 4l6 2.5L21 4v13.5L15 20l-6-2.5L3 20z"/><path d="M9 4v13.5M15 6.5V20"/>',
scroll: '<path d="M7 4h11a2 2 0 0 1 2 2v1h-4"/><path d="M16 7v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1h10"/><path d="M7 4a2 2 0 0 0-2 2v11"/><path d="M8.5 9h4.5M8.5 12.5h4.5"/>',
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>',
grid: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M3.5 9.5h17M3.5 14.5h17M9 4.5v15M15 4.5v15"/>',
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/>',
redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/>',
};
function icon(name) {
const i = document.createElement("i");
i.className = "ic";
i.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name] || ""}</svg>`;
return i;
}
// ---- app.js 564-593 ----
// ---------- menus (dropdowns + right-click) ----------
function showMenu(items, { x, y, anchor } = {}) {
const m = $("menu");
m.replaceChildren(
...items.map((it) =>
it === "-"
? el("div", { class: "sep" })
: el("button", { class: `item${it.danger ? " danger" : ""}`, disabled: !!it.disabled, onclick: () => (hideMenu(), it.run()) }, [icon(it.icon || "none"), el("span", { text: it.label }), it.hint ? el("kbd", { text: it.hint }) : null]),
),
);
m.hidden = false;
const r = anchor?.getBoundingClientRect();
const w = m.offsetWidth;
const h = m.offsetHeight;
let left = anchor ? r.right - w : x;
let top = anchor ? r.bottom + 6 : y;
left = Math.max(8, Math.min(left, innerWidth - w - 8));
if (top + h > innerHeight - 8) top = anchor ? r.top - h - 6 : innerHeight - h - 8;
m.style.left = `${left}px`;
m.style.top = `${top}px`;
replay(m, "open");
}
function hideMenu() {
$("menu").hidden = true;
}
document.addEventListener("mousedown", (e) => !e.target.closest("#menu") && hideMenu());
document.addEventListener("keydown", (e) => e.key === "Escape" && hideMenu());
window.addEventListener("blur", hideMenu);
const cmd = (id) => ({ disabled: $(id).disabled, run: () => $(id).onclick() });
// ---- app.js 818-823 ----
/** Restarts a CSS animation class. */
function replay(node, cls) {
node.classList.remove(cls);
void node.offsetWidth;
node.classList.add(cls);
}
// ---- app.js 1051-1063 ----
/** Editors with unsaved changes (their state lives in the window, a reload would drop it). */
function unsavedWork() {
const out = [];
if (window.FXE && window.FXE.text !== window.FXE.saved) out.push("FX");
if (window.MAP?.dirty) out.push("Map");
if (window.WND?.dirty) out.push("Wnd");
if (typeof IMS !== "undefined" && IMS.dirty) out.push("UI image sets"); // (imageset.js: a script-level const)
if (window.MDL?.dirtyItems?.().length) out.push("3D");
if (window.ANS?.clip?.dirty) out.push("Anim");
if (window.COS?.dirty) out.push("Costume");
if (window.TEX?.dirty) out.push("Texture");
return out;
}
// ---- web overrides (src/legacy/host-web-after.js), loaded after the app.js copies ----
// Same as the desktop, but an unpacked game folder (no .iop) is a "dir" source, not "packs".
function profileSource() {
return WS?.gameDir ? { kind: WS.sourceKind === "dir" ? "dir" : "packs", folder: WS.gameDir, name: WS.name } : null;
}
function profileSourceOption() {
const s = profileSource();
return s ? [{ value: s, title: `${WS.name} — game folder`, sub: s.kind === "dir" ? "unpacked files" : "its packs" }] : [];
}
File diff suppressed because it is too large. Load diff
+754
View File
@@ -0,0 +1,754 @@
"use strict";
// Anim Studio maths, no UI (also runs under node for tests):
// Q quaternions the io3DEngine way (x, y, z, w; world = qmul(parentWorld, local), qrot rotates)
// clips an editable .ani: tracks of keys { t (ms), p, q, raw, rv } — raw / rv are the stored rotation
// words and the .ani version they belong to, written back untouched while the key isn't edited
// retarget any rig → a Lost Saga skeleton: world rotations moved over with the rest poses aligned,
// the root scaled by leg length, the feet put where the source's feet are (two-bone IK) so
// they stay on the floor, then baked to keys and thinned
// ops trim / cut / speed / reverse / insert / loop / mirror on clips
const AnimKit = (() => {
// =====================================================================
// quaternions + vectors
// =====================================================================
const Q = {
id: () => [0, 0, 0, 1],
mul: (a, b) => [
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
],
conj: (q) => [-q[0], -q[1], -q[2], q[3]],
norm(q) {
const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
return [q[0] / l, q[1] / l, q[2] / l, q[3] / l];
},
rot(q, v) {
const [x, y, z, w] = q;
const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0];
const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux;
return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)];
},
axis(a, ang) {
const l = Math.hypot(a[0], a[1], a[2]) || 1;
const s = Math.sin(ang / 2) / l;
return [a[0] * s, a[1] * s, a[2] * s, Math.cos(ang / 2)];
},
/** Shortest rotation taking direction u to direction v. */
fromTo(u, v) {
const a = V.norm(u), b = V.norm(v);
const d = V.dot(a, b);
if (d > 1 - 1e-9) return [0, 0, 0, 1];
if (d < -1 + 1e-9) {
const ax = Math.abs(a[0]) < 0.9 ? V.cross(a, [1, 0, 0]) : V.cross(a, [0, 1, 0]);
return Q.axis(ax, Math.PI);
}
const c = V.cross(a, b);
return Q.norm([c[0], c[1], c[2], 1 + d]);
},
slerp(a, b, f) {
let [bx, by, bz, bw] = b;
let cos = a[0] * bx + a[1] * by + a[2] * bz + a[3] * bw;
if (cos < 0) {
cos = -cos;
bx = -bx; by = -by; bz = -bz; bw = -bw;
}
let s0, s1;
if (cos > 0.9995) {
s0 = 1 - f;
s1 = f;
} else {
const th = Math.acos(cos);
const sin = Math.sin(th);
s0 = Math.sin((1 - f) * th) / sin;
s1 = Math.sin(f * th) / sin;
}
return Q.norm([s0 * a[0] + s1 * bx, s0 * a[1] + s1 * by, s0 * a[2] + s1 * bz, s0 * a[3] + s1 * bw]);
},
/** Angle between two rotations (radians). */
angle(a, b) {
const d = Math.abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]);
return 2 * Math.acos(Math.min(1, d));
},
/** Rotation whose basis maps the rest frame (x, y, z) onto the current one (orthonormal). */
fromBases(rest, cur) {
const m = [0, 0, 0, 0, 0, 0, 0, 0, 0]; // row-major R, R·r_i = c_i
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) m[j * 3 + k] += cur[i][j] * rest[i][k];
return Q.fromMat(m);
},
fromMat(m) {
const [m00, m01, m02, m10, m11, m12, m20, m21, m22] = m;
const tr = m00 + m11 + m22;
let q;
if (tr > 0) {
const s = Math.sqrt(tr + 1) * 2;
q = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
} else if (m00 > m11 && m00 > m22) {
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
q = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
} else if (m11 > m22) {
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
q = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
} else {
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
q = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
}
return Q.norm(q);
},
/** XYZ Euler angles in degrees (q = Rz·Ry·Rx applied to column vectors) and back. */
toEuler(q) {
const [x, y, z, w] = q;
const sx = 2 * (w * x + y * z), cx = 1 - 2 * (x * x + y * y);
const sy = Math.max(-1, Math.min(1, 2 * (w * y - z * x)));
const sz = 2 * (w * z + x * y), cz = 1 - 2 * (y * y + z * z);
const d = 180 / Math.PI;
return [Math.atan2(sx, cx) * d, Math.asin(sy) * d, Math.atan2(sz, cz) * d];
},
fromEuler(e) {
const r = Math.PI / 180;
return Q.mul(Q.axis([0, 0, 1], e[2] * r), Q.mul(Q.axis([0, 1, 0], e[1] * r), Q.axis([1, 0, 0], e[0] * r)));
},
};
const V = {
add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
scale: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],
len: (a) => Math.hypot(a[0], a[1], a[2]),
norm(a) {
const l = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / l, a[1] / l, a[2] / l];
},
lerp: (a, b, f) => [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f],
};
/** Orthonormal frame from a direction and a reference vector (the bend plane). */
function frame(dir, ref) {
const x = V.norm(dir);
let z = V.cross(x, ref);
if (V.len(z) < 1e-6) z = V.cross(x, Math.abs(x[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]);
z = V.norm(z);
return [x, V.cross(z, x), z];
}
// =====================================================================
// clips
// =====================================================================
const S = (text) => ({ raw: null, text });
const nameOf = (s) => (typeof s === "string" ? s : s.text);
/** LSF.parseAni result → editable clip. */
function fromLsf(a, name = "") {
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4;
return {
name,
version: a.version,
total: a.total,
events: a.events.map((e) => ({ type: e.type, sub: e.sub, time: e.time })),
tracks: a.tracks.map((t) => {
const keys = [];
for (let k = 0; k < t.times.length; k++)
keys.push({
t: t.times[k],
p: [t.trans[k * 3], t.trans[k * 3 + 1], t.trans[k * 3 + 2]],
q: [t.rot[k * 4], t.rot[k * 4 + 1], t.rot[k * 4 + 2], t.rot[k * 4 + 3]],
raw: Array.from(t.rawRot.subarray(k * words, k * words + words)),
rv: a.version,
});
return { name: t.name, weight: t.weight, keys };
}),
trailing: a.trailing,
};
}
/** Clip → the object LSF.writeAni writes (untouched keys keep their stored words). */
function toLsf(c) {
const words = c.version === 4001 ? 1 : c.version === 4002 ? 2 : 4;
return {
version: c.version,
events: c.events.map((e) => ({ type: typeof e.type === "string" ? S(e.type) : e.type, sub: typeof e.sub === "string" ? S(e.sub) : e.sub, time: e.time })),
total: Math.max(0, Math.round(c.total)),
tracks: c.tracks.map((t) => {
const keys = t.keys.slice().sort((a, b) => a.t - b.t);
const n = keys.length;
const out = { name: typeof t.name === "string" ? S(t.name) : t.name, weight: t.weight, times: new Int32Array(n), trans: new Float32Array(n * 3), rot: new Float32Array(n * 4), rawRot: new Uint32Array(n * words) };
keys.forEach((k, i) => {
out.times[i] = Math.round(k.t);
out.trans.set(k.p, i * 3);
out.rot.set(k.q, i * 4);
const raw = k.raw && k.rv === c.version && k.raw.length === words ? k.raw : LSF.encodeRot(c.version, k.q);
out.rawRot.set(raw, i * words);
});
return out;
}),
trailing: c.trailing,
};
}
function emptyClip(name, version = 4002, total = 1000) {
return { name, version, total, events: [], tracks: [], trailing: undefined };
}
const key = (t, p, q) => ({ t, p: p.slice(), q: Q.norm(q), raw: null, rv: 0 });
const copyKey = (k, t = k.t) => ({ t, p: k.p.slice(), q: k.q.slice(), raw: k.raw ? k.raw.slice() : null, rv: k.rv });
/** Interpolated key value at t (ms): lerp position, slerp rotation, held at the ends. */
function sample(keys, t) {
const n = keys.length;
if (!n) return null;
if (t <= keys[0].t) return { p: keys[0].p.slice(), q: keys[0].q.slice() };
if (t >= keys[n - 1].t) return { p: keys[n - 1].p.slice(), q: keys[n - 1].q.slice() };
let lo = 0, hi = n - 1;
while (hi - lo > 1) {
const m = (lo + hi) >> 1;
if (keys[m].t <= t) lo = m;
else hi = m;
}
const a = keys[lo], b = keys[hi];
const f = b.t > a.t ? (t - a.t) / (b.t - a.t) : 1;
return { p: V.lerp(a.p, b.p, f), q: Q.slerp(a.q, b.q, f) };
}
/** Puts a key at t (replacing one within 0.5 ms). → the key */
function setKey(track, t, p, q) {
t = Math.round(t);
let i = track.keys.findIndex((k) => Math.abs(k.t - t) < 0.5);
const k = key(t, p, q);
if (i >= 0) track.keys[i] = k;
else {
i = track.keys.findIndex((x) => x.t > t);
track.keys.splice(i < 0 ? track.keys.length : i, 0, k);
}
return k;
}
const sortKeys = (tr) => tr.keys.sort((a, b) => a.t - b.t);
const allTimes = (c) => [...new Set([0, Math.round(c.total), ...c.tracks.flatMap((t) => t.keys.map((k) => Math.round(k.t)))])].sort((a, b) => a - b);
/** Drops keys that the neighbours already give (within the tolerances). Keeps first / last. */
function thin(keys, rotTol = 0.15 * (Math.PI / 180), posTol = 0.02) {
if (keys.length <= 2) {
if (keys.length === 2 && Q.angle(keys[0].q, keys[1].q) < rotTol && V.len(V.sub(keys[0].p, keys[1].p)) < posTol) return [keys[0]];
return keys;
}
const out = [keys[0]];
let a = 0;
for (let i = 1; i < keys.length - 1; i++) {
// can a → i+1 stand in for every key between them?
const A = keys[a], B = keys[i + 1];
let ok = true;
for (let j = a + 1; j <= i && ok; j++) {
const f = (keys[j].t - A.t) / Math.max(1e-6, B.t - A.t);
ok = Q.angle(Q.slerp(A.q, B.q, f), keys[j].q) < rotTol && V.len(V.sub(V.lerp(A.p, B.p, f), keys[j].p)) < posTol;
}
if (!ok) {
out.push(keys[i]);
a = i;
}
}
const last = keys[keys.length - 1];
if (out.length === 1 && Q.angle(out[0].q, last.q) < rotTol && V.len(V.sub(out[0].p, last.p)) < posTol) return out;
out.push(last);
return out;
}
// =====================================================================
// Lost Saga skeleton posing (SkinKit.prepare / link'ed skeleton + a clip)
// =====================================================================
function trackMap(sk, clip) {
return clip ? clip.tracks.map((t) => sk.byName.get(nameOf(t.name).toLowerCase()) ?? -1) : [];
}
/** Local position / rotation of every bone at t (track positions × the link rate, like the client). */
function locals(sk, clip, map, t, override) {
const bones = sk.data.bones;
const lp = bones.map((b) => b.localPos.slice());
const lr = bones.map((b) => b.localRot.slice());
if (clip)
clip.tracks.forEach((tr, ti) => {
const bi = map[ti];
if (bi < 0 || tr.weight <= 0) return;
const s = sample(tr.keys, t);
if (!s) return;
const r = sk.rate[bi];
lp[bi] = [s.p[0] * r, s.p[1] * r, s.p[2] * r];
lr[bi] = s.q;
});
if (override) for (const [bi, o] of override) {
if (o.p) lp[bi] = V.scale(o.p, sk.rate[bi]);
if (o.q) lr[bi] = o.q;
}
return { lp, lr };
}
function world(sk, lp, lr) {
const n = lp.length;
const wp = new Array(n), wr = new Array(n);
for (const i of sk.order) {
const pi = sk.parent[i];
if (pi < 0) {
wr[i] = lr[i];
wp[i] = lp[i];
} else {
wr[i] = Q.mul(wr[pi], lr[i]);
wp[i] = V.add(wp[pi], Q.rot(wr[pi], lp[i]));
}
}
return { wp, wr };
}
function lsPose(sk, clip, map, t, override) {
const l = locals(sk, clip, map, t, override);
return Object.assign(world(sk, l.lp, l.lr), l);
}
/** A Lost Saga skeleton + clip as a retarget source. */
function lsRig(sk, clip) {
const map = trackMap(sk, clip);
const rest = lsPose(sk, null, null, 0);
return {
kind: "ls",
names: sk.data.bones.map((b) => b.name.text),
parent: sk.parent,
restWp: rest.wp,
restWr: rest.wr,
restLp: sk.data.bones.map((b) => b.localPos.slice()),
total: clip.total,
times: allTimes(clip),
events: clip.events,
pose: (t) => lsPose(sk, clip, map, t),
};
}
/** Knee position of a two-bone chain from H reaching T (lengths a, b), bending towards pole. */
function twoBone(H, T, a, b, pole) {
const d0 = V.sub(T, H);
let d = V.len(d0);
const u = d > 1e-6 ? V.scale(d0, 1 / d) : [0, -1, 0];
d = Math.max(Math.abs(a - b) + 1e-4, Math.min(a + b - 1e-4, d));
const cosA = (a * a + d * d - b * b) / (2 * a * d);
const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
let nrm = V.sub(pole, V.scale(u, V.dot(pole, u)));
if (V.len(nrm) < 1e-6) nrm = Math.abs(u[1]) < 0.9 ? V.cross(u, [0, 1, 0]) : [0, 0, -1];
nrm = V.norm(nrm);
return V.add(H, V.add(V.scale(u, a * cosA), V.scale(nrm, a * sinA)));
}
// =====================================================================
// retarget: rig → Lost Saga skeleton
// =====================================================================
/** Which child a bone points at, for aligning rest poses (first match wins). */
const AIM = [/ head$/, / neck$/, / spine\d?$/, / calf$/, / foot$/, / toe0$/, / forearm$/, / hand$/, / upperarm$/, / finger2$/, / finger\d1$/, / finger\d2$/];
/**
* Default bone map target bone index → source bone index (-1 = keeps its rest local rotation).
* Names are matched case-insensitively.
*/
function defaultMap(src, sk) {
const names = sk.data.bones.map((b) => b.name.text);
const by = new Map(src.names.map((n, i) => [n.toLowerCase(), i]));
return names.map((n) => by.get(n.toLowerCase()) ?? -1);
}
function legChain(sk, side) {
const b = (n) => sk.byName.get(`bip01 ${side} ${n}`) ?? -1;
const c = { hip: b("thigh"), knee: b("calf"), ankle: b("foot"), toe: b("toe0") };
return c.hip >= 0 && c.knee >= 0 && c.ankle >= 0 ? c : null;
}
/**
* Bakes src onto the Lost Saga skeleton sk. opts:
* map target bone → source bone (defaultMap)
* root target root bone (Bip01) / its source bone (the map's)
* scale source → target units (default: leg length ratio)
* footIK put the ankles where the source's ankles are (no sliding, feet on the floor)
* ground (no foot IK) move the root each frame so the lowest foot is as high as the source's
* inPlace drop the root's horizontal travel
* start/end source range (ms), fps of the baked keys, version, tolerance (degrees) for thinning
* → { clip, info }
*/
function retarget(src, sk, opts = {}) {
const map = opts.map || defaultMap(src, sk);
const bones = sk.data.bones;
const n = bones.length;
const rest = lsPose(sk, null, null, 0);
const root = sk.byName.get("bip01") ?? sk.order[0];
const rootSrc = map[root];
if (rootSrc < 0) throw new Error("The root bone (Bip01) has no source bone.");
const legs = ["l", "r"].map((s) => legChain(sk, s)).filter(Boolean);
// scale: leg length (hip → ankle) of the two rest poses, else heights
let scale = opts.scale;
if (!scale) {
const lens = legs
.map((l) => [l, map[l.hip], map[l.knee], map[l.ankle]])
.filter(([, a, b, c]) => a >= 0 && b >= 0 && c >= 0)
.map(([l, a, b, c]) => {
const tl = V.len(V.sub(rest.wp[l.knee], rest.wp[l.hip])) + V.len(V.sub(rest.wp[l.ankle], rest.wp[l.knee]));
const sl = V.len(V.sub(src.restWp[b], src.restWp[a])) + V.len(V.sub(src.restWp[c], src.restWp[b]));
return tl / sl;
});
scale = lens.length ? lens.reduce((a, b) => a + b) / lens.length : rest.wp[root][1] / Math.max(1e-6, src.restWp[rootSrc][1]) || 1;
}
// rest alignment: A[b] turns the target's rest direction onto the source's rest direction
const A = new Array(n).fill(null);
const childs = bones.map(() => []);
sk.parent.forEach((p, i) => p >= 0 && childs[p].push(i));
for (const b of sk.order) {
if (map[b] < 0) continue;
// feet stand flat in both rest poses: the same posture, nothing to align
if (/ (foot|toe\d*)$/.test(bones[b].name.text.toLowerCase())) {
A[b] = Q.id();
continue;
}
let aim = -1;
for (const re of AIM) {
aim = childs[b].find((c) => map[c] >= 0 && re.test(bones[c].name.text.toLowerCase())) ?? -1;
if (aim >= 0) break;
}
if (aim >= 0) {
const dt = V.sub(rest.wp[aim], rest.wp[b]);
const ds = V.sub(src.restWp[map[aim]], src.restWp[map[b]]);
// the source's rest direction is in its own rest frame; compare them in world
if (V.len(dt) > 1e-4 && V.len(ds) > 1e-4) A[b] = Q.fromTo(dt, ds);
}
if (!A[b]) {
// finger tips, the toe, the head: the alignment of the nearest aligned parent
let p = sk.parent[b];
while (p >= 0 && !A[p]) p = sk.parent[p];
A[b] = p >= 0 && /finger|toe/.test(bones[b].name.text.toLowerCase()) ? A[p] : Q.id();
}
}
if (opts.alignRoot === false) A[root] = Q.id();
const restLR = bones.map((b) => b.localRot.slice());
const restLP = bones.map((b) => b.localPos.slice());
// local positions: the skeleton's own, or (Lost Saga sources) the source's, scaled to the bone
const lpOf = (b, S) => {
const m = map[b];
if (src.kind !== "ls" || m < 0 || !S.lp) return restLP[b];
const l = V.len(src.restLp[m]);
return V.scale(S.lp[m], l > 1e-6 ? V.len(restLP[b]) / l : 1);
};
/** Target world rotations + positions for a source pose; rootPos placed by the caller. */
function solve(S, rootPos) {
const wr = new Array(n), wp = new Array(n);
for (const b of sk.order) {
const pi = sk.parent[b];
const m = map[b];
if (m >= 0) {
const delta = Q.mul(S.wr[m], Q.conj(src.restWr[m]));
wr[b] = Q.norm(Q.mul(delta, Q.mul(A[b], rest.wr[b])));
} else wr[b] = pi < 0 ? restLR[b] : Q.mul(wr[pi], restLR[b]);
const lp = b === root ? rootPos : lpOf(b, S);
wp[b] = pi < 0 ? lp : V.add(wp[pi], Q.rot(wr[pi], lp));
}
return { wr, wp, S };
}
// where the target root sits for a source pose: its rest place + the scaled source travel, lifted
// so the aligned rest pose's feet stand where the target's rest feet do
const srcRest = { wr: src.restWr, wp: src.restWp };
const aligned = solve(srcRest, rest.wp[root]);
const footBones = legs.map((l) => l.ankle);
const lift = footBones.length ? footBones.reduce((s, b) => s + rest.wp[b][1] - aligned.wp[b][1], 0) / footBones.length : 0;
const restRoot = V.add(rest.wp[root], [0, lift, 0]);
const restAligned = solve(srcRest, restRoot);
// the body moves with the source's body joint (rootPos)
const posSrc = src.rootPos >= 0 ? src.rootPos : rootSrc;
const place = (p) => V.add(restRoot, V.scale(V.sub(p, src.restWp[posSrc]), scale));
// where each target ankle goes: the source ankle's travel from rest, scaled, from the aligned rest
const ikLegs = legs
.filter((l) => map[l.ankle] >= 0)
.map((l) => ({ ...l, a: V.len(V.sub(rest.wp[l.knee], rest.wp[l.hip])), b: V.len(V.sub(rest.wp[l.ankle], rest.wp[l.knee])), src: map[l.ankle], srcToe: l.toe >= 0 ? map[l.toe] : -1 }));
const footLift = (P, l, ref) => Math.min(P.wp[l.ankle][1] - ref.wp[l.ankle][1], l.toe >= 0 ? P.wp[l.toe][1] - ref.wp[l.toe][1] : Infinity);
const start = Math.max(0, opts.start ?? 0);
const end = Math.min(src.total, opts.end ?? src.total);
const fps = opts.fps || 30;
const times = [];
for (let t = start; t < end - 1e-6; t += 1000 / fps) times.push(t);
times.push(end);
const keys = bones.map(() => []);
let dropped = 0, maxDrop = 0, jumped = 0;
const jump = opts.jump === "on" || opts.jump === true;
for (const ts of times) {
const S = src.pose(ts);
let rp = place(S.wp[posSrc]);
// jump: both source feet off the floor while the source body stays put → the body goes up
// by the lower ankle's height (the feet stay where they are, the legs unfold to standing)
// jumpScale > 1 also lifts the feet: the whole jump gets higher
let extraY = 0;
if (jump && ikLegs.length) {
const up = Math.min(...ikLegs.map((l) => S.wp[l.src][1] - src.restWp[l.src][1]));
const dz = 0.15; // source units: heels / tiptoes aren't a jump
if (up > dz) {
extraY = (up - dz) * scale * Math.max(0, (opts.jumpScale ?? 1) - 1);
rp = V.add(rp, [0, (up - dz) * scale + extraY, 0]);
jumped++;
}
}
// in place: the root's travel over the floor comes off the root and the feet alike
const travel = opts.inPlace ? [rp[0] - restRoot[0], 0, rp[2] - restRoot[2]] : [0, 0, 0];
rp = V.sub(rp, travel);
let P = solve(S, rp);
if (opts.footIK !== false && ikLegs.length) {
const targets = ikLegs.map((l) => V.add(V.sub(V.add(restAligned.wp[l.ankle], V.scale(V.sub(S.wp[l.src], src.restWp[l.src]), scale)), travel), [0, extraY, 0]));
// a planted foot the leg can't reach: lower the body just enough (else the foot would hang);
// feet in the air (kicks) just get a straight leg
let drop = 0;
ikLegs.forEach((l, i) => {
if (targets[i][1] - restAligned.wp[l.ankle][1] > 0.6 * scale) return;
const d = V.sub(targets[i], P.wp[l.hip]);
const L = (l.a + l.b) * 0.998;
const h2 = d[0] * d[0] + d[2] * d[2];
if (V.len(d) > L && h2 < L * L) drop = Math.max(drop, Math.min(0.25 * L, -Math.sqrt(L * L - h2) - d[1]));
});
if (drop > 1e-4) {
rp = V.sub(rp, [0, drop, 0]);
P = solve(S, rp);
dropped++;
maxDrop = Math.max(maxDrop, drop);
}
ikLegs.forEach((l, i) => legIK(P, l, targets[i]));
P = refk(P);
// toes as high above the floor as the source's (a Lost Saga foot is about twice as long as
// the source's, scaled: the same tilt would push its toes through the floor)
if (opts.toeFit !== false) {
let moved = false;
for (const l of ikLegs) {
if (l.toe < 0 || l.srcToe < 0) continue;
const want = restAligned.wp[l.toe][1] + (S.wp[l.srcToe][1] - src.restWp[l.srcToe][1]) * scale;
const A0 = P.wp[l.ankle];
const v = V.sub(P.wp[l.toe], A0);
const len = V.len(v);
const flat = Math.hypot(v[0], v[2]);
if (len < 1e-4 || flat < 1e-4) continue;
const pitch = Math.asin(Math.max(-1, Math.min(1, v[1] / len)));
const goal = Math.asin(Math.max(-1, Math.min(1, (want - A0[1]) / len)));
if (Math.abs(goal - pitch) < 1e-4) continue;
// turn about the horizontal axis across the foot, whichever sign raises / lowers the toe
const axis = V.norm(V.cross([v[0], 0, v[2]], [0, 1, 0]));
let D = Q.axis(axis, goal - pitch);
if (Math.sign(Q.rot(D, v)[1] - v[1]) !== Math.sign(goal - pitch)) D = Q.axis(axis, pitch - goal);
P.wr[l.ankle] = Q.norm(Q.mul(D, P.wr[l.ankle]));
moved = true;
}
if (moved) P = refk(P);
}
} else if (opts.ground !== false && ikLegs.length) {
// the lowest foot as high above its rest as the source's lowest foot (scaled)
const want = Math.min(...ikLegs.map((l) => (S.wp[l.src][1] - src.restWp[l.src][1]) * scale));
const have = Math.min(...ikLegs.map((l) => footLift(P, l, restAligned)));
if (isFinite(want) && isFinite(have)) P = solve(S, V.add(rp, [0, want - have, 0]));
}
const t = Math.round(ts - start);
for (let b = 0; b < n; b++) {
const pi = sk.parent[b];
const q = pi < 0 ? P.wr[b] : Q.norm(Q.mul(Q.conj(P.wr[pi]), P.wr[b]));
const lp = b === root ? (pi < 0 ? P.wp[b] : Q.rot(Q.conj(P.wr[pi]), V.sub(P.wp[b], P.wp[pi]))) : lpOf(b, S);
keys[b].push(key(t, V.scale(lp, 1 / sk.rate[b]), q));
}
}
// two-bone IK on the target: swing thigh and calf (twist kept), foot keeps its world rotation
function legIK(P, l, T) {
const H = P.wp[l.hip];
let pole = V.sub(P.wp[l.knee], V.lerp(H, P.wp[l.ankle], 0.5));
const K = twoBone(H, T, l.a, l.b, V.len(pole) > 1e-4 ? pole : Q.rot(P.wr[root], [0, 0, -1]));
const footW = P.wr[l.ankle];
const sw1 = Q.fromTo(V.sub(P.wp[l.knee], H), V.sub(K, H));
P.wr[l.hip] = Q.norm(Q.mul(sw1, P.wr[l.hip]));
const kneeDir = Q.rot(sw1, V.sub(P.wp[l.ankle], P.wp[l.knee]));
const A2 = V.add(K, V.scale(V.norm(V.sub(T, K)), l.b));
const sw2 = Q.fromTo(kneeDir, V.sub(A2, K));
P.wr[l.knee] = Q.norm(Q.mul(sw2, Q.mul(sw1, P.wr[l.knee])));
P.wr[l.ankle] = footW;
// the foot's children (toe) keep their world rotation relative to the foot: nothing to do,
// their local rotations are taken from the world ones below
}
// world rotations after IK → positions again
function refk(P) {
const wp = new Array(n);
for (const b of sk.order) {
const pi = sk.parent[b];
wp[b] = pi < 0 ? P.wp[b] : V.add(wp[pi], Q.rot(P.wr[pi], lpOf(b, P.S)));
}
return { wr: P.wr, wp, S: P.S };
}
const tol = (opts.tolerance ?? 0.15) * (Math.PI / 180);
const clip = emptyClip(opts.name || "retarget", opts.version || 4002, Math.round(end - start));
for (let b = 0; b < n; b++) {
const k = thin(keys[b], tol, 0.02);
clip.tracks.push({ name: S(bones[b].name.text), weight: 1, keys: k });
}
clip.events = (src.events || []).filter((e) => e.time >= start && e.time <= end).map((e) => ({ ...e, time: e.time - start }));
const mapped = map.filter((m) => m >= 0).length;
return { clip, info: { scale, mapped, bones: n, frames: times.length, keys: clip.tracks.reduce((s, t) => s + t.keys.length, 0), lift, restRoot, srcRoot: src.restWp[posSrc], dropped, maxDrop, jump, jumped } };
}
// =====================================================================
// clip operations (range in ms; keys outside keep their stored bytes)
// =====================================================================
/** Makes sure every track has a key exactly at t (sampled), so a cut there keeps the shape. */
function splitAt(c, t) {
t = Math.round(t);
for (const tr of c.tracks) {
if (!tr.keys.length || tr.keys.some((k) => Math.abs(k.t - t) < 0.5)) continue;
if (t <= tr.keys[0].t || t >= tr.keys[tr.keys.length - 1].t) continue;
const s = sample(tr.keys, t);
tr.keys.push(key(t, s.p, s.q));
sortKeys(tr);
}
}
const evShift = (c, fn) => (c.events = c.events.map((e) => ({ ...e, time: fn(e.time) })).filter((e) => e.time !== null));
/** Keeps [a, b] only, starting at 0. */
function trim(c, a, b) {
for (const tr of c.tracks) {
if (!tr.keys.length) continue;
const sa = sample(tr.keys, a), sb = sample(tr.keys, b);
const mid = tr.keys.filter((k) => k.t > a + 0.5 && k.t < b - 0.5).map((k) => copyKey(k, k.t - a));
const ka = tr.keys.find((k) => Math.abs(k.t - a) < 0.5);
const kb = tr.keys.find((k) => Math.abs(k.t - b) < 0.5);
tr.keys = [ka ? copyKey(ka, 0) : key(0, sa.p, sa.q), ...mid];
if (b - a > 0.5) tr.keys.push(kb ? copyKey(kb, b - a) : key(Math.round(b - a), sb.p, sb.q));
if (tr.keys.length === 2 && tr.keys[0].raw && tr.keys[1].raw && tr.keys[0].raw.join() === tr.keys[1].raw.join() && V.len(V.sub(tr.keys[0].p, tr.keys[1].p)) < 1e-6) tr.keys.pop();
}
evShift(c, (t) => (t >= a && t <= b ? t - a : null));
c.total = Math.round(b - a);
}
/** Removes [a, b]; what was after b follows a straight away. */
function cut(c, a, b) {
splitAt(c, a);
splitAt(c, b);
const d = b - a;
for (const tr of c.tracks) {
const keep = tr.keys.filter((k) => k.t <= a + 0.5 || k.t >= b - 0.5);
// two keys at a (the one at a and the one from b): keep the later pose
tr.keys = keep.map((k) => (k.t >= b - 0.5 ? copyKey(k, Math.round(k.t - d)) : k)).filter((k, i, arr) => !(Math.abs(k.t - a) < 0.5 && arr.some((o, j) => j > i && Math.abs(o.t - a) < 0.5)));
sortKeys(tr);
}
evShift(c, (t) => (t < a ? t : t > b ? t - d : null));
c.total = Math.round(c.total - d);
}
/** Plays [a, b] at `speed` × (2 = twice as fast); later keys move with it. */
function speed(c, a, b, s) {
splitAt(c, a);
splitAt(c, b);
const d = (b - a) / s - (b - a);
const map = (t) => (t <= a ? t : t <= b ? a + (t - a) / s : t + d);
for (const tr of c.tracks) {
tr.keys = tr.keys.map((k) => (k.t > a ? copyKey(k, Math.round(map(k.t))) : k));
sortKeys(tr);
}
evShift(c, map);
c.total = Math.round(c.total + d);
}
/** Plays [a, b] backwards. */
function reverse(c, a, b) {
splitAt(c, a);
splitAt(c, b);
for (const tr of c.tracks) {
tr.keys = tr.keys.map((k) => (k.t >= a - 0.5 && k.t <= b + 0.5 ? copyKey(k, Math.round(a + b - k.t)) : k));
sortKeys(tr);
}
evShift(c, (t) => (t >= a && t <= b ? a + b - t : t));
}
/**
* Puts clip `o` in at time `at` (append: at = c.total), `gap` ms to blend into it and out of it.
* Bones only one of them moves hold their pose (or the skeleton's rest, `bind(name)` → {p, q}).
*/
function insert(c, o, at, gap = 0, bind = () => null) {
at = Math.round(Math.max(0, Math.min(c.total, at)));
splitAt(c, at);
const len = Math.round(o.total + 2 * gap);
const names = new Map(c.tracks.map((t) => [nameOf(t.name).toLowerCase(), t]));
for (const tr of c.tracks) {
const ot = o.tracks.find((x) => nameOf(x.name).toLowerCase() === nameOf(tr.name).toLowerCase());
const before = tr.keys.filter((k) => k.t <= at + 0.5);
const after = tr.keys.filter((k) => k.t > at + 0.5).map((k) => copyKey(k, k.t + len));
let mid;
if (ot?.keys.length) mid = ot.keys.map((k) => copyKey(k, k.t + at + gap));
else {
const s = sample(tr.keys, at);
mid = s ? [key(at + gap, s.p, s.q), key(at + gap + Math.round(o.total), s.p, s.q)] : [];
}
if (gap === 0 && before.length && mid.length && Math.abs(before[before.length - 1].t - mid[0].t) < 0.5) before.pop();
tr.keys = [...before, ...mid, ...after];
if (!after.length && at < c.total) {
const s = sample(before, at);
if (s) tr.keys.push(key(at + len, s.p, s.q));
}
sortKeys(tr);
}
for (const ot of o.tracks) {
if (names.has(nameOf(ot.name).toLowerCase()) || !ot.keys.length) continue;
const b = bind(nameOf(ot.name)) || { p: ot.keys[0].p, q: ot.keys[0].q };
const keys = [];
if (at + gap > 0) keys.push(key(0, b.p, b.q), key(at, b.p, b.q));
keys.push(...ot.keys.map((k) => copyKey(k, k.t + at + gap)));
if (at < c.total) keys.push(key(at + len, b.p, b.q), key(c.total + len, b.p, b.q));
c.tracks.push({ name: ot.name, weight: ot.weight, keys: keys.filter((k, i, a) => !a.slice(i + 1).some((x) => Math.abs(x.t - k.t) < 0.5)) });
}
evShift(c, (t) => (t <= at ? t : t + len));
c.events.push(...o.events.map((e) => ({ ...e, time: e.time + at + gap })));
c.total = Math.round(c.total + len);
}
/** Last pose = first pose on every track (a key at the end, the first key's stored bytes). */
function makeLoop(c) {
const end = Math.round(c.total);
for (const tr of c.tracks) {
if (tr.keys.length < 2) continue;
tr.keys = tr.keys.filter((k) => k.t < end - 0.5);
tr.keys.push(copyKey(tr.keys[0], end));
}
}
/** "Bip01 L Thigh" ↔ "Bip01 R Thigh" (also left/right, _l/_r). */
function mirrorName(name) {
const swaps = [[/(^|[ _.])L([ _.]|$)/, "$1R$2"], [/(^|[ _.])R([ _.]|$)/, "$1L$2"], [/left/i, "right"], [/right/i, "left"]];
for (const [re, to] of swaps) if (re.test(name)) return name.replace(re, to);
return name;
}
/** Mirrors the whole clip left ↔ right across the plane x = 0 (axis "x") or z = 0 ("z"). */
function mirror(c, sk, axis = "x") {
const ax = axis === "z" ? 2 : 0;
const flip = (v) => v.map((x, i) => (i === ax ? -x : x));
const flipQ = (q) => (ax === 0 ? [q[0], -q[1], -q[2], q[3]] : [-q[0], -q[1], q[2], q[3]]);
const bones = sk.data.bones;
const n = bones.length;
const partner = bones.map((b) => sk.byName.get(mirrorName(b.name.text).toLowerCase()) ?? -1).map((p, i) => (p >= 0 ? p : i));
const map = trackMap(sk, c);
const rest = lsPose(sk, null, null, 0);
// W'(b) = M·W(b')·M · (M·Wrest(b')·M)⁻¹ · Wrest(b); positions mirrored
const corr = bones.map((_, b) => Q.mul(Q.conj(flipQ(rest.wr[partner[b]])), rest.wr[b]));
const times = allTimes(c);
const keyed = new Set(map.filter((x) => x >= 0));
const want = [...new Set([...keyed, ...[...keyed].map((b) => partner[b])])];
const out = new Map(want.map((b) => [b, []]));
for (const t of times) {
const P = lsPose(sk, c, map, t);
const wr = new Array(n), wp = new Array(n);
for (let b = 0; b < n; b++) {
wr[b] = Q.norm(Q.mul(flipQ(P.wr[partner[b]]), corr[b]));
wp[b] = flip(P.wp[partner[b]]);
}
for (const b of want) {
const pi = sk.parent[b];
const q = pi < 0 ? wr[b] : Q.norm(Q.mul(Q.conj(wr[pi]), wr[b]));
const lp = pi < 0 ? wp[b] : Q.rot(Q.conj(wr[pi]), V.sub(wp[b], wp[pi]));
out.get(b).push(key(t, V.scale(lp, 1 / sk.rate[b]), q));
}
}
const weight = new Map(c.tracks.map((t, i) => [map[i], t.weight]));
const others = c.tracks.filter((_, i) => map[i] < 0);
c.tracks = [...want.map((b) => ({ name: S(bones[b].name.text), weight: weight.get(b) ?? weight.get(partner[b]) ?? 1, keys: thin(out.get(b), 0.05 * (Math.PI / 180), 0.005) })), ...others];
c.events = c.events.map((e) => ({ ...e, sub: e.sub }));
}
return { Q, V, S, nameOf, fromLsf, toLsf, emptyClip, key, copyKey, sample, setKey, sortKeys, allTimes, thin, trackMap, locals, world, lsPose, lsRig, retarget, defaultMap, trim, cut, speed, reverse, insert, makeLoop, mirror, mirrorName, splitAt, twoBone };
})();
if (typeof window !== "undefined") window.AnimKit = AnimKit;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+182
View File
@@ -0,0 +1,182 @@
"use strict";
// Texture decoding for the UI Image Set editor: DDS (DXT1/DXT3/DXT5, 32/24/16-bit RGB masks) and
// TGA (true-colour, RLE) → ImageData. BMP/PNG/JPG go through the browser (createImageBitmap).
const TexDecode = (() => {
const rgb565 = (c) => [((c >> 11) & 31) * 255 / 31, ((c >> 5) & 63) * 255 / 63, (c & 31) * 255 / 31];
/** One 4×4 DXT colour block → 16 RGB(A) values into out (at pixel x,y). */
function colorBlock(src, o, out, w, h, x, y, dxt1, alphaSrc) {
const c0 = src[o] | (src[o + 1] << 8);
const c1 = src[o + 2] | (src[o + 3] << 8);
const p0 = rgb565(c0);
const p1 = rgb565(c1);
const pal = [p0, p1];
if (!dxt1 || c0 > c1) {
pal.push([(2 * p0[0] + p1[0]) / 3, (2 * p0[1] + p1[1]) / 3, (2 * p0[2] + p1[2]) / 3]);
pal.push([(p0[0] + 2 * p1[0]) / 3, (p0[1] + 2 * p1[1]) / 3, (p0[2] + 2 * p1[2]) / 3]);
} else {
pal.push([(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2, (p0[2] + p1[2]) / 2]);
pal.push(null); // transparent black (DXT1 1-bit alpha)
}
const bits = src[o + 4] | (src[o + 5] << 8) | (src[o + 6] << 16) | (src[o + 7] << 24);
for (let i = 0; i < 16; i++) {
const px = x + (i & 3);
const py = y + (i >> 2);
if (px >= w || py >= h) continue;
const c = pal[(bits >>> (2 * i)) & 3];
const d = (py * w + px) * 4;
if (!c) {
out[d] = out[d + 1] = out[d + 2] = out[d + 3] = 0;
continue;
}
out[d] = c[0];
out[d + 1] = c[1];
out[d + 2] = c[2];
out[d + 3] = alphaSrc ? alphaSrc[i] : 255;
}
}
function dxt(src, offset, w, h, kind) {
const out = new Uint8ClampedArray(w * h * 4);
const blockBytes = kind === "DXT1" ? 8 : 16;
let o = offset;
const alpha = new Uint8Array(16);
for (let y = 0; y < h; y += 4) {
for (let x = 0; x < w; x += 4) {
let a = null;
if (kind === "DXT3") {
for (let i = 0; i < 8; i++) {
const b = src[o + i];
alpha[i * 2] = (b & 15) * 17;
alpha[i * 2 + 1] = (b >> 4) * 17;
}
a = alpha;
} else if (kind === "DXT5") {
const a0 = src[o];
const a1 = src[o + 1];
const pal = [a0, a1];
if (a0 > a1) for (let i = 1; i < 7; i++) pal.push(((7 - i) * a0 + i * a1) / 7);
else {
for (let i = 1; i < 5; i++) pal.push(((5 - i) * a0 + i * a1) / 5);
pal.push(0, 255);
}
// 48 bits of 3-bit indices
let lo = src[o + 2] | (src[o + 3] << 8) | (src[o + 4] << 16);
let hi = src[o + 5] | (src[o + 6] << 8) | (src[o + 7] << 16);
for (let i = 0; i < 8; i++) alpha[i] = pal[(lo >> (3 * i)) & 7];
for (let i = 0; i < 8; i++) alpha[8 + i] = pal[(hi >> (3 * i)) & 7];
a = alpha;
}
colorBlock(src, o + (kind === "DXT1" ? 0 : 8), out, w, h, x, y, kind === "DXT1", a);
o += blockBytes;
}
}
return new ImageData(out, w, h);
}
const shiftOf = (mask) => {
if (!mask) return [0, 0];
let s = 0;
while (!((mask >>> s) & 1)) s++;
let n = 0;
while ((mask >>> (s + n)) & 1) n++;
return [s, n];
};
function masked(src, offset, w, h, bpp, masks, hasAlpha) {
const out = new Uint8ClampedArray(w * h * 4);
const bytes = bpp / 8;
const pitch = w * bytes;
const chans = masks.map(shiftOf);
const dv = new DataView(src.buffer, src.byteOffset, src.byteLength);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const o = offset + y * pitch + x * bytes;
const v = bytes === 4 ? dv.getUint32(o, true) : bytes === 3 ? src[o] | (src[o + 1] << 8) | (src[o + 2] << 16) : dv.getUint16(o, true);
const d = (y * w + x) * 4;
for (let c = 0; c < 3; c++) {
const [s, n] = chans[c];
out[d + c] = n ? (((v >>> s) & ((1 << n) - 1)) * 255) / ((1 << n) - 1) : 0;
}
const [as, an] = chans[3];
out[d + 3] = hasAlpha && an ? (((v >>> as) & ((1 << an) - 1)) * 255) / ((1 << an) - 1) : 255;
}
}
return new ImageData(out, w, h);
}
function dds(bytes) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (dv.getUint32(0, true) !== 0x20534444) throw new Error("Not a DDS file");
const h = dv.getUint32(12, true);
const w = dv.getUint32(16, true);
const pf = dv.getUint32(80, true);
const fourCC = String.fromCharCode(bytes[84], bytes[85], bytes[86], bytes[87]);
const offset = 128;
if (pf & 4) {
if (fourCC === "DXT1" || fourCC === "DXT3" || fourCC === "DXT5") return dxt(bytes, offset, w, h, fourCC);
if (fourCC === "DXT2" || fourCC === "DXT4") return dxt(bytes, offset, w, h, fourCC === "DXT2" ? "DXT3" : "DXT5");
throw new Error(`DDS format ${fourCC} isn't supported`);
}
const bpp = dv.getUint32(88, true);
const masks = [dv.getUint32(92, true), dv.getUint32(96, true), dv.getUint32(100, true), dv.getUint32(104, true)];
return masked(bytes, offset, w, h, bpp, masks, !!(pf & 1));
}
function tga(bytes) {
const idLen = bytes[0];
const type = bytes[2];
const w = bytes[12] | (bytes[13] << 8);
const h = bytes[14] | (bytes[15] << 8);
const bpp = bytes[16];
const topDown = (bytes[17] & 0x20) !== 0;
if (![2, 10].includes(type) || ![24, 32].includes(bpp)) throw new Error("Only true-colour TGA (24/32-bit) is supported");
const px = bpp / 8;
const out = new Uint8ClampedArray(w * h * 4);
let o = 18 + idLen;
let i = 0;
const put = (b, g, r, a) => {
const x = i % w;
const y = Math.floor(i / w);
const d = ((topDown ? y : h - 1 - y) * w + x) * 4;
out[d] = r;
out[d + 1] = g;
out[d + 2] = b;
out[d + 3] = a;
i++;
};
while (i < w * h) {
if (type === 2) {
put(bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255);
o += px;
continue;
}
const hdr = bytes[o++];
const n = (hdr & 127) + 1;
if (hdr & 128) {
const c = [bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255];
o += px;
for (let k = 0; k < n; k++) put(...c);
} else {
for (let k = 0; k < n; k++) {
put(bytes[o], bytes[o + 1], bytes[o + 2], px === 4 ? bytes[o + 3] : 255);
o += px;
}
}
}
return new ImageData(out, w, h);
}
/** Any supported texture → ImageBitmap. */
async function decode(bytes, ext) {
const e = (ext || "").toLowerCase();
if (e === "dds") return createImageBitmap(dds(bytes));
if (e === "tga") return createImageBitmap(tga(bytes));
return createImageBitmap(new Blob([bytes]));
}
return { decode, dds, tga };
})();
window.TexDecode = TexDecode;
+547
View File
@@ -0,0 +1,547 @@
"use strict";
// The script is the source of truth. Property edits replace only their own line so comments,
// unknown keys, model/light blocks, and the original byte encoding survive a round trip.
(() => {
const FXE = { src: null, rows: [], where: null, name: "", text: "", saved: "", selected: null, playing: true };
window.FXE = FXE;
let view = null, kit = null, inst = null, last = 0, loadSeq = 0, modelLoadSeq = 0;
const textures = new Map();
let modelViews = [];
const bytesToRaw = (bytes) => {
let out = "";
for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.slice(i, i + 8192));
return out;
};
const rawToBytes = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255);
const ending = () => FXE.text.includes("\r\n") ? "\r\n" : FXE.text.includes("\r") ? "\r" : "\n";
const field = (key, label, type = "text", options = []) => ({ key, label, type, options });
const commonSystem = [
field("quota", "Max particles", "number"), field("cull_each", "Cull each", "bool"),
field("follow_parent", "Follow parent"), field("end_condition", "End condition", "select", ["never_end", "time_limit"]),
field("limit_time", "End time (ms)", "number"), field("start_create_particles", "Start count", "number"),
field("link_biped_name", "Linked bone"), field("random_sync_index", "Random sync", "number"),
];
const schema = {
info: [
["General", field("direction", "Direction", "vec3"), field("collision_box", "Collision box", "text"),
field("skeleton_name", "Skeleton"), field("animation_name", "Animation"), field("update_always", "Update always", "bool")],
],
particle: [
["System", ...commonSystem],
["Appearance", field("texture", "Texture"), field("default_width", "Width min / max", "vec2"),
field("default_height", "Height min / max", "vec2"), field("render_type", "Render type", "select", ["add", "src_alpha", "screen", "lighten", "darken", "subtract", "rev_subtract"]),
field("tex_filtering", "Texture filter", "select", ["bilinear", "point", "trilinear"]), field("depth_check", "Depth check", "bool"),
field("modulate2x", "Modulate ×2", "bool"), field("billboard_type", "Billboard", "select", ["point", "common", "direction"]),
field("common_dir", "Common direction", "vec3"), field("common_dir_none_billboard", "Fixed direction", "bool")],
["Offset", field("pos_offset", "Position", "vec3"), field("rot_offset", "Rotation", "vec4")],
],
model: [
["Model", field("mesh_file", "Mesh file"), ...commonSystem],
["Offset", field("pos_offset", "Position", "vec3"), field("rot_offset", "Rotation", "vec4")],
],
emitter: [
["Emission", field("emit_pos", "Position", "vec3"), field("emit_dir", "Direction", "vec3"),
field("emit_angle", "Angle", "number"), field("emit_rate", "Per second", "number"),
field("emit_speed", "Speed min / max", "vec2"), field("emit_live_time", "Life min / max", "vec2"),
field("emit_wait_time", "Wait (ms)", "number"), field("emit_start_tex_rotation", "Texture rotation", "vec2"),
field("start_rot_axis", "Start rotation axis", "vec3"), field("start_rot_angle", "Start rotation angle", "vec2"),
field("area_size", "Area size", "vec3"), field("inner_size", "Inner size", "vec3")],
["Curves", field("color_rate", "Colour curve"), field("alpha_rate", "Alpha curve")],
],
light: [
["Light", field("type", "Type", "select", ["point", "spot", "directional"]),
field("position", "Position", "vec3"), field("targetpos", "Target", "vec3"), field("direction", "Direction", "vec3"),
field("diffuse", "Diffuse", "vec4"), field("specular", "Specular", "vec4"), field("attenuation", "Attenuation", "vec3"),
field("spot_theta", "Spot inner", "number"), field("spot_phi", "Spot outer", "number"),
field("spot_falloff", "Falloff", "number"), field("light_start", "Start", "number"), field("light_end", "End", "number"),
field("light_texture", "Light texture"), field("decal_texture", "Decal texture")],
],
};
const affectorSchema = {
linear: [field("force_vector", "Force", "vec3"), field("force_method", "Method"), field("force_rate", "Force curve")],
scale: [field("scale_rate", "Scale curve")],
texrotation: [field("rot_range", "Rotation range", "vec2"), field("rot_speed", "Speed min / max", "vec2")],
rotation: [field("rot_axis", "Axis", "vec3"), field("rot_speed", "Speed min / max", "vec2")],
emit: [field("power", "Power", "number"), field("force_rate", "Force curve"), field("change_direction", "Change direction", "bool")],
};
function tree(text) {
const lines = text.split(/\r\n|\n|\r/), roots = [], stack = [];
let header = -1;
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t || t.startsWith("//")) continue;
if (t === "{" || t.endsWith("{")) {
const h = t === "{" ? lines[header]?.trim() || "" : t.slice(0, -1).trim();
const [kind = "", ...rest] = h.split(/\s+/);
const node = { kind: kind.toLowerCase(), type: rest.join(" "), start: t === "{" ? header : i, open: i, end: -1, children: [], parent: stack.at(-1) || null };
if (node.parent) node.parent.children.push(node); else roots.push(node);
stack.push(node);
} else if (t === "}") {
if (!stack.length) throw new Error(`Unexpected } at line ${i + 1}`);
stack.pop().end = i;
} else if (!stack.length || /^(info|particle|model|light|emitter|affector)\b/i.test(t)) header = i;
}
if (stack.length) throw new Error(`Missing } for ${stack.at(-1).kind}`);
return { lines, roots };
}
const flatten = (nodes) => nodes.flatMap((n) => [n, ...flatten(n.children)]);
const nodeAt = (parsed, start) => flatten(parsed.roots).find((n) => n.start === start);
function attrs(parsed, node) {
const nested = node.children.map((n) => [n.start, n.end]);
const out = [];
for (let i = node.open + 1; i < node.end; i++) {
if (nested.some(([a, b]) => i >= a && i <= b)) continue;
const m = /^\s*([\w-]+)(?:\s+(.*))?$/.exec(parsed.lines[i]);
if (m && !m[1].startsWith("//")) out.push({ line: i, key: m[1], value: m[2] || "" });
}
return out;
}
function setText(text, selection = FXE.selected) {
FXE.text = text;
if ($("fx-script").value !== text) $("fx-script").value = text;
let parsed;
try {
parsed = tree(text);
$("fx-parse-status").textContent = `${parsed.roots.length} blocks`;
} catch (err) {
$("fx-parse-status").textContent = err.message;
parsed = null;
}
FXE.parsed = parsed;
FXE.selected = parsed && nodeAt(parsed, selection) ? selection : parsed?.roots[0]?.start ?? null;
renderHierarchy();
renderProperties();
refreshPreview();
const dirty = text !== FXE.saved;
$("fx-dirty").hidden = !dirty;
$("fx-save").disabled = !dirty;
$("fx-save-as").disabled = !text;
$("fx-title").textContent = FXE.name || "Untitled";
}
function renderHierarchy() {
const box = $("fx-hierarchy");
if (!FXE.parsed) return box.replaceChildren(el("small", { class: "muted", text: "Fix the script braces to inspect blocks." }));
const rows = [];
const totals = { particle: 0, model: 0 };
const walk = (nodes, depth) => nodes.forEach((n) => {
const label = n.kind === "particle" || n.kind === "model" ? `${n.kind === "model" ? "Model" : "Particle"} ${totals[n.kind]++}` : n.kind === "emitter" || n.kind === "affector" ? `${n.kind} ${n.type}` : n.kind;
rows.push(el("button", { class: `fx-node${FXE.selected === n.start ? " on" : ""}`, style: `padding-left:${8 + depth * 16}px`, text: label, onclick: () => { FXE.selected = n.start; renderHierarchy(); renderProperties(); } }));
walk(n.children, depth + 1);
});
walk(FXE.parsed.roots, 0);
box.replaceChildren(...rows);
}
function setField(node, key, value) {
const parsed = tree(FXE.text), current = nodeAt(parsed, node.start);
if (!current) return;
const lines = parsed.lines;
const a = attrs(parsed, current).find((x) => x.key.toLowerCase() === key.toLowerCase());
if (a) {
const indent = /^\s*/.exec(lines[a.line])[0];
lines[a.line] = `${indent}${a.key}${value ? ` ${value}` : ""}`;
} else {
const first = attrs(parsed, current)[0];
const indent = first ? /^\s*/.exec(lines[first.line])[0] : /^\s*/.exec(lines[current.open])[0] + "\t";
const at = current.children[0]?.start ?? current.end;
lines.splice(at, 0, `${indent}${key}${value ? ` ${value}` : ""}`);
}
setText(lines.join(ending()), current.start);
}
function setBlockType(node, type) {
const parsed = tree(FXE.text), lines = parsed.lines;
const line = lines[node.start];
lines[node.start] = line.replace(/^(\s*(?:emitter|affector)\s+)\S+/i, `$1${type}`);
setText(lines.join(ending()), node.start);
}
async function browseResource(node, key, current) {
if (!FXE.src) return toast("Choose an effect source first.", true);
const mesh = key === "mesh_file";
let names;
try { names = await call("mdl:names", FXE.src, mesh ? "mesh" : "texture"); }
catch (err) { return toast(err.message, true); }
const list = el("div", { class: "fx-resource-list" });
const search = el("input", { type: "search", placeholder: mesh ? "Search meshes" : "Search textures", spellcheck: "false" });
let chosen = current;
const shown = el("small", { class: "muted" });
const render = () => {
const q = search.value.trim().toLowerCase();
const matches = names.filter((n) => n.toLowerCase().includes(q));
shown.textContent = `${matches.length.toLocaleString()} matches · showing first 100`;
list.replaceChildren(...matches.slice(0, 100).map((name) => {
const value = mesh ? name.replace(/\.msh$/i, "") : name;
return el("button", { class: `fx-resource${value.toLowerCase() === chosen.toLowerCase() ? " on" : ""}`, text: name,
onclick: () => { chosen = value; render(); } });
}));
};
search.oninput = render;
render();
const ok = await modal(mesh ? "Choose effect mesh" : "Choose effect texture", [search, shown, list],
[{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]);
if (ok && chosen) setField(node, key, chosen);
}
function control(node, spec, value) {
if (/^vec[234]$/.test(spec.type)) {
const size = Number(spec.type.slice(-1)), parts = value.trim().split(/\s+/).filter(Boolean);
const wrap = el("div", { class: "fx-vector" });
const inputs = Array.from({ length: size }, (_, i) => el("input", {
type: "number", step: "any", value: parts[i] || "", placeholder: ["X", "Y", "Z", "W"][i],
onchange: () => setField(node, spec.key, inputs.map((x) => x.value || "0").join(" ")),
}));
wrap.replaceChildren(...inputs);
return wrap;
}
if (spec.type === "bool" || spec.type === "select") {
const options = spec.type === "bool" ? ["true", "false"] : spec.options;
const values = value && !options.some((x) => x.toLowerCase() === value.toLowerCase()) ? [value, ...options] : options;
const select = el("select", { onchange: (e) => setField(node, spec.key, e.target.value) }, [
el("option", { value: "", text: "—" }), ...values.map((x) => el("option", { value: x, text: x })),
]);
select.value = values.find((x) => x.toLowerCase() === value.toLowerCase()) || "";
return select;
}
const input = el("input", { type: spec.type === "number" ? "number" : "text", step: spec.type === "number" ? "any" : undefined,
value, spellcheck: "false", onchange: (e) => setField(node, spec.key, e.target.value.trim()) });
if (["mesh_file", "texture", "light_texture", "decal_texture"].includes(spec.key)) {
return el("div", { class: "fx-resource-field" }, [input, el("button", { class: "iconbtn only", title: "Browse resources", text: "…", onclick: () => browseResource(node, spec.key, input.value.trim()) })]);
}
return input;
}
function renderProperties() {
const node = FXE.parsed && nodeAt(FXE.parsed, FXE.selected);
$("fx-selection").textContent = node ? `${node.kind} ${node.type}` : "";
$("fx-add-field").disabled = !node;
const system = node?.kind === "particle" || node?.kind === "model" ? node : node?.parent;
const hasSystem = system?.kind === "particle" || system?.kind === "model";
$("fx-add-emitter").disabled = !hasSystem || system.children.some((x) => x.kind === "emitter");
$("fx-add-affector").disabled = !hasSystem;
$("fx-duplicate-block").disabled = !node || !["particle", "model"].includes(node.kind);
const siblings = node && FXE.parsed ? FXE.parsed.roots.filter((x) => x.kind === node.kind) : [];
$("fx-move-up").disabled = !node || !["particle", "model"].includes(node.kind) || siblings.indexOf(node) <= 0;
$("fx-move-down").disabled = !node || !["particle", "model"].includes(node.kind) || siblings.indexOf(node) >= siblings.length - 1;
$("fx-delete-block").disabled = !node || node.kind === "info";
const box = $("fx-properties");
if (!node) return box.replaceChildren(el("span", { class: "muted", text: "Select a block." }));
const groups = node.kind === "affector" ? [[`${node.type || "Affector"} settings`, field("wait_time", "Wait (ms)", "number"), ...(affectorSchema[node.type.toLowerCase()] || [])]] : schema[node.kind] || [];
const fields = [], existing = attrs(FXE.parsed, node), shown = new Set();
if (node.kind === "emitter" || node.kind === "affector") {
fields.push(el("label", { text: "Type" }));
const options = node.kind === "emitter" ? ["Point", "Box", "Cylinder", "Ellipse", "Ellipsoid", "HollowEllipsoid"] : ["Linear", "Scale", "TexRotation", "Rotation", "Emit"];
const values = options.some((x) => x.toLowerCase() === node.type.toLowerCase()) ? options : [node.type, ...options];
const select = el("select", { onchange: (e) => setBlockType(node, e.target.value) }, values.map((x) => el("option", { value: x, text: x })));
select.value = values.find((x) => x.toLowerCase() === node.type.toLowerCase());
fields.push(select);
}
for (const group of groups) {
fields.push(el("div", { class: "fx-group", text: group[0] }));
for (const spec of group.slice(1)) {
const a = existing.find((x) => x.key.toLowerCase() === spec.key);
shown.add(spec.key);
fields.push(el("label", { text: spec.label, title: spec.key }));
fields.push(control(node, spec, a?.value || ""));
}
}
const other = existing.filter((a) => !shown.has(a.key.toLowerCase()));
if (other.length) fields.push(el("div", { class: "fx-group", text: "Other fields" }));
for (const a of other) {
fields.push(el("label", { text: a.key, title: a.key }));
fields.push(control(node, field(a.key, a.key), a.value));
}
box.replaceChildren(...fields);
}
function mutateLines(fn, selected = FXE.selected) {
if (!FXE.parsed) return;
const lines = FXE.parsed.lines.slice();
fn(lines);
setText(lines.join(ending()), selected);
}
function ensureView() {
if (view) return view;
try { view = new GL3D.View($("fx-canvas"), { alpha: false }); }
catch (err) { toast(`Effect preview unavailable: ${err.message}`, true); return null; }
// ioEffectEdit renders directly to an opaque back buffer. Additive DDS
// textures carry black RGB around their glow; SRC_ALPHA + ONE leaves that
// black unchanged on the gray target without changing the texture data.
view.env = { clear: [94 / 255, 94 / 255, 94 / 255] };
kit = MeshKit.create(view, () => FXE.src);
view.opts.lit = false;
view.frame([-180, -40, -180], [180, 220, 180]);
drawGrid();
return view;
}
function drawGrid() {
if (!view) return;
if (!$("fx-grid").classList.contains("on")) return view.setOverlay("fx-grid", null);
const a = [];
const line = (x, z, xx, zz, c) => a.push(x, 0, z, ...c, xx, 0, zz, ...c);
for (let v = -300; v <= 300; v += 50) {
const c = v === 0 ? [0.82, 0.69, 0.48, 0.62] : [0.7, 0.7, 0.7, 0.35];
line(v, -300, v, 300, c); line(-300, v, 300, v, c);
}
view.setOverlay("fx-grid", new Float32Array(a));
}
function clearModels() {
modelLoadSeq++;
for (const entry of modelViews) for (const id of entry?.slots || []) view?.removeMesh(id);
modelViews = [];
}
async function loadModel(index, spec, seq) {
const name = (spec.attrs.mesh_file || "").trim().replace(/\.msh$/i, "");
if (!name || name.toLowerCase() === "none" || !FXE.src) return;
try {
const hit = await kit.readNamed(`${name}.msh`, "mesh", true);
if (!hit || seq !== modelLoadSeq) return;
const mesh = await kit.loadMesh(hit, name);
if (seq !== modelLoadSeq) return view.removeMesh(mesh.id);
view.setVisible(mesh.id, false);
modelViews[index] = { mesh, slots: [mesh.id] };
await kit.loadTextures(mesh);
if (seq !== modelLoadSeq) return view.removeMesh(mesh.id);
syncModels();
} catch (err) { if (seq === modelLoadSeq) console.warn(`Effect model ${name}: ${err.message}`); }
}
function syncModels() {
if (!view || !inst) return;
inst.modelSystems.forEach((system, index) => {
const entry = modelViews[index];
if (!entry) return;
kit.animate(entry.mesh, system.time / 1000);
system.parts.forEach((part, i) => {
let id = entry.slots[i];
if (!id) {
id = `fx-model-${modelLoadSeq}-${index}-${i}`;
const m = entry.mesh;
view.addMesh(id, { pos: m.data.pos, normal: m.data.normal, uv0: m.data.uv0, uv1: m.data.uv1, indices: m.data.indices, groups: m.groups });
entry.slots[i] = id;
}
view.setTransformQuaternion(id, part.pos, part.modelScale, part.modelRotation);
const t = Math.min(1, part.age / part.life), color = system.color(t);
view.setTint(id, [color[0] / 255, color[1] / 255, color[2] / 255, Math.max(0, Math.min(1, system.alpha(t)[0]))]);
view.setVisible(id, true);
});
entry.slots.forEach((id, i) => { if (i >= system.parts.length) view.setVisible(id, false); });
});
}
function refreshPreview() {
inst = null;
clearModels();
if (!FXE.text || !ensureView()) return;
try { inst = Effects.instance(Effects.parse(FXE.text), [0, 0, 0]); }
catch (err) { $("fx-parse-status").textContent = err.message; }
view.setParticles([]);
if (inst) {
const seq = modelLoadSeq;
inst.def.models.forEach((spec, index) => loadModel(index, spec, seq));
}
// Loaded textures stay attached to this WebGL context across script edits.
}
function texture(name) {
if (!name || !FXE.src || !view) return null;
const key = name.toLowerCase();
if (!textures.has(key)) {
textures.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);
textures.set(key, t.tex);
});
}
return textures.get(key);
}
async function tick(now) {
const dt = Math.min(100, Math.max(0, now - (last || now)));
last = now;
if (view && inst && FXE.playing && $("view-fx").classList.contains("on")) {
Effects.update(inst, dt);
syncModels();
const parts = Effects.build(inst, view.cameraAxes());
for (const p of parts) p.tex = texture(p.texture);
view.setParticles(parts);
$("fx-stats").textContent = `${Effects.count(inst)} particles · ${inst.systems.length} particle systems · ${inst.modelSystems.length} model systems`;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
const list = new VTable($("fx-list"), [{ key: 1, label: "Effect", width: 220, grow: true, title: true }, { key: 2, label: "Pack", width: 75, cls: "mono dim" }], { multi: false, id: "fx" });
list.keyOf = (r) => r[0];
list.sortBy(1);
list.onOpen = (r) => openHit(() => call("mdl:readKey", FXE.src, r[0]), r[1]);
function filterList() {
const q = $("fx-search").value.trim().toLowerCase();
const rows = FXE.rows.filter((r) => r[1].toLowerCase().includes(q));
list.setRows(rows);
$("fx-count").textContent = rows.length.toLocaleString();
}
$("fx-search").oninput = filterList;
async function loadList() {
if (!FXE.src) return;
try {
const entries = await call("fx:list", FXE.src);
FXE.rows = entries.filter(([, path]) => /(^|\/)resource\/effect\/.+\.txt$/i.test(path))
.map(([key, path, pack]) => [key, path.replace(/^.*?resource\/effect\//i, ""), pack || "", path]);
filterList();
} catch (err) { toast(err.message, true); }
}
async function chooseSource() {
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
const options = [...profileSourceOption(), ...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file })),
{ value: "packs", title: "Folder with .iop packs…", sub: "Game client packs" }, { value: "dir", title: "Extracted resource folder…", sub: "Folder containing resource/effect" }];
let choice = options[0]?.value;
const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [
el("input", { type: "radio", name: "fxsrc", checked: i === 0, onchange: () => { choice = o.value; } }),
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
])));
if (!(await modal("Effect source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return;
if (choice === "packs" || choice === "dir") {
const folder = await call("dialog:folder", choice === "packs" ? "Folder with .iop packs" : "Extracted resources");
if (!folder) return;
choice = { kind: choice, folder };
}
try {
const d = await call("mdl:describe", choice);
FXE.src = choice;
$("fx-src-label").textContent = choice.kind === "dir" ? "Extracted folder" : "Packs";
$("fx-src").title = d.label;
kit?.clearTextures(); textures.clear(); refreshPreview();
await loadList();
} catch (err) { toast(err.message, true); }
}
$("fx-src").onclick = chooseSource;
async function openHit(read, name) {
if (FXE.text !== FXE.saved && !(await confirmBox("Unsaved effect", "Discard changes to the current effect?", "Discard"))) return;
const seq = ++loadSeq;
try {
const hit = await read();
if (!hit || seq !== loadSeq) return;
FXE.where = hit.where; FXE.name = name || hit.source; FXE.saved = bytesToRaw(hit.bytes);
FXE.selected = null; setText(FXE.saved);
$("fx-empty").hidden = true;
view?.resize();
} catch (err) { toast(err.message, true); }
}
$("fx-open-file").onclick = async () => {
const file = await call("fx:openFile");
if (file) await openHit(() => call("mdl:readFile", file), file.replace(/^.*[\\/]/, ""));
};
$("fx-new").onclick = async () => {
if (FXE.text !== FXE.saved && !(await confirmBox("Unsaved effect", "Discard changes to the current effect?", "Discard"))) return;
FXE.where = null; FXE.name = "new_effect.txt"; FXE.saved = ""; FXE.selected = null;
setText("info\r\n{\r\n}\r\n\r\nparticle\r\n{\r\nquota 100\r\ndefault_width 50 50\r\ndefault_height 50 50\r\nrender_type add\r\ntexture effect.dds\r\nemitter point\r\n{\r\nemit_rate 20\r\nemit_speed 40 80\r\nemit_live_time 1000 1500\r\n}\r\n}\r\n");
$("fx-empty").hidden = true;
view?.resize();
};
$("fx-script").oninput = (e) => setText(e.target.value);
$("fx-add-particle").onclick = () => {
const lines = FXE.parsed?.lines.slice() || FXE.text.split(/\r\n|\n|\r/);
const at = lines.length;
lines.push("particle", "{", "quota 100", "default_width 50 50", "default_height 50 50", "render_type add", "emitter point", "{", "emit_rate 20", "emit_speed 40 80", "emit_live_time 1000 1500", "}", "}");
setText(lines.join(ending()), at);
};
$("fx-add-model").onclick = () => {
const lines = FXE.parsed?.lines.slice() || FXE.text.split(/\r\n|\n|\r/);
const at = lines.length;
lines.push("model", "{", "mesh_file none", "quota 20", "cull_each false", "follow_parent none", "end_condition never_end", "limit_time 1000", "start_create_particles 1", "link_biped_name none", "random_sync_index -1", "pos_offset 0 0 0", "rot_offset 0 0 0 1", "emitter Point", "{", "emit_pos 0 0 0", "emit_dir 0 1 0", "emit_rate 1", "emit_speed 0 0", "emit_live_time 1000 1000", "}", "}");
setText(lines.join(ending()), at);
};
$("fx-add-emitter").onclick = () => {
const selected = nodeAt(FXE.parsed, FXE.selected);
const system = ["particle", "model"].includes(selected?.kind) ? selected : selected?.parent;
if (!system || !["particle", "model"].includes(system.kind) || system.children.some((x) => x.kind === "emitter")) return;
mutateLines((lines) => lines.splice(system.end, 0, "emitter Point", "{", "emit_pos 0 0 0", "emit_dir 0 1 0", "emit_rate 10", "emit_speed 0 0", "emit_live_time 1000 1000", "}"), system.start);
};
$("fx-add-affector").onclick = async () => {
const selected = nodeAt(FXE.parsed, FXE.selected);
const system = ["particle", "model"].includes(selected?.kind) ? selected : selected?.parent;
if (!system || !["particle", "model"].includes(system.kind)) return;
const type = await prompt("Add affector", "Type (Linear, Scale, TexRotation, Rotation, Emit)", "Linear");
if (!type || !/^[a-z][\w-]*$/i.test(type)) return;
mutateLines((lines) => lines.splice(system.end, 0, `affector ${type}`, "{", "wait_time 0", "}"), system.start);
};
$("fx-duplicate-block").onclick = () => {
const node = nodeAt(FXE.parsed, FXE.selected);
if (!node || !["particle", "model"].includes(node.kind)) return;
const lines = FXE.parsed.lines.slice(), block = lines.slice(node.start, node.end + 1);
lines.splice(node.end + 1, 0, "", ...block);
setText(lines.join(ending()), node.end + 2);
};
function moveBlock(direction) {
const node = nodeAt(FXE.parsed, FXE.selected);
if (!node || !["particle", "model"].includes(node.kind)) return;
const siblings = FXE.parsed.roots.filter((x) => x.kind === node.kind);
const other = siblings[siblings.indexOf(node) + direction];
if (!other) return;
const [a, b] = node.start < other.start ? [node, other] : [other, node];
const lines = FXE.parsed.lines.slice();
const first = lines.slice(a.start, a.end + 1), gap = lines.slice(a.end + 1, b.start), second = lines.slice(b.start, b.end + 1);
lines.splice(a.start, b.end - a.start + 1, ...second, ...gap, ...first);
const selected = node === a ? a.start + second.length + gap.length : a.start;
setText(lines.join(ending()), selected);
}
$("fx-move-up").onclick = () => moveBlock(-1);
$("fx-move-down").onclick = () => moveBlock(1);
$("fx-delete-block").onclick = async () => {
const node = nodeAt(FXE.parsed, FXE.selected);
if (!node || node.kind === "info") return;
if (!(await confirmBox("Remove block", `Remove ${node.kind} ${node.type}?`, "Remove", "danger"))) return;
mutateLines((lines) => lines.splice(node.start, node.end - node.start + 1), node.parent?.start ?? null);
};
$("fx-add-field").onclick = async () => {
const node = nodeAt(FXE.parsed, FXE.selected);
if (!node) return;
const key = await prompt("Add field", "Script key (for example: texture)", "");
if (!key || !/^[a-z][\w-]*$/i.test(key)) return;
mutateLines((lines) => lines.splice(node.end, 0, `${key} `), node.start);
};
$("fx-play").onclick = () => {
FXE.playing = !FXE.playing;
$("fx-play").classList.toggle("on", FXE.playing);
if (!FXE.playing) view?.setParticles([]);
};
$("fx-restart").onclick = refreshPreview;
$("fx-grid").onclick = () => { $("fx-grid").classList.toggle("on"); drawGrid(); };
$("fx-reset").onclick = () => view?.reset();
document.querySelector('[data-view="fx"]').addEventListener("click", () => {
ensureView();
view?.resize();
if (!FXE.src) { const src = profileSource(); if (src) { FXE.src = src; $("fx-src-label").textContent = "Profile packs"; loadList(); } }
});
async function save(as = false) {
if (!FXE.text) return;
if ([...FXE.text].some((c) => c.charCodeAt(0) > 255)) return toast("This script contains characters outside its original 8-bit encoding. Use ASCII for new fields.", true);
let where = FXE.where;
if (as || !where) {
const file = await call("dialog:saveAs", FXE.name || "new_effect.txt", "txt");
if (!file) return;
where = { kind: "file", file };
}
try {
await call("mdl:writeBack", where, rawToBytes(FXE.text));
FXE.where = where; FXE.saved = FXE.text;
if (where.kind === "file") FXE.name = where.file.replace(/^.*[\\/]/, "");
$("fx-title").textContent = FXE.name;
$("fx-dirty").hidden = true; $("fx-save").disabled = true;
toast(`Saved ${FXE.name}`);
if (FXE.src) { await call("mdl:reload", FXE.src); await loadList(); }
} catch (err) { toast(err.message, true); }
}
$("fx-save").onclick = () => save();
$("fx-save-as").onclick = () => save(true);
document.addEventListener("keydown", (e) => {
if (!$("view-fx").classList.contains("on") || !e.ctrlKey || e.key.toLowerCase() !== "s") return;
e.preventDefault(); save();
});
FXE.previewAsset = async (src, name) => {
const changed = FXE.src?.kind !== src.kind || FXE.src?.folder !== src.folder || FXE.src?.sessionId !== src.sessionId;
FXE.src = src;
$("fx-src-label").textContent = "Hero source";
document.querySelector('[data-view="fx"]').click();
if (changed) await loadList();
await openHit(() => call("mdl:read", src, name, "effect", true), name);
};
})();
+454
View File
@@ -0,0 +1,454 @@
"use strict";
// Effect scripts (resource/effect/*.txt) and their particle systems, following io3DEngine
// (ioEffectFactory::ParseScript, ioParticleSystem::Update, ioParticleEmitter, the emit point
// generators and affectors). Used by the map editor to play the map's effects.
//
// Units: times in ms, speeds / forces per second, angles in degrees (TexRotation speed: rad/s).
// Everything is simulated in game space (map effects have no rotation or scale).
const Effects = (() => {
// ---------- parser ----------
/** → { info, particles, models } with editable attributes and nested emitters/affectors. */
function parse(text) {
const lines = text.split(/\r\n|\r|\n/).map((l) => l.replace(/\s+$/, "").replace(/^\s+/, ""));
let i = 0;
const out = { info: {}, particles: [], models: [], light: false };
const skipToBrace = () => {
while (i < lines.length && !lines[i].startsWith("{")) i++;
i++;
};
const readBlock = (onNested) => {
const attrs = {};
while (i < lines.length) {
const l = lines[i];
if (!l || l.startsWith("//")) {
i++;
continue;
}
if (l.startsWith("}")) {
i++;
break;
}
const sp = l.search(/\s/);
const key = (sp < 0 ? l : l.slice(0, sp)).toLowerCase();
const rest = sp < 0 ? "" : l.slice(sp + 1).trim();
if (onNested && (key === "emitter" || key === "affector")) {
i++;
skipToBrace();
onNested(key, rest, readBlock(null));
continue;
}
attrs[key] = rest;
i++;
}
return attrs;
};
while (i < lines.length) {
const l = lines[i];
if (!l || l.startsWith("//") || l.startsWith("{") || l.startsWith("}")) {
i++;
continue;
}
const tag = l.split(/\s/)[0].toLowerCase();
i++;
skipToBrace();
if (tag === "particle" || tag === "model") {
const ps = { emitter: null, affectors: [] };
ps.attrs = readBlock((kind, type, attrs) => {
if (kind === "emitter") ps.emitter = { type: type.toLowerCase(), attrs };
else ps.affectors.push({ type: type.toLowerCase(), attrs });
});
out[tag === "model" ? "models" : "particles"].push(ps);
} else {
const attrs = readBlock(null);
if (tag === "info") out.info = attrs;
else if (tag === "light") out.light = true;
}
}
return out;
}
// ---------- small helpers ----------
const nums = (s) => (s || "").trim().split(/\s+/).filter(Boolean).map(Number);
const pair = (s, d) => {
const n = nums(s);
return n.length >= 2 ? n : n.length === 1 ? [n[0], n[0]] : d;
};
const vec = (s, d) => {
const n = nums(s);
return n.length >= 3 ? n.slice(0, 3) : d;
};
const rnd = (a, b) => a + Math.random() * (b - a);
const norm = (v) => {
const l = Math.hypot(v[0], v[1], v[2]) || 1;
return [v[0] / l, v[1] / l, v[2] / l];
};
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const quatMul = (a, b) => [
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
];
const quatRotate = (q, v) => {
const u = q.slice(0, 3), w = q[3];
const t = cross(u, v).map((n) => n * 2);
const c = cross(u, t);
return [v[0] + w * t[0] + c[0], v[1] + w * t[1] + c[1], v[2] + w * t[2] + c[2]];
};
/** Keyed curve "t v1 v2 …" (k values per key) → sampler(t01) → [v…] (linear, clamped) */
function curve(s, k, dflt) {
const n = nums(s);
const keys = [];
for (let j = 0; j + k < n.length + 1; j += k + 1) {
const vals = n.slice(j + 1, j + 1 + k);
while (vals.length < k) vals.push(0);
keys.push([n[j], vals]);
}
if (!keys.length) keys.push([0, dflt]);
return (t) => {
let i = 0;
while (i < keys.length && keys[i][0] <= t) i++;
if (i === 0) return keys[0][1];
if (i === keys.length) return keys[keys.length - 1][1];
const [t0, a] = keys[i - 1], [t1, b] = keys[i];
const f = t1 > t0 ? (t - t0) / (t1 - t0) : 0;
return a.map((v, j) => v + (b[j] - v) * f);
};
}
// ---------- one particle system ----------
function makeSystem(ps, model = false) {
const a = ps.attrs;
const e = ps.emitter?.attrs || {};
const type = ps.emitter?.type || "point";
const sys = {
model,
mesh: model ? (a.mesh_file || "").trim() : "",
quota: Number(a.quota) || 0,
width: pair(a.default_width, [100, 100]),
height: pair(a.default_height, [100, 100]),
render: (a.render_type || "add").toLowerCase(),
depth: a.depth_check !== "false",
texture: (a.texture || "").split(/[\\/]/).pop(),
linkBone: (a.link_biped_name || "").trim(),
linkOffset: vec(a.pos_offset, [0, 0, 0]),
followParent: (a.follow_parent || "none").toLowerCase(),
linkRotation: nums(a.rot_offset).length === 4 ? nums(a.rot_offset) : [0, 0, 0, 1],
billboard: (a.billboard_type || "point").toLowerCase(),
endLimit: (a.end_condition || "").toLowerCase() === "time_limit",
limit: Number(a.limit_time) || 0,
commonDir: norm(vec(a.common_dir, [0, 1, 0])),
commonFixed: a.common_dir_none_billboard === "true",
interval: Number(a.iteration_interval) || 0,
burst: Number(a.start_create_particles) || 0,
// In the standalone editor the owner/bone offset is not applied to an
// unattached model. The emitter position is its visible local origin.
offset: model ? [0, 0, 0] : vec(a.pos_offset, [0, 0, 0]),
rotOffset: model ? nums(a.rot_offset) : [],
mod2x: a.modulate2x === "true",
// emitter
shape: type,
emitPos: vec(e.emit_pos, [0, 0, 0]),
emitDir: vec(e.emit_dir, [0, 1, 0]),
angle: ((Number(e.emit_angle) || 0) * Math.PI) / 180,
rate: e.emit_rate !== undefined ? Number(e.emit_rate) : 10,
speed: pair(e.emit_speed, [1, 1]),
life: pair(e.emit_live_time, [5000, 5000]),
wait: Number(e.emit_wait_time) || 0,
texRot: pair(e.emit_start_tex_rotation, [0, 0]).map((d) => (d * Math.PI) / 180),
startRotAxis: norm(vec(e.start_rot_axis, [0, 1, 0])),
startRotAngle: pair(e.start_rot_angle, [0, 0]),
area: vec(e.area_size, [100, 100, 100]),
inner: nums(e.inner_size),
color: curve(e.color_rate, 3, [255, 255, 255]),
alpha: curve(e.alpha_rate, 1, [1]),
affectors: ps.affectors.map((f) => ({ type: f.type, a: f.attrs, wait: Number(f.attrs.wait_time) || 0 })),
};
for (const f of sys.affectors) {
if (f.type === "linear") {
f.force = vec(f.a.force_vector, [0, -100, 0]);
f.rate = curve(f.a.force_rate, 1, [1]);
} else if (f.type === "scale") f.scale = curve(f.a.scale_rate, model ? 3 : 2, model ? [1, 1, 1] : [1, 1]);
else if (f.type === "texrotation") {
f.range = pair(f.a.rot_range, [0, 0]).map((d) => (d * Math.PI) / 180);
f.speed = pair(f.a.rot_speed, [0, 0]); // rad/s as the client uses it
} else if (f.type === "emit") {
f.power = Number(f.a.power) || 0;
f.rate = curve(f.a.force_rate, 1, [1]);
}
}
// emitter local axes (GenAreaAxis)
const d = norm(sys.emitDir);
let up = Math.abs(dot(d, [1, 0, 0])) > 0.99 ? [0, -1, 0] : [1, 0, 0];
const side = cross(up, d);
up = cross(d, side);
sys.axes = { d, up, side };
sys.parts = [];
sys.time = 0;
sys.acc = 0;
sys.started = false;
sys.stopped = false; // time_limit reached (no more emission)
sys.ended = false;
return sys;
}
function spawnPoint(s) {
const { d, up, side } = s.axes;
const [sx, sy, sz] = s.area;
const RX = up.map((v) => (v * sx) / 2), RY = d.map((v) => (v * sy) / 2), RZ = side.map((v) => (v * sz) / 2);
const c = s.emitPos;
const at = (a, b, cc) => [c[0] + a * RX[0] + b * RY[0] + cc * RZ[0], c[1] + a * RX[1] + b * RY[1] + cc * RZ[1], c[2] + a * RX[2] + b * RY[2] + cc * RZ[2]];
const r = () => Math.random() * 2 - 1;
switch (s.shape) {
case "box":
return at(r(), r(), r());
case "cylinder": {
let x = r(), z = r();
const l = Math.hypot(x, z);
if (l > 1) (x /= l), (z /= l);
return at(x, r(), z);
}
case "ellipse": {
const ang = Math.random() * Math.PI * 2;
const ix = s.inner[0] ?? 0.5, iz = s.inner[1] ?? 0.5;
return at(ix * Math.sin(ang), Math.random(), iz * Math.cos(ang));
}
case "ellipsoid": {
let x = r(), y = r(), z = r();
const l = Math.hypot(x, y, z);
if (l > 1) (x /= l), (y /= l), (z /= l);
return at(x, y, z);
}
case "hollowellipsoid": {
const al = Math.random() * Math.PI * 2, be = Math.random() * Math.PI;
const inner = s.inner.map((v) => Math.max(0, Math.min(1, v)));
const rx = rnd(inner[0] ?? 0.5, 1), ry = rnd(inner[1] ?? 0.5, 1), rz = rnd(inner[2] ?? 0.5, 1);
return at(rx * Math.cos(al) * Math.sin(be), ry * Math.cos(be), rz * Math.sin(al) * Math.sin(be));
}
default:
return c.slice();
}
}
/** emit_dir turned by a random angle within the cone, around a random azimuth. */
function coneDir(s) {
const { d, up, side } = s.axes;
if (!s.angle) return d.slice();
const th = Math.random() * s.angle, ph = Math.random() * Math.PI * 2;
const u = norm(up), v = norm(side);
const k = Math.sin(th);
return norm([d[0] * Math.cos(th) + (u[0] * Math.cos(ph) + v[0] * Math.sin(ph)) * k, d[1] * Math.cos(th) + (u[1] * Math.cos(ph) + v[1] * Math.sin(ph)) * k, d[2] * Math.cos(th) + (u[2] * Math.cos(ph) + v[2] * Math.sin(ph)) * k]);
}
function spawn(s, origin, lead) {
const rotation = s.ownerRotation && s.followParent !== "pos_only" ? s.ownerRotation : null;
const p = rotation ? quatRotate(rotation, spawnPoint(s)) : spawnPoint(s);
const dir = rotation ? quatRotate(rotation, coneDir(s)) : coneDir(s);
const speed = rnd(s.speed[0], s.speed[1]);
const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : s.offset;
const part = {
pos: [p[0] + origin[0] + offset[0] + dir[0] * speed * lead, p[1] + origin[1] + offset[1] + dir[1] * speed * lead, p[2] + origin[2] + offset[2] + dir[2] * speed * lead],
dir,
speed,
age: 0,
life: Math.max(1, rnd(s.life[0], s.life[1])),
w0: rnd(s.width[0], s.width[1]),
h0: rnd(s.height[0], s.height[1]),
rot: rnd(s.texRot[0], s.texRot[1]),
rotSpeed: 0,
};
if (s.model) {
// Effect scripts use Direct3D's left-handed axis-angle convention.
const angle = -rnd(s.startRotAngle[0], s.startRotAngle[1]) * Math.PI / 360;
const axis = s.startRotAxis;
const q = [axis[0] * Math.sin(angle), axis[1] * Math.sin(angle), axis[2] * Math.sin(angle), Math.cos(angle)];
part.modelRotation = quatMul(rotation || [0, 0, 0, 1], quatMul(s.rotOffset.length === 4 ? s.rotOffset : [0, 0, 0, 1], q));
part.modelScale = s.affectors.find((f) => f.type === "scale")?.scale(0) || [1, 1, 1];
}
for (const f of s.affectors) {
if (f.type === "texrotation") {
part.rot = rnd(f.range[0], f.range[1]);
part.rotSpeed = rnd(f.speed[0], f.speed[1]);
}
}
part.w = part.w0;
part.h = part.h0;
s.parts.push(part);
}
function step(s, dt, origin) {
const dts = dt / 1000;
if (!s.started) {
s.started = true;
for (let k = 0; k < Math.min(s.burst, s.quota || s.burst); k++) spawn(s, origin, 0);
}
s.time += dt;
// expire + age
s.parts = s.parts.filter((p) => p.age < p.life);
for (const p of s.parts) p.age += dt;
// affectors
for (const f of s.affectors) {
if (s.time < f.wait) continue;
for (const p of s.parts) {
const t = Math.min(1, p.age / p.life);
if (f.type === "linear") {
const r = f.rate(t)[0];
const v = [p.dir[0] * p.speed + f.force[0] * dts * r, p.dir[1] * p.speed + f.force[1] * dts * r, p.dir[2] * p.speed + f.force[2] * dts * r];
p.speed = Math.hypot(v[0], v[1], v[2]);
if (p.speed > 1e-6) p.dir = [v[0] / p.speed, v[1] / p.speed, v[2] / p.speed];
} else if (f.type === "scale") {
if (s.model) p.modelScale = f.scale(t);
else {
const [sx, sy] = f.scale(t);
p.w = p.w0 * sx;
p.h = p.h0 * sy;
}
} else if (f.type === "texrotation") p.rot += p.rotSpeed * dts;
else if (f.type === "emit") {
const emitPos = s.ownerRotation && s.followParent !== "pos_only" ? quatRotate(s.ownerRotation, s.emitPos) : s.emitPos;
const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : [0, 0, 0];
const c = [origin[0] + offset[0] + emitPos[0], origin[1] + offset[1] + emitPos[1], origin[2] + offset[2] + emitPos[2]];
const d = norm([p.pos[0] - c[0], p.pos[1] - c[1], p.pos[2] - c[2]]);
const k = f.power * dts * f.rate(t)[0];
p.pos[0] += d[0] * k;
p.pos[1] += d[1] * k;
p.pos[2] += d[2] * k;
}
}
}
// integrate
for (const p of s.parts) {
p.pos[0] += p.dir[0] * p.speed * dts;
p.pos[1] += p.dir[1] * p.speed * dts;
p.pos[2] += p.dir[2] * p.speed * dts;
}
// emit
if (s.endLimit && s.time >= s.limit) s.stopped = true;
if (!s.stopped && s.time >= s.wait && s.quota > 0) {
s.acc += (s.rate * dt) / 1000;
let n = Math.floor(s.acc);
s.acc -= n;
n = Math.min(n, s.quota - s.parts.length);
for (let k = 0; k < n; k++) spawn(s, origin, (k * dts) / Math.max(1, n));
}
if ((s.stopped || (!s.endLimit && s.rate < 0.1)) && !s.parts.length && s.time > 0) s.ended = true;
}
// ---------- an effect instance ----------
function instance(def, origin) {
return { def, origin, systems: def.particles.map((p) => makeSystem(p)), modelSystems: def.models.map((m) => makeSystem(m, true)), restartAt: 0 };
}
function update(inst, dt) {
let live = false;
for (const s of [...inst.systems, ...inst.modelSystems]) {
if (s.ended) continue;
if (s.heroAttached && s.followParent !== "none") {
const base = s.ownerOrigin || inst.origin, offset = s.ownerOffset || [0, 0, 0];
const current = [base[0] + offset[0], base[1] + offset[1], base[2] + offset[2]];
const rotation = s.followParent === "all" ? (s.ownerRotation || [0, 0, 0, 1]) : [0, 0, 0, 1];
if (s.previousParent) {
const inverse = [-s.previousRotation[0], -s.previousRotation[1], -s.previousRotation[2], s.previousRotation[3]];
for (const p of s.parts) {
const local = quatRotate(inverse, [p.pos[0] - s.previousParent[0], p.pos[1] - s.previousParent[1], p.pos[2] - s.previousParent[2]]);
const next = quatRotate(rotation, local);
p.pos = [current[0] + next[0], current[1] + next[1], current[2] + next[2]];
p.dir = quatRotate(rotation, quatRotate(inverse, p.dir));
if (p.modelRotation) p.modelRotation = quatMul(rotation, quatMul(inverse, p.modelRotation));
}
}
s.previousParent = current;
s.previousRotation = rotation;
}
if (s.interval > 0) {
s.left = (s.left || 0) + dt;
while (s.left >= s.interval) {
step(s, s.interval, s.ownerOrigin || inst.origin);
s.left -= s.interval;
}
} else step(s, Math.min(dt, 100), s.ownerOrigin || inst.origin);
if (!s.ended) live = true;
}
// map effects play while their systems live; one-shot ones restart in the preview after a pause
if (!live && !inst.heroPreview) {
inst.restartAt += dt;
if (inst.restartAt > 1200) {
inst.systems = inst.def.particles.map(makeSystem);
inst.modelSystems = inst.def.models.map((m) => makeSystem(m, true));
inst.restartAt = 0;
}
}
}
const BLEND = {
add: { op: "add", src: "src_alpha", dst: "one" },
src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
screen: { op: "add", src: "inv_dest_color", dst: "one" },
lighten: { op: "max", src: "one", dst: "one" },
darken: { op: "min", src: "one", dst: "one" },
rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" },
subtract: { op: "subtract", src: "src_alpha", dst: "one" },
};
/**
* Quads of every live particle (6 vertices × [x y z u v r g b a]) grouped per system.
* cam: { right, up, dir } in game space.
*/
function build(inst, cam) {
const out = [];
for (const s of inst.systems) {
if (!s.parts.length) continue;
const data = new Float32Array(s.parts.length * 6 * 9);
let o = 0;
for (const p of s.parts) {
const t = Math.min(1, p.age / p.life);
const c = s.color(t);
const al = Math.max(0, Math.min(1, s.alpha(t)[0]));
let X, Y;
if (s.billboard === "common") {
Y = s.commonDir;
X = s.commonFixed ? norm(cross(Math.abs(Y[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0], Y)) : norm(cross(Y, cam.dir));
} else if (s.billboard === "direction") {
X = p.dir;
Y = cross(X, cam.dir);
} else {
X = cam.right;
Y = cam.up;
}
const hw = p.w / 2, hh = p.h / 2;
const cs = Math.cos(p.rot), sn = Math.sin(p.rot);
const uv = (u, v) => {
// rotation about (0.5, 0.5)
const x = u - 0.5, y = v - 0.5;
return [x * cs - y * sn + 0.5, x * sn + y * cs + 0.5];
};
const corner = (sx, sy) => [p.pos[0] + X[0] * hw * sx + Y[0] * hh * sy, p.pos[1] + X[1] * hw * sx + Y[1] * hh * sy, p.pos[2] + X[2] * hw * sx + Y[2] * hh * sy];
const q = [
[corner(-1, 1), uv(0, 0)],
[corner(1, 1), uv(1, 0)],
[corner(1, -1), uv(1, 1)],
[corner(-1, -1), uv(0, 1)],
];
for (const k of [0, 1, 2, 0, 2, 3]) {
const [pp, tt] = q[k];
data[o++] = pp[0];
data[o++] = pp[1];
data[o++] = pp[2];
data[o++] = tt[0];
data[o++] = tt[1];
data[o++] = c[0] / 255;
data[o++] = c[1] / 255;
data[o++] = c[2] / 255;
data[o++] = al;
}
}
out.push({ texture: s.texture, blend: BLEND[s.render] || BLEND.add, depth: s.depth, mod2x: s.mod2x, data, count: s.parts.length * 6 });
}
return out;
}
const count = (inst) => inst.systems.reduce((a, s) => a + s.parts.length, 0);
return { parse, instance, update, build, count };
})();
window.Effects = Effects;
File diff suppressed because it is too large. Load diff
+666
View File
@@ -0,0 +1,666 @@
"use strict";
// Small WebGL2 viewer for the model and map tools: textured meshes (positions can be updated
// every frame for CPU skinning), per-mesh position/scale, flat-coloured meshes (collision),
// wireframe, line / point overlays (grid, bones, boxes, markers), map lighting and fog, an orbit
// camera and picking rays.
// The game data is Direct3D left-handed (Y up); the scene is drawn through a Z mirror so it looks
// the same as in the client. Culling is off (many game materials are two-sided).
const GL3D = (() => {
// ---------- column-major mat4 (GL convention) ----------
const M = {
mul(a, b) {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
return o;
},
persp(fovy, aspect, near, far) {
const f = 1 / Math.tan(fovy / 2);
const o = new Float32Array(16);
o[0] = f / aspect;
o[5] = f;
o[10] = (far + near) / (near - far);
o[11] = -1;
o[14] = (2 * far * near) / (near - far);
return o;
},
lookAt(eye, at, up) {
const z = norm(sub(eye, at));
const x = norm(cross(up, z));
const y = cross(z, x);
return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]);
},
};
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const norm = (a) => {
const l = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / l, a[1] / l, a[2] / l];
};
const MIRROR = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1]); // D3D LH → GL RH
const IDENT3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
/** Mirror · translate · rotate · scale, for a mesh placed at pos (game coordinates). */
const placed = (pos, scale, rotation = [0, 0, 0]) => {
const [x, y, z] = rotation.map((v) => (v * Math.PI) / 180);
const cx = Math.cos(x), sx = Math.sin(x), cy = Math.cos(y), sy = Math.sin(y), cz = Math.cos(z), sz = Math.sin(z);
const rx = new Float32Array([1, 0, 0, 0, 0, cx, sx, 0, 0, -sx, cx, 0, 0, 0, 0, 1]);
const ry = new Float32Array([cy, 0, -sy, 0, 0, 1, 0, 0, sy, 0, cy, 0, 0, 0, 0, 1]);
const rz = new Float32Array([cz, sz, 0, 0, -sz, cz, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const r = M.mul(ry, M.mul(rx, rz));
const s = new Float32Array([scale[0], 0, 0, 0, 0, scale[1], 0, 0, 0, 0, scale[2], 0, 0, 0, 0, 1]);
const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, pos[0], pos[1], -pos[2], 1]);
return M.mul(t, M.mul(MIRROR, M.mul(r, s)));
};
const placedQuaternion = (pos, scale, rotation) => {
const length = Math.hypot(...rotation) || 1;
const [x, y, z, w] = rotation.map((v) => v / length);
const r = new Float32Array([
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0,
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0,
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0,
0, 0, 0, 1,
]);
const s = new Float32Array([scale[0], 0, 0, 0, 0, scale[1], 0, 0, 0, 0, scale[2], 0, 0, 0, 0, 1]);
const t = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, pos[0], pos[1], -pos[2], 1]);
return M.mul(t, M.mul(MIRROR, M.mul(r, s)));
};
const MESH_VS = `#version 300 es
layout(location=0) in vec3 aPos; layout(location=1) in vec3 aNrm;
layout(location=2) in vec2 aUv0; layout(location=3) in vec2 aUv1;
uniform mat4 uVP; uniform mat4 uModel;
out vec2 vUv0; out vec2 vUv1; out vec3 vN; out vec3 vW;
void main(){ vec4 w = uModel * vec4(aPos,1.0); vW = w.xyz; vN = mat3(uModel) * aNrm; vUv0 = aUv0; vUv1 = aUv1; gl_Position = uVP * w; }`;
const MESH_FS = `#version 300 es
precision highp float;
in vec2 vUv0; in vec2 vUv1; in vec3 vN; in vec3 vW;
uniform sampler2D uTex; uniform sampler2D uLm; uniform int uHasTex; uniform int uHasLm; uniform int uUvSet; uniform int uLmSet;
uniform int uLit; uniform vec3 uEye; uniform vec4 uTint; uniform float uAlphaCut; uniform int uHasNrm; uniform int uOpaque;
uniform int uFlat; uniform vec4 uColor; uniform mat3 uTexM; uniform mat3 uLmM; uniform float uColorMul; uniform float uEmit;
uniform int uLmAddDiffuse; uniform vec3 uDiffuse;
uniform int uEnv; uniform vec3 uAmbient; uniform vec3 uLightCol; uniform vec3 uLightDir;
uniform int uFog; uniform vec3 uFogCol; uniform vec2 uFogRange;
out vec4 o;
vec3 fogged(vec3 c){
if (uFog==0) return c;
float f = clamp((distance(vW, uEye) - uFogRange.x) / max(1.0, uFogRange.y - uFogRange.x), 0.0, 1.0);
return mix(c, uFogCol, f);
}
void main(){
vec3 n = uHasNrm==1 ? normalize(vN) : normalize(cross(dFdx(vW), dFdy(vW)));
if (uFlat==1) {
float d = abs(dot(n, normalize(vec3(0.3, 1.0, 0.2))));
o = vec4(uColor.rgb * (0.55 + 0.45 * d), uColor.a);
return;
}
vec2 tuv = (vec3(uUvSet==1 ? vUv1 : vUv0, 1.0) * uTexM).xy;
vec4 c = uHasTex==1 ? texture(uTex, tuv) : vec4(0.72,0.70,0.76,1.0);
if (c.a < uAlphaCut) discard;
float a = uOpaque==1 ? 1.0 : c.a;
vec3 emit = c.rgb * uEmit;
c.rgb *= uColorMul;
if (uHasLm==1) {
vec3 lm = texture(uLm, (vec3(uLmSet==1 ? vUv1 : vUv0, 1.0) * uLmM).xy).rgb;
c.rgb *= uLmAddDiffuse==1 ? min(vec3(1.0), lm + uDiffuse) : lm;
o = vec4(fogged(c.rgb + emit), a) * uTint; return;
}
vec3 light;
if (uEnv==1) {
float d = max(dot(n, -uLightDir), 0.0);
light = uAmbient + uLightCol * (0.35 + 0.65 * d);
} else {
vec3 l = normalize(uEye - vW + vec3(0.0, 60.0, 0.0));
light = vec3(uLit==1 ? 0.45 + 0.65 * abs(dot(n, l)) : 1.0);
}
o = vec4(fogged(c.rgb * light + emit), a) * uTint;
}`;
const LINE_VS = `#version 300 es
layout(location=0) in vec3 aPos; layout(location=1) in vec4 aCol;
uniform mat4 uVP; uniform mat4 uModel; uniform float uPoint;
out vec4 vCol;
void main(){ vCol = aCol; gl_Position = uVP * uModel * vec4(aPos,1.0); gl_PointSize = uPoint; }`;
const LINE_FS = `#version 300 es
precision mediump float; in vec4 vCol; uniform int uRound; out vec4 o;
void main(){
if (uRound==1) { float r = length(gl_PointCoord-vec2(0.5)); if (r>0.5) discard; o = vec4(r > 0.36 ? vCol.rgb*0.35 : vCol.rgb, vCol.a); return; }
o = vCol;
}`;
const PART_VS = `#version 300 es
layout(location=0) in vec3 aPos; layout(location=1) in vec2 aUv; layout(location=2) in vec4 aCol;
uniform mat4 uVP; uniform mat4 uModel;
out vec2 vUv; out vec4 vCol; out vec3 vW;
void main(){ vec4 w = uModel * vec4(aPos,1.0); vW = w.xyz; vUv = aUv; vCol = aCol; gl_Position = uVP * w; }`;
const PART_FS = `#version 300 es
precision mediump float;
in vec2 vUv; in vec4 vCol; in vec3 vW;
uniform sampler2D uTex; uniform int uHasTex; uniform float uMod;
out vec4 o;
void main(){
vec4 c = (uHasTex==1 ? texture(uTex, vUv) : vec4(1.0)) * vec4(vCol.rgb * uMod, vCol.a);
if (c.a <= 1.0/255.0) discard;
o = c;
}`;
function program(gl, vs, fs) {
const p = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(p, s);
}
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
const u = {};
const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) {
const name = gl.getActiveUniform(p, i).name;
u[name] = gl.getUniformLocation(p, name);
}
return { p, u };
}
class View {
constructor(canvas, options = {}) {
this.canvas = canvas;
const gl = (this.gl = canvas.getContext("webgl2", { antialias: true, preserveDrawingBuffer: true, alpha: options.alpha !== false, premultipliedAlpha: !!options.premultipliedAlpha }));
if (!gl) throw new Error("WebGL2 isn't available");
this.meshProg = program(gl, MESH_VS, MESH_FS);
this.lineProg = program(gl, LINE_VS, LINE_FS);
this.partProg = program(gl, PART_VS, PART_FS);
this.particles = []; // [{ tex, blend, depth, mod2x, data, count }]
this.partVao = gl.createVertexArray();
this.partBuf = gl.createBuffer();
gl.bindVertexArray(this.partVao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.partBuf);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 36, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 36, 12);
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 36, 20);
gl.bindVertexArray(null);
this.meshes = new Map(); // id → gpu mesh
this.overlays = new Map(); // name → { vao, count, mode, depth, point }
this.opts = { textures: true, wire: false, lit: true, solid: true };
/** Map lighting: { ambient:[r,g,b], lightColor:[r,g,b], lightDir:[x,y,z] (game coords), fog:{color,start,end}|null, clear:[r,g,b]|null } */
this.env = null;
this.cam = { yaw: 0.6, pitch: 0.35, dist: 300, target: [0, 80, 0], fov: 0.8 };
this.white = this.texture(new ImageData(new Uint8ClampedArray([255, 255, 255, 255]), 1, 1));
this.onPointerDown = null; // (e) → true when the tool takes the drag (no orbit)
this.beforeDraw = null;
this.onCamera = null;
this.dirty = true;
this.controls();
new ResizeObserver(() => this.resize()).observe(canvas.parentElement);
const loop = () => {
if (this.dirty && this.canvas.offsetParent) this.draw();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
resize() {
const r = this.canvas.parentElement.getBoundingClientRect();
const dpr = devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(r.width * dpr));
this.canvas.height = Math.max(1, Math.round(r.height * dpr));
this.canvas.style.width = `${r.width}px`;
this.canvas.style.height = `${r.height}px`;
this.dirty = true;
}
// ---------- resources ----------
texture(img) {
const gl = this.gl;
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
const pow2 = (n) => (n & (n - 1)) === 0;
if (pow2(img.width) && pow2(img.height)) {
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
} else gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
return t;
}
/**
* mesh: { pos, normal?, uv0?, uv1?, indices, groups: [{ start, count, tex?, lmTex?, uvSet, lmSet, alphaCut, blend, color? }] }
* opts: { pos, scale } places it (map objects); { wireColor } draws its wireframe always.
*/
addMesh(id, m, opts = {}) {
const gl = this.gl;
this.removeMesh(id);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const bufs = [];
const buf = (loc, data, size, usage = gl.STATIC_DRAW) => {
const b = gl.createBuffer();
bufs.push(b);
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, data, usage);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
return b;
};
const V = m.pos.length / 3;
const g = { vao, bufs, groups: m.groups, visible: true, hasNormal: !!m.normal, model: MIRROR, wireColor: opts.wireColor || null };
if (opts.pos || opts.scale || opts.rotation) g.model = placed(opts.pos || [0, 0, 0], opts.scale || [1, 1, 1], opts.rotation || [0, 0, 0]);
g.pos = buf(0, m.pos, 3, gl.DYNAMIC_DRAW);
g.nrm = buf(1, m.normal || new Float32Array(V * 3), 3, gl.DYNAMIC_DRAW);
buf(2, m.uv0 || new Float32Array(V * 2), 2);
buf(3, m.uv1 || m.uv0 || new Float32Array(V * 2), 2);
const idx = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices);
g.ibo = gl.createBuffer();
bufs.push(g.ibo);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
gl.bindVertexArray(null);
// wireframe: every triangle edge once
const seen = new Set();
const edges = [];
for (let i = 0; i + 2 < idx.length; i += 3) {
for (const [a, b] of [[idx[i], idx[i + 1]], [idx[i + 1], idx[i + 2]], [idx[i + 2], idx[i]]]) {
const k = a < b ? a * 4194304 + b : b * 4194304 + a;
if (!seen.has(k)) {
seen.add(k);
edges.push(a, b);
}
}
}
g.wvao = gl.createVertexArray();
gl.bindVertexArray(g.wvao);
gl.bindBuffer(gl.ARRAY_BUFFER, g.pos);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.disableVertexAttribArray(1);
g.wibo = gl.createBuffer();
bufs.push(g.wibo);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g.wibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(edges), gl.STATIC_DRAW);
g.wcount = edges.length;
gl.bindVertexArray(null);
this.meshes.set(id, g);
this.dirty = true;
}
setTransform(id, pos, scale = [1, 1, 1], rotation = [0, 0, 0]) {
const g = this.meshes.get(id);
if (g) g.model = placed(pos, scale, rotation);
this.dirty = true;
}
setTransformQuaternion(id, pos, scale, rotation) {
const g = this.meshes.get(id);
if (g) g.model = placedQuaternion(pos, scale, rotation);
this.dirty = true;
}
updateMesh(id, pos, normal) {
const g = this.meshes.get(id);
if (!g) return;
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, g.pos);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, pos);
if (normal) {
gl.bindBuffer(gl.ARRAY_BUFFER, g.nrm);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, normal);
}
this.dirty = true;
}
setGroupTexture(id, gi, tex, slot = "tex") {
const g = this.meshes.get(id);
if (g?.groups[gi]) g.groups[gi][slot] = tex;
this.dirty = true;
}
setVisible(id, v) {
const g = this.meshes.get(id);
if (g) g.visible = v;
this.dirty = true;
}
setTint(id, tint) {
const g = this.meshes.get(id);
if (g) g.tint = tint;
this.dirty = true;
}
setHighlight(id, on) {
const g = this.meshes.get(id);
if (g) g.highlight = on;
this.dirty = true;
}
removeMesh(id) {
const g = this.meshes.get(id);
if (!g) return;
const gl = this.gl;
gl.deleteVertexArray(g.vao);
gl.deleteVertexArray(g.wvao);
for (const b of g.bufs) gl.deleteBuffer(b);
this.meshes.delete(id);
this.dirty = true;
}
clear() {
for (const id of [...this.meshes.keys()]) this.removeMesh(id);
for (const n of [...this.overlays.keys()]) this.setOverlay(n, null);
}
/** Line / point overlay: data = Float32Array of x,y,z,r,g,b,a per vertex. */
setOverlay(name, data, { mode = "lines", depth = true, point = 6 } = {}) {
const gl = this.gl;
const old = this.overlays.get(name);
const visible = old ? old.visible : true;
if (old) {
gl.deleteVertexArray(old.vao);
gl.deleteBuffer(old.buf);
this.overlays.delete(name);
}
this.dirty = true;
if (!data || !data.length) return;
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 28, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 28, 12);
gl.bindVertexArray(null);
this.overlays.set(name, { vao, buf, count: data.length / 7, mode, depth, point, visible });
}
showOverlay(name, v) {
const o = this.overlays.get(name);
if (o) o.visible = v;
this.dirty = true;
}
// ---------- camera ----------
frame(min, max) {
const c = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
const r = Math.max(1, Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2);
this.cam.target = [c[0], c[1], -c[2]];
this.cam.dist = (r / Math.sin(this.cam.fov / 2)) * 1.05;
this.cam.radius = r;
this.home = { ...this.cam, target: this.cam.target.slice() };
this.moved();
}
reset() {
if (this.home) this.cam = { ...this.home, target: this.home.target.slice() };
this.moved();
}
/** Straight down (for placing things on the floor plan). */
topView() {
this.cam.pitch = 1.5;
this.cam.yaw = 0;
this.moved();
}
moved() {
this.dirty = true;
this.onCamera?.();
}
eye() {
const c = this.cam;
return [c.target[0] + c.dist * Math.cos(c.pitch) * Math.sin(c.yaw), c.target[1] + c.dist * Math.sin(c.pitch), c.target[2] + c.dist * Math.cos(c.pitch) * Math.cos(c.yaw)];
}
viewProj() {
const aspect = this.canvas.width / Math.max(1, this.canvas.height);
const r = this.cam.radius || 100;
const near = Math.max(0.05, this.cam.dist - r * 20) * 0.02 + 0.05;
const far = this.cam.dist + r * 40 + 1000;
return M.mul(M.persp(this.cam.fov, aspect, near, far), M.lookAt(this.eye(), this.cam.target, [0, 1, 0]));
}
/** Camera right / up / forward in game coordinates (for billboards). */
cameraAxes() {
const eye = this.eye();
const f = norm(sub(this.cam.target, eye));
const r = norm(cross(f, [0, 1, 0]));
const u = cross(r, f);
const g = (v) => [v[0], v[1], -v[2]];
return { right: g(r), up: g(u), dir: g(f) };
}
/** Particle quads to draw this frame (effects). */
setParticles(list) {
this.particles = list || [];
this.dirty = true;
}
/** Picking ray through a screen point (client px), in game coordinates. → { o, d } */
ray(clientX, clientY) {
const b = this.canvas.getBoundingClientRect();
const nx = ((clientX - b.left) / b.width) * 2 - 1;
const ny = 1 - ((clientY - b.top) / b.height) * 2;
const eye = this.eye();
const f = norm(sub(this.cam.target, eye));
const r = norm(cross(f, [0, 1, 0]));
const u = cross(r, f);
const t = Math.tan(this.cam.fov / 2);
const aspect = b.width / Math.max(1, b.height);
const d = norm([f[0] + nx * t * aspect * r[0] + ny * t * u[0], f[1] + nx * t * aspect * r[1] + ny * t * u[1], f[2] + nx * t * aspect * r[2] + ny * t * u[2]]);
return { o: [eye[0], eye[1], -eye[2]], d: [d[0], d[1], -d[2]] };
}
controls() {
const c = this.canvas;
let drag = null;
c.addEventListener("mousedown", (e) => {
e.preventDefault();
if (this.onPointerDown?.(e)) return;
drag = { x: e.clientX, y: e.clientY, pan: e.button !== 0 || e.shiftKey };
});
c.addEventListener("contextmenu", (e) => e.preventDefault());
window.addEventListener("mouseup", () => (drag = null));
window.addEventListener("mousemove", (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
drag.x = e.clientX;
drag.y = e.clientY;
if (drag.pan) {
const s = (this.cam.dist * Math.tan(this.cam.fov / 2) * 2) / c.clientHeight;
const yaw = this.cam.yaw;
const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
const up = [-Math.sin(this.cam.pitch) * Math.sin(yaw), Math.cos(this.cam.pitch), -Math.sin(this.cam.pitch) * Math.cos(yaw)];
for (let i = 0; i < 3; i++) this.cam.target[i] += -dx * s * right[i] + dy * s * up[i];
} else {
this.cam.yaw -= dx * 0.008;
this.cam.pitch = Math.max(-1.55, Math.min(1.55, this.cam.pitch + dy * 0.008));
}
this.moved();
});
c.addEventListener(
"wheel",
(e) => {
e.preventDefault();
this.cam.dist = Math.max(0.5, this.cam.dist * (e.deltaY < 0 ? 0.88 : 1 / 0.88));
this.moved();
},
{ passive: false },
);
c.addEventListener("dblclick", (e) => !this.onPointerDown && this.reset());
}
// ---------- drawing ----------
draw() {
const gl = this.gl;
this.dirty = false;
this.beforeDraw?.();
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
const env = this.env;
if (env?.clear) gl.clearColor(env.clear[0], env.clear[1], env.clear[2], 1);
else gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
const vp = this.viewProj();
const eye = this.eye();
// meshes: opaque first, then blended
const mp = this.meshProg;
gl.useProgram(mp.p);
gl.uniformMatrix4fv(mp.u.uVP, false, vp);
gl.uniform3fv(mp.u.uEye, eye);
gl.uniform1i(mp.u.uLit, this.opts.lit ? 1 : 0);
gl.uniform1i(mp.u.uTex, 0);
gl.uniform1i(mp.u.uLm, 1);
gl.uniform1i(mp.u.uEnv, env?.ambient ? 1 : 0);
if (env?.ambient) {
gl.uniform3fv(mp.u.uAmbient, env.ambient);
gl.uniform3fv(mp.u.uLightCol, env.lightColor || [0.6, 0.6, 0.6]);
const d = norm(env.lightDir || [0, -1, 0]);
gl.uniform3fv(mp.u.uLightDir, [d[0], d[1], -d[2]]);
}
gl.uniform1i(mp.u.uFog, env?.fog ? 1 : 0);
if (env?.fog) {
gl.uniform3fv(mp.u.uFogCol, env.fog.color);
gl.uniform2fv(mp.u.uFogRange, [env.fog.start, env.fog.end]);
}
if (this.opts.solid) {
for (const pass of [0, 1]) {
if (pass) {
// blended sub-meshes (decals, glows, shadows): after the opaque ones, no depth writes,
// pulled slightly forward so coplanar layers don't flicker
gl.enable(gl.BLEND);
gl.depthMask(false);
gl.depthFunc(gl.LEQUAL);
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(-1, -2);
}
for (const g of this.meshes.values()) {
if (!g.visible) continue;
gl.bindVertexArray(g.vao);
gl.uniformMatrix4fv(mp.u.uModel, false, g.model);
gl.uniform1i(mp.u.uHasNrm, g.hasNormal ? 1 : 0);
for (const grp of g.groups) {
const blended = grp.blend || (grp.color && grp.color[3] < 1);
if (!!blended !== !!pass || !grp.count) continue;
if (pass) this.blendMode(grp.blendFunc);
if (grp.color) {
gl.uniform1i(mp.u.uFlat, 1);
gl.uniform4fv(mp.u.uColor, grp.color);
gl.drawElements(gl.TRIANGLES, grp.count, gl.UNSIGNED_INT, grp.start * 4);
continue;
}
gl.uniform1i(mp.u.uFlat, 0);
const tex = this.opts.textures && grp.tex;
const lm = this.opts.textures && grp.lmTex;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, lm || this.white);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex || this.white);
gl.uniform1i(mp.u.uHasTex, tex ? 1 : 0);
gl.uniform1i(mp.u.uHasLm, lm ? 1 : 0);
gl.uniform1i(mp.u.uUvSet, grp.uvSet || 0);
gl.uniform1i(mp.u.uLmSet, grp.lmSet || 0);
gl.uniform1i(mp.u.uLmAddDiffuse, grp.lmAddDiffuse ? 1 : 0);
gl.uniform3fv(mp.u.uDiffuse, grp.diffuseColor || [1, 1, 1]);
gl.uniform1i(mp.u.uOpaque, pass ? 0 : 1);
gl.uniformMatrix3fv(mp.u.uTexM, false, grp.texM || IDENT3);
gl.uniformMatrix3fv(mp.u.uLmM, false, grp.lmM || IDENT3);
gl.uniform1f(mp.u.uColorMul, grp.colorMul ?? 1);
gl.uniform1f(mp.u.uEmit, grp.emissive || 0);
gl.uniform1f(mp.u.uAlphaCut, grp.blend ? grp.alphaCut ?? 0 : tex ? grp.alphaCut ?? 0.35 : 0);
gl.uniform4fv(mp.u.uTint, grp.highlight || g.highlight ? [1.3, 1.15, 0.7, 1] : g.tint || [1, 1, 1, 1]);
gl.drawElements(gl.TRIANGLES, grp.count, gl.UNSIGNED_INT, grp.start * 4);
}
}
gl.disable(gl.BLEND);
gl.depthMask(true);
gl.depthFunc(gl.LESS);
gl.disable(gl.POLYGON_OFFSET_FILL);
gl.blendEquation(gl.FUNC_ADD);
}
}
// particles: after the meshes, blended, no depth writes
if (this.particles.length) {
const pp = this.partProg;
gl.useProgram(pp.p);
gl.uniformMatrix4fv(pp.u.uVP, false, vp);
gl.uniformMatrix4fv(pp.u.uModel, false, MIRROR);
gl.uniform1i(pp.u.uTex, 0);
gl.bindVertexArray(this.partVao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.partBuf);
gl.enable(gl.BLEND);
gl.depthMask(false);
gl.activeTexture(gl.TEXTURE0);
for (const b of this.particles) {
if (!b.count) continue;
if (b.depth) gl.enable(gl.DEPTH_TEST);
else gl.disable(gl.DEPTH_TEST);
this.blendMode(b.blend);
gl.bindTexture(gl.TEXTURE_2D, b.tex || this.white);
gl.uniform1i(pp.u.uHasTex, b.tex ? 1 : 0);
gl.uniform1f(pp.u.uMod, b.mod2x ? 2 : 1);
gl.bufferData(gl.ARRAY_BUFFER, b.data, gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, b.count);
}
gl.depthMask(true);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.BLEND);
gl.blendEquation(gl.FUNC_ADD);
gl.bindVertexArray(null);
}
// wireframe + overlays
const lp = this.lineProg;
gl.useProgram(lp.p);
gl.uniformMatrix4fv(lp.u.uVP, false, vp);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.uniform1i(lp.u.uRound, 0);
for (const g of this.meshes.values()) {
if (!g.visible || (!this.opts.wire && !g.wireColor)) continue;
const w = g.wireColor || [0.55, 0.85, 1, this.opts.solid ? 0.45 : 0.8];
gl.vertexAttrib4f(1, w[0], w[1], w[2], w[3]); // constant colour (attribute 1 is off in the wire VAO)
gl.uniformMatrix4fv(lp.u.uModel, false, g.model);
gl.bindVertexArray(g.wvao);
gl.drawElements(gl.LINES, g.wcount, gl.UNSIGNED_INT, 0);
}
gl.uniformMatrix4fv(lp.u.uModel, false, MIRROR);
for (const o of this.overlays.values()) {
if (!o.visible) continue;
if (o.depth) gl.enable(gl.DEPTH_TEST);
else gl.disable(gl.DEPTH_TEST);
gl.uniform1f(lp.u.uPoint, o.point * (devicePixelRatio || 1));
gl.uniform1i(lp.u.uRound, o.mode === "points" ? 1 : 0);
gl.bindVertexArray(o.vao);
gl.drawArrays(o.mode === "points" ? gl.POINTS : gl.LINES, 0, o.count);
}
gl.disable(gl.BLEND);
gl.enable(gl.DEPTH_TEST);
gl.bindVertexArray(null);
}
/** Material scene_blend { op, src, dst } → GL state. Default: alpha blending. */
blendMode(b) {
const gl = this.gl;
const F = { zero: gl.ZERO, one: gl.ONE, src_color: gl.SRC_COLOR, inv_src_color: gl.ONE_MINUS_SRC_COLOR, src_alpha: gl.SRC_ALPHA, inv_src_alpha: gl.ONE_MINUS_SRC_ALPHA, dest_alpha: gl.DST_ALPHA, inv_dest_alpha: gl.ONE_MINUS_DST_ALPHA, dest_color: gl.DST_COLOR, inv_dest_color: gl.ONE_MINUS_DST_COLOR };
const O = { add: gl.FUNC_ADD, subtract: gl.FUNC_SUBTRACT, rev_subtract: gl.FUNC_REVERSE_SUBTRACT, min: gl.MIN, max: gl.MAX };
gl.blendEquationSeparate(O[b?.op] ?? gl.FUNC_ADD, gl.FUNC_ADD);
// Transparent views need accumulated alpha coverage. Effect preview uses
// an opaque clear target and keeps the native RGB blend factors above.
gl.blendFuncSeparate(F[b?.src] ?? gl.SRC_ALPHA, F[b?.dst] ?? gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
}
/** Screen position (CSS px) of a game-space point, or null behind the camera. */
project(p) {
const vp = this.viewProj();
const x = p[0], y = p[1], z = -p[2];
const cx = vp[0] * x + vp[4] * y + vp[8] * z + vp[12];
const cy = vp[1] * x + vp[5] * y + vp[9] * z + vp[13];
const cw = vp[3] * x + vp[7] * y + vp[11] * z + vp[15];
if (cw <= 0) return null;
return [((cx / cw + 1) / 2) * this.canvas.clientWidth, ((1 - cy / cw) / 2) * this.canvas.clientHeight];
}
}
return { View };
})();
window.GL3D = GL3D;
+43
View File
@@ -0,0 +1,43 @@
"use strict";
// Edit one INI value without rewriting comments, unrelated keys, or line endings.
const HeroIni = (() => {
const split = (raw) => raw.split(/\r\n|\n|\r/);
const ending = (raw) => raw.includes("\r\n") ? "\r\n" : raw.includes("\r") ? "\r" : "\n";
function parse(raw) {
const lines = split(raw), sections = [];
let current = null;
for (let i = 0; i < lines.length; i++) {
const header = /^\s*\[([^\]]+)\]\s*(?:;.*)?$/.exec(lines[i]);
if (header) {
if (current) current.end = i;
current = { name: header[1].trim(), start: i, end: lines.length, fields: [] };
sections.push(current);
continue;
}
const m = /^\s*([^\s=;#]+)\s*=\s*(.*)$/.exec(lines[i]);
if (m && current) current.fields.push({ key: m[1], value: m[2].trim(), line: i });
}
return { raw, lines, sections };
}
function section(doc, name) { return doc.sections.find((s) => s.name.toLowerCase() === name.toLowerCase()); }
function get(doc, name, key) {
const s = section(doc, name);
return s?.fields.findLast((f) => f.key.toLowerCase() === key.toLowerCase())?.value ?? "";
}
function set(raw, name, key, value) {
const doc = parse(raw), s = section(doc, name);
if (!s) throw new Error(`Section [${name}] is missing`);
const field = s.fields.findLast((f) => f.key.toLowerCase() === key.toLowerCase());
const lines = doc.lines;
if (field) {
const old = lines[field.line], prefix = /^\s*[^\s=;#]+\s*=\s*/.exec(old)?.[0] || `${key} = `;
lines[field.line] = prefix + value;
} else {
lines.splice(s.end, 0, `${key} = ${value}`);
}
return lines.join(ending(raw));
}
return { parse, section, get, set };
})();
if (typeof module !== "undefined") module.exports = HeroIni;
+847
View File
@@ -0,0 +1,847 @@
"use strict";
// Experimental editor for the existing mercenary INIs. Edits are exported as loose files.
(() => {
const HERO = { src: null, rows: [], id: "", name: "", docs: {}, tab: "gear", assetRows: {}, previewReady: false, actionToken: 0,
practice: { active: false, keys: new Set(), pos: [0, 0, 0], yaw: 0, vy: 0, grounded: true, dashUntil: 0, hp: 100, target: [0, 0, 220], combo: 0, comboAt: 0, directions: [], lastTap: {}, lastMotion: "", busy: false, chord: false } };
window.HERO = HERO;
HERO.openTab = (t) => document.querySelector(`[data-hero-tab="${t}"]`)?.click();
const kinds = ["item", "skill", "attack", "buff"];
const raw = (bytes) => {
let out = "";
for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.slice(i, i + 8192));
return out;
};
const doc = (kind) => HERO.docs[kind] && HeroIni.parse(HERO.docs[kind].text);
const value = (kind, section, key) => { const d = doc(kind); return d ? HeroIni.get(d, section, key) : ""; };
// web fix: STR(n) → the text the game shows (text table key ini_<file stem>::<section>_<n>)
const shown = (kind, section, v) => {
const m = /^\s*STR\(\s*(\d+)/i.exec(v || "");
return m ? HERO.strs?.[`ini_${HERO.id}_${kind}::${section}_${m[1]}`.toLowerCase()] || v : v;
};
const strText = (kind, section, key) => shown(kind, section, value(kind, section, key));
const dirty = () => kinds.filter((kind) => HERO.docs[kind]?.text !== HERO.docs[kind]?.saved);
const opt = (value, title, sub = "") => ({ value, title, sub });
function updateStatus() {
const changed = dirty();
$("hero-dirty").hidden = !changed.length;
$("hero-export").disabled = !changed.length;
$("hero-export-status").textContent = changed.length
? `Ready to export: ${changed.map((x) => `${HERO.id}_${x}.ini`).join(", ")}`
: "Only changed INI files will be exported. The game packs remain untouched.";
}
async function setSource(src) {
setPractice(false);
const d = await call("mdl:describe", src);
HERO.src = { ...src, label: d.label };
HERO.rows = await call("hero:list", HERO.src);
HERO.docs = {}; HERO.id = ""; HERO.name = "";
HERO.previewReady = false;
$("hero-src-label").textContent = HERO.src.kind === "dir" ? "Extracted folder" : HERO.src.kind === "packs" ? "Pack folder" : "Profile packs";
$("hero-title").textContent = "Hero Creator · experimental";
$("hero-work").hidden = true; $("hero-empty").hidden = false;
$("hero-preview").disabled = true;
HERO.assetRows = {};
renderList(); updateStatus();
}
async function chooseSource() {
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
const options = [
...profileSourceOption(),
...sessions.map((s) => opt({ kind: "session", sessionId: s.id, name: s.name }, `Packs next to ${s.name}`, s.file)),
opt("packs", "Folder with .iop packs…", "Client folder with rs.iop and resource packs"),
opt("dir", "Extracted folder…", "Folder containing config/mercenary"),
];
if (!options.length) return toast("No asset source found.", true);
let choice = options[0].value;
const body = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [
el("input", { type: "radio", name: "herosrc", checked: i === 0, onchange: () => { choice = o.value; } }),
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
])));
if (!(await modal("Hero source", body, [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return;
if (choice === "packs" || choice === "dir") {
const folder = await call("dialog:folder", "Choose the game resource folder");
if (!folder) return;
choice = { kind: choice, folder };
}
try { await setSource(choice); } catch (err) { toast(err.message, true); }
}
$("hero-src").onclick = chooseSource;
function renderList() {
const q = $("hero-search").value.trim().toLowerCase();
const rows = HERO.rows.filter(([id, name]) => !q || id.includes(q) || name.toLowerCase().includes(q));
$("hero-count").textContent = String(rows.length);
$("hero-list").replaceChildren(...rows.map(([id, name]) => el("button", {
class: `hero-row${id === HERO.id ? " on" : ""}`, onclick: () => openHero(id, name),
}, [el("span", { class: "hero-id", text: id }), el("span", { class: "hero-row-name", text: name })])));
}
$("hero-search").oninput = renderList;
HERO.setSource = (src) => setSource(src);
HERO.open = (id, name) => openHero(id, name || id);
async function openHero(id, name) {
if (dirty().length && !(await confirmBox("Unsaved Hero Creator changes", "Discard your current edits?", "Discard", "danger"))) return;
try {
setPractice(false);
const docs = {};
for (const kind of kinds) {
const hit = await call("mdl:readPath", HERO.src, `config/mercenary/${id}/${id}_${kind}.ini`);
if (hit) docs[kind] = { text: raw(hit.bytes), saved: raw(hit.bytes) };
}
if (!docs.item) throw new Error(`No ${id}_item.ini in this source`);
try { HERO.strs = await call("text:forStems", HERO.src, Object.keys(docs).map((k) => `${id}_${k}`)); } catch { HERO.strs = {}; }
HERO.id = id; HERO.name = name; HERO.docs = docs; HERO.tab = "gear";
HERO.previewReady = false;
$("hero-title").textContent = `${id} · ${name}`;
$("hero-name").textContent = `${id} · ${name}`;
$("hero-summary").textContent = "Edit gear appearance, move animations, skills and effect references. Export writes loose INI files for testing.";
$("hero-work").hidden = false; $("hero-empty").hidden = true;
$("hero-preview").disabled = false;
$("hero-practice").disabled = false;
for (const b of document.querySelectorAll("[data-hero-tab]")) b.classList.toggle("on", b.dataset.heroTab === "gear");
renderList(); renderContent(); renderActions(); updateStatus();
buildPreview();
} catch (err) { toast(err.message, true); }
}
for (const b of document.querySelectorAll("[data-hero-tab]")) b.onclick = () => {
HERO.tab = b.dataset.heroTab;
for (const x of document.querySelectorAll("[data-hero-tab]")) x.classList.toggle("on", x === b);
renderContent();
};
function edit(kind, section, key, newValue) {
if (/[\r\n]/.test(newValue) || [...newValue].some((c) => c.charCodeAt(0) > 255)) return toast("Use a single-line 8-bit INI value.", true);
const d = HERO.docs[kind];
d.text = HeroIni.set(d.text, section, key, newValue);
if (kind === "item" || kind === "skill" || kind === "attack") renderActions();
if (kind === "item" && isMesh(key) && /^(?:equip|body)_mesh/i.test(key)) {
HERO.previewReady = false;
clearTimeout(HERO.previewTimer);
HERO.previewTimer = setTimeout(buildPreview, 250);
}
updateStatus();
}
const isMesh = (key) => /^(equip_mesh\d*|body_mesh\d*|field_mesh\d*|collision_mesh|mesh_file)$/i.test(key);
const isMove = (_key, val) => /\.ani(?:\s|$)/i.test(val);
const isFx = (key, val) => /effect/i.test(key) && /\.txt(?:\s|$)/i.test(val);
const assetType = (key, val) => isMesh(key) ? "mesh" : /\.ani(?:\s|$)/i.test(val) ? "animation" : /\.txt(?:\s|$)/i.test(val) ? "effect" : "";
function fieldRow(kind, section, f) {
const input = el("input", { type: "text", value: f.value, spellcheck: "false", title: `${kind}.ini [${section}] ${f.key}` });
input.addEventListener("change", () => edit(kind, section, f.key, input.value));
input.addEventListener("focus", () => {
$("hero-field-detail").textContent = `${HERO.id}_${kind}.ini · [${section}] · ${f.key}`;
});
const type = assetType(f.key, f.value);
const buttons = [];
if (type) buttons.push(el("button", { class: "iconbtn only", title: `Choose ${type}`, text: "…", onclick: async () => {
const picked = await pickAsset(type, input.value);
if (picked) { input.value = picked; edit(kind, section, f.key, picked); }
}}));
if (type) buttons.push(el("button", { class: "iconbtn only", title: `Preview ${type}`, text: "View", onclick: () => previewAsset(type, input.value) }));
return el("div", { class: "hero-field" }, [el("label", { text: f.key }), input, el("span", {}, buttons)]);
}
function card(title, fields, opts = {}) {
const head = el("div", { class: "hero-card-head" }, [el("h3", { text: title }), el("div", { class: "spacer" }), ...(opts.actions || [])]);
return el("section", { class: "hero-card" }, [head, el("div", { class: "hero-fields" }, fields)]);
}
const itemSections = () => doc("item")?.sections.filter((s) => /^item\d+$/i.test(s.name)) || [];
function renderGear() {
return itemSections().map((s) => {
const name = strText("item", s.name, "name");
const slot = value("item", s.name, "item_type") || s.name;
const fields = s.fields.filter((f) => isMesh(f.key));
const donor = el("button", { class: "iconbtn", text: "Copy appearance…", onclick: () => copyAppearance(s.name) });
return card(`${slot} · ${name}`, fields.map((f) => fieldRow("item", s.name, f)), { actions: [donor] });
});
}
function renderMoves() {
const cards = [];
for (const s of itemSections()) {
const fields = s.fields.filter((f) => isMove(f.key, f.value));
if (fields.length) cards.push(card(`${value("item", s.name, "item_type") || s.name} · ${strText("item", s.name, "name")}`, fields.map((f) => fieldRow("item", s.name, f))));
}
return cards;
}
function renderSkills() {
const d = doc("skill");
if (!d) return [];
return d.sections.filter((s) => /^skill\d+$/i.test(s.name)).map((s) => {
const fields = s.fields.filter((f) => isMove(f.key, f.value) || isFx(f.key, f.value) || /^(name|desc_name|need_gauge|type|sub_type)$/i.test(f.key));
return card(`${s.name} · ${strText("skill", s.name, "desc_name") || strText("skill", s.name, "name")}`, fields.map((f) => fieldRow("skill", s.name, f)));
});
}
function renderEffects() {
const cards = [];
for (const kind of kinds) {
const d = doc(kind);
if (!d) continue;
for (const s of d.sections) {
const fields = s.fields.filter((f) => isFx(f.key, f.value));
if (fields.length) cards.push(card(`${kind.toUpperCase()} · [${s.name}]`, fields.map((f) => fieldRow(kind, s.name, f))));
}
}
return cards;
}
// =====================================================================
// Flow: input → animation (.ani events) → hits (fire_s n ↔ attack_type n) → [attributeN] → effects /
// sounds / buffs, with the checks the client would fail on (ioBaseChar::RefreshFireTimeListWithBase,
// ioPlayStage::AddWeaponExplosionEffect, ioClientAniEventHandler)
// =====================================================================
const FLOW_LABELS = { normal_attack: "D combo", dash_attack: "Dash + D", extra_dash_attack: "Dash + D (extra)", extend_dash_attack: "Dash + D (extended)", jump_attack: "Air + D", extend_jump_attack: "Air + D (extended)", land_jump_attack: "Landing + D", counter_attack: "Counter", extend_attack: "Hold D", normal_charged: "Charged D", jump_charged: "Charged air D", dash_jump_attack: "Dash jump + D", retreat_attack: "Retreat attack", attack: "Skill", jump_attack_skill: "Skill in the air" };
const ATTR_TYPES = { 0: "dummy", 1: "contact (hitbox)", 2: "missile", 3: "throw", 4: "bomb", 5: "mine" };
const flowNames = new Map();
function flowList(folder) {
const key = `${folder}|${HERO.src?.folder || HERO.src?.sessionId || ""}`;
if (!flowNames.has(key)) flowNames.set(key, call("mdl:names", HERO.src, folder).then((l) => new Set(l.map((n) => n.toLowerCase().split("/").pop()))).catch(() => new Set()));
return flowNames.get(key);
}
const fname = (v) => String(v || "").replace(/\s*;.*$/, "").trim().split(/\s+/)[0];
/** Every attacking action of the hero: { kind, section, label, ani, rate, types: [{ id, wounded }] } */
function flowActions() {
const out = [];
for (const kind of ["item", "skill"]) {
const d = doc(kind);
if (!d) continue;
for (const s of d.sections) {
const get = (k) => fname(s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value);
const title = kind === "skill" ? `${s.name} · ${get("desc_name") && !/^STR\(/.test(get("desc_name")) ? get("desc_name") : get("name") || ""}` : `${get("item_type") || s.name} · ${s.name}`;
for (const f of s.fields) {
const m = /^(.*)_type_count$/i.exec(f.key);
if (!m) continue;
const p = m[1], count = Number.parseInt(fname(f.value), 10) || 0;
const aniKey = kind === "skill" && /^attack$/i.test(p) ? "skill_motion" : kind === "skill" && /^jump_attack$/i.test(p) ? "jump_skill_motion" : p;
const ani = get(aniKey);
if (!/\.ani$/i.test(ani)) continue;
const rate = Number.parseFloat(get(`${p}_time_rate`) || (kind === "skill" ? get(aniKey === "jump_skill_motion" ? "jump_animation_time_rate" : "animation_time_rate") : "") || "1") || 1;
const types = Array.from({ length: count }, (_, i) => ({ id: get(`${p}_type${i + 1}`), wounded: get(`${p}_type${i + 1}_wounded`) }));
const base = p.replace(/\d+$/, ""), num = (/(\d+)$/.exec(p) || [])[1];
const label = kind === "skill" ? `${title}${/^jump_attack$/i.test(p) ? " (in the air)" : /^attack$/i.test(p) ? "" : ` (${p})`}` : `${FLOW_LABELS[base] || p}${num ? ` ${Number(num)}` : ""}`;
out.push({ kind, section: s.name, title, label, key: p, aniKey, ani, rate, types, preDelay: kind === "skill" ? get("pre_delay_animation") : "", preEffect: kind === "skill" ? get("pre_delay_effect") : "", gauge: kind === "skill" ? get("need_gauge") : "" });
}
}
}
return out;
}
function flowAttr(id) {
const d = doc("attack");
const s = d && HeroIni.section(d, `attribute${id}`);
if (!s) return null;
const get = (k) => fname(s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value).replace(/^(-?[0-9.]+)f$/i, "$1");
const buffs = Array.from({ length: Number.parseInt(get("target_buff_cnt"), 10) || 0 }, (_, i) => get(`target_buff${i + 1}`)).filter(Boolean);
return { id, type: Number(get("type") || 0), damage: get("damage_rate"), push: get("push_power"), blow: get("blow_power"), airBlow: get("air_blow_power"),
explosion: get("explosion_effect"), extra: get("extra_explosion_effect"), weaponEffect: get("weapon_effect"), wounded: get("wounded_sound"), defensed: get("defensed_sound"),
buffs, breakBuff: get("defense_break_buff"), shake: get("shake_camera") };
}
const aniCache = new Map();
async function flowAni(name) {
const key = name.toLowerCase();
if (!aniCache.has(key)) aniCache.set(key, call("mdl:read", HERO.src, name, "animation", true).then((h) => (h ? LSF.parseAni(h.bytes) : null)).catch(() => null));
return aniCache.get(key);
}
let flowAudio = null;
async function flowPlay(path) {
try {
const h = await call("mdl:readPath", HERO.src, `wave/${path}`) || await call("mdl:readPath", HERO.src, `wave/${path.replace(/(\.\w+)$/, "_1$1")}`);
if (!h) return toast(`${path} is not in the packs`, true);
flowAudio ??= new AudioContext();
const b = await flowAudio.decodeAudioData(h.bytes.slice().buffer);
const s = flowAudio.createBufferSource(); s.buffer = b; s.connect(flowAudio.destination); s.start();
} catch (err) { toast(err.message, true); }
}
async function flowOpenAni(name) {
document.querySelector('[data-view="ans"]').click();
if (!window.ANS?.setSource) return;
if (ANS.src?.folder !== HERO.src.folder || ANS.src?.kind !== HERO.src.kind) await ANS.setSource(HERO.src, { quiet: true });
for (let i = 0; i < 50 && !(ANS.rows && ANS.rows.length); i++) await new Promise((r) => setTimeout(r, 100));
const row = ANS.rows.find((r) => r[3] === "ani" && `${r[1]}.ani`.toLowerCase() === name.toLowerCase());
if (!row) return toast(`${name} is not in the Anim Studio list`, true);
await ANS.openRow(row);
if (ANS.setHit) ANS.setHit({ hero: HERO.id, weapon: "" });
}
async function flowOpenFx(name) {
try {
const h = await call("mdl:read", HERO.src, name, "effect", true);
if (!h) return toast(`${name} is not in the packs`, true);
let text = "";
for (let i = 0; i < h.bytes.length; i += 8192) text += String.fromCharCode(...h.bytes.subarray(i, i + 8192));
document.querySelector('[data-view="fxlab"]').click();
FXL.openScript(text, name, h.where);
} catch (err) { toast(err.message, true); }
}
const chip = (text, cls = "", attrs = {}) => el("span", { class: `flow-chip ${cls}`, text, ...attrs });
function fileChip(name, kind, exists) {
if (!name || /^none$/i.test(name)) return null;
const missing = exists && !exists.has(name.toLowerCase().split("/").pop()) && !(kind === "sound" && exists.has(name.toLowerCase().split("/").pop().replace(/(\.\w+)$/, "_1$1")));
const kids = [el("span", { text: name })];
if (kind === "sound") kids.push(el("button", { class: "flow-mini", title: "Listen", onclick: () => flowPlay(name) }, [icon("play")]));
if (kind === "effect") kids.push(el("button", { class: "flow-mini", title: "Open in FX Lab", onclick: () => flowOpenFx(name) }, [icon("brush")]));
return el("span", { class: `flow-chip file ${kind}${missing ? " bad" : ""}`, title: missing ? `${name}: not in the packs` : name }, kids);
}
function renderFlow() {
const box = el("div", { class: "flow" }, [el("div", { class: "muted", text: "Reading the animations…" })]);
const seq = (HERO.flowSeq = (HERO.flowSeq || 0) + 1);
(async () => {
const [effects, waves] = await Promise.all([flowList("effect"), flowList("wave")]);
const acts = flowActions();
const cards = [];
let problems = 0;
for (const a of acts) {
const data = await flowAni(a.ani);
if (seq !== HERO.flowSeq) return;
const ev = (data?.events || []).map((e) => ({ t: e.time, type: e.type.text.toLowerCase(), info: e.sub.text.trim() })).sort((x, y) => x.t - y.t);
const starts = ev.filter((e) => e.type === "fire_s"), ends = ev.filter((e) => e.type === "fire_e");
const ok = data && starts.length === a.types.length;
if (!ok) problems++;
const rateT = (t) => Math.round(t * a.rate);
const others = ev.filter((e) => !/^fire_[se]$/.test(e.type) && /sound|effect|face_change|trail_s|auto_shake/.test(e.type));
const hits = a.types.map((ty, i) => {
const s = starts[i], e = ends[i], at = flowAttr(ty.id);
const box2 = s ? (s.info.split(/\s+/)[0] || "front of the body") : "—";
return el("div", { class: `flow-hit${!s ? " bad" : ""}` }, [
el("div", { class: "flow-hit-head" }, [
chip(`hit ${i + 1}`, "n"),
chip(s ? `${rateT(s.t)}–${rateT(e ? e.t : s.t)} ms` : "no fire_s", s ? "" : "bad"),
chip(box2, "box"),
el("span", { class: "flow-arrow", text: "→" }),
chip(`attribute${ty.id}`, at ? "attr" : "bad"),
at ? chip(ATTR_TYPES[at.type] || `type ${at.type}`) : null,
at?.damage ? chip(`damage ×${at.damage}`) : null,
at && (at.push || at.blow) ? chip(`push ${at.push || 0} · blow ${at.blow || 0}${at.airBlow ? ` · air ${at.airBlow}` : ""}`) : null,
]),
at ? el("div", { class: "flow-hit-body" }, [
at.explosion ? el("span", { class: "flow-l" }, [el("em", { text: "on target" }), fileChip(at.explosion, "effect", effects)]) : null,
at.extra ? el("span", { class: "flow-l" }, [el("em", { text: "attacker only" }), fileChip(at.extra, "effect", effects)]) : null,
at.weaponEffect ? el("span", { class: "flow-l" }, [el("em", { text: "projectile" }), fileChip(at.weaponEffect, "effect", effects)]) : null,
at.wounded ? el("span", { class: "flow-l" }, [el("em", { text: "hit sound" }), fileChip(at.wounded, "sound", waves)]) : null,
at.defensed ? el("span", { class: "flow-l" }, [el("em", { text: "blocked sound" }), fileChip(at.defensed, "sound", waves)]) : null,
...at.buffs.map((b) => el("span", { class: "flow-l" }, [el("em", { text: "buff on target" }), chip(b, "buff")])),
at.breakBuff ? el("span", { class: "flow-l" }, [el("em", { text: "guard break" }), chip(at.breakBuff, "buff")]) : null,
ty.wounded ? el("span", { class: "flow-l" }, [el("em", { text: "target plays" }), chip(ty.wounded)]) : null,
]) : el("div", { class: "flow-hit-body bad", text: `[attribute${ty.id}] is not in ${HERO.id}_attack.ini` }),
]);
});
if (starts.length > a.types.length) hits.push(el("div", { class: "flow-hit bad" }, [chip(`${starts.length - a.types.length} extra fire_s without an attack type`, "bad")]));
const evChips = others.map((e) => {
const kind = /sound/.test(e.type) ? "sound" : /effect/.test(e.type) ? "effect" : "";
return el("span", { class: "flow-l" }, [el("em", { text: `${rateT(e.t)} ms ${e.type}` }), kind ? fileChip(e.info.split(/\s+/)[0], kind, kind === "sound" ? waves : effects) : chip(e.info || "—")]);
});
cards.push(el("section", { class: `flow-card${ok ? "" : " bad"}` }, [
el("div", { class: "flow-head" }, [
el("b", { text: a.label }),
el("span", { class: "muted", text: `${a.title} · ${a.kind}.ini ${a.key}` }),
el("div", { class: "spacer" }),
el("span", { class: `flow-check ${ok ? "ok" : "bad"}`, text: !data ? "✗ animation not in the packs" : ok ? `✓ ${starts.length} fire_s = ${a.types.length} attack types` : `✗ ${starts.length} fire_s ≠ ${a.types.length} attack types (the attack never fires)` }),
]),
el("div", { class: "flow-row" }, [
a.preDelay ? el("div", { class: "flow-step" }, [el("em", { text: "ready" }), chip(a.preDelay), a.preEffect ? fileChip(a.preEffect, "effect", effects) : null]) : null,
a.preDelay ? el("span", { class: "flow-arrow", text: "→" }) : null,
el("div", { class: "flow-step" }, [
el("em", { text: `animation${a.rate !== 1 ? ` ×${a.rate}` : ""}${data ? ` · ${rateT(data.total || 0)} ms` : ""}` }),
el("span", { class: `flow-chip ani${data ? "" : " bad"}` }, [el("span", { text: a.ani }), el("button", { class: "flow-mini", title: "Open in Anim Studio (with the hitbox check)", onclick: () => flowOpenAni(a.ani) }, [icon("bone")])]),
]),
evChips.length ? el("span", { class: "flow-arrow", text: "·" }) : null,
evChips.length ? el("div", { class: "flow-step flow-events" }, evChips) : null,
]),
el("div", { class: "flow-hits" }, hits.length ? hits : [el("div", { class: "muted", text: "No attack types." })]),
]));
}
if (seq !== HERO.flowSeq) return;
box.replaceChildren(
el("div", { class: `flow-summary ${problems ? "bad" : "ok"}`, text: `${acts.length} attacking actions · ${problems ? `${problems} with a fire_s / attack type mismatch or a missing animation` : "all fire_s counts match"}. Red files are not in the packs.` }),
...cards,
);
})().catch((err) => box.replaceChildren(el("div", { class: "bad", text: err.message })));
return [box];
}
function renderContent() {
if (!HERO.id) return;
const cards = HERO.tab === "gear" ? renderGear() : HERO.tab === "moves" ? renderMoves() : HERO.tab === "skills" ? renderSkills() : HERO.tab === "flow" ? renderFlow() : renderEffects();
$("hero-content").replaceChildren(...(cards.length ? cards : [el("div", { class: "hero-empty-note", text: "No matching fields in this hero's config." })]));
}
async function copyAppearance(section) {
const idInput = el("input", { type: "text", placeholder: "Hero ID, e.g. 052", spellcheck: "false" });
if (!(await modal("Copy gear appearance", [
el("p", { text: "Copy mesh references from the same gear slot of another hero. Item codes and combat data stay with the base hero." }),
el("label", { text: `Source hero for ${section}` }), idInput,
], [{ label: "Cancel", value: false }, { label: "Copy meshes", kind: "primary", value: true }]))) return;
const id = idInput.value.trim().padStart(3, "0");
if (!/^\d{3}$/.test(id)) return toast("Enter a 3-digit hero ID.", true);
try {
const hit = await call("mdl:readPath", HERO.src, `config/mercenary/${id}/${id}_item.ini`);
if (!hit) throw new Error(`Hero ${id} gear config was not found`);
const donor = HeroIni.parse(raw(hit.bytes)), s = HeroIni.section(donor, section);
if (!s) throw new Error(`Donor has no [${section}]`);
const meshFields = s.fields.filter((f) => isMesh(f.key));
if (!meshFields.length) throw new Error(`Donor [${section}] has no mesh references`);
for (const f of meshFields) edit("item", section, f.key, f.value);
renderContent(); toast(`Copied ${meshFields.length} mesh fields from hero ${id}.`);
} catch (err) { toast(err.message, true); }
}
async function assetsFor(type) {
if (HERO.assetRows[type]) return HERO.assetRows[type];
const rows = type === "effect" ? await call("fx:list", HERO.src) : await call("mdl:list", HERO.src);
HERO.assetRows[type] = rows.map((r) => r[1].split("/").pop()).filter((name) =>
type === "effect" ? /\.txt$/i.test(name) : type === "animation" ? /\.ani$/i.test(name) : /\.msh$/i.test(name));
return HERO.assetRows[type];
}
async function pickAsset(type, current) {
try {
const rows = await assetsFor(type);
const search = el("input", { type: "search", value: current.replace(/\.[^.]+$/, ""), placeholder: `Search ${type}s`, spellcheck: "false" });
const list = el("div", { class: "hero-picker-list" });
let chosen = "";
const render = () => {
const q = search.value.trim().toLowerCase();
list.replaceChildren(...rows.filter((x) => !q || x.toLowerCase().includes(q)).slice(0, 100).map((name) => el("button", {
class: `hero-picker-row${name === chosen ? " on" : ""}`, text: name,
onclick: () => { chosen = name; render(); }, ondblclick: () => { chosen = name; $("modal-actions").lastChild.click(); },
})));
};
search.oninput = render; render();
const ok = await modal(`Choose ${type}`, [search, list], [{ label: "Cancel", value: false }, { label: "Use selected", kind: "primary", value: true }]);
return ok ? chosen : "";
} catch (err) { toast(err.message, true); return ""; }
}
async function previewAsset(type, name) {
if (!name) return;
try {
if (type === "effect") {
await FXE.previewAsset(HERO.src, name);
} else if (type === "animation") {
await MDL.previewHero(HERO.src, gearMeshes(), name, { hair: helmetHair() });
} else {
await MDL.previewAsset(HERO.src, name, type);
}
} catch (err) { toast(err.message, true); }
}
// web fix: the helmet section (item_type HELMET) and its hair rule for the man body (default 0 = half hair)
function helmetHair() {
const s = itemSections().find((x) => /^helmet$/i.test(value("item", x.name, "item_type")) && value("item", x.name, "equip_mesh0"));
if (!s) return null;
const v = value("item", s.name, "hair_change_all0_hm");
return v === "" ? 0 : Number(v);
}
const gearMeshes = () => itemSections().flatMap((s) => s.fields.filter((f) => /^(?:equip|body)_mesh\d*$/i.test(f.key)).map((f) => f.value)).filter(Boolean);
async function buildPreview() {
if (!HERO.id) return;
const meshes = gearMeshes();
const animation = value("item", "item1", "delay_animation") || value("item", "item1", "normal_attack01");
$("hero-preview-status").textContent = "Loading body, gear and animation…";
$("hero-play-status").textContent = "Loading hero…";
try {
await MDL.previewHero(HERO.src, meshes, animation, { embedded: true, hair: helmetHair() });
if (HERO.practice.active) { MDL.setHeroTransform(HERO.practice.pos, HERO.practice.yaw); MDL.setHeroPracticeTarget(HERO.practice.target); }
HERO.previewReady = true;
$("hero-preview-status").textContent = `${meshes.length} gear meshes · ${animation || "default pose"}`;
$("hero-play-status").textContent = "Ready · drag to rotate, wheel to zoom";
} catch (err) { HERO.previewReady = false; $("hero-preview-status").textContent = err.message; $("hero-play-status").textContent = "Preview unavailable"; toast(err.message, true); }
}
$("hero-preview").onclick = buildPreview;
const ani = (s) => /\.ani$/i.test(String(s || "").trim()) ? String(s).trim() : "";
const fx = (s) => /\.txt$/i.test(String(s || "").trim()) ? String(s).trim() : "";
function attributeSummary(index) {
if (!index || !doc("attack")) return "";
const section = `attribute${index}`;
if (!HeroIni.section(doc("attack"), section)) return `attack ${index}`;
const damage = value("attack", section, "damage_rate");
const effect = fx(value("attack", section, "explosion_effect")) || fx(value("attack", section, "extra_explosion_effect"));
return `attack ${index}${damage ? ` · damage ${damage}` : ""}${effect ? ` · ${effect}` : ""}`;
}
function attackRefs(kind, section, prefix) {
const count = Math.min(20, Number.parseInt(value(kind, section, `${prefix}_type_count`) || value(kind, section, "attack_type_count"), 10) || 0);
return Array.from({ length: count }, (_, i) => value(kind, section, `${prefix}_type${i + 1}`) || value(kind, section, `attack_type${i + 1}`)).filter(Boolean);
}
function actionEffect(kind, section, refs, preferred = []) {
for (const key of preferred) {
const name = fx(value(kind, section, key));
if (name) return name;
}
for (const index of refs) {
const name = fx(value("attack", `attribute${index}`, "explosion_effect")) || fx(value("attack", `attribute${index}`, "extra_explosion_effect"));
if (name) return name;
}
return "";
}
function iconCanvas(name) {
const canvas = el("canvas", { class: "hero-skill-icon", width: 40, height: 40, title: name || "No icon" });
if (!name) return canvas;
(async () => {
try {
await HERO.iconReady;
if (!canvas.isConnected) return;
const i = name.indexOf("#");
const image = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : await UIRes.image(null, null, name);
if (!image?.bmp || !canvas.isConnected) return;
const scale = Math.min(40 / image.w, 40 / image.h);
const w = image.w * scale, h = image.h * scale;
canvas.getContext("2d").drawImage(image.bmp, image.x, image.y, image.w, image.h, (40 - w) / 2, (40 - h) / 2, w, h);
} catch (err) { console.warn(`Hero icon ${name}: ${err.message}`); }
})();
return canvas;
}
function makeAction(label, subtitle, action, iconName = "") {
const children = iconName ? [iconCanvas(iconName)] : [];
children.push(el("span", {}, [el("b", { text: label }), el("small", { text: subtitle || "" })]));
const button = el("button", { class: "hero-action", onclick: (e) => runAction(action, e.currentTarget), title: action.description }, children);
button.heroAction = action;
return button;
}
function skillSpec(section, label) {
const title = strText("skill", section, "desc_name") || strText("skill", section, "name");
const animationKeys = ["pre_delay_animation", "skill_motion", "skill_loop_motion", "loop_animation", "skill_end_attack", "end_animation"];
const animations = animationKeys.map((key) => ani(value("skill", section, key))).filter(Boolean);
const refs = attackRefs("skill", section, "attack");
const effect = actionEffect("skill", section, refs, ["pre_delay_all_effect", "skill_charging_effect", "pre_delay_effect", "skill_map_effect", "range_effect"]);
const buffs = Array.from({ length: Math.min(12, Number.parseInt(value("skill", section, "owner_buff_cnt"), 10) || 0) }, (_, i) => value("skill", section, `owner_buff${i + 1}`)).filter(Boolean);
const description = [title, value("skill", section, "desc"), animations.length ? "" : "Passive / no animation", ...animations, ...buffs.map((b) => `buff ${b}`), ...refs.map(attributeSummary)].filter(Boolean).join("\n");
return { title, action: { label, animations, effect, refs, description } };
}
function renderActions() {
if (HERO.iconSource !== HERO.src) {
HERO.iconSource = HERO.src;
HERO.iconReady = UIRes.load(HERO.src).catch((err) => console.warn(`Hero icons: ${err.message}`));
}
const weapon = "item1", max = Math.min(12, Number.parseInt(value("item", weapon, "max_combo"), 10) || 0);
const normal = Array.from({ length: max }, (_, i) => {
const key = `normal_attack${String(i + 1).padStart(2, "0")}`;
return { key, name: ani(value("item", weapon, key)), refs: attackRefs("item", weapon, key) };
}).filter((step) => step.name);
const combos = normal.map((_, i) => {
const steps = normal.slice(0, i + 1);
const refs = steps.flatMap((s) => s.refs);
const label = Array(i + 1).fill("D").join(" ");
return makeAction(label, `${i + 1} hit${i ? "s" : ""}`, {
label, animations: steps.map((s) => s.name), effect: actionEffect("item", weapon, refs), effectTarget: true, refs,
description: `${steps.map((s) => s.name).join(" → ")}\n${refs.map(attributeSummary).join("\n")}`,
});
});
// The client has many item subclasses with their own attack families. Use
// the animation-bearing config fields so no subclass needs a hardcoded list.
const fields = HeroIni.section(doc("item"), weapon)?.fields || [];
const used = new Set(normal.map((s) => s.key.toLowerCase()));
const addMove = (label, keys) => {
const steps = keys.map((key) => ({ key, name: ani(value("item", weapon, key)), refs: attackRefs("item", weapon, key) })).filter((x) => x.name);
if (!steps.length) return;
steps.forEach((x) => used.add(x.key.toLowerCase()));
const refs = steps.flatMap((x) => x.refs);
combos.push(makeAction(label, steps.map((x) => x.name).join(" → "), {
label, animations: steps.map((x) => x.name), effect: actionEffect("item", weapon, refs), effectTarget: true, refs,
description: steps.map((x) => `[${x.key}] ${x.name}`).concat(refs.map(attributeSummary)).join("\n"),
}));
};
const commandIds = [...new Set(fields.map((f) => /^((?:jump_)?cmd_info)(\d+)$/.exec(f.key.toLowerCase())).filter(Boolean).map((m) => `${m[1]}${m[2]}`))];
for (const id of commandIds) {
const m = /^((?:jump_)?cmd_info)(\d+)$/.exec(id), base = `${m[1]}${m[2]}`;
const code = value("item", weapon, base);
const arrows = { "1": "↑", "2": "→", "3": "↓", "4": "←" };
const label = `${m[1].startsWith("jump") ? "Air " : ""}${[...code].map((c) => arrows[c] || c).join("")}`;
const keys = fields.filter((f) => new RegExp(`^${base}_combo\\d+$`, "i").test(f.key) && ani(f.value)).map((f) => f.key);
if (keys.length) addMove(label, keys);
if (!keys.length && value("item", weapon, `${base}_skill_use`) === "1") {
const slot = Number(value("item", weapon, `${base}_skill_slot`));
const spec = skillSpec(`skill${slot + 1}`, label);
combos.push(makeAction(label, spec.title || `Skill slot ${slot + 1}`, spec.action));
}
for (const suffix of ["d", "s"]) {
const branches = fields.filter((f) => new RegExp(`^${base}_combo\\d+_${suffix}$`, "i").test(f.key) && ani(f.value)).map((f) => f.key);
for (const branch of branches) addMove(`${label} + ${suffix.toUpperCase()}`, [...keys.slice(0, -1), branch]);
}
}
const labels = { dash_attack: "Dash + D", assault_dash_attack: "Assault dash + D", jump_attack: "Air + D", extend_jump_attack: "Extended air + D", land_jump_attack: "Landing + D", counter_attack: "Counter", run_animation: "Run", dash_animation: "Dash", jumpping_animation: "Jump", jump_end_animation: "Land", defense_animation: "Defense" };
const isSelfMove = (key) => !/(?:_wounded(?:_|$)|^wound|^shop_|^award_|^win_|^lose_|^special_motion|_camera$)/i.test(key);
for (const f of fields) {
const key = f.key.toLowerCase();
if (used.has(key) || !ani(f.value) || !isSelfMove(key)) continue;
const label = labels[key] || (/half.*attack/i.test(key) ? `${key.startsWith("dash") ? "Dash " : key.startsWith("jump") ? "Air " : ""}Half D · ${key}` : key.replace(/_/g, " "));
addMove(label, [f.key]);
}
$("hero-combos").replaceChildren(...combos);
const slots = [["SD", "skill1"], ["ASD", "skill2"], ["AD", "skill3"], ["AS", "skill4"]];
$("hero-skills").replaceChildren(...slots.map(([label, section]) => {
if (!doc("skill") || !HeroIni.section(doc("skill"), section)) return makeAction(label, "No skill", { label, animations: [], description: "No skill definition" });
const spec = skillSpec(section, label);
return makeAction(label, spec.title, spec.action, value("skill", section, "icon") || value("skill", section, "skill_emoticon"));
}));
}
async function runAction(action, button) {
const token = ++HERO.actionToken;
const practice = HERO.practice;
if (practice.active) { practice.busy = true; practice.lastMotion = ""; }
MDL.clearHeroEffect();
$("hero-action-label").textContent = action.label;
$("hero-action-info").textContent = action.description || "No config details for this action.";
for (const b of document.querySelectorAll(".hero-action")) b.classList.toggle("on", b === button);
if (!action.animations.length) {
MDL.stopHeroSequence();
if (action.effect && !(practice.active && action.effectTarget)) MDL.playHeroEffect(HERO.src, action.effect, { target: action.effectTarget }).catch((err) => console.warn(`Hero effect: ${err.message}`));
if (practice.active) { practiceHit(action); practice.busy = false; }
$("hero-play-status").textContent = "Passive / no animation in config";
return;
}
if (!HERO.previewReady) await buildPreview();
if (!HERO.previewReady || token !== HERO.actionToken) return;
$("hero-play-status").textContent = `${action.animations.length} animation step(s)`;
try {
if (action.effect && !(practice.active && action.effectTarget)) MDL.playHeroEffect(HERO.src, action.effect, { target: action.effectTarget }).catch((err) => console.warn(`Hero effect: ${err.message}`));
let hitDone = false;
const hit = () => { if (!hitDone && token === HERO.actionToken && practice.active) { hitDone = true; practiceHit(action); } };
await MDL.playSequence(action.animations, {
onStep: (name) => { if (token === HERO.actionToken) $("hero-play-status").textContent = `${action.label} · ${name}`; },
onEvent: (event) => {
if (token !== HERO.actionToken) return;
if (/^fire_(?:s|e|sliding)$/i.test(event.type.text)) hit();
if (event.type.text.toLowerCase() !== "auto_effect") return;
const name = fx(event.sub.text);
if (name) MDL.playHeroEffect(HERO.src, name).catch((err) => console.warn(`Animation effect: ${err.message}`));
},
onDone: () => { if (token === HERO.actionToken) { hit(); $("hero-play-status").textContent = `${action.label} complete`; if (practice.active) { practice.busy = false; practice.lastMotion = ""; updatePracticeMotion(); } else MDL.playNamed(value("item", "item1", "delay_animation")); } },
});
if (practice.active) setTimeout(hit, 400);
} catch (err) { practice.busy = false; toast(err.message, true); $("hero-play-status").textContent = err.message; }
}
const practiceButtons = () => [...document.querySelectorAll("#hero-combos .hero-action")];
const actionLabel = (button) => button?.querySelector("b")?.textContent || "";
const actionButton = (label) => practiceButtons().find((button) => actionLabel(button) === label);
const practiceVisible = () => HERO.practice.active && HERO.id && $("view-hero").classList.contains("on");
function practiceHit(action) {
const p = HERO.practice;
if (!p.active || (!action.refs?.length && !action.effectTarget)) return;
const dx = p.target[0] - p.pos[0], dz = p.target[2] - p.pos[2];
const distance = Math.hypot(dx, dz);
const facing = (Math.sin(p.yaw * Math.PI / 180) * dx + Math.cos(p.yaw * Math.PI / 180) * dz) / (distance || 1);
if (distance > 155 || facing < 0.2) { $("hero-play-status").textContent = `${action.label} · miss`; return; }
const rates = (action.refs || []).map((ref) => Number.parseFloat(value("attack", `attribute${ref}`, "damage_rate")) || 1);
const damage = Math.max(5, Math.min(35, Math.round(rates.reduce((sum, rate) => sum + rate, 0) * 2)));
p.hp = Math.max(0, p.hp - damage);
$("hero-dummy-health").textContent = `Dummy ${p.hp} / 100 · −${damage}`;
$("hero-play-status").textContent = `${action.label} · hit ${damage}`;
if (action.effectTarget && action.effect) MDL.playHeroEffect(HERO.src, action.effect, { target: true }).catch((err) => console.warn(`Practice hit effect: ${err.message}`));
if (p.hp === 0) setTimeout(() => { if (p.active && p.hp === 0) { p.hp = 100; $("hero-dummy-health").textContent = "Dummy 100 / 100"; } }, 1800);
}
function updatePracticeMotion() {
const p = HERO.practice;
if (!practiceVisible() || !HERO.previewReady || p.busy) return;
const moving = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].some((key) => p.keys.has(key));
const dashing = p.keys.has("Shift") || performance.now() < p.dashUntil;
const key = !p.grounded ? "jumpping_animation" : p.keys.has("s") ? "defense_animation" : moving ? dashing ? "dash_animation" : "run_animation" : "delay_animation";
const name = ani(value("item", "item1", key)) || ani(value("item", "item1", "delay_animation"));
if (!name || name === p.lastMotion) return;
p.lastMotion = name;
MDL.playHeroMotion(name).catch((err) => console.warn(`Practice motion ${name}: ${err.message}`));
}
async function setPractice(on) {
const p = HERO.practice;
if (on && !HERO.previewReady) await buildPreview();
if (on && !HERO.previewReady) return;
p.active = !!on;
p.keys.clear(); p.directions = []; p.combo = 0; p.busy = false; p.chord = false;
p.pos = [0, 0, 0]; p.yaw = 0; p.vy = 0; p.grounded = true; p.dashUntil = 0; p.hp = 100; p.lastMotion = "";
clearTimeout(p.attackTimer);
$("hero-practice").classList.toggle("on", p.active);
$("hero-practice").textContent = p.active ? "Exit training" : "Training mode";
$("hero-practice-hud").hidden = !p.active;
$("hero-practice-help").hidden = !p.active;
$("hero-dummy-health").textContent = "Dummy 100 / 100";
MDL.clearHeroEffect();
MDL.stopHeroSequence();
MDL.setHeroTransform(p.pos, p.yaw);
MDL.setHeroPracticeTarget(p.active ? p.target : null);
MDL.setPracticeFloor?.(p.active ? PRACTICE_ARENA : null);
if (p.active) {
$("hero-stage").focus({ preventScroll: true });
if (MDL.view) { MDL.view.cam.target = [0, 75, -110]; MDL.view.cam.dist = Math.max(450, MDL.view.cam.dist); MDL.view.dirty = true; }
$("hero-play-status").textContent = "Training ready · move toward the dummy";
updatePracticeMotion();
} else {
MDL.view?.reset();
$("hero-play-status").textContent = "Training stopped";
}
}
$("hero-practice").onclick = () => setPractice(!HERO.practice.active);
function commandButton() {
const p = HERO.practice, now = performance.now();
const maxTime = Number.parseInt(value("item", "item1", "input_max_time"), 10) || 800;
p.directions = p.directions.filter((x) => now - x.time <= maxTime);
const input = p.directions.map((x) => x.code).join("");
const fields = HeroIni.section(doc("item"), "item1")?.fields || [];
const commands = fields.filter((f) => /^(?:jump_)?cmd_info\d+$/i.test(f.key) && /^\d+$/.test(f.value))
.filter((f) => f.key.startsWith("jump_") === !p.grounded && input.endsWith(f.value))
.sort((a, b) => b.value.length - a.value.length);
const arrows = { "1": "↑", "2": "→", "3": "↓", "4": "←" };
for (const cmd of commands) {
const label = `${cmd.key.startsWith("jump_") ? "Air " : ""}${[...cmd.value].map((c) => arrows[c]).join("")}`;
const button = actionButton(label);
if (button) { p.directions = []; return button; }
}
return null;
}
function practiceAttack(held = 0) {
const p = HERO.practice;
if (!practiceVisible() || !HERO.previewReady) return;
let button = null;
if (held >= Math.max(250, Number.parseInt(value("item", "item1", "half_charge_time"), 10) || Number.parseInt(value("item", "item1", "half_charging_time"), 10) || 450))
button = practiceButtons().find((b) => /^Half D\b/.test(actionLabel(b)));
if (!button) button = commandButton();
if (!button && !p.grounded) button = practiceButtons().find((b) => /^(Air \+ D|jump attack\d+|Extended air \+ D)/i.test(actionLabel(b)));
if (!button && (p.keys.has("Shift") || performance.now() < p.dashUntil)) button = actionButton("Assault dash + D") || actionButton("Dash + D");
if (button) { p.combo = 0; runAction(button.heroAction, button); return; }
const max = Math.min(12, Number.parseInt(value("item", "item1", "max_combo"), 10) || 1);
p.combo = performance.now() - p.comboAt < 850 ? p.combo % max + 1 : 1;
p.comboAt = performance.now();
button = actionButton(Array(p.combo).fill("D").join(" ")) || actionButton("D");
if (!button) return;
const key = `normal_attack${String(p.combo).padStart(2, "0")}`;
const name = ani(value("item", "item1", key));
const refs = attackRefs("item", "item1", key);
const action = { label: actionLabel(button), animations: name ? [name] : button.heroAction.animations,
effect: actionEffect("item", "item1", refs), effectTarget: true, refs,
description: `[${key}] ${name}\n${refs.map(attributeSummary).join("\n")}` };
runAction(action, button);
}
function practiceSkill(slot) {
const button = [...document.querySelectorAll("#hero-skills .hero-action")][slot];
if (button) { HERO.practice.combo = 0; runAction(button.heroAction, button); }
}
const practiceKey = (event) => event.key.length === 1 ? event.key.toLowerCase() : event.key;
document.addEventListener("keydown", (event) => {
if (!practiceVisible() || event.target.matches?.("input, textarea, select, [contenteditable]") || event.ctrlKey || event.metaKey || event.altKey) return;
const p = HERO.practice, key = practiceKey(event), now = performance.now();
if (key === "Escape") { event.preventDefault(); setPractice(false); return; }
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Shift", "a", "s", "d", "1", "2", "3", "4"].includes(key)) return;
event.preventDefault();
const wasDown = p.keys.has(key);
p.keys.add(key);
if (/^Arrow/.test(key)) {
if (!wasDown) {
const codes = { ArrowUp: "1", ArrowRight: "2", ArrowDown: "3", ArrowLeft: "4" };
p.directions.push({ code: codes[key], time: now });
p.directions = p.directions.slice(-12);
if (now - (p.lastTap[key] || 0) < 280) p.dashUntil = now + 500;
p.lastTap[key] = now;
}
updatePracticeMotion();
return;
}
if (key === "Shift") { updatePracticeMotion(); return; }
if (wasDown) return;
if (/^[1-4]$/.test(key)) { practiceSkill(Number(key) - 1); return; }
const a = p.keys.has("a"), s = p.keys.has("s"), d = p.keys.has("d");
const slot = a && s && d ? 1 : s && d ? 0 : a && d ? 2 : a && s ? 3 : -1;
if (slot >= 0 && !p.chord) {
p.chord = true; p.attackPending = false; clearTimeout(p.attackTimer);
practiceSkill(slot);
return;
}
if (key === "a" && p.grounded) {
p.grounded = false;
p.vy = 275 * (Number.parseFloat(value("item", "item1", "jump_power_rate")) || 1);
p.lastMotion = "";
updatePracticeMotion();
} else if (key === "s") updatePracticeMotion();
else if (key === "d") {
p.attackDownAt = now; p.attackPending = true;
const hasHalf = practiceButtons().some((button) => /^Half D\b/.test(actionLabel(button)));
if (!hasHalf) p.attackTimer = setTimeout(() => {
if (p.attackPending && p.keys.has("d") && !p.chord) { p.attackPending = false; practiceAttack(); }
}, 75);
}
});
document.addEventListener("keyup", (event) => {
if (!HERO.practice.active) return;
const p = HERO.practice, key = practiceKey(event);
if (!p.keys.has(key)) return;
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Shift", "a", "s", "d"].includes(key)) event.preventDefault();
p.keys.delete(key);
if (key === "d") {
clearTimeout(p.attackTimer);
if (p.attackPending && !p.chord) practiceAttack(performance.now() - p.attackDownAt);
p.attackPending = false;
}
if (!["a", "s", "d"].some((k) => p.keys.has(k))) p.chord = false;
if (/^Arrow/.test(key) || key === "Shift" || key === "s") updatePracticeMotion();
});
window.addEventListener("blur", () => { if (HERO.practice.active) { HERO.practice.keys.clear(); HERO.practice.attackPending = false; updatePracticeMotion(); } });
let practiceLastFrame = 0;
const PRACTICE_ARENA = 450; // half size of the training floor, game units
function practiceTick(now) {
const dt = Math.min(0.05, Math.max(0, (now - (practiceLastFrame || now)) / 1000));
practiceLastFrame = now;
const p = HERO.practice;
if (practiceVisible() && HERO.previewReady) {
const dx = Number(p.keys.has("ArrowRight")) - Number(p.keys.has("ArrowLeft"));
const dz = Number(p.keys.has("ArrowUp")) - Number(p.keys.has("ArrowDown"));
const length = Math.hypot(dx, dz) || 1;
const moving = !!(dx || dz);
const dashing = p.keys.has("Shift") || now < p.dashUntil;
const dashRate = Number.parseFloat(value("item", "item1", "dash_run_max_speed_rate")) || 1;
const speed = (dashing ? 240 * dashRate : 125) * (p.busy ? 0.35 : 1);
if (moving) {
const edge = PRACTICE_ARENA - 20; // keep the whole body on the floor grid
p.pos[0] = Math.max(-edge, Math.min(edge, p.pos[0] + dx / length * speed * dt));
p.pos[2] = Math.max(-edge, Math.min(edge, p.pos[2] + dz / length * speed * dt));
p.yaw = Math.atan2(dx, dz) * 180 / Math.PI;
}
if (!p.grounded) {
p.vy -= 780 * dt;
p.pos[1] += p.vy * dt;
if (p.pos[1] <= 0) { p.pos[1] = 0; p.vy = 0; p.grounded = true; p.lastMotion = ""; updatePracticeMotion(); }
}
if (moving || !p.grounded) MDL.setHeroTransform(p.pos, p.yaw);
// Camera follows the hero (looking a little toward the dummy) and backs off when the two are far
// apart, so the hero never walks out of the frame. It never zooms in: a manual zoom-out stays.
// The view's z axis is the game's z flipped (see the start target [0, 75, -110]).
if (MDL.view) {
const cam = MDL.view.cam, k = Math.min(1, 4 * dt);
const fx = p.pos[0] + (p.target[0] - p.pos[0]) * 0.35, fz = p.pos[2] + (p.target[2] - p.pos[2]) * 0.35;
const need = Math.min(1000, 380 + Math.hypot(p.target[0] - p.pos[0], p.target[2] - p.pos[2]) * 0.9);
const before = cam.target[0] + cam.target[2] + cam.dist;
cam.target[0] += (fx - cam.target[0]) * k;
cam.target[2] += (-fz - cam.target[2]) * k;
if (cam.dist < need) cam.dist += (need - cam.dist) * k;
if (Math.abs(cam.target[0] + cam.target[2] + cam.dist - before) > 0.01) MDL.view.dirty = true;
}
updatePracticeMotion();
}
requestAnimationFrame(practiceTick);
}
requestAnimationFrame(practiceTick);
$("hero-idle").onclick = async () => {
if (HERO.practice.active) await setPractice(false);
HERO.actionToken++;
MDL.stopHeroSequence();
const name = value("item", "item1", "delay_animation");
if (name) await MDL.playNamed(name);
$("hero-action-label").textContent = "Idle";
$("hero-play-status").textContent = name || "Idle pose";
};
$("hero-stop").onclick = () => { if (HERO.practice.active) setPractice(false); HERO.actionToken++; MDL.stopHeroSequence(); $("hero-play-status").textContent = "Stopped"; };
$("hero-export").onclick = async () => {
const files = dirty().map((kind) => ({ kind, text: HERO.docs[kind].text }));
if (!files.length) return;
const folder = await call("dialog:folder", "Choose a folder for the exported hero configs");
if (!folder) return;
try {
const out = await call("hero:export", folder, HERO.id, files);
for (const f of files) HERO.docs[f.kind].saved = f.text;
updateStatus();
$("hero-export-status").textContent = `Exported to ${out.folder}`;
toast(`Exported ${out.files.length} config file(s).`);
} catch (err) { toast(err.message, true); }
};
document.querySelector('[data-view="hero"]').addEventListener("click", async () => {
if (HERO.src) {
if (HERO.id && MDL.scene?.model?.name === "Hero preview") MDL.embedHero(true);
else if (HERO.id) buildPreview();
return;
}
const src = profileSource();
if (src) try { await setSource(src); } catch (err) { toast(err.message, true); }
});
})();
+866
View File
@@ -0,0 +1,866 @@
"use strict";
// UI Image Set editor (uiimageset.xml): browse imagesets, see every image as a box on its texture,
// move/resize boxes, draw new ones, add/duplicate/delete images and imagesets, then save the XML
// into the pack or as a file. Lines that weren't edited are written back exactly as they were.
const IMS = {
doc: null, // { eol, head: [lines], tail: [lines], sets: [set] }
source: null, // { kind: "pack", sessionId, entry, file } | { kind: "file", file }
texSource: null, // where textures come from (see setTexSource)
dirty: false,
set: -1,
img: -1,
undo: [],
redo: [],
zoom: 1,
panX: 20,
panY: 20,
showAll: true,
draw: false,
tex: new Map(), // file → Promise<{ bitmap, w, h, source } | { error }>
};
// ---------- XML model ----------
const parseAttrs = (s) => [...s.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)].map((m) => [m[1], m[2]]);
const attr = (o, k) => o.attrs.find((a) => a[0] === k)?.[1] ?? "";
const num = (o, k) => Number(attr(o, k)) || 0;
function setAttr(o, k, v) {
const a = o.attrs.find((x) => x[0] === k);
if (a) a[1] = String(v);
else o.attrs.push([k, String(v)]);
o.raw = null; // this line is written again
}
// Lines keep their own line break: the file mixes CRLF with bare CR in places, and untouched lines
// must come back byte for byte. `raw` = line text + its break; edited lines get the file's usual one.
function splitLines(text) {
const out = [];
const re = /([^\r\n]*)(\r\n|\r|\n|$)/g;
let m;
while ((m = re.exec(text)) && m.index < text.length) {
out.push({ text: m[1], raw: m[0] });
if (!m[2]) break;
}
return out;
}
function parseDoc(text) {
const crlf = (text.match(/\r\n/g) || []).length;
const eol = crlf || !text.includes("\n") ? "\r\n" : "\n";
const doc = { eol, head: [], tail: [], sets: [] };
let cur = null;
let pending = []; // lines between two imagesets (blank lines): they stay after the previous one
for (const { text: line, raw } of splitLines(text)) {
let m;
if ((m = /^\s*<Imageset\b(.*?)(\/?)>\s*$/.exec(line))) {
if (doc.sets.length) doc.sets[doc.sets.length - 1].after = pending;
pending = [];
const set = { attrs: parseAttrs(m[1]), raw, images: [], rawClose: null, selfClosing: m[2] === "/", after: [] };
doc.sets.push(set);
cur = set.selfClosing ? null : set;
} else if (cur && (m = /^\s*<Image\b(.*?)\/>\s*$/.exec(line))) {
cur.images.push({ attrs: parseAttrs(m[1]), raw });
} else if (cur && /^\s*<\/Imageset>\s*$/.test(line)) {
cur.rawClose = raw;
cur = null;
} else if (cur) {
cur.images.push({ keep: true, raw }); // blank line etc. inside a set, kept as is
} else if (!doc.sets.length) doc.head.push(raw);
else pending.push(raw);
}
doc.tail = pending; // whatever follows the last imageset (</ImagesetLayout>)
if (!doc.head.length) doc.head = [`<?xml version="1.0" encoding="euc-kr" ?>${eol}`, `<ImagesetLayout>${eol}`];
if (!doc.tail.some((l) => /<\/ImagesetLayout>/.test(l))) doc.tail.unshift(`</ImagesetLayout>${eol}`);
return doc;
}
const attrText = (attrs) => attrs.map(([k, v]) => `${k}="${v}"`).join(" ");
function serialize(doc) {
const e = doc.eol;
const out = [...doc.head];
// a line before a generated one must end with a break
const push = (s) => {
const last = out.length - 1;
if (last >= 0 && !/[\r\n]$/.test(out[last])) out[last] += e;
out.push(s);
};
for (const s of doc.sets) {
const selfClose = s.selfClosing && !s.images.length;
push(s.raw ?? ` <Imageset ${attrText(s.attrs)}${selfClose ? " /" : ""}>${e}`);
if (!selfClose) {
for (const i of s.images) push(i.raw ?? ` <Image ${attrText(i.attrs)} />${e}`);
push(s.rawClose ?? ` </Imageset>${e}`);
}
for (const a of s.after || []) push(a);
}
for (const t of doc.tail) push(t);
return out.join("");
}
const images = (set) => set.images.filter((i) => !i.keep);
const curSet = () => IMS.doc?.sets[IMS.set] || null;
const curImg = () => (curSet() ? images(curSet())[IMS.img] || null : null);
const rectOf = (i) => ({ x: num(i, "X"), y: num(i, "Y"), w: num(i, "Width"), h: num(i, "Height") });
const clone = (o) => JSON.parse(JSON.stringify(o));
const validName = (s) => /^[\x21-\x7e]+$/.test(s) && !/["<>&]/.test(s);
// ---------- undo ----------
function snapshot() {
IMS.undo.push({ sets: IMS.doc.sets.map((s) => s), setCopy: IMS.set >= 0 ? { i: IMS.set, data: clone(curSet()) } : null, set: IMS.set, img: IMS.img });
if (IMS.undo.length > 200) IMS.undo.shift();
IMS.redo = [];
markDirty();
}
function restore(from, to) {
const s = from.pop();
if (!s) return;
to.push({ sets: IMS.doc.sets.map((x) => x), setCopy: s.setCopy ? { i: s.setCopy.i, data: clone(IMS.doc.sets[s.setCopy.i] || s.setCopy.data) } : null, set: IMS.set, img: IMS.img });
IMS.doc.sets = s.sets.slice();
if (s.setCopy) IMS.doc.sets[s.setCopy.i] = clone(s.setCopy.data);
IMS.set = s.set;
IMS.img = s.img;
markDirty();
renderAll();
}
const undo = () => restore(IMS.undo, IMS.redo);
const redo = () => restore(IMS.redo, IMS.undo);
function markDirty() {
IMS.dirty = true;
$("ims-dirty").hidden = false;
}
// ---------- loading ----------
async function imsSessions() {
const list = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
const sel = $("ims-session");
sel.replaceChildren(...(list.length ? list.map((s) => el("option", { value: s.id, text: s.name, title: s.file })) : [el("option", { value: "", text: "Open rs.iop in IOP Manager first" })]));
const rs = list.find((s) => s.name.toLowerCase() === "rs.iop");
if (rs) sel.value = rs.id;
sel.disabled = !list.length;
$("ims-load").disabled = !list.length;
return list;
}
async function confirmDiscard() {
if (!IMS.dirty) return true;
return confirmBox("Unsaved changes", "The current imageset file has changes that aren't saved. Discard them?", "Discard", "danger");
}
// ---------- texture source ----------
// session = the packs next to a file open in IOP Manager; packs = a folder of .iop files;
// dir = a folder of extracted files (e.g. map.iop extracted)
async function setTexSource(src) {
try {
const d = await call("ims:describeSource", src);
IMS.texSource = { ...src, label: d.label };
} catch (err) {
return toast(err.message, true);
}
$("ims-texsrc-label").textContent = IMS.texSource.kind === "dir" ? "Extracted folder" : IMS.texSource.kind === "packs" ? "Pack folder" : "Packs next to " + IMS.texSource.name;
$("ims-texsrc").title = `Textures: ${IMS.texSource.label}`;
IMS.tex.clear();
if (curSet()) {
await loadTexture();
drawCanvas();
drawCrop();
}
}
/** Asks where the textures are; resolves true when a source was set. */
async function chooseTexSource() {
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
const pick = (value) => (choice = value);
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: "map.iop, map2.iop, sakura.iop… — passwords found from the profiles" },
{ value: "dir", title: "A folder of extracted files…", sub: "e.g. map.iop extracted: resource/texture/*.dds" },
];
const list = el(
"div",
{ class: "choice-list" },
options.map((o, i) =>
el("label", { class: "choice" }, [
el("input", { type: "radio", name: "texsrc", checked: i === 0, onchange: () => pick(o.value) }),
el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })]),
]),
),
);
choice = options[0].value;
const ok = await modal("Where are the textures?", ["The imageset XML only names its textures (ui_icon_event1.dds). Pick where to read them from so the boxes match the right pictures.", list], [
{ label: "Cancel", value: false },
{ label: "Use this", kind: "primary", value: true },
]);
if (!ok) 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 setTexSource(src);
return true;
}
$("ims-texsrc").onclick = () => chooseTexSource();
async function loadDoc(result) {
IMS.doc = parseDoc(result.text);
IMS.source = result.source;
IMS.dirty = false;
IMS.undo = [];
IMS.redo = [];
IMS.set = -1;
IMS.img = -1;
IMS.tex.clear();
$("ims-dirty").hidden = true;
$("ims-title").textContent = result.source.kind === "pack" ? `${result.source.file.split(/[\\/]/).pop()} › ${result.source.entry}` : result.source.file;
$("ims-empty").hidden = true;
renderAll();
if (IMS.doc.sets.length) selectSet(0);
const n = IMS.doc.sets.reduce((a, s) => a + images(s).length, 0);
toast(`${IMS.doc.sets.length.toLocaleString()} imagesets, ${n.toLocaleString()} images.`);
}
$("ims-load").onclick = async () => {
if (!(await confirmDiscard())) return;
const id = Number($("ims-session").value);
try {
const entry = $("ims-entry").value.trim() || "xml/uiimageset.xml";
const r = await call("ims:loadPack", id, entry);
// Textures default to the packs next to this one (can be changed with the Textures button)
const s = (await call("iop:sessions")).find((x) => x.id === id);
await setTexSource({ kind: "session", sessionId: id, name: s?.name || "pack" });
await loadDoc(r);
} catch (err) {
toast(err.message, true);
}
};
$("ims-open-file").onclick = async () => {
if (!(await confirmDiscard())) return;
const file = await call("dialog:openXml");
if (!file) return;
try {
const r = await call("ims:loadFile", file);
// A loose XML says nothing about where its textures are: ask
if (!(await chooseTexSource())) toast("No texture source: the boxes show without pictures. Use the Textures button to pick one.", true);
await loadDoc(r);
} catch (err) {
toast(err.message, true);
}
};
// ---------- saving ----------
async function saveToPack() {
if (IMS.source?.kind !== "pack") return toast("This XML came from a file. Use “Save as file…”, or load it from a pack.", true);
try {
await call("ims:savePack", IMS.source.sessionId, IMS.source.entry, serialize(IMS.doc));
IMS.dirty = false;
$("ims-dirty").hidden = true;
toast(`Saved into ${IMS.source.file.split(/[\\/]/).pop()} (${IMS.source.entry}).`);
} catch (err) {
toast(err.message, true);
}
}
async function saveToFile() {
const name = (IMS.source?.entry || IMS.source?.file || "uiimageset.xml").split(/[\\/]/).pop();
const file = await call("dialog:saveXml", name);
if (!file) return;
try {
await call("ims:saveFile", file, serialize(IMS.doc));
if (IMS.source?.kind === "file") {
IMS.source.file = file;
IMS.dirty = false;
$("ims-dirty").hidden = true;
}
toast(`Saved ${file}.`);
} catch (err) {
toast(err.message, true);
}
}
$("ims-save").onclick = (e) =>
showMenu(
[
{ label: "Save into the pack", icon: "box", disabled: IMS.source?.kind !== "pack", hint: "Ctrl+S", run: saveToPack },
{ label: "Save as file…", icon: "download", disabled: !IMS.doc, run: saveToFile },
"-",
{ label: "Export this image as PNG…", icon: "external", disabled: !curImg(), run: exportPng },
{
label: "Reload textures",
icon: "refresh",
disabled: !IMS.texSource,
run: async () => {
await call("ims:reloadTextures", IMS.texSource);
IMS.tex.clear();
await loadTexture();
drawCanvas();
drawCrop();
},
},
],
{ anchor: e.currentTarget },
);
$("ims-undo").onclick = undo;
$("ims-redo").onclick = redo;
// ---------- imageset list ----------
function renderSets() {
const q = $("ims-search").value.trim().toLowerCase();
const list = $("ims-sets");
if (!IMS.doc) return list.replaceChildren();
const frag = document.createDocumentFragment();
IMS.doc.sets.forEach((s, i) => {
const name = attr(s, "Name");
const file = attr(s, "File");
if (q && !name.toLowerCase().includes(q) && !file.toLowerCase().includes(q)) return;
frag.append(
el("div", { class: `ims-set${i === IMS.set ? " on" : ""}`, "data-i": i, onclick: () => selectSet(i) }, [
el("b", { text: name || "(no name)" }),
el("span", { text: `${file} · ${images(s).length}` }),
]),
);
});
list.replaceChildren(frag);
$("ims-set-count").textContent = `${IMS.doc.sets.length.toLocaleString()}`;
}
$("ims-search").addEventListener("input", renderSets);
async function selectSet(i, { keepView = false } = {}) {
IMS.set = i;
IMS.img = images(curSet()).length ? 0 : -1;
for (const n of $("ims-sets").children) n.classList.toggle("on", Number(n.dataset.i) === i);
renderSide();
await loadTexture();
if (!keepView) fitView();
drawCanvas();
}
function selectImg(i) {
IMS.img = i;
renderImages();
renderImgForm();
drawCanvas();
}
// ---------- textures ----------
function textureFor(file) {
const key = file.toLowerCase();
if (!IMS.tex.has(key)) {
IMS.tex.set(
key,
(async () => {
if (!IMS.texSource) return { error: "No texture source — use the Textures button" };
const t = await call("ims:texture", IMS.texSource, file);
if (!t) return { error: `${file} isn't in ${IMS.texSource.label}` };
try {
const bitmap = await TexDecode.decode(t.bytes, t.ext);
return { bitmap, w: bitmap.width, h: bitmap.height, source: t.source };
} catch (err) {
return { error: `${file}: ${err.message}` };
}
})(),
);
}
return IMS.tex.get(key);
}
async function loadTexture() {
const s = curSet();
IMS.cur = null;
$("ims-texinfo").textContent = "Loading texture…";
if (!s) return;
const t = await textureFor(attr(s, "File"));
if (curSet() !== s) return;
IMS.cur = t.error ? null : t;
$("ims-texinfo").textContent = t.error ? t.error : `${t.w} × ${t.h} · ${t.source}`;
$("ims-texinfo").classList.toggle("bad", !!t.error);
}
// ---------- side panel ----------
function field(label, value, onCommit, { type = "text", width } = {}) {
const input = el("input", { type, value, spellcheck: "false" });
if (width) input.style.width = width;
input.addEventListener("change", () => onCommit(input.value.trim(), input));
input.addEventListener("keydown", (e) => e.key === "Enter" && input.blur());
return el("label", { class: "fld" }, [el("span", { text: label }), input]);
}
function renderSide() {
const s = curSet();
$("ims-side").hidden = !s;
if (!s) return;
$("ims-setform").replaceChildren(
field("Name", attr(s, "Name"), (v, inp) => {
if (!validName(v)) return ((inp.value = attr(s, "Name")), toast("Use letters, digits and _ (no spaces or quotes).", true));
snapshot();
setAttr(s, "Name", v);
renderSets();
}),
field("Texture", attr(s, "File"), async (v, inp) => {
if (!validName(v)) return ((inp.value = attr(s, "File")), toast("Use a plain file name, e.g. ui_icon_event1.dds.", true));
snapshot();
setAttr(s, "File", v);
renderSets();
await loadTexture();
drawCanvas();
}),
...s.attrs.filter(([k]) => k !== "Name" && k !== "File").map(([k, v]) =>
field(k, v, (nv) => {
snapshot();
setAttr(s, k, nv);
}),
),
);
renderImages();
renderImgForm();
}
function renderImages() {
const s = curSet();
if (!s) return;
const q = $("ims-img-search").value.trim().toLowerCase();
const frag = document.createDocumentFragment();
images(s).forEach((im, i) => {
const name = attr(im, "Name");
if (q && !name.toLowerCase().includes(q)) return;
const r = rectOf(im);
frag.append(el("div", { class: `ims-img${i === IMS.img ? " on" : ""}`, onclick: () => selectImg(i) }, [el("span", { text: name }), el("em", { text: `${r.w}×${r.h}` })]));
});
$("ims-images").replaceChildren(frag);
$("ims-img-count").textContent = images(s).length.toLocaleString();
$("ims-images").querySelector(".on")?.scrollIntoView({ block: "nearest" });
}
$("ims-img-search").addEventListener("input", renderImages);
function renderImgForm() {
const im = curImg();
$("ims-imgbox").hidden = !im;
if (!im) return;
const intField = (k) =>
field(k, attr(im, k), (v, inp) => {
if (v !== "" && !/^-?\d+$/.test(v)) return ((inp.value = attr(im, k)), toast("Whole numbers only.", true));
snapshot();
if (v === "" && k.startsWith("Offset")) {
im.attrs = im.attrs.filter((a) => a[0] !== k);
im.raw = null;
} else setAttr(im, k, v || "0");
renderImages();
drawCanvas();
drawCrop();
}, { type: "text", width: "100%" });
$("ims-imgform").replaceChildren(
field("Name", attr(im, "Name"), (v, inp) => {
if (!validName(v)) return ((inp.value = attr(im, "Name")), toast("Use letters, digits and _ (no spaces or quotes).", true));
if (images(curSet()).some((x) => x !== im && attr(x, "Name") === v)) return ((inp.value = attr(im, "Name")), toast("Another image in this imageset has that name.", true));
snapshot();
setAttr(im, "Name", v);
renderImages();
}),
el("div", { class: "fld-grid" }, ["X", "Y", "Width", "Height", "OffsetX", "OffsetY"].map(intField)),
);
drawCrop();
}
/** The selected image at its real size (2× when small). */
function drawCrop() {
const im = curImg();
const c = $("ims-crop");
const ctx = c.getContext("2d");
if (!im || !IMS.cur) {
c.width = c.height = 0;
return;
}
const r = rectOf(im);
const scale = Math.max(1, Math.min(4, Math.floor(160 / Math.max(r.w, r.h, 1))));
c.width = Math.max(1, r.w * scale);
c.height = Math.max(1, r.h * scale);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(IMS.cur.bitmap, r.x, r.y, r.w, r.h, 0, 0, r.w * scale, r.h * scale);
}
async function exportPng() {
const im = curImg();
if (!im || !IMS.cur) return;
const r = rectOf(im);
const c = document.createElement("canvas");
c.width = r.w;
c.height = r.h;
c.getContext("2d").drawImage(IMS.cur.bitmap, r.x, r.y, r.w, r.h, 0, 0, r.w, r.h);
const file = await call("dialog:savePng", `${attr(im, "Name")}.png`);
if (!file) return;
await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]);
toast(`Saved ${file}.`);
}
// ---------- add / duplicate / delete ----------
function uniqueName(set, base) {
const names = new Set(images(set).map((i) => attr(i, "Name")));
if (!names.has(base)) return base;
for (let n = 2; ; n++) if (!names.has(`${base}_${n}`)) return `${base}_${n}`;
}
function addImage(rect) {
const s = curSet();
if (!s) return;
snapshot();
const r = rect || { x: 0, y: 0, w: 32, h: 32 };
const im = { attrs: [["Name", uniqueName(s, "new_image")], ["X", r.x], ["Y", r.y], ["Width", r.w], ["Height", r.h]].map(([k, v]) => [k, String(v)]), raw: null };
s.images.push(im);
s.selfClosing = false;
if (!s.rawClose) s.raw = null;
IMS.img = images(s).length - 1;
renderSets();
renderImages();
renderImgForm();
drawCanvas();
}
$("ims-add-img").onclick = () => addImage();
$("ims-dup-img").onclick = () => {
const im = curImg();
if (!im) return;
const r = rectOf(im);
addImage({ x: r.x + 4, y: r.y + 4, w: r.w, h: r.h });
const copy = curImg();
for (const [k, v] of im.attrs) if (!["Name", "X", "Y"].includes(k)) setAttr(copy, k, v);
setAttr(copy, "Name", uniqueName(curSet(), attr(im, "Name") + "_copy"));
renderImages();
renderImgForm();
};
function deleteImage() {
const s = curSet();
const im = curImg();
if (!im) return;
snapshot();
s.images.splice(s.images.indexOf(im), 1);
IMS.img = Math.min(IMS.img, images(s).length - 1);
renderSets();
renderImages();
renderImgForm();
drawCanvas();
}
$("ims-del-img").onclick = deleteImage;
$("ims-add-set").onclick = async () => {
if (!IMS.doc) return;
const name = await prompt("New imageset", "Imageset name (letters, digits, _)", "NewImageset");
if (!name) return;
if (!validName(name)) return toast("Use letters, digits and _ (no spaces or quotes).", true);
if (IMS.doc.sets.some((s) => attr(s, "Name") === name)) return toast("An imageset with that name already exists.", true);
const file = await prompt("Texture", "Texture file name (in resource/texture of the packs)", curSet() ? attr(curSet(), "File") : "ui_new.dds");
if (!file) return;
snapshot();
IMS.doc.sets.push({ attrs: [["Name", name], ["File", file]], raw: null, images: [], rawClose: null, selfClosing: false });
renderSets();
await selectSet(IMS.doc.sets.length - 1);
$("ims-sets").lastChild?.scrollIntoView({ block: "nearest" });
};
$("ims-del-set").onclick = async () => {
const s = curSet();
if (!s) return;
if (!(await confirmBox("Delete imageset", `Delete the imageset “${attr(s, "Name")}” and its ${images(s).length} images?`, "Delete", "danger"))) return;
snapshot();
IMS.doc.sets.splice(IMS.set, 1);
IMS.set = Math.min(IMS.set, IMS.doc.sets.length - 1);
renderSets();
if (IMS.set >= 0) selectSet(IMS.set);
else renderAll();
};
// ---------- canvas ----------
const canvas = $("ims-canvas");
const ctx = canvas.getContext("2d");
let checker = null;
function resizeCanvas() {
const r = canvas.parentElement.getBoundingClientRect();
const dpr = devicePixelRatio || 1;
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
canvas.style.width = `${r.width}px`;
canvas.style.height = `${r.height}px`;
drawCanvas();
}
new ResizeObserver(resizeCanvas).observe(canvas.parentElement);
function fitView() {
const t = IMS.cur;
const r = canvas.parentElement.getBoundingClientRect();
if (!t) {
IMS.zoom = 1;
IMS.panX = IMS.panY = 20;
return updateZoomLabel();
}
IMS.zoom = Math.max(0.05, Math.min(8, Math.min((r.width - 40) / t.w, (r.height - 40) / t.h)));
IMS.panX = (r.width - t.w * IMS.zoom) / 2;
IMS.panY = (r.height - t.h * IMS.zoom) / 2;
updateZoomLabel();
}
function setZoom(z, cx, cy) {
const r = canvas.getBoundingClientRect();
const x = cx ?? r.width / 2;
const y = cy ?? r.height / 2;
const nz = Math.max(0.05, Math.min(16, z));
IMS.panX = x - ((x - IMS.panX) * nz) / IMS.zoom;
IMS.panY = y - ((y - IMS.panY) * nz) / IMS.zoom;
IMS.zoom = nz;
updateZoomLabel();
drawCanvas();
}
const updateZoomLabel = () => ($("ims-zoom").textContent = `${Math.round(IMS.zoom * 100)}%`);
$("ims-zoom-in").onclick = () => setZoom(IMS.zoom * 1.25);
$("ims-zoom-out").onclick = () => setZoom(IMS.zoom / 1.25);
$("ims-zoom").onclick = () => setZoom(1);
$("ims-fit").onclick = () => (fitView(), drawCanvas());
$("ims-showall").onclick = () => {
IMS.showAll = !IMS.showAll;
$("ims-showall").classList.toggle("on", IMS.showAll);
drawCanvas();
};
$("ims-drawmode").onclick = () => {
IMS.draw = !IMS.draw;
$("ims-drawmode").classList.toggle("on", IMS.draw);
canvas.classList.toggle("drawing", IMS.draw);
};
function drawCanvas() {
const dpr = devicePixelRatio || 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(dpr * IMS.zoom, 0, 0, dpr * IMS.zoom, dpr * IMS.panX, dpr * IMS.panY);
const t = IMS.cur;
const s = curSet();
if (t) {
if (!checker) {
const c = document.createElement("canvas");
c.width = c.height = 16;
const g = c.getContext("2d");
g.fillStyle = "#2a2830";
g.fillRect(0, 0, 16, 16);
g.fillStyle = "#34313b";
g.fillRect(0, 0, 8, 8);
g.fillRect(8, 8, 8, 8);
checker = ctx.createPattern(c, "repeat");
}
ctx.fillStyle = checker;
ctx.fillRect(0, 0, t.w, t.h);
ctx.imageSmoothingEnabled = IMS.zoom < 1;
ctx.drawImage(t.bitmap, 0, 0);
}
if (!s) return;
const px = 1 / IMS.zoom;
const list = images(s);
list.forEach((im, i) => {
if (i === IMS.img || (!IMS.showAll && i !== IMS.hover)) return;
const r = rectOf(im);
ctx.lineWidth = px;
ctx.strokeStyle = i === IMS.hover ? "rgba(255,255,255,.95)" : "rgba(233,180,60,.55)";
ctx.strokeRect(r.x + px / 2, r.y + px / 2, r.w - px, r.h - px);
});
const sel = curImg();
if (sel) {
const r = rectOf(sel);
ctx.fillStyle = "rgba(233,180,60,.18)";
ctx.fillRect(r.x, r.y, r.w, r.h);
ctx.lineWidth = 2 * px;
ctx.strokeStyle = "#f4c95a";
ctx.strokeRect(r.x, r.y, r.w, r.h);
// offset point (OffsetX/Y: the image's pivot)
if (attr(sel, "OffsetX") !== "" || attr(sel, "OffsetY") !== "") {
const ox = r.x + num(sel, "OffsetX");
const oy = r.y + num(sel, "OffsetY");
ctx.strokeStyle = "#ff6b5e";
ctx.beginPath();
ctx.moveTo(ox - 5 * px, oy);
ctx.lineTo(ox + 5 * px, oy);
ctx.moveTo(ox, oy - 5 * px);
ctx.lineTo(ox, oy + 5 * px);
ctx.stroke();
}
ctx.fillStyle = "#f4c95a";
for (const [hx, hy] of handles(r)) ctx.fillRect(hx - 3.5 * px, hy - 3.5 * px, 7 * px, 7 * px);
}
if (IMS.drawRect) {
const r = IMS.drawRect;
ctx.lineWidth = px;
ctx.setLineDash([4 * px, 3 * px]);
ctx.strokeStyle = "#3ec07a";
ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.setLineDash([]);
}
}
const handles = (r) => [
[r.x, r.y, "nw"],
[r.x + r.w / 2, r.y, "n"],
[r.x + r.w, r.y, "ne"],
[r.x + r.w, r.y + r.h / 2, "e"],
[r.x + r.w, r.y + r.h, "se"],
[r.x + r.w / 2, r.y + r.h, "s"],
[r.x, r.y + r.h, "sw"],
[r.x, r.y + r.h / 2, "w"],
];
const CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
function toTex(e) {
const b = canvas.getBoundingClientRect();
return { x: (e.clientX - b.left - IMS.panX) / IMS.zoom, y: (e.clientY - b.top - IMS.panY) / IMS.zoom };
}
function hitHandle(p) {
const sel = curImg();
if (!sel) return null;
const tol = 6 / IMS.zoom;
return handles(rectOf(sel)).find(([hx, hy]) => Math.abs(p.x - hx) <= tol && Math.abs(p.y - hy) <= tol)?.[2] || null;
}
/** Smallest box under the point (so small icons inside bigger ones can be picked). */
function hitImage(p) {
const s = curSet();
if (!s) return -1;
let best = -1;
let area = Infinity;
images(s).forEach((im, i) => {
const r = rectOf(im);
if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h && r.w * r.h < area) {
best = i;
area = r.w * r.h;
}
});
return best;
}
let drag = null;
canvas.addEventListener("mousedown", (e) => {
if (!curSet()) return;
canvas.focus();
const p = toTex(e);
if (e.button === 1 || e.button === 2 || (e.button === 0 && e.altKey)) {
drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: IMS.panX, py: IMS.panY };
} else if (IMS.draw) {
drag = { mode: "draw", start: { x: Math.round(p.x), y: Math.round(p.y) } };
} else {
const h = hitHandle(p);
if (h) {
snapshot();
drag = { mode: "resize", h, start: p, r: rectOf(curImg()) };
} else {
const i = hitImage(p);
if (i >= 0) {
if (i !== IMS.img) selectImg(i);
snapshot();
drag = { mode: "move", start: p, r: rectOf(curImg()), moved: false };
} else drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: IMS.panX, py: IMS.panY };
}
}
e.preventDefault();
});
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
window.addEventListener("mousemove", (e) => {
if (!$("view-ui").classList.contains("on")) return;
if (!drag) {
if (e.target !== canvas || !curSet()) return;
const p = toTex(e);
const h = !IMS.draw && hitHandle(p);
canvas.style.cursor = IMS.draw ? "crosshair" : h ? CURSOR[h] : hitImage(p) >= 0 ? "move" : "grab";
const hov = hitImage(p);
if (hov !== IMS.hover) {
IMS.hover = hov;
$("ims-hover").textContent = hov >= 0 ? `${attr(images(curSet())[hov], "Name")} ${Math.floor(p.x)}, ${Math.floor(p.y)}` : `${Math.floor(p.x)}, ${Math.floor(p.y)}`;
drawCanvas();
} else $("ims-hover").textContent = `${hov >= 0 ? attr(images(curSet())[hov], "Name") + " " : ""}${Math.floor(p.x)}, ${Math.floor(p.y)}`;
return;
}
if (drag.mode === "pan") {
IMS.panX = drag.px + e.clientX - drag.sx;
IMS.panY = drag.py + e.clientY - drag.sy;
canvas.style.cursor = "grabbing";
return drawCanvas();
}
const p = toTex(e);
if (drag.mode === "draw") {
const x = Math.round(p.x);
const y = Math.round(p.y);
IMS.drawRect = { x: Math.min(x, drag.start.x), y: Math.min(y, drag.start.y), w: Math.abs(x - drag.start.x), h: Math.abs(y - drag.start.y) };
return drawCanvas();
}
const im = curImg();
const dx = Math.round(p.x - drag.start.x);
const dy = Math.round(p.y - drag.start.y);
let { x, y, w, h } = drag.r;
if (drag.mode === "move") {
x += dx;
y += dy;
drag.moved = drag.moved || dx || dy;
} else {
if (drag.h.includes("w")) (x += dx), (w -= dx);
if (drag.h.includes("e")) w += dx;
if (drag.h.includes("n")) (y += dy), (h -= dy);
if (drag.h.includes("s")) h += dy;
if (w < 1) (x += w - 1), (w = 1);
if (h < 1) (y += h - 1), (h = 1);
}
setAttr(im, "X", x);
setAttr(im, "Y", y);
setAttr(im, "Width", w);
setAttr(im, "Height", h);
drawCanvas();
$("ims-hover").textContent = `${attr(im, "Name")} X ${x} Y ${y} ${w} × ${h}`;
});
window.addEventListener("mouseup", () => {
if (!drag) return;
const d = drag;
drag = null;
canvas.style.cursor = "";
if (d.mode === "draw" && IMS.drawRect) {
const r = IMS.drawRect;
IMS.drawRect = null;
if (r.w >= 2 && r.h >= 2) addImage(r);
else drawCanvas();
} else if (d.mode === "move" || d.mode === "resize") {
if (d.mode === "move" && !d.moved) IMS.undo.pop(); // just a click: nothing changed
renderImages();
renderImgForm();
}
});
canvas.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const b = canvas.getBoundingClientRect();
setZoom(IMS.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15), e.clientX - b.left, e.clientY - b.top);
},
{ passive: false },
);
// Keyboard: arrows nudge (Shift = 10 px), Delete removes, Ctrl+Z / Ctrl+Y, Ctrl+S saves
document.addEventListener("keydown", (e) => {
if (!$("view-ui").classList.contains("on") || !IMS.doc) return;
if (e.target.matches?.("input, textarea, select")) return;
if (e.ctrlKey && e.key.toLowerCase() === "z") return e.preventDefault(), undo();
if (e.ctrlKey && e.key.toLowerCase() === "y") return e.preventDefault(), redo();
if (e.ctrlKey && e.key.toLowerCase() === "s") return e.preventDefault(), IMS.source?.kind === "pack" ? saveToPack() : saveToFile();
const im = curImg();
if (!im) return;
const step = e.shiftKey ? 10 : 1;
const move = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
if (move) {
e.preventDefault();
if (!e.repeat) snapshot();
setAttr(im, "X", num(im, "X") + move[0]);
setAttr(im, "Y", num(im, "Y") + move[1]);
drawCanvas();
renderImgForm();
} else if (e.key === "Delete") deleteImage();
});
function renderAll() {
renderSets();
renderSide();
drawCanvas();
}
// Refresh the pack list whenever the view opens
document.querySelector('[data-view="ui"]').addEventListener("click", () => imsSessions());
+232
View File
@@ -0,0 +1,232 @@
"use strict";
// Items tab: every item of the game, read from its packs by src/main/items.js (names from the text
// table, icons from xml/uiimageset.xml through UIRes, prices, box contents, descriptions). It reads
// the packs as they are, so it always shows the current rs.iop / map.iop; Reload reads them again.
(() => {
const IT = { src: null, data: null, cat: "etc", text: null, table: null, loading: false, detailSeq: 0 };
window.IT = IT; // (dev probes)
const n = (v) => Number(v || 0).toLocaleString();
const PRICE = (s) => (s.cash ? `${n(s.cash)} cash` : s.peso ? `${n(s.peso)} peso` : "");
// ---------- icons (UIRes: imageset → texture, same drawing as the window editor) ----------
function iconCanvas(name, px) {
const c = el("canvas", { class: "it-icon", width: px, height: px });
c.style.width = `${px}px`;
c.style.height = `${px}px`;
if (name) drawIcon(c, name, px);
return c;
}
async function drawIcon(c, name, px) {
const i = name.indexOf("#");
const im = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : await UIRes.image(null, null, name);
if (!im?.bmp) {
c.classList.add("missing");
c.title = `${name} (not found)`;
return;
}
const ctx = c.getContext("2d");
const scale = Math.min(px / im.w, px / im.h);
const w = Math.round(im.w * scale);
const h = Math.round(im.h * scale);
ctx.imageSmoothingQuality = "high";
ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, (px - w) / 2, (px - h) / 2, w, h);
c.title = name;
}
// ---------- list ----------
function table() {
if (IT.table) return IT.table;
IT.table = new VTable(
$("it-table"),
[
{ key: "icon", label: "", width: 40, format: (r) => iconCanvas(r.icon, 30) },
{ key: "id", label: "ID", width: 90, cls: "num mono" },
{ key: "name", label: "Name", grow: true, width: 200, format: (r) => r.name || "(no name)", sort: (r) => r.name || "￿", title: true },
{ key: "kind", label: "Type", width: 110, title: true },
{ key: "price", label: "Price", width: 120, cls: "num", format: (r) => (r.shop?.length ? PRICE(r.shop.find((s) => s.cash || s.peso) || r.shop[0]) : ""), sort: (r) => (r.shop?.[0]?.cash || 0) * 1e6 + (r.shop?.[0]?.peso || 0) },
],
{ multi: false, rowHeight: 36, id: "items" },
);
IT.table.keyOf = (r) => r.index;
IT.table.onSelect = (_sel, r) => r && showDetail(r);
return IT.table;
}
function renderCats() {
const nav = $("it-cats");
nav.replaceChildren(
...IT.data.categories.map((c) =>
el("button", { class: c.id === IT.cat ? "on" : "", title: c.error || "", onclick: () => pickCat(c.id) }, [
el("span", { text: c.label }),
el("b", { text: c.error ? "!" : n(c.items.filter(shown).length) }),
]),
),
);
}
const shown = (it) => !$("it-hide").checked || (it.name && !/^STR\(/i.test(it.name));
function pickCat(id) {
IT.cat = id;
renderCats();
const c = IT.data.categories.find((x) => x.id === id);
table().setRows(c.items.map((it, index) => ({ ...it, index })), { animate: "fade" });
filter();
$("it-detail").replaceChildren(el("div", { class: "it-empty muted", text: c.error ? `Couldn't read this category: ${c.error}` : "Pick an item." }));
}
function filter() {
const q = $("it-search").value.trim().toLowerCase();
table().setView((r) => shown(r) && (!q || String(r.id).includes(q) || (r.name || "").toLowerCase().includes(q) || (r.icon || "").toLowerCase().includes(q) || (r.section || "").toLowerCase().includes(q)));
$("it-count").textContent = `${n(table().view.length)} of ${n(table().rows.length)}`;
}
// ---------- detail ----------
function row(label, value) {
return value === undefined || value === null || value === "" ? null : el("div", { class: "it-kv" }, [el("span", { class: "muted", text: label }), el("span", { text: String(value) })]);
}
async function showDetail(r) {
const seq = ++IT.detailSeq;
const box = $("it-detail");
const shop = (r.shop || []).filter((s) => s.cash || s.peso || s.value || s.period);
box.replaceChildren(
el("div", { class: "it-head" }, [
iconCanvas(r.icon, 72),
el("div", {}, [
el("h3", { text: r.name || "(no name)" }),
el("div", { class: "muted", text: `${IT.data.categories.find((c) => c.id === IT.cat)?.label} · ID ${r.id}` }),
el("code", { class: "muted", text: r.icon || "no icon" }),
]),
]),
el("div", { class: "it-kvs" }, [
row("Original", r.originalName),
row("Type", r.kind),
row("Group", r.group),
row("Grade", r.grade || null),
row("Classes", r.enableClass),
row("Level", r.needLevel || null),
row("Sell price", r.sellPeso ? `${n(r.sellPeso)} peso` : r.canSell === false ? "can't be sold" : null),
row("Active", r.active === null || r.active === undefined ? null : r.active ? "yes" : "no"),
row("Source", `${r.file} [${r.section}]`),
].filter(Boolean)),
...(shop.length
? [
el("h4", { text: "Shop" }),
el("table", { class: "it-shop" }, [
el("tr", {}, ["Item", "Count / days", "Peso", "Cash", "Bonus peso", "On sale"].map((h) => el("th", { text: h }))),
...shop.map((s) =>
el("tr", { class: s.active === false ? "off" : "" }, [
el("td", { text: s.label || "" }),
el("td", { text: s.period ? `${n(s.period)} h` : s.value ? n(s.value) : "" }),
el("td", { class: "num", text: s.peso ? n(s.peso) : "" }),
el("td", { class: "num", text: s.cash ? n(s.cash) : "" }),
el("td", { class: "num", text: s.bonusPeso ? n(s.bonusPeso) : "" }),
el("td", { text: s.active === null || s.active === undefined ? "" : s.active ? "yes" : "no" }),
]),
),
]),
]
: []),
...(r.contents?.length
? [
el("h4", { text: `Contents (${r.contents.length})` }),
el(
"div",
{ class: "it-contents" },
r.contents.map((c) =>
el("div", { class: "it-content" }, [
iconCanvas(c.icon, 26),
el("span", { text: c.name && !/^STR\(/i.test(c.name) ? c.name : `#${c.code}` }),
el("span", { class: "muted num", text: c.percent !== null && c.percent !== undefined ? `${c.percent.toFixed(c.percent < 1 ? 2 : 1)}%` : "" }),
]),
),
),
]
: []),
el("div", { class: "it-manuals muted", text: r.manuals?.length ? "Loading the description…" : "" }),
);
let d = null;
try {
d = await call("items:detail", IT.src, IT.text, IT.cat, r.index);
} catch (err) {
toast(err.message, true);
}
if (seq !== IT.detailSeq || !d) return;
const manuals = box.querySelector(".it-manuals");
manuals.classList.remove("muted");
manuals.replaceChildren(
...d.manuals.map((m) =>
el("div", { class: "it-manual" }, [
el("h4", { text: `${m.label} description` }, [el("span", { class: "muted", text: ` ${m.file} [Manual${m.n}]` })]),
...(m.manual
? [
...(m.manual.title.length ? [el("div", { class: "it-mtitle" }, m.manual.title.map((x) => el("span", { text: x.text, style: x.color ? `color:${x.color}` : "" })))] : []),
...m.manual.lines.map((l) => el("div", { class: "it-mline" }, l.runs.map((x) => el("span", { text: x.text, style: x.color ? `color:${x.color}` : "" })))),
]
: [el("div", { class: "muted", text: "(missing)" })]),
]),
),
el("details", { class: "it-raw" }, [
el("summary", { text: `All fields (${Object.keys(d.fields).length})` }),
el("table", {}, Object.entries(d.fields).map(([k, v]) => el("tr", {}, [el("td", { class: "mono", text: k }), el("td", { class: "mono", text: v })]))),
]),
);
}
// ---------- loading ----------
async function load({ reload = false } = {}) {
if (IT.loading) return;
if (!IT.src) IT.src = profileSource();
if (!IT.src) {
$("it-source").textContent = "This profile has no game folder: set one in the profile, or pick a folder.";
return;
}
IT.loading = true;
$("it-source").textContent = `Reading ${IT.src.folder}…`;
try {
const [data] = await Promise.all([call("items:load", IT.src, { textFile: IT.text, reload }), UIRes.load(IT.src)]);
IT.data = data;
IT.text = data.text;
$("it-text").replaceChildren(...data.texts.map((t) => el("option", { value: t.file, text: t.label, selected: t.file === data.text })));
$("it-source").textContent = `${IT.src.folder} · ${n(data.strings)} texts from ${data.text || "no text table"}${data.missing.length ? ` · missing: ${data.missing.join(", ")}` : ""}`;
if (!data.categories.some((c) => c.id === IT.cat)) IT.cat = data.categories[0]?.id;
pickCat(IT.cat);
} catch (err) {
$("it-source").textContent = err.message;
toast(err.message, true);
} finally {
IT.loading = false;
}
}
$("it-search").addEventListener("input", filter);
$("it-hide").addEventListener("change", () => (renderCats(), filter()));
$("it-text").addEventListener("change", () => {
IT.text = $("it-text").value;
load();
});
$("it-reload").addEventListener("click", () => {
UIRes.reset();
load({ reload: true });
});
$("it-folder").addEventListener("click", async () => {
const dir = await call("dialog:folder", "Game folder (with rs.iop / map.iop)");
if (!dir) return;
IT.src = { kind: "packs", folder: dir, name: dir };
IT.data = null;
UIRes.reset();
load();
});
// first time the tab opens: the profile's game folder
document.querySelector('#tabs [data-view="items"]').addEventListener("click", () => {
if (!IT.data) load();
});
window.addEventListener("profile-changed", () => {
IT.src = null;
IT.data = null;
if ($("view-items").classList.contains("on")) load();
});
})();
+710
View File
@@ -0,0 +1,710 @@
"use strict";
// Lost Saga 3D formats (io3DEngine): .msh meshes, .skl skeletons, .ani animations, material
// scripts and model configs. Little-endian, no compression; strings are int32 length + raw bytes
// (CP949, no terminator). Readers keep the raw bytes of everything they don't understand or the
// editor doesn't touch, so a save changes only what was edited.
const LSF = (() => {
const cp949 = new TextDecoder("euc-kr");
class Reader {
constructor(bytes) {
this.b = bytes;
this.dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
this.o = 0;
}
need(n) {
if (this.o + n > this.b.length) throw new Error(`File ends early (offset ${this.o}, needs ${n} bytes)`);
}
i32() {
this.need(4);
const v = this.dv.getInt32(this.o, true);
this.o += 4;
return v;
}
u32() {
this.need(4);
const v = this.dv.getUint32(this.o, true);
this.o += 4;
return v;
}
f32() {
this.need(4);
const v = this.dv.getFloat32(this.o, true);
this.o += 4;
return v;
}
/** n floats as a Float32Array (copied, so it is aligned). */
f32s(n) {
this.need(n * 4);
const out = new Float32Array(this.b.slice(this.o, this.o + n * 4).buffer);
this.o += n * 4;
return out;
}
u32s(n) {
this.need(n * 4);
const out = new Uint32Array(this.b.slice(this.o, this.o + n * 4).buffer);
this.o += n * 4;
return out;
}
str() {
const n = this.i32();
if (n < 0 || n > 4096) throw new Error(`Bad string length ${n} at offset ${this.o - 4}`);
this.need(n);
const raw = this.b.slice(this.o, this.o + n);
this.o += n;
return { raw, text: cp949.decode(raw) };
}
magic(m) {
this.need(4);
const s = String.fromCharCode(this.b[this.o], this.b[this.o + 1], this.b[this.o + 2]);
if (s !== m || this.b[this.o + 3] !== 0) throw new Error(`Not a .${m.toLowerCase()} file`);
this.o += 4;
}
}
class Writer {
constructor(size = 1 << 16) {
this.b = new Uint8Array(size);
this.dv = new DataView(this.b.buffer);
this.o = 0;
}
grow(n) {
if (this.o + n <= this.b.length) return;
let size = this.b.length * 2;
while (size < this.o + n) size *= 2;
const nb = new Uint8Array(size);
nb.set(this.b);
this.b = nb;
this.dv = new DataView(nb.buffer);
}
i32(v) {
this.grow(4);
this.dv.setInt32(this.o, v, true);
this.o += 4;
}
u32(v) {
this.grow(4);
this.dv.setUint32(this.o, v >>> 0, true);
this.o += 4;
}
f32(v) {
this.grow(4);
this.dv.setFloat32(this.o, v, true);
this.o += 4;
}
bytes(u8) {
this.grow(u8.length);
this.b.set(u8, this.o);
this.o += u8.length;
}
/** A string: its original bytes when the text wasn't changed, else ASCII. */
str(s) {
const raw = s.raw && cp949.decode(s.raw) === s.text ? s.raw : ascii(s.text);
this.i32(raw.length);
this.bytes(raw);
}
done() {
return this.b.slice(0, this.o);
}
}
function ascii(text) {
const out = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (c > 0x7e || c < 0x20) throw new Error(`“${text}”: only plain ASCII text can be written here.`);
out[i] = c;
}
return out;
}
const S = (text) => ({ raw: null, text }); // a new string (written as ASCII)
// ---------- .msh ----------
const MESH_TYPES = ["Static", "Animated", "Lightmap", "Billboard", "Normal billboard", "Animate effect", "Static vertex colour"];
const FVF = { POSITION: 0x1, POSITION2: 0x2, WEIGHTS: 0x8, INDICES: 0x10, NORMAL: 0x20, COLOR0: 0x40, UV0: 0x100, UV1: 0x200, TANGENT: 0x1000, BINORMAL: 0x2000 };
function parseMsh(bytes) {
const r = new Reader(bytes);
r.magic("MSH");
const m = { version: r.i32(), type: r.i32(), mask: r.u32() };
m.aabbMin = [r.f32(), r.f32(), r.f32()];
m.aabbMax = [r.f32(), r.f32(), r.f32()];
m.radius = r.f32();
const subCount = r.i32();
if (subCount < 0 || subCount > 4096) throw new Error("Bad sub-mesh count");
m.subs = [];
for (let i = 0; i < subCount; i++) m.subs.push({ minIndex: r.i32(), vertexCount: r.i32(), indexStart: r.i32(), faceCount: r.i32() });
const V = (m.vertexCount = r.i32());
if (V < 0 || V > 4e6) throw new Error("Bad vertex count");
const k = m.mask;
if (k & FVF.POSITION) m.pos = r.f32s(V * 3);
if (k & FVF.NORMAL) m.normal = r.f32s(V * 3);
if (k & FVF.TANGENT) m.tangent = r.f32s(V * 3);
if (k & FVF.BINORMAL) m.binormal = r.f32s(V * 3);
if (k & FVF.COLOR0) m.color = r.u32s(V);
if (k & FVF.UV0) m.uv0 = r.f32s(V * 2);
if (k & FVF.UV1) m.uv1 = r.f32s(V * 2);
if (k & FVF.WEIGHTS) {
const n = r.i32();
if (n < 0 || n > 1024) throw new Error("Bad bone count");
m.boneNames = [];
for (let i = 0; i < n; i++) m.boneNames.push(r.str());
// per vertex: weight[4], boneIndex[4] (indices stored as floats)
const blk = r.f32s(V * 8);
m.weights = new Float32Array(V * 4);
m.boneIdx = new Uint8Array(V * 4);
for (let v = 0; v < V; v++) {
for (let j = 0; j < 4; j++) {
m.weights[v * 4 + j] = blk[v * 8 + j];
m.boneIdx[v * 4 + j] = Math.max(0, Math.min(255, Math.round(blk[v * 8 + 4 + j])));
}
}
}
if (k & FVF.POSITION2) m.pos2 = r.f32s(V * 3);
const F = (m.faceCount = r.i32());
if (F < 0 || F > 8e6) throw new Error("Bad face count");
const at = r.o;
// Index width: 16-bit in the client files we checked; some newer engines read 32-bit.
// Pick the one whose end lines up with the file (after the control points for v2002).
const tryWidth = (w) => {
const end = at + F * 3 * w;
if (end > bytes.length) return null;
if (m.version !== 2002) return end === bytes.length ? { end, cps: [] } : null;
try {
const rr = new Reader(bytes);
rr.o = end;
const cps = readControlPoints(rr);
return rr.o === bytes.length ? { end, cps } : null;
} catch {
return null;
}
};
let hit = tryWidth(2);
m.index32 = false;
if (!hit && (hit = tryWidth(4))) m.index32 = true;
if (!hit) {
// unexpected trailing data: read 16-bit and keep the rest as is
hit = { end: at + F * 6, cps: [], tail: true };
if (hit.end > bytes.length) throw new Error("File ends inside the index list");
}
const ib = bytes.slice(at, hit.end).buffer;
m.indices = m.index32 ? new Uint32Array(ib) : new Uint16Array(ib);
m.bodyEnd = hit.end; // everything before the control points
m.controlPoints = hit.cps;
m.trailing = hit.tail ? bytes.slice(hit.end) : null;
m.bytes = bytes;
return m;
}
function readControlPoints(r) {
const n = r.i32();
if (n < 0 || n > 10000) throw new Error("Bad control point count");
const out = [];
for (let i = 0; i < n; i++) out.push({ name: r.str(), bone: r.str(), extra: r.str(), point: [r.f32(), r.f32(), r.f32()] });
return out;
}
/** The mesh with its control points rewritten (geometry bytes kept); v2000/2001 become 2002. */
function writeMsh(m) {
const w = new Writer(m.bodyEnd + 256);
w.bytes(m.bytes.subarray(0, m.bodyEnd));
if (m.trailing) {
w.bytes(m.trailing);
return w.done();
}
const cps = m.controlPoints || [];
if (m.version !== 2002 && !cps.length) return w.done();
w.dv.setInt32(4, 2002, true);
w.i32(cps.length);
for (const c of cps) {
w.str(c.name);
w.str(c.bone);
w.str(c.extra);
for (const v of c.point) w.f32(v);
}
return w.done();
}
/**
* A whole new mesh from its fields (the Costume tab's imports): { type, subs, pos, normal, uv0,
* boneNames: [text], weights (4 per vertex, biggest first), boneIdx, indices }. Writes v2000 with
* 16-bit indices like every client mesh; the box and radius are computed.
*/
function buildMsh(m) {
const V = m.pos.length / 3;
if (m.indices.length && Math.max(...m.indices) >= 65536) throw new Error("More than 65 536 vertices: split the mesh.");
let mask = FVF.POSITION | FVF.UV0;
if (m.normal) mask |= FVF.NORMAL;
if (m.boneNames?.length) mask |= FVF.WEIGHTS | FVF.INDICES;
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
for (let v = 0; v < V; v++) for (let k = 0; k < 3; k++) {
min[k] = Math.min(min[k], m.pos[v * 3 + k]);
max[k] = Math.max(max[k], m.pos[v * 3 + k]);
}
if (!V) min.fill(0), max.fill(0);
const radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; // half the box diagonal, like the exporter
const w = new Writer(64 + V * 80 + m.indices.length * 2);
w.bytes(Uint8Array.of(0x4d, 0x53, 0x48, 0)); // "MSH\0"
w.i32(2000);
w.i32(m.type ?? 1);
w.u32(mask);
for (const v of [...min, ...max, radius]) w.f32(v);
w.i32(m.subs.length);
for (const s of m.subs) for (const v of [s.minIndex, s.vertexCount, s.indexStart, s.faceCount]) w.i32(v);
w.i32(V);
for (const v of m.pos) w.f32(v);
if (m.normal) for (const v of m.normal) w.f32(v);
for (const v of m.uv0) w.f32(v);
if (mask & FVF.WEIGHTS) {
w.i32(m.boneNames.length);
for (const b of m.boneNames) w.str(typeof b === "string" ? S(b) : b); // text, or a read / built { raw, text }
// per vertex: weight[4], boneIndex[4] as floats; the engine stops at the first zero weight
for (let v = 0; v < V; v++) {
for (let j = 0; j < 4; j++) w.f32(m.weights[v * 4 + j]);
for (let j = 0; j < 4; j++) w.f32(m.boneIdx[v * 4 + j]);
}
}
w.i32(m.indices.length / 3);
w.grow(m.indices.length * 2);
for (const i of m.indices) {
w.dv.setUint16(w.o, i, true);
w.o += 2;
}
return w.done();
}
// ---------- .skl ----------
function parseSkl(bytes) {
const r = new Reader(bytes);
r.magic("SKL");
const s = { version: r.i32(), bones: [] };
const n = r.i32();
if (n < 0 || n > 4096) throw new Error("Bad bone count");
for (let i = 0; i < n; i++) {
const b = { name: r.str() };
b.localPos = [r.f32(), r.f32(), r.f32()];
b.localRot = [r.f32(), r.f32(), r.f32(), r.f32()];
b.invPos = [r.f32(), r.f32(), r.f32()];
b.invRot = [r.f32(), r.f32(), r.f32(), r.f32()];
b.objectTM = Array.from(r.f32s(16));
b.parent = r.str();
const c = r.i32();
b.children = [];
for (let j = 0; j < c; j++) b.children.push(r.str().text);
s.bones.push(b);
}
s.linked = r.o < bytes.length ? r.str().text : "";
return s;
}
// ---------- .ani ----------
const ANI_VERSIONS = { 4000: "full rotations (16 bytes)", 4001: "small-three rotations (4 bytes)", 4002: "8-byte rotations" };
/** 4001: index of the largest component in bits 31-30, the other three as 10-bit fields. */
function smallThree(dw, out, o) {
const max = dw >>> 30;
const a = 2 * (((dw >>> 20) & 1023) / 1023) - 1;
const b = 2 * (((dw >>> 10) & 1023) / 1023) - 1;
const c = 2 * ((dw & 1023) / 1023) - 1;
const extra = Math.sqrt(Math.max(0, 1 - (a * a + b * b + c * c)));
const vals = [a, b, c];
for (let j = 0, k = 0; j < 4; j++) out[o + j] = j === max ? extra : vals[k++];
}
function eightBytes(hi, lo, out, o) {
out[o] = ((hi >>> 16) / 65535) * 2 - 1;
out[o + 1] = ((hi & 0xffff) / 65535) * 2 - 1;
out[o + 2] = ((lo >>> 16) / 65535) * 2 - 1;
out[o + 3] = (lo & 0xffff) / 65535;
}
/** QComp::CompSmallThree / Comp8Bytes (QuaternionCompression.cpp), the inverse of the two above. */
function compSmallThree(q) {
let mx = 0;
for (let i = 1; i < 4; i++) if (Math.abs(q[i]) > Math.abs(q[mx])) mx = i;
const s = q[mx] < 0 ? -1 : 1;
let dw = mx << 30;
for (let i = 0, k = 0; i < 4; i++) {
if (i === mx) continue;
const v = Math.trunc(((s * q[i] + 1) * 0.5) * 1023);
dw |= Math.max(0, Math.min(v, 1023)) << (10 * (2 - k++));
}
return dw >>> 0;
}
function compEight(q) {
const s = q[3] < 0 ? -1 : 1;
const c = (v) => Math.max(0, Math.min(Math.trunc(((s * v + 1) * 0.5) * 65535), 65535));
return [((c(q[0]) << 16) | c(q[1])) >>> 0, ((c(q[2]) << 16) | Math.max(0, Math.min(Math.trunc(s * q[3] * 65535), 65535))) >>> 0];
}
/** Stored rotation words of a (normalised) quaternion for an .ani version. */
function encodeRot(version, q) {
const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
const n = [q[0] / len, q[1] / len, q[2] / len, q[3] / len];
if (version === 4001) return [compSmallThree(n)];
if (version === 4002) return compEight(n);
const dv = new DataView(new ArrayBuffer(16));
n.forEach((v, j) => dv.setFloat32(j * 4, v, true));
return [0, 1, 2, 3].map((j) => dv.getUint32(j * 4, true));
}
/** What a stored rotation reads back as (to show edits with the precision they'll have in the game). */
function decodeRot(version, words) {
const out = [0, 0, 0, 1];
if (version === 4001) smallThree(words[0], out, 0);
else if (version === 4002) eightBytes(words[0], words[1], out, 0);
else {
const dv = new DataView(new ArrayBuffer(4));
for (let j = 0; j < 4; j++) {
dv.setUint32(0, words[j], true);
out[j] = dv.getFloat32(0, true);
}
}
const len = Math.hypot(...out) || 1;
return out.map((v) => v / len);
}
function parseAni(bytes) {
const r = new Reader(bytes);
r.magic("ANI");
const a = { version: r.i32(), events: [], tracks: [] };
if (!ANI_VERSIONS[a.version]) throw new Error(`Unknown .ani version ${a.version}`);
const ne = r.i32();
if (ne < 0 || ne > 100000) throw new Error("Bad event count");
for (let i = 0; i < ne; i++) a.events.push({ type: r.str(), sub: r.str(), time: r.f32() });
a.total = r.i32();
const nt = r.i32();
if (nt < 0 || nt > 4096) throw new Error("Bad track count");
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4; // 4000: four raw floats
const bits = new DataView(new ArrayBuffer(4));
for (let i = 0; i < nt; i++) {
const t = { name: r.str(), weight: r.f32() };
const n = r.i32();
if (n < 0 || n > 1e6) throw new Error("Bad key count");
t.times = new Int32Array(n);
t.trans = new Float32Array(n * 3);
t.rot = new Float32Array(n * 4);
t.rawRot = new Uint32Array(n * words); // stored rotation words, written back untouched
for (let k = 0; k < n; k++) {
if (words === 4)
for (let j = 0; j < 4; j++) {
bits.setUint32(0, (t.rawRot[k * 4 + j] = r.u32()), true);
t.rot[k * 4 + j] = bits.getFloat32(0, true);
}
else if (words === 1) smallThree((t.rawRot[k] = r.u32()), t.rot, k * 4);
else eightBytes((t.rawRot[k * 2] = r.u32()), (t.rawRot[k * 2 + 1] = r.u32()), t.rot, k * 4);
const q = t.rot.subarray(k * 4, k * 4 + 4);
const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
for (let j = 0; j < 4; j++) q[j] /= len;
t.trans[k * 3] = r.f32();
t.trans[k * 3 + 1] = r.f32();
t.trans[k * 3 + 2] = r.f32();
t.times[k] = r.i32();
}
a.tracks.push(t);
}
if (r.o !== bytes.length) a.trailing = bytes.slice(r.o);
return a;
}
/** Writes an animation; rotations keep their stored bytes (no re-compression). */
function writeAni(a) {
const w = new Writer(1 << 16);
w.bytes(new Uint8Array([0x41, 0x4e, 0x49, 0]));
w.i32(a.version);
const events = a.events.slice().sort((x, y) => x.time - y.time);
w.i32(events.length);
for (const e of events) {
w.str(e.type);
w.str(e.sub);
w.f32(e.time);
}
w.i32(Math.round(a.total));
w.i32(a.tracks.length);
const words = a.version === 4001 ? 1 : a.version === 4002 ? 2 : 4;
for (const t of a.tracks) {
w.str(t.name);
w.f32(t.weight);
const n = t.times.length;
w.i32(n);
for (let k = 0; k < n; k++) {
for (let j = 0; j < words; j++) w.u32(t.rawRot[k * words + j]);
w.f32(t.trans[k * 3]);
w.f32(t.trans[k * 3 + 1]);
w.f32(t.trans[k * 3 + 2]);
w.i32(t.times[k]);
}
}
if (a.trailing) w.bytes(a.trailing);
return w.done();
}
/** Sampled local transform of a track at time t (ms): lerp position, slerp rotation. */
function sampleTrack(t, time, pos, rot) {
const n = t.times.length;
if (!n) return false;
let i = 0;
while (i < n && t.times[i] <= time) i++;
if (i === 0 || i === n) {
const k = i === 0 ? 0 : n - 1;
pos[0] = t.trans[k * 3];
pos[1] = t.trans[k * 3 + 1];
pos[2] = t.trans[k * 3 + 2];
for (let j = 0; j < 4; j++) rot[j] = t.rot[k * 4 + j];
return true;
}
const a = i - 1;
const b = i;
const span = t.times[b] - t.times[a];
const f = span > 0 ? (time - t.times[a]) / span : 0;
for (let j = 0; j < 3; j++) pos[j] = t.trans[a * 3 + j] + (t.trans[b * 3 + j] - t.trans[a * 3 + j]) * f;
slerp(t.rot, a * 4, t.rot, b * 4, f, rot);
return true;
}
function slerp(qa, oa, qb, ob, f, out) {
let ax = qa[oa], ay = qa[oa + 1], az = qa[oa + 2], aw = qa[oa + 3];
let bx = qb[ob], by = qb[ob + 1], bz = qb[ob + 2], bw = qb[ob + 3];
let cos = ax * bx + ay * by + az * bz + aw * bw;
if (cos < 0) {
cos = -cos;
bx = -bx;
by = -by;
bz = -bz;
bw = -bw;
}
let s0, s1;
if (cos > 0.9995) {
s0 = 1 - f;
s1 = f;
} else {
const th = Math.acos(cos);
const sin = Math.sin(th);
s0 = Math.sin((1 - f) * th) / sin;
s1 = Math.sin(f * th) / sin;
}
out[0] = s0 * ax + s1 * bx;
out[1] = s0 * ay + s1 * by;
out[2] = s0 * az + s1 * bz;
out[3] = s0 * aw + s1 * bw;
const len = Math.hypot(out[0], out[1], out[2], out[3]) || 1;
for (let j = 0; j < 4; j++) out[j] /= len;
}
// ---------- material script (OGRE-like text) ----------
/** → [{ passes: [{ shader, units: [{ texture, uvSet, light }], sceneBlend, alphaReject, cull, lighting }] }] */
function parseMaterial(text) {
const mats = [];
const stack = [];
let pendingBlock = null;
let mat = null;
let pass = null;
let unit = null;
for (let line of text.split(/\r\n|\r|\n/)) {
line = line.replace(/\/\/.*$/, "").trim();
if (!line) continue;
if (line === "{") {
stack.push(pendingBlock);
if (pendingBlock === "material") mats.push((mat = { passes: [] }));
else if (pendingBlock === "pass" && mat) mat.passes.push((pass = { units: [] }));
else if (pendingBlock === "texture_unit" && pass) pass.units.push((unit = { uvSet: 0 }));
pendingBlock = null;
continue;
}
if (line === "}") {
const top = stack.pop();
if (top === "texture_unit") unit = null;
else if (top === "pass") pass = null;
else if (top === "material") mat = null;
continue;
}
const [key, ...rest] = line.split(/\s+/);
if (["material", "technique", "pass", "texture_unit"].includes(key) && !rest.length) {
pendingBlock = key;
continue;
}
pendingBlock = key; // unknown block names ("technique x") still nest
if (unit) {
const wave = () => ({ type: (rest[0] || "").toLowerCase(), wave: (rest[1] || "sine").toLowerCase(), base: Number(rest[2]) || 0, freq: Number(rest[3]) || 0, phase: Number(rest[4]) || 0, amp: Number(rest[5]) || 0, once: rest[6] === "true" });
if (key === "texture") unit.texture = rest[0];
else if (key === "color_blend") unit.colorBlend = rest.map((x) => x.toLowerCase());
else if (key === "tex_coord_set") unit.uvSet = Number(rest[0]) || 0;
else if (key === "use_light_texture") unit.light = rest[0] === "true";
// texture animation (ioTextureUnitState): frames, scroll, rotate, wave transforms, colour waves
else if (key === "anim_texture") unit.frames = { count: Math.max(1, Number(rest[0]) || 1), duration: Number(rest[1]) || 1000, once: rest[2] === "true" };
else if (key === "scroll_anim") unit.scroll = { u: Number(rest[0]) || 0, v: Number(rest[1]) || 0, once: rest[2] === "true" };
else if (key === "rotate_anim") unit.rotate = { speed: Number(rest[0]) || 0, once: rest[1] === "true" };
else if (key === "wave_xform") (unit.waves ||= []).push(wave());
else if (key === "color_xform") (unit.colorWaves ||= []).push(wave());
else if (key === "scale_uv_on_center") unit.scaleCenter = rest[0] === "true";
} else if (pass) {
if (key === "shader_group") pass.shader = rest[0];
else if (key === "scene_blend") pass.sceneBlend = rest.join(" ");
else if (key === "alpha_rejection") pass.alphaReject = rest.join(" ");
else if (key === "cull") pass.cull = rest[0];
else if (key === "lighting") pass.lighting = rest[0] !== "false";
else if (key === "depth_write") pass.depthWrite = rest[0] !== "false";
} else if (mat && key === "diffuse") {
mat.diffuse = rest.slice(0, 3).map(Number);
}
}
return mats;
}
/**
* What the viewer draws for one material: the first real texture of the first non-outline pass.
* LightMap passes have the lightmap first (UV set 0) and the diffuse after it (usually set 1).
*/
function diffuseOf(mat) {
for (const p of mat?.passes || []) {
if (/outline/i.test(p.shader || "")) continue;
const units = p.units.filter((x) => x.texture && !x.light);
if (!units.length) continue;
const lightmap = /lightmap/i.test(p.shader || "") && units.length > 1;
const u = lightmap ? units[units.length - 1] : units[0];
const animOf = (x) => (x && (x.frames || x.scroll || x.rotate || x.waves || x.colorWaves) ? { frames: x.frames || null, scroll: x.scroll || null, rotate: x.rotate || null, waves: x.waves || [], colorWaves: x.colorWaves || [], scaleCenter: !!x.scaleCenter } : null);
return {
texture: u.texture,
uvSet: u.uvSet,
anim: animOf(u),
lightmap: lightmap ? units[0].texture : null,
lightmapSet: lightmap ? units[0].uvSet : 0,
lightmapAnim: lightmap ? animOf(units[0]) : null,
lmAddDiffuse: lightmap && units[0].colorBlend?.join(" ") === "add texture diffuse",
diffuseColor: mat.diffuse || [1, 1, 1],
blend: p.sceneBlend || "",
alphaReject: p.alphaReject || "",
};
}
return null;
}
// ---------- model config (#MODEL_CFG) ----------
function parseModelCfg(text) {
const cfg = { skeleton: "", animations: [], meshes: [] };
const lines = text.split(/\r\n|\r|\n/).map((l) => l.trim());
let list = null;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (!l || l.startsWith(";") || l.startsWith("//")) continue; // commented-out entries
if (l === "#SKELETON") cfg.skeleton = lines[++i] || "";
else if (l === "#ANIMATION_LIST") list = cfg.animations;
else if (l === "#MESH_LIST") list = cfg.meshes;
else if (l === "#ANIMATION_LIST_END" || l === "#MESH_LIST_END") list = null;
else if (l === "#MODEL_COLOR") cfg.color = (lines[++i] || "").split(/\s+/).map(Number);
else if (l === "#AABOX") cfg.aabb = [(lines[++i] || "").split(/\s+/).map(Number), (lines[++i] || "").split(/\s+/).map(Number)];
else if (l === "#COLLISION_MESH") cfg.collision = lines[++i] || "";
else if (l === "#OUTLINE") {
// file, on/off, colour (r g b), thickness
cfg.outline = { file: lines[++i] || "", on: (lines[++i] || "") !== "0", color: (lines[++i] || "").split(/\s+/).map(Number), size: Number(lines[++i]) || 0 };
}
else if (l.startsWith("#")) list = null;
else if (list) list.push(l);
}
return cfg;
}
// ---------- .cms collision mesh (ioOpcodeManagerImpl::LoadOpcodeShapeMesh) ----------
/** "CMS\0", version, vertex count, float3[], face count, uint16[3 * faces]. */
function parseCms(bytes) {
const r = new Reader(bytes);
r.magic("CMS");
const c = { version: r.i32() };
// 5000 = world / object collision; 5500 / 5700 are character attack / hit box groups
if (c.version !== 5000) throw new Error(`Not a collision mesh (version ${c.version}: a character box group)`);
const V = r.i32();
if (V < 0 || V > 4e6) throw new Error("Bad collision vertex count");
c.pos = r.f32s(V * 3);
const F = r.i32();
if (F < 0 || F > 8e6) throw new Error("Bad collision face count");
r.need(F * 6);
c.indices = new Uint16Array(bytes.slice(r.o, r.o + F * 6).buffer);
return c;
}
// ---------- .edg outline edges (ioEdgeRender::LoadFileInternal) ----------
/** vertices + normals, face normals, edges { a, b (vertices), fa, fb (faces, 0xFFFF = open) }. */
function parseEdg(bytes) {
const r = new Reader(bytes);
r.magic("EDG");
const e = { version: r.i32() };
const V = r.i32();
if (V < 0 || V > 4e6) throw new Error("Bad edge vertex count");
e.pos = r.f32s(V * 3);
e.normal = r.f32s(V * 3);
const F = r.i32();
if (F < 0 || F > 8e6) throw new Error("Bad edge face count");
e.faceNormals = r.f32s(F * 3);
const E = r.i32();
if (E < 0 || E > 8e6) throw new Error("Bad edge count");
r.need(E * 8);
e.edges = new Uint16Array(bytes.slice(r.o, r.o + E * 8).buffer); // a, b, fa, fb per edge
return e;
}
// ---------- texture animation at time t (seconds), like ioTexture*Effect + RecalcTextureMatrix ----------
const waveValue = (w, t) => {
let x = t * w.freq;
x = w.once && x >= 1 ? 1 : x - Math.floor(x);
let o = 0;
if (w.wave === "sine") o = Math.sin(x * Math.PI * 2);
else if (w.wave === "triangle") o = x < 0.25 ? x * 4 : x <= 0.75 ? 1 - (x - 0.25) * 4 : (x - 0.75) * 4 - 1;
else if (w.wave === "square") o = x <= 0.5 ? 1 : -1;
else if (w.wave === "sawtooth") o = x * 2 - 1;
else if (w.wave === "inverse_sawtooth") o = 1 - x * 2;
return w.base + (o + 1) * 0.5 * w.amp;
};
const wrap = (v, once) => (once ? Math.max(-1, Math.min(1, v)) : v - Math.trunc(v));
/** → { m: Float32Array(9) for vec3(uv,1) * m (GLSL), frame, colorMul, emissive } */
function texAnimAt(a, t) {
let uMod = 0, vMod = 0, rot = 0, uScale = 1, vScale = 1;
if (a.scroll) {
uMod = wrap(-a.scroll.u * t, a.scroll.once);
vMod = wrap(-a.scroll.v * t, a.scroll.once);
}
if (a.rotate) rot = (-a.rotate.speed * t) % 360;
let colorMul = 1, emissive = 0;
for (const w of a.waves) {
const r = waveValue(w, t);
if (w.type === "translate_u") uMod = r;
else if (w.type === "translate_v") vMod = r;
else if (w.type === "rotate") rot = r * 360;
else if (w.type === "scale_u") uScale = r >= 0 ? 1 + r : 1 / -r;
else if (w.type === "scale_v") vScale = r >= 0 ? 1 + r : 1 / -r;
}
for (const w of a.colorWaves) {
const r = waveValue(w, t);
if (w.type === "diffuse" || w.type === "ambient") colorMul *= w.type === "diffuse" ? r : 0.5 + 0.5 * r; // approximation of the pass colour rates
else if (w.type === "emissive") emissive += r;
}
// D3D row-vector 3×3 (uv·M): scale (optionally on the centre), then translate, then rotate about (0.5, 0.5)
const mul = (A, B) => {
const o = new Array(9);
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) o[r * 3 + c] = A[r * 3] * B[c] + A[r * 3 + 1] * B[3 + c] + A[r * 3 + 2] * B[6 + c];
return o;
};
let X = [1 / uScale, 0, 0, 0, 1 / vScale, 0, 0, 0, 1];
if (a.scaleCenter) {
X[6] = -0.5 * X[0] + 0.5;
X[7] = -0.5 * X[4] + 0.5;
}
if (uMod || vMod) X = mul([1, 0, 0, 0, 1, 0, uMod, vMod, 1], X);
if (rot) {
const th = (rot * Math.PI) / 180, c = Math.cos(th), s = Math.sin(th);
X = mul([c, s, 0, -s, c, 0, 0.5 + (-0.5 * c - -0.5 * s), 0.5 + (-0.5 * s + -0.5 * c), 1], X);
}
// GLSL mat3 columns = the D3D matrix's columns, used as vec3(uv, 1) * m
const m = new Float32Array([X[0], X[3], X[6], X[1], X[4], X[7], X[2], X[5], X[8]]);
let frame = 0;
if (a.frames && a.frames.count > 1) {
let r = (t * 1000) / a.frames.duration;
r = a.frames.once && r >= 1 ? 0.999 : r - Math.floor(r);
frame = Math.min(a.frames.count - 1, Math.floor(r * a.frames.count));
}
return { m, frame, colorMul, emissive };
}
const decode = (bytes) => cp949.decode(bytes);
return { texAnimAt, parseMsh, writeMsh, buildMsh, parseCms, parseEdg, parseSkl, parseAni, writeAni, encodeRot, decodeRot, sampleTrack, slerp, parseMaterial, diffuseOf, parseModelCfg, decode, S, MESH_TYPES, FVF, ANI_VERSIONS };
})();
window.LSF = LSF;
File diff suppressed because it is too large. Load diff
+193
View File
@@ -0,0 +1,193 @@
"use strict";
// Loading game meshes into a GL3D.View: the .msh, its material script (Material/<mesh>.txt, one
// material per sub-mesh), the textures (decoded here, uploaded per view) and model configs.
// Shared by the model viewer and the map editor; each keeps its own kit (one texture cache per
// WebGL context).
const MeshKit = (() => {
let seq = 0;
function create(view, getSrc, options = {}) {
const tex = new Map(); // texture name → Promise<{ tex, img, w, h, source } | { error }>
function texture(name) {
const key = name.toLowerCase();
if (!tex.has(key)) {
tex.set(
key,
(async () => {
const src = getSrc();
if (!src) return { error: "no source" };
const t = await call("mdl:texture", src, name);
if (!t) return { error: "not found" };
let img;
if (t.ext === "dds") img = TexDecode.dds(t.bytes);
else if (t.ext === "tga") img = TexDecode.tga(t.bytes);
else img = await createImageBitmap(new Blob([t.bytes]), { premultiplyAlpha: "none" });
const prepared = options.prepareTexture ? options.prepareTexture({ img, ext: t.ext, name }) : img;
return { tex: view.texture(prepared), img: prepared, w: prepared.width, h: prepared.height, source: t.source, ext: t.ext };
})().catch((err) => ({ error: err.message })),
);
}
return tex.get(key);
}
async function readNamed(name, folder, strict = false) {
const src = getSrc();
if (!src) return null;
return call("mdl:read", src, name, folder, strict);
}
/**
* scene_blend "<op> <src> <dst>" (D3D style: add / subtract / rev_subtract / min / max with
* zero, one, src_color, inv_src_color, src_alpha, inv_src_alpha, dest_alpha, inv_dest_alpha,
* dest_color, inv_dest_color) → { op, src, dst }, or null for none / opaque.
*/
function parseBlend(s) {
const t = (s || "").trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!t.length) return null;
// ioMaterialSerializer::ParseSceneBlend delegates named modes to
// ioPass::SetSceneBlending. Keep its factors, especially additive alpha.
if (t.length === 1) {
const named = {
no_alpha: null,
src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
color_key: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
alpha_blend: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
add: { op: "add", src: "src_alpha", dst: "one" },
screen: { op: "add", src: "inv_dest_color", dst: "one" },
lighten: { op: "max", src: "one", dst: "one" },
darken: { op: "min", src: "one", dst: "one" },
rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" },
subtract: { op: "subtract", src: "src_alpha", dst: "one" },
};
if (Object.hasOwn(named, t[0])) return named[t[0]];
}
if (t[0] === "modulate") return { op: "add", src: "dest_color", dst: "zero" };
const [op, src, dst] = t.length >= 3 ? t : ["add", ...t];
if (src === "one" && dst === "zero") return null;
return { op, src: src.replace(/s$/, ""), dst: (dst || "zero").replace(/colors$/, "color") };
}
/** Texture, UV set, lightmap and blending of a sub-mesh from its material. */
function groupLook(d, data) {
const set = (n) => (n === 1 && data.uv1 ? 1 : 0);
const blendFunc = parseBlend(d?.blend);
return {
uvSet: set(d?.uvSet),
texName: d?.texture || null,
lmName: d?.lightmap || null,
lmSet: set(d?.lightmapSet),
lmAddDiffuse: !!d?.lmAddDiffuse,
diffuseColor: d?.diffuseColor || [1, 1, 1],
blend: !!blendFunc,
blendFunc,
texAnim: d?.anim || null,
lmAnim: d?.lightmapAnim || null,
alphaCut: d?.alphaReject ? 0.5 : 0,
tex: null,
lmTex: null,
texInfo: null,
};
}
/** Reads the material script, builds the draw groups and puts the mesh in the view. → m */
async function loadMesh(hit, name, place = {}) {
const data = LSF.parseMsh(hit.bytes);
const m = { id: ++seq, name, data, where: hit.where, source: hit.source, visible: true, dirty: false, mat: null, groups: [] };
const mh = await readNamed(`${name}.txt`, "material", true).catch(() => null);
if (mh) {
const text = LSF.decode(mh.bytes);
m.mat = { text, raw: mh.bytes, where: mh.where, source: mh.source, mats: LSF.parseMaterial(text), dirty: false };
}
const subs = data.subs.length ? data.subs : [{ indexStart: 0, faceCount: data.indices.length / 3 }];
m.groups = subs.map((sub, i) => {
const start = Math.min(sub.indexStart, data.indices.length);
return Object.assign({ start, count: Math.min(sub.faceCount * 3, data.indices.length - start) }, groupLook(LSF.diffuseOf(m.mat?.mats[i]), data));
});
if (data.boneNames?.length) {
m.skinPos = new Float32Array(data.pos.length);
m.skinNrm = data.normal ? new Float32Array(data.normal.length) : null;
}
view.addMesh(m.id, { pos: data.pos, normal: data.normal, uv0: data.uv0, uv1: data.uv1, indices: data.indices, groups: m.groups }, place);
return m;
}
async function loadTextures(m) {
await Promise.all(
m.groups.map(async (g, gi) => {
if (g.texName) {
const t = await texture(g.texName);
g.texInfo = t;
if (t.tex) view.setGroupTexture(m.id, gi, t.tex);
// anim_texture N: frames <name>1.dds … <name>(N-1).dds after the base one
const n = g.texAnim?.frames?.count || 0;
if (n > 1) {
const dot = g.texName.lastIndexOf(".");
const stem = dot > 0 ? g.texName.slice(0, dot) : g.texName;
const ext = dot > 0 ? g.texName.slice(dot) : "";
const frames = [t.tex || null];
for (let i = 1; i < n; i++) frames.push((await texture(`${stem}${i}${ext}`)).tex || null);
g.frameTex = frames;
}
}
if (g.lmName) {
const t = await texture(g.lmName);
if (t.tex) view.setGroupTexture(m.id, gi, t.tex, "lmTex");
}
}),
);
}
/** Re-reads a mesh's draw groups after its material text changed. */
function applyMaterial(m) {
m.groups.forEach((g, i) => {
Object.assign(g, groupLook(LSF.diffuseOf(m.mat.mats[i]), m.data));
view.setGroupTexture(m.id, i, null);
view.setGroupTexture(m.id, i, null, "lmTex");
});
return loadTextures(m);
}
/** A model config (resource/model/<path>) by its path, e.g. "staticmodel/prisoner.txt". */
async function readModel(path) {
const src = getSrc();
if (!src) return null;
const hit = await call("mdl:readPath", src, `model/${path.replace(/\\/g, "/").replace(/^\/+/, "")}`);
return hit ? { hit, cfg: LSF.parseModelCfg(LSF.decode(hit.bytes)) } : null;
}
/** Texture animations of a mesh at time t (s); t = null puts them back to rest. → true when something animates */
function animate(m, t) {
let any = false;
for (const g of m.groups) {
if (g.texAnim) {
any = true;
if (t === null) {
g.texM = null;
g.colorMul = 1;
g.emissive = 0;
if (g.frameTex?.[0]) g.tex = g.frameTex[0];
} else {
const r = LSF.texAnimAt(g.texAnim, t);
g.texM = r.m;
g.colorMul = r.colorMul;
g.emissive = r.emissive;
if (g.frameTex?.[r.frame]) g.tex = g.frameTex[r.frame];
}
}
if (g.lmAnim) {
any = true;
g.lmM = t === null ? null : LSF.texAnimAt(g.lmAnim, t).m;
}
}
return any;
}
return { texture, readNamed, groupLook, loadMesh, loadTextures, applyMaterial, readModel, animate, clearTextures: () => tex.clear() };
}
return { create };
})();
window.MeshKit = MeshKit;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+176
View File
@@ -0,0 +1,176 @@
"use strict";
// Skeleton poses and CPU skinning, the io3DEngine way (D3D row vectors: v' = v · BipedSpaceTM ·
// BoneWorld; keys replace the bone's local position / rotation; linked skeletons use the offset TM
// and bone-length ratio of the skeleton they link to). Used by the map editor for animated map
// objects; the model viewer (model.js) has the same maths inline.
const SkinKit = (() => {
const M4 = {
fromQT(q, p) {
const [x, y, z, w] = q;
return new Float64Array([
1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0,
2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0,
2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0,
p[0], p[1], p[2], 1,
]);
},
mul(a, b) {
const o = new Float64Array(16);
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) o[r * 4 + c] = a[r * 4] * b[c] + a[r * 4 + 1] * b[4 + c] + a[r * 4 + 2] * b[8 + c] + a[r * 4 + 3] * b[12 + c];
return o;
},
/** Inverse of an affine row-vector matrix (rotation / scale part + translation row). */
invertAffine(m) {
const a = m[0], b = m[1], c = m[2], d = m[4], e = m[5], f = m[6], g = m[8], h = m[9], i = m[10];
const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
if (Math.abs(det) < 1e-12) return M4.fromQT([0, 0, 0, 1], [0, 0, 0]);
const k = 1 / det;
const r = new Float64Array(16);
r[0] = (e * i - f * h) * k; r[1] = (c * h - b * i) * k; r[2] = (b * f - c * e) * k;
r[4] = (f * g - d * i) * k; r[5] = (a * i - c * g) * k; r[6] = (c * d - a * f) * k;
r[8] = (d * h - e * g) * k; r[9] = (b * g - a * h) * k; r[10] = (a * e - b * d) * k;
const tx = m[12], ty = m[13], tz = m[14];
r[12] = -(tx * r[0] + ty * r[4] + tz * r[8]);
r[13] = -(tx * r[1] + ty * r[5] + tz * r[9]);
r[14] = -(tx * r[2] + ty * r[6] + tz * r[10]);
r[15] = 1;
return r;
},
};
const qmul = (a, b) => [
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
];
function qrot(q, v) {
const [x, y, z, w] = q;
const ux = y * v[2] - z * v[1], uy = z * v[0] - x * v[2], uz = x * v[1] - y * v[0];
const uux = y * uz - z * uy, uuy = z * ux - x * uz, uuz = x * uy - y * ux;
return [v[0] + 2 * (w * ux + uux), v[1] + 2 * (w * uy + uuy), v[2] + 2 * (w * uz + uuz)];
}
/** A parsed .skl (LSF.parseSkl) → skeleton ready for posing. */
function prepare(data) {
const n = data.bones.length;
const byName = new Map();
data.bones.forEach((b, i) => byName.has(b.name.text.toLowerCase()) || byName.set(b.name.text.toLowerCase(), i));
const parent = data.bones.map((b) => {
const p = b.parent.text.toLowerCase();
return p === "noparent" || !byName.has(p) ? -1 : byName.get(p);
});
const order = [];
const done = new Uint8Array(n);
const visit = (i, depth) => {
if (done[i] || depth > n) return;
if (parent[i] >= 0) visit(parent[i], depth + 1);
if (!done[i]) {
done[i] = 1;
order.push(i);
}
};
for (let i = 0; i < n; i++) visit(i, 0);
return { data, byName, parent, order, bst: data.bones.map((b) => M4.fromQT(b.invRot, b.invPos)), rate: new Float64Array(n).fill(1), linked: data.linked || "" };
}
/** Applies the skeleton it links to (same bone count required, like the client). */
function link(sk, other) {
if (!other || other.bones.length !== sk.data.bones.length) return false;
const rm = (a) => Float64Array.from(a);
sk.data.bones.forEach((b, i) => {
const o = other.bones[i];
const offset = M4.mul(M4.invertAffine(rm(o.objectTM)), rm(b.objectTM));
sk.bst[i] = M4.mul(offset, M4.fromQT(b.invRot, b.invPos));
const l = Math.hypot(...o.localPos);
sk.rate[i] = l > 1e-6 ? Math.hypot(...b.localPos) / l : 1;
});
return true;
}
const mapTracks = (sk, ani) => Int32Array.from(ani.tracks, (t) => sk.byName.get(t.name.text.toLowerCase()) ?? -1);
const palette = (sk, meshData) => (meshData.boneNames ? Int32Array.from(meshData.boneNames, (b) => sk.byName.get(b.text.toLowerCase()) ?? -1) : null);
/** Bone world transforms + skin matrices at t (ms). */
function pose(sk, ani, map, t) {
const bones = sk.data.bones;
const n = bones.length;
const lp = bones.map((b) => b.localPos.slice());
const lr = bones.map((b) => b.localRot.slice());
if (ani) {
const p = [0, 0, 0];
const q = [0, 0, 0, 1];
ani.tracks.forEach((tr, ti) => {
const bi = map[ti];
if (bi < 0 || tr.weight <= 0 || !LSF.sampleTrack(tr, t, p, q)) return;
const r = sk.rate[bi];
lp[bi] = [p[0] * r, p[1] * r, p[2] * r];
lr[bi] = q.slice();
});
}
const wp = new Array(n);
const wr = new Array(n);
for (const i of sk.order) {
const pi = sk.parent[i];
if (pi < 0) {
wr[i] = lr[i];
wp[i] = lp[i];
} else {
wr[i] = qmul(wr[pi], lr[i]);
const v = qrot(wr[pi], lp[i]);
wp[i] = [v[0] + wp[pi][0], v[1] + wp[pi][1], v[2] + wp[pi][2]];
}
}
const skin = new Array(n);
for (let i = 0; i < n; i++) skin[i] = M4.mul(sk.bst[i], M4.fromQT(wr[i] || [0, 0, 0, 1], wp[i] || [0, 0, 0]));
return { wp, wr, skin };
}
/** Skins a meshkit mesh (m.data, m.skinPos / m.skinNrm) with a palette into its buffers. */
function skin(m, pal, ps) {
const d = m.data;
const V = d.vertexCount;
const P = d.pos;
const N = d.normal;
const out = m.skinPos;
const on = m.skinNrm;
for (let v = 0; v < V; v++) {
let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0, tw = 0;
const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2];
const qx = N ? N[v * 3] : 0, qy = N ? N[v * 3 + 1] : 0, qz = N ? N[v * 3 + 2] : 0;
for (let j = 0; j < 4; j++) {
const w = d.weights[v * 4 + j];
if (w <= 0) continue;
const bi = pal[d.boneIdx[v * 4 + j]];
if (bi === undefined || bi < 0) continue;
const M = ps.skin[bi];
x += w * (px * M[0] + py * M[4] + pz * M[8] + M[12]);
y += w * (px * M[1] + py * M[5] + pz * M[9] + M[13]);
z += w * (px * M[2] + py * M[6] + pz * M[10] + M[14]);
if (N) {
nx += w * (qx * M[0] + qy * M[4] + qz * M[8]);
ny += w * (qx * M[1] + qy * M[5] + qz * M[9]);
nz += w * (qx * M[2] + qy * M[6] + qz * M[10]);
}
tw += w;
}
if (tw <= 0) {
x = px; y = py; z = pz; nx = qx; ny = qy; nz = qz;
} else if (Math.abs(tw - 1) > 1e-3) {
x /= tw; y /= tw; z /= tw;
}
out[v * 3] = x;
out[v * 3 + 1] = y;
out[v * 3 + 2] = z;
if (on) {
on[v * 3] = nx;
on[v * 3 + 1] = ny;
on[v * 3 + 2] = nz;
}
}
}
return { M4, prepare, link, mapTracks, palette, pose, skin };
})();
window.SkinKit = SkinKit;
+768
View File
@@ -0,0 +1,768 @@
"use strict";
// Tables tab (DAT Editor): the client's .dat tables (resource/tables in the packs) and the server's
// (ls_gamesvr\config\tables) as a spreadsheet, read and written by src/main/dattable.js. The columns
// come from the file and can't change (the game checks the header version); rows and values can.
// Grid: click / drag / shift to select, type or F2 / double-click to edit, Enter / Tab to move,
// Ctrl+C / Ctrl+V with Excel (tab-separated), Delete clears, Ctrl+Z / Ctrl+Y, Ctrl+S saves.
(() => {
const T = { lists: { client: [], server: [] }, cur: null, meta: null, busy: false };
window.DT = T; // (dev probes)
const LS = (k, v) => {
try {
if (v === undefined) return localStorage.getItem(k) || "";
localStorage.setItem(k, v);
} catch {}
return "";
};
const RH = 26;
// ---------- table list ----------
const clientSrc = () => T.src || profileSource();
async function listTables() {
const src = clientSrc();
const server = LS("dt:server");
try {
T.lists.client = src ? await call("dat:client", src) : [];
} catch (err) {
T.lists.client = [];
toast(err.message, true);
}
T.lists.server = server ? await call("dat:folder", server) : [];
$("dt-source").textContent = [src ? `client: ${src.folder}` : "no game folder in this profile", server ? `server: ${server}` : "no server folder"].join(" · ");
renderList();
}
function renderList() {
const group = (title, items, kind, empty) => [
el("div", { class: "dt-group", text: title }),
...(items.length
? items.map((it) =>
el(
"button",
{
class: `dt-item${T.cur && T.cur.refKey === `${kind}|${it.key}` ? " on" : ""}`,
title: it.path,
onclick: () => openTable(kind === "client" ? { kind: "pack", src: clientSrc(), key: it.key } : { kind: "file", file: it.path }, `${kind}|${it.key}`),
},
[el("span", { text: it.name.replace(/\.dat$/i, "") }), kind === "client" && it.pack ? el("small", { class: "muted", text: it.pack }) : null],
),
)
: [el("div", { class: "dt-none muted", text: empty })]),
];
const opened = T.cur && T.cur.ref.kind === "file" && !T.lists.server.some((x) => x.path === T.cur.ref.file) ? group("File", [{ key: T.cur.ref.file, name: T.cur.ref.file.split(/[\\/]/).pop(), path: T.cur.ref.file }], "file", "") : [];
$("dt-list").replaceChildren(...group("Client (packs)", T.lists.client, "client", "No resource/tables/*.dat in the packs."), ...group("Server", T.lists.server, "server", "Pick the server's tables folder."), ...opened);
}
// ---------- opening ----------
async function leave() {
if (!T.cur?.dirty()) return true;
return confirmBox("Unsaved changes", `${T.cur.name} has changes that aren't saved. Drop them?`, "Drop changes", "danger");
}
async function openTable(ref, refKey, { encoding = "auto" } = {}) {
if (T.cur && T.cur.refKey !== refKey && !(await leave())) return;
let d;
try {
d = await call("dat:open", ref, encoding);
} catch (err) {
return toast(err.message, true);
}
if (T.cur) call("dat:close", T.cur.id);
const folder = LS("dt:maker");
let meta = null;
try {
meta = folder ? await call("dat:meta", folder, d.name) : null;
} catch {}
T.cur = new Sheet(d, ref, refKey, meta);
$("dt-enc").value = d.encoding;
$("dt-search").value = "";
renderList();
T.cur.mount($("dt-grid"));
status();
}
// ---------- the sheet ----------
class Sheet {
constructor(d, ref, refKey, meta) {
this.id = d.id;
this.ref = ref;
this.refKey = refKey;
this.name = d.name;
this.info = d;
this.meta = meta;
this.cols = d.cols.map((c) => ({ ...c, meta: meta?.cols?.[c.name] || null, enums: meta?.enums?.[c.name] || null }));
this.setData(d.rows);
this.undo = [];
this.redo = [];
this.sort = null;
this.filter = "";
this.act = { r: 0, c: 0 };
this.anchor = { r: 0, c: 0 };
this.widths = this.cols.map((c, i) => this.guessWidth(c, i));
}
setData(rows) {
this.orig = rows;
this.rows = rows.map((v, o) => ({ o, v: [...v] }));
this.view = this.rows.map((_, i) => i);
}
guessWidth(c, i) {
let w = c.name.length * 7.5 + 26;
for (const r of this.rows.slice(0, 200)) w = Math.max(w, String(r.v[i]).length * 7 + 18);
return Math.round(Math.min(Math.max(w, 56), c.text ? 320 : 150));
}
dirty() {
if (this.rows.length !== this.orig.length) return true;
return this.rows.some((r, i) => r.o !== i || r.v.some((v, c) => v !== this.orig[i][c]));
}
cellDirty(row, c) {
return row.o < 0 || row.v[c] !== this.orig[row.o]?.[c];
}
// ----- view -----
applyView() {
const q = this.filter.toLowerCase();
let idx = this.rows.map((_, i) => i);
if (q) idx = idx.filter((i) => this.rows[i].v.some((v) => String(v).toLowerCase().includes(q)));
if (this.sort) {
const { c, desc } = this.sort;
const num = !this.cols[c].text;
idx.sort((a, b) => {
const x = this.rows[a].v[c];
const y = this.rows[b].v[c];
const r = num ? Number(x) - Number(y) : String(x).localeCompare(String(y));
return (desc ? -r : r) || a - b;
});
}
this.view = idx;
this.act.r = Math.min(this.act.r, Math.max(0, idx.length - 1));
this.anchor = { ...this.act };
}
// ----- DOM -----
mount(host) {
this.host = host;
host.replaceChildren();
host.tabIndex = 0;
this.head = el("div", { class: "dt-head" });
this.body = el("div", { class: "dt-rows" });
host.append(this.head, this.body);
host.onscroll = () => this.draw();
host.onkeydown = (e) => this.key(e);
host.onmousedown = (e) => this.mouse(e);
host.ondblclick = (e) => {
const cell = e.target.closest(".dt-cell");
if (cell) this.edit();
};
host.oncopy = (e) => this.copy(e);
host.onpaste = (e) => this.paste(e);
this.drawHead();
this.layout();
host.focus();
}
colLeft(c) {
let x = 56;
for (let i = 0; i < c; i++) x += this.widths[i];
return x;
}
layout() {
const total = this.colLeft(this.cols.length);
this.head.style.width = `${total}px`;
this.body.style.width = `${total}px`;
this.body.style.height = `${this.view.length * RH}px`;
this.draw();
}
drawHead() {
const cells = [el("div", { class: "dt-h dt-rn", text: "#", style: "width:56px" })];
this.cols.forEach((c, i) => {
const tip = [`${c.name} — ${c.typeName}`, c.meta?.desc, c.meta?.export ? `used by: ${c.meta.export}` : null, c.enums ? `enum: ${c.enums.map((e) => `${e.value} ${e.name}`).join(", ")}` : null].filter(Boolean).join("\n");
const arrow = this.sort?.c === i ? (this.sort.desc ? " ▼" : " ▲") : "";
const h = el("div", { class: "dt-h", title: tip, style: `width:${this.widths[i]}px` }, [
el("b", { text: c.name + arrow }),
el("small", { text: c.meta?.desc ? `${c.typeName} · ${c.meta.desc}` : c.typeName }),
el("i", { class: "dt-resize", onmousedown: (e) => this.resize(e, i) }),
]);
h.addEventListener("click", (e) => {
if (e.target.classList.contains("dt-resize")) return;
this.sort = this.sort?.c !== i ? { c: i, desc: false } : this.sort.desc ? null : { c: i, desc: true };
this.applyView();
this.drawHead();
this.layout();
status();
});
cells.push(h);
});
this.head.replaceChildren(...cells);
}
resize(e, i) {
e.preventDefault();
e.stopPropagation();
const x0 = e.clientX;
const w0 = this.widths[i];
const move = (ev) => {
this.widths[i] = Math.max(40, w0 + ev.clientX - x0);
this.drawHead();
this.layout();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
sel() {
return { r0: Math.min(this.act.r, this.anchor.r), r1: Math.max(this.act.r, this.anchor.r), c0: Math.min(this.act.c, this.anchor.c), c1: Math.max(this.act.c, this.anchor.c) };
}
display(c, v) {
if (c.enums && v !== "") {
const e = c.enums.find((x) => String(x.value) === String(v));
if (e) return [el("span", { text: v }), el("small", { class: "dt-enum", text: e.name })];
}
return [document.createTextNode(v)];
}
draw() {
if (!this.host) return;
const top = this.host.scrollTop - 34;
const h = this.host.clientHeight;
const first = Math.max(0, Math.floor(top / RH) - 4);
const last = Math.min(this.view.length, Math.ceil((top + h) / RH) + 4);
const s = this.sel();
const frag = document.createDocumentFragment();
for (let vr = first; vr < last; vr++) {
const row = this.rows[this.view[vr]];
const line = el("div", { class: `dt-row${row.o < 0 ? " new" : ""}`, style: `top:${vr * RH}px` });
line.append(el("div", { class: "dt-cell dt-rn", text: String(this.view[vr] + 1), style: "width:56px", "data-r": vr, "data-c": -1 }));
this.cols.forEach((c, ci) => {
const inSel = vr >= s.r0 && vr <= s.r1 && ci >= s.c0 && ci <= s.c1;
const cls = ["dt-cell", c.text ? "txt" : "num", this.cellDirty(row, ci) ? "dirty" : "", inSel ? "sel" : "", vr === this.act.r && ci === this.act.c ? "act" : ""].filter(Boolean).join(" ");
line.append(el("div", { class: cls, style: `width:${this.widths[ci]}px`, "data-r": vr, "data-c": ci }, this.display(c, row.v[ci])));
});
frag.append(line);
}
this.body.replaceChildren(frag);
}
scrollTo(vr, c) {
const y = vr * RH;
const hh = 34;
if (y < this.host.scrollTop) this.host.scrollTop = y;
else if (y + RH > this.host.scrollTop + this.host.clientHeight - hh) this.host.scrollTop = y + RH - this.host.clientHeight + hh;
if (c >= 0) {
const x = this.colLeft(c);
const w = this.widths[c];
if (x - 56 < this.host.scrollLeft) this.host.scrollLeft = x - 56;
else if (x + w > this.host.scrollLeft + this.host.clientWidth) this.host.scrollLeft = x + w - this.host.clientWidth;
}
}
// ----- selection -----
cellAt(e) {
const cell = e.target.closest?.(".dt-cell");
if (!cell) return null;
return { r: Number(cell.dataset.r), c: Number(cell.dataset.c) };
}
mouse(e) {
if (e.button !== 0 || e.target.closest(".dt-h") || e.target.closest(".dt-editor")) return;
const at = this.cellAt(e);
if (!at) return;
this.commitEdit();
if (at.c < 0) {
// row number: whole rows
if (!e.shiftKey) this.anchor = { r: at.r, c: 0 };
this.act = { r: at.r, c: this.cols.length - 1 };
this.anchor.c = 0;
} else {
this.act = at;
if (!e.shiftKey) this.anchor = { ...at };
}
this.draw();
status();
const move = (ev) => {
const t = document.elementFromPoint(ev.clientX, ev.clientY)?.closest?.(".dt-cell");
if (!t || !this.host.contains(t)) return;
const r = Number(t.dataset.r);
const c = Number(t.dataset.c);
if (r === this.act.r && (c === this.act.c || c < 0)) return;
this.act = { r, c: c < 0 ? this.act.c : c };
this.draw();
status();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
move(dr, dc, extend) {
this.act = { r: Math.max(0, Math.min(this.view.length - 1, this.act.r + dr)), c: Math.max(0, Math.min(this.cols.length - 1, this.act.c + dc)) };
if (!extend) this.anchor = { ...this.act };
this.scrollTo(this.act.r, this.act.c);
this.draw();
status();
}
key(e) {
if (this.editor) return;
const ctrl = e.ctrlKey || e.metaKey;
const k = e.key;
const page = Math.max(1, Math.floor(this.host.clientHeight / RH) - 2);
if (k === "ArrowDown") this.move(ctrl ? this.view.length : 1, 0, e.shiftKey);
else if (k === "ArrowUp") this.move(ctrl ? -this.view.length : -1, 0, e.shiftKey);
else if (k === "ArrowRight") this.move(0, ctrl ? this.cols.length : 1, e.shiftKey);
else if (k === "ArrowLeft") this.move(0, ctrl ? -this.cols.length : -1, e.shiftKey);
else if (k === "PageDown") this.move(page, 0, e.shiftKey);
else if (k === "PageUp") this.move(-page, 0, e.shiftKey);
else if (k === "Home") this.move(ctrl ? -this.view.length : 0, -this.cols.length, e.shiftKey);
else if (k === "End") this.move(ctrl ? this.view.length : 0, this.cols.length, e.shiftKey);
else if (k === "Tab") this.move(0, e.shiftKey ? -1 : 1, false);
else if (k === "Enter" && ctrl) addRow();
else if (k === "Enter" || k === "F2") this.edit();
else if (k === "Delete" && ctrl) deleteRows();
else if (k === "Delete" || k === "Backspace") this.clear();
else if (ctrl && k.toLowerCase() === "a") {
this.anchor = { r: 0, c: 0 };
this.act = { r: this.view.length - 1, c: this.cols.length - 1 };
this.draw();
status();
} else if (ctrl && k.toLowerCase() === "z") this.doUndo();
else if (ctrl && k.toLowerCase() === "y") this.doRedo();
else if (ctrl && k.toLowerCase() === "s") save();
else if (ctrl && k.toLowerCase() === "d") duplicateRows();
else if (ctrl && k.toLowerCase() === "f") $("dt-search").focus();
else if (!ctrl && !e.altKey && k.length === 1) {
this.edit(k);
e.preventDefault();
return;
} else return;
e.preventDefault();
}
// ----- editing -----
edit(start) {
if (!this.view.length) return;
const vr = this.act.r;
const c = this.act.c;
const row = this.rows[this.view[vr]];
this.scrollTo(vr, c);
const col = this.cols[c];
const input = el("input", { class: "dt-editor", type: "text", spellcheck: "false" });
input.value = start ?? row.v[c];
input.style.left = `${this.colLeft(c)}px`;
input.style.top = `${vr * RH}px`;
input.style.width = `${Math.max(this.widths[c], col.text ? 260 : 120)}px`;
if (col.enums) input.setAttribute("list", this.enumList(col));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
this.commitEdit();
this.move(e.shiftKey ? -1 : 1, 0, false);
this.host.focus();
} else if (e.key === "Tab") {
e.preventDefault();
this.commitEdit();
this.move(0, e.shiftKey ? -1 : 1, false);
this.host.focus();
} else if (e.key === "Escape") {
e.preventDefault();
this.cancelEdit();
}
e.stopPropagation();
});
input.addEventListener("blur", () => this.commitEdit());
this.body.append(input);
this.editor = { input, row, c };
input.focus();
if (start === undefined) input.select();
}
enumList(col) {
const id = `dt-enum-${col.name}`;
if (!document.getElementById(id)) document.body.append(el("datalist", { id }, col.enums.map((e) => el("option", { value: String(e.value), label: e.name }))));
return id;
}
commitEdit() {
const ed = this.editor;
if (!ed) return;
this.editor = null;
let v = ed.input.value;
const col = this.cols[ed.c];
// an enum can be typed by its name
if (col.enums && !/^-?\d+$/.test(v.trim())) {
const e = col.enums.find((x) => x.name.toLowerCase() === v.trim().toLowerCase());
if (e) v = String(e.value);
}
if (!col.text) v = v.trim();
ed.input.remove();
if (v !== ed.row.v[ed.c]) this.change([{ row: ed.row, c: ed.c, v }]);
else this.draw();
}
cancelEdit() {
const ed = this.editor;
if (!ed) return;
this.editor = null;
ed.input.remove();
this.host.focus();
this.draw();
}
/** cell changes [{ row, c, v }] as one undo step */
change(list) {
const step = { cells: list.map((x) => ({ row: x.row, c: x.c, old: x.row.v[x.c], v: x.v })) };
for (const x of step.cells) x.row.v[x.c] = x.v;
this.undo.push(step);
this.redo = [];
this.draw();
status();
}
/** a row operation: the rows array before and after */
rowsStep(after) {
this.undo.push({ before: this.rows, after });
this.redo = [];
this.rows = after;
this.applyView();
this.layout();
status();
}
doUndo() {
const s = this.undo.pop();
if (!s) return;
if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.old;
else {
this.rows = s.before;
this.applyView();
this.layout();
}
this.redo.push(s);
this.draw();
status();
}
doRedo() {
const s = this.redo.pop();
if (!s) return;
if (s.cells) for (const x of s.cells) x.row.v[x.c] = x.v;
else {
this.rows = s.after;
this.applyView();
this.layout();
}
this.undo.push(s);
this.draw();
status();
}
clear() {
const s = this.sel();
const list = [];
for (let vr = s.r0; vr <= s.r1; vr++) for (let c = s.c0; c <= s.c1; c++) list.push({ row: this.rows[this.view[vr]], c, v: this.cols[c].text ? "" : "0" });
this.change(list);
}
// ----- clipboard (tab-separated, like Excel) -----
copy(e) {
if (this.editor) return;
const s = this.sel();
const lines = [];
for (let vr = s.r0; vr <= s.r1; vr++) {
const row = this.rows[this.view[vr]];
lines.push(row.v.slice(s.c0, s.c1 + 1).map((v) => (/[\t\n"]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : v)).join("\t"));
}
e.clipboardData.setData("text/plain", lines.join("\r\n"));
e.preventDefault();
toast(`Copied ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} cells.`);
}
paste(e) {
if (this.editor) return;
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
if (!text) return;
const grid = parseTsv(text);
if (!grid.length) return;
const s = this.sel();
// one value into a bigger selection fills it; otherwise the block starts at the top-left cell
const fill = grid.length === 1 && grid[0].length === 1 && (s.r1 > s.r0 || s.c1 > s.c0);
const list = [];
let after = null;
const need = fill ? 0 : s.r0 + grid.length - this.view.length;
if (need > 0) {
// more rows than there are below: new rows at the end
after = [...this.rows];
for (let i = 0; i < need; i++) after.push({ o: -1, v: this.cols.map((c) => (c.text ? "" : "0")) });
this.rows = after;
this.applyView();
}
const rowsR = fill ? s.r1 - s.r0 + 1 : grid.length;
const colsR = fill ? s.c1 - s.c0 + 1 : Math.min(grid[0].length, this.cols.length - s.c0);
for (let i = 0; i < rowsR; i++) {
const row = this.rows[this.view[s.r0 + i]];
if (!row) break;
for (let j = 0; j < colsR; j++) {
const c = s.c0 + j;
if (c >= this.cols.length) break;
const v = fill ? grid[0][0] : grid[i][j] ?? "";
list.push({ row, c, v: this.cols[c].text ? v : v.trim() });
}
}
if (after) {
// rows added and filled: one undo step for both
const before = this.rows.slice(0, this.rows.length - need);
for (const x of list) x.row.v[x.c] = x.v;
this.undo.push({ before, after: this.rows });
this.redo = [];
this.layout();
} else this.change(list);
this.anchor = { r: s.r0, c: s.c0 };
this.act = { r: s.r0 + rowsR - 1, c: s.c0 + colsR - 1 };
this.draw();
status();
toast(`Pasted ${rowsR} × ${colsR} cells${need > 0 ? ` (${need} new rows)` : ""}.`);
}
selectedRows() {
const s = this.sel();
return this.view.slice(s.r0, s.r1 + 1);
}
}
function parseTsv(text) {
const rows = [];
let row = [];
let cell = "";
let q = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) {
if (ch === '"' && text[i + 1] === '"') (cell += '"'), i++;
else if (ch === '"') q = false;
else cell += ch;
} else if (ch === '"' && cell === "") q = true;
else if (ch === "\t") row.push(cell), (cell = "");
else if (ch === "\n") row.push(cell.replace(/\r$/, "")), rows.push(row), (row = []), (cell = "");
else cell += ch;
}
if (cell !== "" || row.length) row.push(cell.replace(/\r$/, "")), rows.push(row);
return rows;
}
// ---------- row commands ----------
function addRow() {
const S = T.cur;
if (!S) return;
const at = S.view.length ? S.view[S.sel().r1] + 1 : S.rows.length;
const blank = { o: -1, v: S.cols.map((c) => (c.text ? "" : "0")) };
// a new key: the first column one above the largest when it's a number
const key = S.cols[0];
if (key && !key.text) blank.v[0] = String(Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1);
const after = [...S.rows.slice(0, at), blank, ...S.rows.slice(at)];
S.rowsStep(after);
const vr = S.view.indexOf(at);
if (vr >= 0) {
S.act = { r: vr, c: 0 };
S.anchor = { ...S.act };
S.scrollTo(vr, 0);
S.draw();
}
}
function duplicateRows() {
const S = T.cur;
if (!S || !S.view.length) return;
const pick = S.selectedRows().sort((a, b) => a - b);
const at = pick[pick.length - 1] + 1;
let next = Math.max(0, ...S.rows.map((r) => Number(r.v[0]) || 0)) + 1;
const copies = pick.map((i) => {
const v = [...S.rows[i].v];
if (!S.cols[0].text) v[0] = String(next++);
return { o: -1, v };
});
S.rowsStep([...S.rows.slice(0, at), ...copies, ...S.rows.slice(at)]);
toast(`Duplicated ${copies.length} row(s)${!S.cols[0].text ? ` with new ${S.cols[0].name} values` : ""}.`);
}
async function deleteRows() {
const S = T.cur;
if (!S || !S.view.length) return;
const pick = new Set(S.selectedRows());
if (pick.size > 1 && !(await confirmBox("Delete rows", `Delete ${pick.size} rows?`, "Delete", "danger"))) return;
S.rowsStep(S.rows.filter((_, i) => !pick.has(i)));
}
// ---------- save / import / export ----------
async function save() {
const S = T.cur;
if (!S || T.busy || !S.dirty()) return;
S.commitEdit();
T.busy = true;
try {
const enums = Object.fromEntries(S.cols.filter((c) => c.enums).map((c) => [c.name, c.enums]));
const r = await call(
"dat:save",
S.id,
S.rows.map((x) => ({ o: x.o, v: x.v })),
{ enums },
);
S.info = r;
S.setData(r.rows);
S.undo = [];
S.redo = [];
S.applyView();
S.layout();
toast(`Saved ${S.name}: ${r.rows.length} rows${r.backup ? ` (backup: ${r.backup.split(/[\\/]/).pop()})` : ""}.`);
} catch (err) {
toast(err.message, true);
} finally {
T.busy = false;
status();
}
}
async function exportTable() {
const S = T.cur;
if (!S) return;
const file = await call("dialog:saveAs", `${S.name}.xlsx`, "xlsx");
if (!file) return;
try {
const rows = S.rows.map((r) => r.v);
const out = /\.csv$/i.test(file) ? await call("dat:exportCsv", S.id, rows, file) : await call("dat:exportXlsx", S.id, rows, file);
toast(`Exported ${rows.length} rows to ${out}`);
} catch (err) {
toast(err.message, true);
}
}
async function importTable() {
const S = T.cur;
if (!S) return;
const file = await call("dat:pickFile", "Import rows from .xlsx / .csv", "sheet");
if (!file) return;
let r;
try {
r = await call("dat:import", S.id, file);
} catch (err) {
return toast(err.message, true);
}
const notes = [r.missing.length ? `Columns not in the file (left empty / 0): ${r.missing.join(", ")}.` : null, r.extra.length ? `Columns in the file that the table doesn't have (ignored): ${r.extra.join(", ")}.` : null].filter(Boolean);
const ok = await confirmBox("Import", [`Replace the ${S.rows.length} rows of ${S.name} with the ${r.rows.length} rows of ${file.split(/[\\/]/).pop()}? You can undo it, and nothing is written until you Save.`, ...notes].join("\n\n"), "Replace");
if (!ok) return;
// rows that match an existing key keep their original (so unchanged cells stay byte-identical)
const byKey = new Map(S.rows.map((x) => [x.v[0], x.o]));
S.rowsStep(r.rows.map((v) => ({ o: byKey.has(v[0]) ? byKey.get(v[0]) : -1, v: v.map((x, i) => (S.cols[i].text ? x : x.trim() || "0")) })));
}
// ---------- status ----------
function status() {
const S = T.cur;
$("dt-save").disabled = !S || !S.dirty() || T.busy;
$("dt-undo").disabled = !S?.undo.length;
$("dt-redo").disabled = !S?.redo.length;
for (const id of ["dt-add", "dt-dup", "dt-del", "dt-import", "dt-export", "dt-enc", "dt-search"]) $(id).disabled = !S;
if (!S) return ($("dt-status").textContent = "");
const s = S.sel();
const changed = S.rows.filter((r) => r.o < 0 || r.v.some((v, c) => v !== S.orig[r.o]?.[c])).length;
const col = S.cols[S.act.c];
const row = S.rows[S.view[S.act.r]];
$("dt-status").textContent = [
`${S.name} · ${S.info.label}`,
`${S.rows.length} rows${S.view.length !== S.rows.length ? ` (${S.view.length} shown)` : ""} × ${S.cols.length} columns · ${S.info.rowSize} bytes/row · header ${S.info.headerVersion}`,
changed || S.rows.length !== S.orig.length ? `${changed} changed row(s)${S.rows.length !== S.orig.length ? `, ${S.rows.length - S.orig.length >= 0 ? "+" : ""}${S.rows.length - S.orig.length} rows` : ""}` : "no changes",
col && row ? `${col.name} (${col.typeName})${col.meta?.desc ? ` — ${col.meta.desc}` : ""}${s.r1 > s.r0 || s.c1 > s.c0 ? ` · ${s.r1 - s.r0 + 1} × ${s.c1 - s.c0 + 1} selected` : ""}` : "",
S.meta?.source ? `source: ${S.meta.source}` : "",
]
.filter(Boolean)
.join(" · ");
}
// ---------- wiring ----------
$("dt-add").addEventListener("click", addRow);
$("dt-dup").addEventListener("click", duplicateRows);
$("dt-del").addEventListener("click", deleteRows);
$("dt-undo").addEventListener("click", () => T.cur?.doUndo());
$("dt-redo").addEventListener("click", () => T.cur?.doRedo());
$("dt-save").addEventListener("click", save);
$("dt-export").addEventListener("click", exportTable);
$("dt-import").addEventListener("click", importTable);
$("dt-search").addEventListener("input", () => {
if (!T.cur) return;
T.cur.filter = $("dt-search").value.trim();
T.cur.applyView();
T.cur.layout();
status();
});
$("dt-enc").addEventListener("change", async () => {
const S = T.cur;
if (!S) return;
if (S.dirty() && !(await confirmBox("Code page", "Reading the texts in another code page drops the unsaved changes.", "Switch", "danger"))) {
$("dt-enc").value = S.info.encoding;
return;
}
const d = await call("dat:encoding", S.id, $("dt-enc").value);
S.info = { ...S.info, ...d };
S.setData(d.rows);
S.undo = [];
S.redo = [];
S.applyView();
S.layout();
status();
});
$("dt-client").addEventListener("click", async () => {
const dir = await call("dialog:folder", "Game folder (with the .iop packs)");
if (!dir) return;
T.src = { kind: "packs", folder: dir, name: dir };
listTables();
});
$("dt-server").addEventListener("click", async () => {
const dir = await call("dialog:folder", "The game server's tables folder (ls_gamesvr\\config\\tables)");
if (!dir) return;
LS("dt:server", dir);
listTables();
});
$("dt-file").addEventListener("click", async () => {
const file = await call("dat:pickFile", "Open a .dat table", "dat");
if (file) openTable({ kind: "file", file }, `file|${file}`);
});
$("dt-maker").addEventListener("click", async () => {
const dir = await call("dialog:folder", "ioDataMaker folder (with Config.xlsx and ExcelData)");
if (!dir) return;
LS("dt:maker", dir);
toast("Column descriptions and enum names come from there now (reopen the table).");
});
$("dt-reload").addEventListener("click", listTables);
window.addEventListener("beforeunload", () => T.cur && call("dat:close", T.cur.id));
document.querySelector('#tabs [data-view="tables"]').addEventListener("click", () => {
if (!T.listed) {
T.listed = true;
listTables();
status();
}
});
window.addEventListener("profile-changed", () => {
T.listed = false;
T.src = null;
if ($("view-tables").classList.contains("on")) {
T.listed = true;
listTables();
}
});
})();
+443
View File
@@ -0,0 +1,443 @@
"use strict";
// Texture Studio maths (Texture tab): selections on RGBA pixels (magic wand, colour range, lasso,
// rectangle), recolouring a selection, resizing, and .dds writing the way the game's textures are
// stored (DXT1 / DXT3 / DXT5 or A8R8G8B8, with mip maps). Pure functions on Uint8(Clamped)Arrays,
// no DOM: the Texture tab draws, this computes.
const TexKit = (() => {
// =====================================================================
// selections: a mask of w × h bytes (0 = out, 255 = in, between = feathered)
// =====================================================================
const dist = (px, i, c) => Math.max(Math.abs(px[i] - c[0]), Math.abs(px[i + 1] - c[1]), Math.abs(px[i + 2] - c[2]), Math.abs(px[i + 3] - c[3]));
/** Pixels like the one at (x, y), within tol (0–255 per channel): touching it (contiguous) or anywhere. */
function wand(px, w, h, x, y, tol, contiguous = true) {
const out = new Uint8Array(w * h);
if (x < 0 || y < 0 || x >= w || y >= h) return out;
const s = (y * w + x) * 4;
const c = [px[s], px[s + 1], px[s + 2], px[s + 3]];
if (!contiguous) {
for (let i = 0; i < w * h; i++) if (dist(px, i * 4, c) <= tol) out[i] = 255;
return out;
}
const stack = new Int32Array(w * h);
let n = 0;
stack[n++] = y * w + x;
out[y * w + x] = 255;
while (n) {
const i = stack[--n];
const ix = i % w, iy = (i - ix) / w;
for (const j of [ix > 0 ? i - 1 : -1, ix < w - 1 ? i + 1 : -1, iy > 0 ? i - w : -1, iy < h - 1 ? i + w : -1]) {
if (j < 0 || out[j] || dist(px, j * 4, c) > tol) continue;
out[j] = 255;
stack[n++] = j;
}
}
return out;
}
/** A polygon [[x, y]…] (image pixels) filled even-odd, sampled at pixel centres. */
function polygon(w, h, pts) {
const out = new Uint8Array(w * h);
if (pts.length < 3) return out;
const xs = [];
for (let y = 0; y < h; y++) {
const cy = y + 0.5;
xs.length = 0;
for (let i = 0; i < pts.length; i++) {
const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % pts.length];
if ((y0 <= cy && y1 > cy) || (y1 <= cy && y0 > cy)) xs.push(x0 + ((cy - y0) / (y1 - y0)) * (x1 - x0));
}
xs.sort((a, b) => a - b);
for (let k = 0; k + 1 < xs.length; k += 2) {
const a = Math.max(0, Math.ceil(xs[k] - 0.5)), b = Math.min(w - 1, Math.floor(xs[k + 1] - 0.5));
for (let x = a; x <= b; x++) out[y * w + x] = 255;
}
}
return out;
}
const rect = (w, h, x0, y0, x1, y1) => polygon(w, h, [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);
/** Mask a combined with b: "new" | "add" | "sub" | "and". */
function combine(a, b, mode) {
if (mode === "new" || !a) return b;
const out = new Uint8Array(a.length);
for (let i = 0; i < a.length; i++)
out[i] = mode === "add" ? Math.max(a[i], b[i]) : mode === "sub" ? Math.min(a[i], 255 - b[i]) : Math.min(a[i], b[i]);
return out;
}
const invert = (m) => m.map((v) => 255 - v);
/** Grows (r > 0) or shrinks (r < 0) a mask by |r| pixels (square neighbourhood). */
function grow(m, w, h, r) {
if (!r) return m;
const pick = r > 0 ? Math.max : Math.min;
let cur = m;
for (let it = 0; it < Math.abs(r); it++) {
const out = new Uint8Array(cur.length);
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++) {
let v = cur[y * w + x];
if (x > 0) v = pick(v, cur[y * w + x - 1]);
if (x < w - 1) v = pick(v, cur[y * w + x + 1]);
if (y > 0) v = pick(v, cur[(y - 1) * w + x]);
if (y < h - 1) v = pick(v, cur[(y + 1) * w + x]);
out[y * w + x] = v;
}
cur = out;
}
return cur;
}
/** Soft edges: a box blur of radius r (twice, about a Gaussian). */
function feather(m, w, h, r) {
if (!r) return m;
let a = Float32Array.from(m);
const pass = (src, horiz) => {
const out = new Float32Array(src.length);
const n = horiz ? w : h, lines = horiz ? h : w;
for (let l = 0; l < lines; l++) {
const at = (k) => (horiz ? l * w + k : k * w + l);
let sum = 0;
for (let k = -r; k <= r; k++) sum += src[at(Math.max(0, Math.min(n - 1, k)))];
for (let k = 0; k < n; k++) {
out[at(k)] = sum / (2 * r + 1);
sum += src[at(Math.min(n - 1, k + r + 1))] - src[at(Math.max(0, k - r))];
}
}
return out;
};
for (let i = 0; i < 2; i++) a = pass(pass(a, true), false);
return Uint8Array.from(a, (v) => Math.round(v));
}
const count = (m) => m.reduce((s, v) => s + (v >= 128 ? 1 : 0), 0);
// =====================================================================
// recolour
// =====================================================================
function rgb2hsl(r, g, b) {
r /= 255, g /= 255, b /= 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
if (mx === mn) return [0, 0, l];
const d = mx - mn, s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
const hh = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [hh / 6, s, l];
}
function hsl2rgb(hh, s, l) {
if (!s) return [l * 255, l * 255, l * 255];
const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
const f = (t) => {
t = ((t % 1) + 1) % 1;
return 255 * (t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p);
};
return [f(hh + 1 / 3), f(hh), f(hh - 1 / 3)];
}
/** How bright the selection's brightest parts are (98th percentile of max(r, g, b)), for colourise. */
function peak(px, mask) {
const hist = new Uint32Array(256);
let n = 0;
for (let i = 0; i < px.length / 4; i++) {
if (mask && mask[i] < 128) continue;
hist[Math.max(px[i * 4], px[i * 4 + 1], px[i * 4 + 2])]++;
n++;
}
let acc = 0;
for (let v = 0; v < 256; v++) if ((acc += hist[v]) >= n * 0.98) return Math.max(1, v);
return 255;
}
/**
* src → dst (same size) with the selection recoloured; outside it dst = src. opts:
* { mode: "colorize" | "adjust", color: [r, g, b], strength (0–1), bright (−1…1), hue (−180…180°),
* sat (−1…1), light (−1…1), contrast (−1…1), ref (colorize: the source's peak) }
* colorize: each pixel's brightness (its max channel against the selection's brightest) times the
* colour, so a dark hair texture keeps its strands; adjust: hue / saturation / lightness shifts.
*/
function recolor(src, dst, mask, opts) {
const o = { mode: "colorize", color: [200, 160, 60], strength: 1, bright: 0, hue: 0, sat: 0, light: 0, contrast: 0, ...opts };
const ref = o.ref || peak(src, mask);
const gain = 1 + o.bright;
const k = 1 + o.contrast;
for (let i = 0; i < src.length / 4; i++) {
const p = i * 4, m = mask ? mask[i] / 255 : 1;
dst[p + 3] = src[p + 3];
if (!m) {
dst[p] = src[p], dst[p + 1] = src[p + 1], dst[p + 2] = src[p + 2];
continue;
}
let r = src[p], g = src[p + 1], b = src[p + 2];
if (o.mode === "colorize") {
const t = Math.max(r, g, b) / ref;
const c = o.color;
r = r + (c[0] * t * gain - r) * o.strength;
g = g + (c[1] * t * gain - g) * o.strength;
b = b + (c[2] * t * gain - b) * o.strength;
} else {
let [hh, s, l] = rgb2hsl(r, g, b);
hh += o.hue / 360;
s = o.sat >= 0 ? s + (1 - s) * o.sat : s * (1 + o.sat);
l = o.light >= 0 ? l + (1 - l) * o.light : l * (1 + o.light);
[r, g, b] = hsl2rgb(hh, Math.max(0, Math.min(1, s)), Math.max(0, Math.min(1, l)));
}
if (o.contrast) (r = (r - 128) * k + 128), (g = (g - 128) * k + 128), (b = (b - 128) * k + 128);
dst[p] = src[p] + (r - src[p]) * m;
dst[p + 1] = src[p + 1] + (g - src[p + 1]) * m;
dst[p + 2] = src[p + 2] + (b - src[p + 2]) * m;
}
return dst;
}
// =====================================================================
// sizes
// =====================================================================
const pow2 = (n) => 2 ** Math.floor(Math.log2(Math.max(1, n)));
/** RGBA w × h → nw × nh (area average going down, bilinear going up). */
function resize(px, w, h, nw, nh) {
if (nw === w && nh === h) return Uint8ClampedArray.from(px);
const out = new Uint8ClampedArray(nw * nh * 4);
const sx = w / nw, sy = h / nh;
for (let y = 0; y < nh; y++)
for (let x = 0; x < nw; x++) {
const acc = [0, 0, 0, 0];
let n = 0;
if (sx >= 1 && sy >= 1) {
for (let yy = Math.floor(y * sy); yy < Math.min(h, Math.ceil((y + 1) * sy)); yy++)
for (let xx = Math.floor(x * sx); xx < Math.min(w, Math.ceil((x + 1) * sx)); xx++) {
const p = (yy * w + xx) * 4;
for (let c = 0; c < 4; c++) acc[c] += px[p + c];
n++;
}
} else {
const fx = Math.max(0, (x + 0.5) * sx - 0.5), fy = Math.max(0, (y + 0.5) * sy - 0.5);
const x0 = Math.min(w - 1, Math.floor(fx)), y0 = Math.min(h - 1, Math.floor(fy));
const x1 = Math.min(w - 1, x0 + 1), y1 = Math.min(h - 1, y0 + 1), ax = fx - x0, ay = fy - y0;
for (const [xx, yy, f] of [[x0, y0, (1 - ax) * (1 - ay)], [x1, y0, ax * (1 - ay)], [x0, y1, (1 - ax) * ay], [x1, y1, ax * ay]]) {
const p = (yy * w + xx) * 4;
for (let c = 0; c < 4; c++) acc[c] += px[p + c] * f;
}
n = 1;
}
const q = (y * nw + x) * 4;
for (let c = 0; c < 4; c++) out[q + c] = acc[c] / n;
}
return out;
}
/** Every pixel opaque / alpha only 0 or 255 / real alpha. */
function alphaKind(px) {
let binary = true, opaque = true;
for (let i = 3; i < px.length; i += 4) {
const a = px[i];
if (a !== 255) opaque = false;
if (a !== 0 && a !== 255) {
binary = false;
break;
}
}
return opaque ? "opaque" : binary ? "binary" : "alpha";
}
// =====================================================================
// DXT (BC1 / BC2 / BC3) encoding
// =====================================================================
const to565 = (r, g, b) => ((Math.round((r * 31) / 255) << 11) | (Math.round((g * 63) / 255) << 5) | Math.round((b * 31) / 255)) >>> 0;
const from565 = (c) => [(((c >> 11) & 31) * 255) / 31, (((c >> 5) & 63) * 255) / 63, ((c & 31) * 255) / 31];
/** The 16 pixels of block (bx, by), edge pixels repeated: Float32Array(64). */
function block(px, w, h, bx, by) {
const b = new Float32Array(64);
for (let j = 0; j < 4; j++)
for (let i = 0; i < 4; i++) {
const x = Math.min(w - 1, bx * 4 + i), y = Math.min(h - 1, by * 4 + j);
const p = (y * w + x) * 4, q = (j * 4 + i) * 4;
b[q] = px[p], b[q + 1] = px[p + 1], b[q + 2] = px[p + 2], b[q + 3] = px[p + 3];
}
return b;
}
/** Colour part of a block (8 bytes) into out at o; use: which of the 16 pixels count (DXT1 transparent ones don't). */
function colorBlock(b, out, o, dxt1Alpha) {
const use = [];
for (let k = 0; k < 16; k++) if (!dxt1Alpha || b[k * 4 + 3] >= 128) use.push(k);
const dv = new DataView(out.buffer, out.byteOffset);
if (!use.length) {
// all transparent: 3-colour mode, every index 3 (transparent black)
dv.setUint16(o, 0, true), dv.setUint16(o + 2, 0, true), dv.setUint32(o + 4, 0xffffffff, true);
return;
}
// principal axis of the colours (a few power iterations), endpoints at the extremes along it
const mean = [0, 0, 0];
for (const k of use) for (let c = 0; c < 3; c++) mean[c] += b[k * 4 + c] / use.length;
const cov = [0, 0, 0, 0, 0, 0];
for (const k of use) {
const r = b[k * 4] - mean[0], g = b[k * 4 + 1] - mean[1], bl = b[k * 4 + 2] - mean[2];
cov[0] += r * r, cov[1] += r * g, cov[2] += r * bl, cov[3] += g * g, cov[4] += g * bl, cov[5] += bl * bl;
}
let ax = [1, 1, 1];
for (let it = 0; it < 6; it++) {
const n = [cov[0] * ax[0] + cov[1] * ax[1] + cov[2] * ax[2], cov[1] * ax[0] + cov[3] * ax[1] + cov[4] * ax[2], cov[2] * ax[0] + cov[4] * ax[1] + cov[5] * ax[2]];
const l = Math.hypot(...n);
if (l < 1e-6) break;
ax = n.map((v) => v / l);
}
let lo = Infinity, hi = -Infinity, kl = use[0], kh = use[0];
for (const k of use) {
const t = (b[k * 4] - mean[0]) * ax[0] + (b[k * 4 + 1] - mean[1]) * ax[1] + (b[k * 4 + 2] - mean[2]) * ax[2];
if (t < lo) (lo = t), (kl = k);
if (t > hi) (hi = t), (kh = k);
}
// the endpoints a touch inside the extremes (less error for the middle colours)
const inset = (k, toward) => [0, 1, 2].map((c) => b[k * 4 + c] + (b[toward * 4 + c] - b[k * 4 + c]) / 16);
const e0 = inset(kh, kl), e1 = inset(kl, kh);
let c0 = to565(...e0), c1 = to565(...e1);
const threeColor = dxt1Alpha && use.length < 16;
if (threeColor ? c0 > c1 : c0 < c1) [c0, c1] = [c1, c0];
if (!threeColor && c0 === c1) {
// one colour: 4-colour mode needs c0 > c1
if (c1 > 0) c1--;
else c0++;
}
const p0 = from565(c0), p1 = from565(c1);
const pal = threeColor
? [p0, p1, p0.map((v, i) => (v + p1[i]) / 2)]
: [p0, p1, p0.map((v, i) => (2 * v + p1[i]) / 3), p0.map((v, i) => (v + 2 * p1[i]) / 3)];
let bits = 0;
for (let k = 15; k >= 0; k--) {
let idx;
if (threeColor && b[k * 4 + 3] < 128) idx = 3;
else {
let best = Infinity;
idx = 0;
for (let i = 0; i < pal.length; i++) {
const c = pal[i], dr = c[0] - b[k * 4], dg = c[1] - b[k * 4 + 1], db = c[2] - b[k * 4 + 2];
const d = dr * dr + dg * dg + db * db;
if (d < best) (best = d), (idx = i);
}
}
bits = (bits << 2) | idx;
}
dv.setUint16(o, c0, true);
dv.setUint16(o + 2, c1, true);
dv.setUint32(o + 4, bits >>> 0, true);
}
/** DXT5 alpha (8 bytes): two endpoints, 3-bit indices. */
function alphaBlock5(b, out, o) {
let lo = 255, hi = 0;
for (let k = 0; k < 16; k++) (lo = Math.min(lo, b[k * 4 + 3])), (hi = Math.max(hi, b[k * 4 + 3]));
lo = Math.round(lo), hi = Math.round(hi);
// a0 > a1: the 8-value mode (one alpha: a0 = a1, every pixel takes index 0 exactly)
const a0 = hi, a1 = lo;
const pal = [a0, a1];
for (let i = 1; i < 7; i++) pal.push(((7 - i) * a0 + i * a1) / 7);
out[o] = a0, out[o + 1] = a1;
// 16 indices × 3 bits: pixels 0–7 in the first 3 bytes, 8–15 in the next 3
for (let half = 0; half < 2; half++) {
let bits = 0;
for (let k = 7; k >= 0; k--) {
const a = b[(half * 8 + k) * 4 + 3];
let best = Infinity, idx = 0;
for (let i = 0; i < 8; i++) {
const d = Math.abs(pal[i] - a);
if (d < best) (best = d), (idx = i);
}
bits = (bits << 3) | idx;
}
for (let i = 0; i < 3; i++) out[o + 2 + half * 3 + i] = (bits >> (8 * i)) & 255;
}
}
/** DXT3 alpha (8 bytes): 4 bits a pixel. */
function alphaBlock3(b, out, o) {
for (let k = 0; k < 16; k += 2) out[o + k / 2] = (Math.round(b[k * 4 + 3] / 17) & 15) | ((Math.round(b[(k + 1) * 4 + 3] / 17) & 15) << 4);
}
/** One mip level → its DXT bytes. */
function dxt(px, w, h, kind) {
const bw = Math.max(1, Math.ceil(w / 4)), bh = Math.max(1, Math.ceil(h / 4));
const size = kind === "DXT1" ? 8 : 16;
const out = new Uint8Array(bw * bh * size);
for (let by = 0; by < bh; by++)
for (let bx = 0; bx < bw; bx++) {
const b = block(px, w, h, bx, by), o = (by * bw + bx) * size;
if (kind === "DXT1") colorBlock(b, out, o, true);
else {
(kind === "DXT5" ? alphaBlock5 : alphaBlock3)(b, out, o);
colorBlock(b, out, o + 8, false);
}
}
return out;
}
/** "auto" → what the game would use: opaque or cut-out (transparent black) DXT1, real alpha DXT5. */
const pickFormat = (px, format) => (format && format !== "auto" ? format : alphaKind(px) === "alpha" ? "DXT5" : "DXT1");
/**
* RGBA → a .dds: { format: "auto" | "DXT1" | "DXT3" | "DXT5" | "ARGB", mips: true }. → { bytes, format, levels }
*/
function encodeDds(px, w, h, opts = {}) {
const format = pickFormat(px, opts.format);
const levels = [];
let cur = px, cw = w, ch = h;
for (;;) {
levels.push(format === "ARGB" ? argb(cur) : dxt(cur, cw, ch, format));
if (!opts.mips || (cw === 1 && ch === 1)) break;
const nw = Math.max(1, cw >> 1), nh = Math.max(1, ch >> 1);
cur = resize(cur, cw, ch, nw, nh);
(cw = nw), (ch = nh);
}
const total = levels.reduce((s, l) => s + l.length, 0);
const out = new Uint8Array(128 + total);
const dv = new DataView(out.buffer);
dv.setUint32(0, 0x20534444, true); // "DDS "
dv.setUint32(4, 124, true);
const compressed = format !== "ARGB";
// caps | height | width | pixel format, + pitch or linear size, + mip map count
dv.setUint32(8, 0x1007 | (compressed ? 0x80000 : 0x8) | (levels.length > 1 ? 0x20000 : 0), true);
dv.setUint32(12, h, true);
dv.setUint32(16, w, true);
dv.setUint32(20, compressed ? levels[0].length : w * 4, true);
dv.setUint32(28, levels.length > 1 ? levels.length : 0, true);
dv.setUint32(76, 32, true);
if (compressed) {
dv.setUint32(80, 0x4, true); // FOURCC
for (let i = 0; i < 4; i++) out[84 + i] = format.charCodeAt(i);
} else {
dv.setUint32(80, 0x41, true); // RGB | alpha pixels
dv.setUint32(88, 32, true);
dv.setUint32(92, 0x00ff0000, true);
dv.setUint32(96, 0x0000ff00, true);
dv.setUint32(100, 0x000000ff, true);
dv.setUint32(104, 0xff000000, true);
}
dv.setUint32(108, 0x1000 | (levels.length > 1 ? 0x400008 : 0), true); // texture (| mip map | complex)
let o = 128;
for (const l of levels) out.set(l, o), (o += l.length);
return { bytes: out, format, levels: levels.length };
}
function argb(px) {
const out = new Uint8Array(px.length);
for (let i = 0; i < px.length; i += 4) (out[i] = px[i + 2]), (out[i + 1] = px[i + 1]), (out[i + 2] = px[i]), (out[i + 3] = px[i + 3]);
return out;
}
/** What a .dds is stored as (its header): { format, w, h, mips }. */
function ddsInfo(bytes) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length < 128 || dv.getUint32(0, true) !== 0x20534444) return null;
const pf = dv.getUint32(80, true);
const format = pf & 4 ? String.fromCharCode(...bytes.subarray(84, 88)) : `RGB${dv.getUint32(88, true)}`;
return { format, w: dv.getUint32(16, true), h: dv.getUint32(12, true), mips: Math.max(1, dv.getUint32(28, true)) };
}
return { wand, polygon, rect, combine, invert, grow, feather, count, peak, recolor, rgb2hsl, hsl2rgb, resize, pow2, alphaKind, pickFormat, encodeDds, ddsInfo };
})();
if (typeof window !== "undefined") window.TexKit = TexKit;
+724
View File
@@ -0,0 +1,724 @@
"use strict";
// Texture Studio (Texture tab): open the pictures of a folder (.png / .jpg / .tga / .bmp / .dds),
// select parts of one (magic wand, colour range, lasso, rectangle), recolour the selection (colourise
// to a colour keeping the shading, or hue / saturation / lightness), and save it as a .dds the way the
// game stores textures (DXT1 / DXT3 / DXT5 / A8R8G8B8, with mip maps) — one file or a whole folder.
// The maths is in texkit.js.
(() => {
const TK = TexKit;
const TEX = {
dir: null,
files: [], // { name, size, mtime }
file: null, // { path, name, stem, ext, info (a .dds: its format) }
w: 0,
h: 0,
base: null, // the pixels as they are now (Uint8ClampedArray RGBA)
shown: null, // what the view shows: base, or base with the recolour previewed
raw: null, // the selection as picked (Uint8Array, 0 / 255)
mask: null, // raw, gaps closed and feathered (what the recolour uses)
undo: [],
redo: [],
tool: "wand",
tol: 24,
contiguous: true,
close: 0,
feather: 1,
rc: { mode: "colorize", color: [230, 190, 100], strength: 1, bright: 0, hue: 0, sat: 0, light: 0, contrast: 0 },
rcOn: false, // the recolour is previewed (not applied yet)
showSel: true,
picking: false, // the next click takes the colourise colour
exp: { format: "auto", mips: true, max: 0 },
dirty: false,
zoom: 1,
pan: [0, 0],
fitted: true, // the view follows the window size until zoomed / moved
};
window.TEX = TEX;
const KEY = "tex:dir";
const stem = (n) => n.replace(/\.[^.]+$/, "");
const extOf = (n) => (n.match(/\.([^.]+)$/)?.[1] || "").toLowerCase();
const join = (d, n) => `${d.replace(/[\\/]+$/, "")}\\${n}`;
const hex = (c) => "#" + c.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
const unhex = (s) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16));
const field = (label, input) => [el("label", { text: label }), input];
const fill = (box, ...kids) => box.replaceChildren(...kids.filter(Boolean));
let cv, g, imgCanvas, selCanvas;
let started = false;
// =====================================================================
// folder + files
// =====================================================================
async function openFolder(dir) {
dir = dir || (await call("dialog:folder", "Folder with the textures"));
if (!dir) return;
TEX.dir = dir;
try {
localStorage.setItem(KEY, dir);
} catch {}
await listFiles();
}
async function listFiles() {
if (!TEX.dir) return;
try {
TEX.files = (await call("tex:list", TEX.dir)).sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
TEX.files = [];
toast(`${TEX.dir}: ${err.message}`, true);
}
$("tex-dir").textContent = TEX.dir;
$("tex-dir").title = TEX.dir;
renderList();
}
function renderList() {
const q = $("tex-search").value.trim().toLowerCase();
const rows = TEX.files.filter((f) => !q || f.name.toLowerCase().includes(q));
$("tex-count").textContent = rows.length ? String(rows.length) : "";
// web fix: draw 300 rows at a time, more while scrolling (10 000+ textures froze the page)
const box = $("tex-list");
const CHUNK = 300;
let shown = Math.min(rows.length, Math.max(CHUNK, TEX._shown || 0));
const rowEl = (f) =>
el("div", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? " on" : ""}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [
el("span", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }),
el("span", { class: "grow", text: f.name }),
el("span", { class: "dim", text: size(f.size) }),
]);
box.onscroll = () => {
if (shown >= rows.length || box.scrollTop + box.clientHeight < box.scrollHeight - 400) return;
box.append(...rows.slice(shown, shown + CHUNK).map(rowEl));
shown = Math.min(rows.length, shown + CHUNK);
TEX._shown = shown;
};
if (TEX._q !== q) (TEX._q = q), (shown = Math.min(rows.length, CHUNK)), (box.scrollTop = 0);
TEX._shown = shown;
$("tex-list").replaceChildren(
...rows.slice(0, shown).map((f) =>
el("div", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? " on" : ""}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [
el("span", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }),
el("span", { class: "grow", text: f.name }),
el("span", { class: "dim", text: size(f.size) }),
]),
),
);
if (!rows.length) $("tex-list").append(el("div", { class: "ans-note", text: TEX.dir ? "No pictures here (.png .jpg .tga .bmp .dds)." : "Open a folder with textures." }));
}
async function keepChanges() {
if (!TEX.dirty) return true;
const r = await modal("Unsaved changes", `${TEX.file.name} has changes that aren't saved.`, [
{ label: "Cancel", value: false },
{ label: "Discard", kind: "danger", value: true },
]);
return r;
}
async function openFile(path) {
if (!(await keepChanges())) return;
const name = path.replace(/\\/g, "/").split("/").pop();
const ext = extOf(name);
loading(true, `Opening ${name}…`);
try {
const { bytes } = await call("mdl:readFile", path);
const bmp = await TexDecode.decode(bytes, ext);
const c = new OffscreenCanvas(bmp.width, bmp.height);
const cx = c.getContext("2d", { willReadFrequently: true });
cx.drawImage(bmp, 0, 0);
const px = cx.getImageData(0, 0, bmp.width, bmp.height).data;
TEX.file = { path, name, stem: stem(name), ext, dir: path.replace(/[\\/][^\\/]*$/, ""), info: ext === "dds" ? TK.ddsInfo(bytes) : null };
TEX.w = bmp.width;
TEX.h = bmp.height;
TEX.base = new Uint8ClampedArray(px);
TEX.raw = TEX.mask = null;
TEX.undo = [];
TEX.redo = [];
TEX.rcOn = false;
TEX.dirty = false;
// a .dds keeps its own format when saved again
if (TEX.file.info && /^DXT[135]$/.test(TEX.file.info.format)) TEX.exp.format = TEX.file.info.format;
else if (TEX.file.info?.format === "RGB32") TEX.exp.format = "ARGB";
else TEX.exp.format = "auto";
TEX.exp.mips = TEX.file.info ? TEX.file.info.mips > 1 : true;
$("tex-empty").hidden = true;
refresh();
fitView();
renderList();
renderSide();
} catch (err) {
toast(`${name}: ${err.message}`, true);
} finally {
loading(false);
}
}
function loading(on, text) {
$("tex-loading").hidden = !on;
if (text) $("tex-loading").querySelector("span").textContent = text;
}
const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
// =====================================================================
// pixels → view
// =====================================================================
/** Recomputes the effective mask, the preview and the view images. */
function refresh() {
if (!TEX.base) return;
const { w, h } = TEX;
if (TEX.raw) {
let m = TEX.raw;
if (TEX.close) m = TK.grow(TK.grow(m, w, h, TEX.close), w, h, -TEX.close);
TEX.mask = TK.feather(m, w, h, TEX.feather);
} else TEX.mask = null;
TEX.shown = TEX.rcOn ? TK.recolor(TEX.base, new Uint8ClampedArray(TEX.base.length), TEX.mask, TEX.rc) : TEX.base;
imgCanvas = imgCanvas && imgCanvas.width === w && imgCanvas.height === h ? imgCanvas : new OffscreenCanvas(w, h);
imgCanvas.getContext("2d").putImageData(new ImageData(TEX.shown, w, h), 0, 0);
drawSelection();
$("tex-dirty").hidden = !TEX.dirty;
$("tex-title").textContent = TEX.file ? `${TEX.file.name} — ${w}×${h}${TEX.file.info ? ` ${TEX.file.info.format}${TEX.file.info.mips > 1 ? " +mips" : ""}` : ""}` : "";
$("tex-save").disabled = !TEX.base;
$("tex-saveas").disabled = !TEX.base;
draw();
}
/** The selection's outline (and a light tint inside) as an image the size of the texture. */
function drawSelection() {
const { w, h, raw } = TEX;
if (!raw) return (selCanvas = null);
const id = new ImageData(w, h);
const d = id.data;
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (raw[i] < 128) continue;
const edge = x === 0 || y === 0 || x === w - 1 || y === h - 1 || raw[i - 1] < 128 || raw[i + 1] < 128 || raw[i - w] < 128 || raw[i + w] < 128;
if (edge) d.set([60, 220, 255, 255], i * 4);
else d.set([60, 180, 255, 38], i * 4);
}
selCanvas = new OffscreenCanvas(w, h);
selCanvas.getContext("2d").putImageData(id, 0, 0);
}
function draw() {
if (!cv) return;
const r = cv.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
if (cv.width !== Math.round(r.width * dpr) || cv.height !== Math.round(r.height * dpr)) (cv.width = Math.round(r.width * dpr)), (cv.height = Math.round(r.height * dpr));
g.setTransform(1, 0, 0, 1, 0, 0);
g.clearRect(0, 0, cv.width, cv.height);
if (!TEX.base) return;
g.setTransform(dpr, 0, 0, dpr, 0, 0);
const z = TEX.zoom, [px, py] = TEX.pan;
// checkerboard under the picture (transparent parts)
g.save();
g.beginPath();
g.rect(px, py, TEX.w * z, TEX.h * z);
g.clip();
const s = 10;
for (let y = Math.floor(py / s) * s; y < py + TEX.h * z; y += s)
for (let x = Math.floor(px / s) * s; x < px + TEX.w * z; x += s) {
g.fillStyle = ((x / s + y / s) & 1) === 0 ? "#2a2a30" : "#1e1e22";
g.fillRect(x, y, s, s);
}
g.restore();
g.imageSmoothingEnabled = z < 1;
g.drawImage(imgCanvas, px, py, TEX.w * z, TEX.h * z);
if (selCanvas && TEX.showSel) {
g.imageSmoothingEnabled = false;
g.drawImage(selCanvas, px, py, TEX.w * z, TEX.h * z);
}
// the lasso / rectangle being drawn
if (drag?.pts?.length > 1) {
g.strokeStyle = "#3cdcff";
g.lineWidth = 1;
g.setLineDash([4, 3]);
g.beginPath();
drag.pts.forEach(([x, y], i) => (i ? g.lineTo : g.moveTo).call(g, px + x * z, py + y * z));
if (drag.tool === "rect") g.closePath();
g.stroke();
g.setLineDash([]);
}
$("tex-zoom").textContent = `${Math.round(z * 100)}%`;
}
function fitView() {
if (!cv || !TEX.base) return;
const r = cv.getBoundingClientRect();
TEX.zoom = Math.min((r.width - 40) / TEX.w, (r.height - 40) / TEX.h);
TEX.zoom = TEX.zoom > 1 ? Math.floor(TEX.zoom) : TEX.zoom;
TEX.pan = [(r.width - TEX.w * TEX.zoom) / 2, (r.height - TEX.h * TEX.zoom) / 2];
TEX.fitted = true;
draw();
}
// =====================================================================
// editing: undo, selection, recolour
// =====================================================================
function pushUndo() {
TEX.undo.push(TEX.base);
if (TEX.undo.length > 20) TEX.undo.shift();
TEX.redo = [];
}
function undo(back = true) {
const from = back ? TEX.undo : TEX.redo, to = back ? TEX.redo : TEX.undo;
if (!from.length) return;
to.push(TEX.base);
TEX.base = from.pop();
TEX.dirty = true;
refresh();
}
/** A new selection piece: shift adds, alt takes away, else it replaces. */
function select(piece, e) {
const mode = e?.shiftKey ? "add" : e?.altKey ? "sub" : "new";
TEX.raw = TK.combine(TEX.raw, piece, mode);
if (!TK.count(TEX.raw)) TEX.raw = null;
refresh();
renderSel();
}
function selectAll(how) {
if (!TEX.base) return;
const n = TEX.w * TEX.h;
if (how === "all") TEX.raw = new Uint8Array(n).fill(255);
else if (how === "none") TEX.raw = null;
else if (how === "invert") TEX.raw = TEX.raw ? TK.invert(TEX.raw) : new Uint8Array(n).fill(255);
else if (TEX.raw) TEX.raw = TK.grow(TEX.raw, TEX.w, TEX.h, how);
refresh();
renderSel();
}
function applyRecolor() {
if (!TEX.rcOn) return;
pushUndo();
TEX.base = TEX.shown;
TEX.rcOn = false;
TEX.dirty = true;
refresh();
renderRecolor();
}
function cancelRecolor() {
TEX.rcOn = false;
refresh();
renderRecolor();
}
let rcTimer = 0;
function setRc(k, v) {
TEX.rc[k] = v;
TEX.rcOn = true;
clearTimeout(rcTimer);
rcTimer = setTimeout(() => {
refresh();
renderRecolorButtons();
}, 30);
}
// =====================================================================
// mouse
// =====================================================================
let drag = null;
const toImage = (e) => {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left - TEX.pan[0]) / TEX.zoom, (e.clientY - r.top - TEX.pan[1]) / TEX.zoom];
};
function bindCanvas() {
cv.addEventListener("contextmenu", (e) => e.preventDefault());
cv.addEventListener("mousedown", (e) => {
if (!TEX.base) return;
cv.focus();
const [x, y] = toImage(e);
const pan = e.button === 1 || e.button === 2 || TEX.tool === "pan" || spaceDown;
if (pan) {
drag = { pan: true, sx: e.clientX, sy: e.clientY, p0: TEX.pan.slice() };
return;
}
if (e.button !== 0) return;
const ix = Math.floor(x), iy = Math.floor(y);
if (TEX.picking) {
if (ix >= 0 && iy >= 0 && ix < TEX.w && iy < TEX.h) {
const p = (iy * TEX.w + ix) * 4;
TEX.picking = false;
cv.classList.remove("pick");
setRc("color", [TEX.base[p], TEX.base[p + 1], TEX.base[p + 2]]);
renderRecolor();
}
return;
}
if (TEX.tool === "wand" || TEX.tool === "color") {
if (ix < 0 || iy < 0 || ix >= TEX.w || iy >= TEX.h) return;
select(TK.wand(TEX.base, TEX.w, TEX.h, ix, iy, TEX.tol, TEX.tool === "wand" && TEX.contiguous), e);
return;
}
drag = { tool: TEX.tool, pts: [[x, y]], shift: e.shiftKey, alt: e.altKey };
if (TEX.tool === "rect") drag.pts = [[x, y], [x, y], [x, y], [x, y]];
});
window.addEventListener("mousemove", (e) => {
if (!drag || !visible()) return;
if (drag.pan) {
TEX.pan = [drag.p0[0] + e.clientX - drag.sx, drag.p0[1] + e.clientY - drag.sy];
TEX.fitted = false;
return draw();
}
const [x, y] = toImage(e);
if (drag.tool === "rect") {
const [x0, y0] = drag.pts[0];
drag.pts = [[x0, y0], [x, y0], [x, y], [x0, y]];
} else {
const last = drag.pts[drag.pts.length - 1];
if (Math.hypot(x - last[0], y - last[1]) * TEX.zoom > 2) drag.pts.push([x, y]);
}
draw();
});
window.addEventListener("mouseup", () => {
if (!drag) return;
const d = drag;
drag = null;
if (d.pan) return;
const pts = d.pts.map(([x, y]) => [Math.max(0, Math.min(TEX.w, x)), Math.max(0, Math.min(TEX.h, y))]);
const piece = d.tool === "rect" ? TK.rect(TEX.w, TEX.h, pts[0][0], pts[0][1], pts[2][0], pts[2][1]) : TK.polygon(TEX.w, TEX.h, pts);
if (TK.count(piece)) select(piece, { shiftKey: d.shift, altKey: d.alt });
else draw();
});
cv.addEventListener(
"wheel",
(e) => {
if (!TEX.base) return;
e.preventDefault();
const r = cv.getBoundingClientRect();
const mx = e.clientX - r.left, my = e.clientY - r.top;
const z0 = TEX.zoom, z1 = Math.max(0.05, Math.min(32, z0 * (e.deltaY < 0 ? 1.2 : 1 / 1.2)));
TEX.pan = [mx - ((mx - TEX.pan[0]) * z1) / z0, my - ((my - TEX.pan[1]) * z1) / z0];
TEX.zoom = z1;
TEX.fitted = false;
draw();
},
{ passive: false },
);
cv.addEventListener("dragover", (e) => e.preventDefault());
cv.addEventListener("drop", (e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) openFile(bridge.pathOf(f));
});
}
let spaceDown = false;
const visible = () => $("view-tex").classList.contains("on");
window.addEventListener("keydown", (e) => {
if (!visible() || /input|select|textarea/i.test(document.activeElement?.tagName || "")) return;
if (e.code === "Space") (spaceDown = true), e.preventDefault();
const k = e.key.toLowerCase();
if (e.ctrlKey && k === "z") (undo(!e.shiftKey), e.preventDefault());
else if (e.ctrlKey && k === "y") (undo(false), e.preventDefault());
else if (e.ctrlKey && k === "a") (selectAll("all"), e.preventDefault());
else if (e.ctrlKey && k === "d") (selectAll("none"), e.preventDefault());
else if (e.ctrlKey && e.shiftKey && k === "i") (selectAll("invert"), e.preventDefault());
else if (e.ctrlKey && k === "s") (save(), e.preventDefault());
else if (!e.ctrlKey && TOOLS.some((t) => t.key === k)) setTool(TOOLS.find((t) => t.key === k).id);
else if (k === "enter" && TEX.rcOn) applyRecolor();
else if (k === "escape") TEX.rcOn ? cancelRecolor() : TEX.picking ? ((TEX.picking = false), cv.classList.remove("pick")) : selectAll("none");
else if (k === "0" && !e.ctrlKey) fitView();
});
window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false));
// =====================================================================
// side cards
// =====================================================================
const TOOLS = [
{ id: "wand", key: "w", label: "Magic wand", tip: "Click: the area of similar colour around that point (W)" },
{ id: "color", key: "c", label: "Colour range", tip: "Click: every pixel of similar colour in the picture (C)" },
{ id: "lasso", key: "l", label: "Lasso", tip: "Drag around what to select (L)" },
{ id: "rect", key: "m", label: "Rectangle", tip: "Drag a box (M)" },
{ id: "pan", key: "h", label: "Hand", tip: "Drag to move the view (H) — or the middle / right button, or hold Space" },
];
function setTool(id) {
TEX.tool = id;
for (const b of $("tex-tools").children) b.classList.toggle("on", b.dataset.tool === id);
cv?.setAttribute("data-tool", id);
renderSel();
}
function renderSide() {
renderSel();
renderRecolor();
renderExport();
}
function renderSel() {
const box = $("tex-sel");
const n = TEX.raw ? TK.count(TEX.raw) : 0;
const num = (value, min, max, onchange) => el("input", { type: "number", min, max, step: "1", value: String(value), onchange: (e) => onchange(Math.max(min, Math.min(max, Math.round(Number(e.target.value) || 0)))) });
fill(box,
el("div", { class: "ans-grid" }, [
...(TEX.tool === "wand" || TEX.tool === "color"
? field("Tolerance", el("div", { class: "ans-vec" }, [
el("input", { type: "range", min: "0", max: "128", value: String(TEX.tol), class: "grow", oninput: (e) => (($("tex-tol-v").textContent = e.target.value), (TEX.tol = Number(e.target.value))) }),
el("span", { id: "tex-tol-v", class: "mono", text: String(TEX.tol) }),
]))
: []),
...field("Close gaps", num(TEX.close, 0, 16, (v) => ((TEX.close = v), refresh()))),
...field("Feather", num(TEX.feather, 0, 16, (v) => ((TEX.feather = v), refresh()))),
]),
TEX.tool === "wand" ? el("label", { class: "ans-check", title: "Off: like Colour range (the colour anywhere in the picture)" }, [el("input", { type: "checkbox", checked: TEX.contiguous, onchange: (e) => (TEX.contiguous = e.target.checked) }), "Only touching pixels"]) : null,
el("div", { class: "ans-row" }, [
el("button", { class: "btn", text: "All", title: "Ctrl+A", onclick: () => selectAll("all") }),
el("button", { class: "btn", text: "None", title: "Ctrl+D / Esc", onclick: () => selectAll("none") }),
el("button", { class: "btn", text: "Invert", title: "Ctrl+Shift+I", onclick: () => selectAll("invert") }),
el("button", { class: "btn", text: "Grow", title: "+2 px", onclick: () => selectAll(2) }),
el("button", { class: "btn", text: "Shrink", title: "−2 px", onclick: () => selectAll(-2) }),
]),
el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: TEX.showSel, onchange: (e) => ((TEX.showSel = e.target.checked), draw()) }), "Show the selection"]),
el("div", { class: "ans-note", text: n ? `${n.toLocaleString()} pixels selected (${((100 * n) / (TEX.w * TEX.h)).toFixed(1)}%). Shift + click / drag adds, Alt takes away.` : "Nothing selected: a recolour changes the whole picture. Shift + click / drag adds to a selection, Alt takes away. Close gaps fills the dark strands a colour pick misses." }),
);
}
function slider(label, k, min, max, scale = 1, unit = "") {
const out = el("span", { class: "mono tex-val", text: `${Math.round(TEX.rc[k] * scale)}${unit}` });
return field(label, el("div", { class: "ans-vec" }, [
el("input", { type: "range", min: String(min), max: String(max), value: String(Math.round(TEX.rc[k] * scale)), class: "grow", oninput: (e) => {
out.textContent = `${e.target.value}${unit}`;
setRc(k, Number(e.target.value) / scale);
}, ondblclick: (e) => {
// back to the start value
const v = k === "strength" ? 1 : 0;
e.target.value = String(Math.round(v * scale));
out.textContent = `${Math.round(v * scale)}${unit}`;
setRc(k, v);
} }),
out,
]));
}
const SWATCHES = [[34, 32, 32], [150, 145, 128], [235, 190, 90], [130, 95, 35], [105, 38, 14], [190, 20, 20], [72, 10, 28], [20, 40, 120], [90, 30, 120], [20, 110, 60], [140, 145, 155], [240, 150, 190]];
function renderRecolor() {
const r = TEX.rc;
const box = $("tex-rc");
const mode = el("div", { class: "seg" }, [["colorize", "Colourise"], ["adjust", "Hue / Sat / Light"]].map(([v, t]) =>
el("button", { class: r.mode === v ? "on" : "", text: t, onclick: () => (setRc("mode", v), renderRecolor()) }),
));
const color = el("input", { type: "color", value: hex(r.color), oninput: (e) => setRc("color", unhex(e.target.value)) });
fill(box,
mode,
r.mode === "colorize"
? el("div", {}, [
el("div", { class: "ans-grid" }, [
...field("Colour", el("div", { class: "ans-vec" }, [
color,
el("button", { class: `iconbtn only${TEX.picking ? " on" : ""}`, title: "Take the colour from the picture (click it)", onclick: () => ((TEX.picking = !TEX.picking), cv.classList.toggle("pick", TEX.picking), renderRecolor()) }, [icon("target")]),
])),
...slider("Strength", "strength", 0, 100, 100, "%"),
...slider("Brightness", "bright", -100, 100, 100),
...slider("Contrast", "contrast", -100, 100, 100),
]),
el("div", { class: "tex-swatches" }, SWATCHES.map((c) => el("button", { class: "tex-sw", title: hex(c), style: `background:${hex(c)}`, onclick: () => (setRc("color", c), renderRecolor()) }))),
el("div", { class: "ans-note", text: "Each pixel keeps its light and dark (strands, shading) in the new colour: made for dark or brown hair." }),
])
: el("div", { class: "ans-grid" }, [...slider("Hue", "hue", -180, 180, 1, "°"), ...slider("Saturation", "sat", -100, 100, 100), ...slider("Lightness", "light", -100, 100, 100), ...slider("Contrast", "contrast", -100, 100, 100)]),
el("div", { id: "tex-rc-btns", class: "ans-row" }),
);
renderRecolorButtons();
}
function renderRecolorButtons() {
const b = $("tex-rc-btns");
if (!b) return;
b.replaceChildren(
el("button", { class: "btn primary", text: "Apply", title: "Enter", disabled: !TEX.rcOn || !TEX.base, onclick: applyRecolor }),
el("button", { class: "btn", text: "Cancel", title: "Esc", disabled: !TEX.rcOn, onclick: cancelRecolor }),
el("span", { class: "grow" }),
el("button", { class: "iconbtn only", title: "Undo (Ctrl+Z)", disabled: !TEX.undo.length, onclick: () => undo(true) }, [icon("undo")]),
el("button", { class: "iconbtn only", title: "Redo (Ctrl+Y)", disabled: !TEX.redo.length, onclick: () => undo(false) }, [icon("redo")]),
);
}
const FORMATS = [
["auto", "Auto (DXT1, DXT5 with alpha)"],
["DXT1", "DXT1 — no / cut-out alpha (smallest)"],
["DXT3", "DXT3 — sharp alpha"],
["DXT5", "DXT5 — smooth alpha"],
["ARGB", "A8R8G8B8 — uncompressed (4× bigger)"],
];
function outSize() {
let w = TEX.w, h = TEX.h;
if (TEX.exp.max && Math.max(w, h) > TEX.exp.max) {
const f = TEX.exp.max / Math.max(w, h);
(w = Math.max(1, Math.round(w * f))), (h = Math.max(1, Math.round(h * f)));
}
// the game's cards want powers of two
return [TK.pow2(w), TK.pow2(h)];
}
function renderExport() {
const box = $("tex-exp");
if (!TEX.base) return box.replaceChildren(el("div", { class: "ans-note", text: "Open a picture." }));
const e = TEX.exp;
const [ow, oh] = outSize();
const fmt = TK.pickFormat(TEX.base, e.format);
fill(box,
el("div", { class: "ans-grid" }, [
...field("Format", el("select", { onchange: (ev) => ((e.format = ev.target.value), renderExport()) }, FORMATS.map(([v, t]) => el("option", { value: v, text: t, selected: v === e.format })))),
...field("Size", el("select", { onchange: (ev) => ((e.max = Number(ev.target.value)), renderExport()) }, [[0, "Keep"], [2048, "≤ 2048"], [1024, "≤ 1024"], [512, "≤ 512"], [256, "≤ 256"], [128, "≤ 128"]].map(([v, t]) => el("option", { value: v, text: t, selected: v === e.max })))),
]),
el("label", { class: "ans-check", title: "Smaller copies for far away (what most of the game's textures have)" }, [el("input", { type: "checkbox", checked: e.mips, onchange: (ev) => ((e.mips = ev.target.checked), renderExport()) }), "Mip maps"]),
el("div", { class: "ans-note", text: `Saves ${ow}×${oh} ${fmt === "ARGB" ? "A8R8G8B8" : fmt}${e.mips ? " + mip maps" : ""}${ow !== TEX.w || oh !== TEX.h ? ` (resized from ${TEX.w}×${TEX.h}${TK.pow2(TEX.w) !== TEX.w || TK.pow2(TEX.h) !== TEX.h ? ": the game wants powers of two" : ""})` : ""}. Transparent black in DXT1 / alpha 0 is where the game shows the skin colour (#MODEL_COLOR).` }),
);
}
// =====================================================================
// saving
// =====================================================================
/** The current pixels (recolour applied if previewed) as .dds bytes with the export settings. */
function ddsBytes(px = TEX.rcOn ? TEX.shown : TEX.base, w = TEX.w, h = TEX.h) {
const [ow, oh] = TEX.base && px === (TEX.rcOn ? TEX.shown : TEX.base) ? outSize() : [TK.pow2(w), TK.pow2(h)];
const p = ow !== w || oh !== h ? TK.resize(px, w, h, ow, oh) : px;
return TK.encodeDds(p, ow, oh, { format: TEX.exp.format, mips: TEX.exp.mips });
}
async function confirmOverwrite(names) {
if (!names.length) return true;
return modal("Replace files?", [el("p", { text: `${names.length} file(s) already exist and will be replaced:` }), el("div", { class: "mono ans-note" }, names.map((n) => el("div", { text: n })))], [
{ label: "Cancel", value: false },
{ label: "Replace", kind: "danger", value: true },
]);
}
async function writeDds(folder, name) {
if (TEX.rcOn) applyRecolor();
loading(true, `Encoding ${name}…`);
await frame();
try {
const r = ddsBytes();
await call("mdl:writeFiles", folder, [{ name, data: r.bytes }]);
TEX.dirty = false;
refresh();
toast(`Saved ${name} (${r.format === "ARGB" ? "A8R8G8B8" : r.format}, ${r.levels} level${r.levels > 1 ? "s" : ""}, ${size(r.bytes.length)}).`);
if (folder === TEX.dir) await listFiles();
} catch (err) {
toast(`Save: ${err.message}`, true);
} finally {
loading(false);
}
}
/** Save: <name>.dds next to the picture (a .dds is replaced). */
async function save() {
if (!TEX.base) return;
const name = `${TEX.file.stem}.dds`;
const exists = TEX.file.ext === "dds" || (TEX.file.dir === TEX.dir && TEX.files.some((f) => f.name.toLowerCase() === name.toLowerCase()));
if (exists && !(await confirmOverwrite([name]))) return;
await writeDds(TEX.file.dir, name);
}
async function saveAs() {
if (!TEX.base) return;
const kind = await modal("Save as", [el("p", { text: "Save the picture as:" })], [
{ label: "Cancel", value: null },
{ label: "PNG", value: "png" },
{ label: "DDS", kind: "primary", value: "dds" },
]);
if (!kind) return;
const file = await call("dialog:saveAs", `${TEX.file.stem}.${kind}`, kind);
if (!file) return;
const folder = file.replace(/[\\/][^\\/]*$/, ""), name = file.replace(/\\/g, "/").split("/").pop();
if (kind === "dds") return writeDds(folder, /\.dds$/i.test(name) ? name : `${name}.dds`);
if (TEX.rcOn) applyRecolor();
const c = new OffscreenCanvas(TEX.w, TEX.h);
c.getContext("2d").putImageData(new ImageData(TEX.base, TEX.w, TEX.h), 0, 0);
const blob = await c.convertToBlob({ type: "image/png" });
const b64 = await new Promise((res) => {
const fr = new FileReader();
fr.onload = () => res(String(fr.result).split(",")[1]);
fr.readAsDataURL(blob);
});
await call("file:writeBase64", file, b64);
toast(`Saved ${name}.`);
if (folder === TEX.dir) listFiles();
}
/** Every picture of the folder that isn't a .dds → <name>.dds with the export settings. */
async function convertFolder() {
if (!TEX.dir) return openFolder();
const src = TEX.files.filter((f) => extOf(f.name) !== "dds");
if (!src.length) return toast("No .png / .jpg / .tga / .bmp in this folder.", true);
const have = new Set(TEX.files.map((f) => f.name.toLowerCase()));
const list = el("div", { class: "tex-conv" }, src.map((f) => el("label", { class: "ans-check" }, [el("input", { type: "checkbox", checked: true, "data-name": f.name }), `${f.name} → ${stem(f.name)}.dds${have.has(`${stem(f.name)}.dds`.toLowerCase()) ? " (replaces)" : ""}`])));
const fmt = FORMATS.find(([v]) => v === TEX.exp.format)[1];
const ok = await modal("Convert to .dds", [
el("p", { text: `Into ${TEX.dir}, as ${fmt}${TEX.exp.mips ? " with mip maps" : ""}${TEX.exp.max ? `, at most ${TEX.exp.max}` : ""} (sizes to powers of two). Change it in Save as .dds.` }),
list,
], [{ label: "Cancel", value: false }, { label: "Convert", kind: "primary", value: true }]);
if (!ok) return;
const names = [...list.querySelectorAll("input:checked")].map((i) => i.dataset.name);
let done = 0;
loading(true, "Converting…");
try {
for (const n of names) {
$("tex-loading").querySelector("span").textContent = `Converting ${n} (${done + 1} / ${names.length})…`;
await frame();
const { bytes } = await call("mdl:readFile", join(TEX.dir, n));
const bmp = await TexDecode.decode(bytes, extOf(n));
const c = new OffscreenCanvas(bmp.width, bmp.height);
const cx = c.getContext("2d", { willReadFrequently: true });
cx.drawImage(bmp, 0, 0);
let px = cx.getImageData(0, 0, bmp.width, bmp.height).data, w = bmp.width, h = bmp.height;
if (TEX.exp.max && Math.max(w, h) > TEX.exp.max) {
const f = TEX.exp.max / Math.max(w, h);
const nw = Math.max(1, Math.round(w * f)), nh = Math.max(1, Math.round(h * f));
(px = TK.resize(px, w, h, nw, nh)), (w = nw), (h = nh);
}
const r = ddsBytes(px, w, h);
await call("mdl:writeFiles", TEX.dir, [{ name: `${stem(n)}.dds`, data: r.bytes }]);
done++;
}
toast(`Converted ${done} file(s) to .dds.`);
} catch (err) {
toast(`Convert: ${err.message} (${done} done)`, true);
} finally {
loading(false);
listFiles();
}
}
// =====================================================================
// start
// =====================================================================
function start() {
if (started) return;
started = true;
cv = $("tex-canvas");
g = cv.getContext("2d");
bindCanvas();
new ResizeObserver(() => (TEX.fitted ? fitView() : draw())).observe(cv);
$("tex-tools").replaceChildren(...TOOLS.map((t) => el("button", { "data-tool": t.id, title: `${t.label}: ${t.tip}`, text: t.label, onclick: () => setTool(t.id) })));
setTool("wand");
$("tex-open").onclick = () => openFolder();
$("tex-openfile").onclick = async () => {
const f = await call("dialog:openExt", "Open a picture", "Pictures", ["png", "jpg", "jpeg", "tga", "bmp", "dds"]);
if (!f) return;
const dir = f.replace(/[\\/][^\\/]*$/, "");
if (dir !== TEX.dir) await openFolder(dir);
openFile(f);
};
$("tex-refresh").onclick = () => listFiles();
$("tex-convert").onclick = () => convertFolder();
$("tex-save").onclick = () => save();
$("tex-saveas").onclick = () => saveAs();
$("tex-fit").onclick = () => fitView();
$("tex-100").onclick = () => {
const r = cv.getBoundingClientRect();
TEX.zoom = 1;
TEX.fitted = false;
TEX.pan = [(r.width - TEX.w) / 2, (r.height - TEX.h) / 2];
draw();
};
$("tex-search").addEventListener("input", renderList);
renderSide();
let dir = null;
try {
dir = localStorage.getItem(KEY);
} catch {}
if (dir) openFolder(dir);
else renderList();
}
// the tab opens: set up once
document.querySelector('#tabs button[data-view="tex"]')?.addEventListener("click", () => setTimeout(start, 0));
Object.assign(TEX, { openFolder, openFile, save, refresh, selectAll, applyRecolor, setRc, convertFolder, start });
})();
+671
View File
@@ -0,0 +1,671 @@
"use strict";
// UI resources and drawing for the window editor, following the client (io3DEngine ioGUIManager,
// ioUIRenderImage, ioUIRenderFrame, ioUITitle, ioButton, ioStringManager):
// UIRes imagesets (xml/uiimageset.xml), frame templates (xml/defaultuiframe.xml), textures,
// the UI texts (text/text.txt) and the game font (font/lostsaga.ttf)
// UIText STR(n) → text ("xml_<file>_<n>"), CP949 encoding
// UIDraw one window: frames, images, title / texts, button states, as the client draws them
// UIProps inputs for attributes (colours, alignments…) and the quick form of a window
const UIRes = (() => {
let src = null;
let sets = new Map(); // imageset name (lower) → { file, images: Map(name lower → {x,y,w,h,ox,oy}) }
let frames = new Map(); // template name (lower) → { name, parts: [{ type, w, h, set, image }] }
const tex = new Map(); // texture file → Promise<ImageBitmap | null>
const imgCache = new Map();
let texts = null; // { encoding, xml, source }
let fontFamily = "";
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;
};
function reset() {
src = null;
sets = new Map();
frames = new Map();
tex.clear();
imgCache.clear();
texts = null;
}
async function load(s) {
if (src && JSON.stringify(src) === JSON.stringify(s)) return;
reset();
src = s;
if (!s) return;
const [is, fr, tx, font] = await Promise.all([
call("mdl:readPath", s, "xml/uiimageset.xml").catch(() => null),
call("mdl:readPath", s, "xml/defaultuiframe.xml").catch(() => null),
call("wnd:texts", s).catch(() => null),
fontFamily ? null : call("wnd:font", s).catch(() => null),
]);
if (is) {
const t = toLatin1(is.bytes);
for (const m of t.matchAll(/<Imageset\b([^>]*)>([\s\S]*?)<\/Imageset>/gi)) {
const a = attrs(m[1]);
const images = new Map();
for (const im of m[2].matchAll(/<Image\b([^>]*)\/?>/gi)) {
const b = attrs(im[1]);
images.set((b.name || "").toLowerCase(), { x: +b.x || 0, y: +b.y || 0, w: +b.width || 0, h: +b.height || 0, ox: +b.offsetx || 0, oy: +b.offsety || 0 });
}
sets.set((a.name || "").toLowerCase(), { file: a.file || "", images });
}
}
if (fr) {
const t = toLatin1(fr.bytes);
for (const m of t.matchAll(/<FrameComponent\b([^>]*)>([\s\S]*?)<\/FrameComponent>/gi)) {
const a = attrs(m[1]);
const parts = [];
for (const im of m[2].matchAll(/<Image\b([^>]*)\/?>/gi)) {
const b = attrs(im[1]);
const [w, h] = String(b.size || "").split(".").map(Number);
parts.push({ type: (b.type || "").toLowerCase(), w: w || 0, h: h || 0, set: b.imageset || "", image: b.image || "" });
}
frames.set((a.name || "").toLowerCase(), { name: a.name, parts });
}
}
texts = tx;
if (font && !fontFamily) {
try {
const face = new FontFace("LostSagaUI", font.bytes);
await face.load();
document.fonts.add(face);
fontFamily = "LostSagaUI";
} catch (err) {
console.warn("[wnd] font", err);
}
}
}
function attrs(s) {
const o = {};
for (const m of s.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"/g)) o[m[1].toLowerCase()] = m[2];
return o;
}
function texture(file) {
const key = file.toLowerCase();
if (!tex.has(key)) {
tex.set(
key,
(async () => {
if (!src) return null;
const t = await call("mdl:texture", src, file);
if (!t) return null;
try {
return await TexDecode.decode(t.bytes, t.ext);
} catch {
return null;
}
})(),
);
}
return tex.get(key);
}
/** An imageset image (or a whole texture with File=): { bmp, x, y, w, h, ox, oy }. */
async function image(set, name, file) {
const key = file ? `file#${file}`.toLowerCase() : `${set}#${name}`.toLowerCase();
if (imgCache.has(key)) return imgCache.get(key);
if (file) {
const bmp = await texture(file);
const v = bmp ? { bmp, x: 0, y: 0, w: bmp.width, h: bmp.height, ox: 0, oy: 0 } : null;
imgCache.set(key, v);
return v;
}
const s = sets.get((set || "").toLowerCase());
const r = s?.images.get((name || "").toLowerCase());
if (!s || !r) {
imgCache.set(key, null);
return null;
}
const bmp = await texture(s.file);
const v = bmp ? { bmp, ...r } : null;
imgCache.set(key, v);
return v;
}
const imageSync = (set, name, file) => imgCache.get(file ? `file#${file}`.toLowerCase() : `${set}#${name}`.toLowerCase());
const frame = (name) => frames.get((name || "").toLowerCase()) || null;
const imagesets = () => [...sets.keys()];
const imagesOf = (set) => [...(sets.get((set || "").toLowerCase())?.images.keys() || [])];
const frameNames = () => [...frames.values()].map((f) => f.name);
return { reset, load, image, imageSync, frame, imagesets, imagesOf, frameNames, texts: () => texts, font: () => fontFamily };
})();
window.UIRes = UIRes;
const UIText = (() => {
// CP949 encoder: the reverse of the browser's euc-kr decoder, built once
let table = null;
function build() {
table = new Map();
const dec = new TextDecoder("euc-kr");
const b = new Uint8Array(2);
for (let hi = 0x81; hi <= 0xfe; hi++) {
for (let lo = 0x41; lo <= 0xfe; lo++) {
b[0] = hi;
b[1] = lo;
const ch = dec.decode(b);
if (ch.length === 1 && ch !== "�" && !table.has(ch)) table.set(ch, [hi, lo]);
}
}
}
function encodeCp949(str) {
if (!table) build();
const out = [];
for (const ch of str) {
const c = ch.codePointAt(0);
if (c < 0x80) out.push(c);
else {
const t = table.get(ch);
if (!t) throw new Error(`“${ch}” can't be written in CP949 (Korean encoding of the client files).`);
out.push(t[0], t[1]);
}
}
return Uint8Array.from(out);
}
let fileKey = ""; // xml file name without folder / extension, lowercase (ioGUIManager::AddWnd)
const setFile = (name) => (fileKey = (name || "").replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "").toLowerCase());
const key = (n) => `xml_${fileKey}_${n}`;
/** A Title / Text / Tooltip value as the client shows it: STR(n) → text.txt "xml_<file>_<n>". */
function resolve(doc, el, text) {
const t = (text || "").trim();
const m = /STR\((\d+)/i.exec(t);
const decoded = LSF.decode(Uint8Array.from(t, (c) => c.charCodeAt(0) & 255));
if (!m) return decoded;
const tx = UIRes.texts();
return tx?.xml[key(m[1])] ?? key(m[1]); // a missing key shows the key, like the client
}
function titleOf(doc, w) {
const title = XmlDoc.child(w, "Title");
const te = title && XmlDoc.child(title, "Text");
return te ? resolve(doc, te, XmlDoc.text(doc, te)) : "";
}
return { encodeCp949, resolve, titleOf, setFile, key };
})();
window.UIText = UIText;
const UIDraw = (() => {
const X = XmlDoc;
const low = (s) => (s || "").toLowerCase();
/** AARRGGBB like ioStringConverter: missing → FF000000, a value of 0 → FFFFFFFF. */
function colorOf(s, dflt = "FF000000") {
if (s === undefined || s === null || s === "") s = dflt;
let v = parseInt(String(s).replace(/^0x/i, ""), 16);
if (!isFinite(v)) v = parseInt(dflt, 16);
if (v === 0) v = 0xffffffff;
if (String(s).replace(/^0x/i, "").length <= 6) v = (0xff000000 | v) >>> 0;
return { a: ((v >>> 24) & 255) / 255, r: (v >>> 16) & 255, g: (v >>> 8) & 255, b: v & 255 };
}
const css = (c, alpha = 1) => `rgba(${c.r},${c.g},${c.b},${(c.a * alpha).toFixed(3)})`;
const BLEND = { add: "lighter", addalpha: "lighter", addcolor: "lighter", screen: "screen", multiply: "multiply", lighten: "lighten", darken: "darken" };
function withBlend(ctx, renderType, fn) {
const rt = low(renderType);
ctx.save();
if (BLEND[rt]) ctx.globalCompositeOperation = BLEND[rt];
if (rt === "gray" || rt === "graylight") ctx.filter = "grayscale(1)";
fn();
ctx.restore();
}
// tinted copies (Color on an image / frame multiplies RGB, keeps alpha)
const tintCache = new Map();
function tinted(im, color) {
if (!color || /^f{6}$/i.test(color.slice(-6))) return im;
const k = `${im.x},${im.y},${im.w},${im.h},${color}`;
let t = tintCache.get(im.bmp)?.get(k);
if (t) return t;
const c = document.createElement("canvas");
c.width = im.w;
c.height = im.h;
const g = c.getContext("2d");
g.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, im.w, im.h);
g.globalCompositeOperation = "multiply";
const col = colorOf(color, "FFFFFFFF");
g.fillStyle = `rgb(${col.r},${col.g},${col.b})`;
g.fillRect(0, 0, im.w, im.h);
g.globalCompositeOperation = "destination-in";
g.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, im.w, im.h);
t = { ...im, bmp: c, x: 0, y: 0 };
if (!tintCache.has(im.bmp)) tintCache.set(im.bmp, new Map());
tintCache.get(im.bmp).set(k, t);
return t;
}
function blit(ctx, im, x, y, w, h, flipH, flipV) {
if (!im || !im.bmp || !im.w || !im.h || w <= 0 || h <= 0) return;
if (!flipH && !flipV) return ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, x, y, w, h);
ctx.save();
ctx.translate(x + (flipH ? w : 0), y + (flipV ? h : 0));
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, 0, 0, w, h);
ctx.restore();
}
/**
* A window <Image> (ioUIRenderImage): Area gives the render rect relative to the window (only
* the position when W or H is 0); x = win + Area.X − imageset OffsetX (+ −w/2 Center, −w Right);
* natural size except Stretch; Tile repeats; Reverse flips; Scale scales; Color tints.
*/
function drawImageEl(ctx, e, r) {
const im0 = UIRes.imageSync(X.attr(e, "Imageset"), X.attr(e, "Image"), X.attr(e, "File"));
if (!im0) return;
const im = tinted(im0, X.attr(e, "Color"));
const area = X.child(e, "Area");
const scale = X.num(e, "Scale", 1) || 1;
const aw = X.num(area, "Width"), ah = X.num(area, "Height");
const hasRect = aw > 0 && ah > 0;
const ha = low(X.attr(area, "HorzAlign") || "left"), va = low(X.attr(area, "VertAlign") || "top");
const nw = im.w * scale, nh = im.h * scale;
const bx = r.x + X.num(area, "X") - im.ox * scale, by = r.y + X.num(area, "Y") - im.oy * scale;
const rev = low(X.attr(area, "Reverse"));
const fh = rev.includes("horz"), fv = rev.includes("vert");
const w = hasRect && ha === "stretch" ? aw : nw;
const h = hasRect && va === "stretch" ? ah : nh;
let x = bx + (ha === "center" ? -w / 2 : ha === "right" ? -w : 0);
let y = by + (va === "center" ? -h / 2 : va === "bottom" ? -h : 0);
withBlend(ctx, X.attr(area, "RenderType"), () => {
const tx = hasRect && ha === "tile" ? Math.max(1, Math.ceil(aw / nw)) : 1;
const ty = hasRect && va === "tile" ? Math.max(1, Math.ceil(ah / nh)) : 1;
for (let i = 0; i < tx; i++) for (let j = 0; j < ty; j++) blit(ctx, im, x + i * w, y + j * h, w, h, fh, fv);
});
}
/**
* A frame template (ioUIRenderFrame) inside a rect: corners at their image size, the part Size
* ("w.h") sets the layout thickness, edges stretched along, centre stretched both ways.
*/
function drawFrame(ctx, tpl, r, color, renderType) {
const f = UIRes.frame(tpl);
if (!f) return false;
const p = Object.fromEntries(f.parts.map((x) => [x.type, x]));
const img = (x) => {
const im = x && UIRes.imageSync(x.set, x.image);
return im ? tinted(im, color) : null;
};
const W = (x) => (x ? x.w || img(x)?.w || 0 : 0);
const H = (x) => (x ? x.h || img(x)?.h || 0 : 0);
const nat = (x) => img(x) || { w: 0, h: 0 };
const fw = r.w, fh = r.h;
withBlend(ctx, renderType, () => {
const put = (x, px, py, pw, ph) => {
const im = img(x);
if (im && pw > 0 && ph > 0) blit(ctx, im, r.x + px - (im.ox || 0), r.y + py - (im.oy || 0), pw, ph);
};
const tl = p.topleft, tr = p.topright, bl = p.bottomleft, br = p.bottomright;
put(tl, 0, 0, nat(tl).w, nat(tl).h);
put(p.topedge, W(tl), 0, fw - W(tl) - W(tr), nat(p.topedge).h);
put(tr, fw - W(tr), 0, nat(tr).w, nat(tr).h);
put(p.leftedge, 0, H(tl), nat(p.leftedge).w, fh - H(tl) - H(bl));
put(p.center, W(p.leftedge), H(p.topedge), fw - W(p.leftedge) - W(p.rightedge), fh - H(p.topedge) - H(p.bottomedge));
put(p.rightedge, fw - W(p.rightedge), H(tr), nat(p.rightedge).w, fh - H(tr) - H(br));
put(bl, 0, fh - H(bl), nat(bl).w, nat(bl).h);
put(p.bottomedge, W(bl), fh - H(p.bottomedge), fw - W(bl) - W(br), nat(p.bottomedge).h);
put(br, fw - W(br), fh - H(br), nat(br).w, nat(br).h);
});
return true;
}
function drawFrameEl(ctx, e, r) {
const area = X.child(e, "Area");
// no Width / Height: to the window's right / bottom edge (e.g. a BottomBar at Y=193)
const ax = X.num(area, "X"), ay = X.num(area, "Y");
const ar = area ? { x: r.x + ax, y: r.y + ay, w: X.num(area, "Width") || Math.max(0, r.w - ax), h: X.num(area, "Height") || Math.max(0, r.h - ay) } : r;
const tpl = X.attr(e, "Templete") || X.attr(e, "Template");
// "…multiply…" templates / MultiplyFrm are the darkening strips the client draws with Multiply
const rt = X.attr(area, "RenderType") || (/multiply/i.test(tpl || "") || /multiply/i.test(X.attr(e, "Type") || "") ? "Multiply" : null);
drawFrame(ctx, tpl, ar, X.attr(e, "Color"), rt);
}
// ---------- text (ioUITitle + ioFontManager) ----------
function fontCss(size, bold) {
const fam = UIRes.font();
return `${bold ? "bold " : ""}${Math.max(1, size)}px ${fam ? `"${fam}", ` : ""}"Microsoft JhengHei", "Malgun Gothic", "Leelawadee UI", sans-serif`;
}
/** Draws one text with the client's styles (outline / shadow in the Back colour). */
function drawText(ctx, str, { x, y, size = 12, style = "Normal", halign = "left", valign = "top", gap = 0, text, back, alpha = 1, widthCut = 0 }) {
if (!str) return;
const st = low(style);
const bold = st.startsWith("bold");
ctx.save();
ctx.font = fontCss(size, bold);
ctx.textAlign = halign === "center" ? "center" : halign === "right" ? "right" : "left";
ctx.textBaseline = "top";
const lines = String(str).split(/#|\n/);
const pitch = gap || 0; // VertGap is 0 when missing (the client overlaps un-gapped lines)
const totalH = size + pitch * (lines.length - 1);
let y0 = valign === "center" ? y - totalH / 2 : valign === "bottom" ? y - totalH : y;
const m = /outline(_full)?(?:_(\d)x)?/.exec(st);
const ring = m ? (m[1] ? Number(m[2] || 1) : 1) : 0;
lines.forEach((line, i) => {
let s = line;
if (widthCut > 0) {
while (s.length > 1 && ctx.measureText(s).width > widthCut) s = s.slice(0, -1);
}
const ly = y0 + i * pitch;
if (ring) {
ctx.lineJoin = "round";
ctx.lineWidth = ring * 2;
ctx.strokeStyle = css(back, alpha);
ctx.strokeText(s, x, ly);
} else if (st.includes("shadow")) {
ctx.fillStyle = css(back, alpha);
ctx.fillText(s, x + 1, ly + 1);
}
ctx.fillStyle = css(text, alpha);
ctx.fillText(s, x, ly);
});
ctx.restore();
}
function drawTitle(ctx, doc, w, r, state) {
const title = X.child(w, "Title");
if (!title) return;
const fmt = X.child(title, "Format");
const colors = X.childrenOf(title, "Color");
const disabled = state === "Disable";
const col = colors.find((c) => low(X.attr(c, "Type")) === low(state)) || colors.find((c) => low(X.attr(c, "Type")) === "normal") || colors[0];
const te = X.child(title, "Text");
const str = te ? UIText.resolve(doc, te, X.text(doc, te)) : "";
let style = X.attr(fmt, "Style") || "Normal";
let dx = 0, dy = 0;
if (disabled && X.attr(fmt, "DisableStyle")) {
const ds = X.attr(fmt, "DisableStyle");
if (low(ds) !== low(style) && low(ds) === "normal" && low(X.attr(fmt, "VertAlign") || "top") === "top") (dx = 2), (dy = 2);
style = ds;
}
drawText(ctx, str, {
x: r.x + X.num(fmt, "X") + dx,
y: r.y + X.num(fmt, "Y") + dy,
size: X.num(fmt, "Size", 12),
style,
halign: low(X.attr(fmt, "HorzAlign") || "left"),
valign: low(X.attr(fmt, "VertAlign") || "top"),
gap: X.num(fmt, "VertGap", 0),
text: colorOf(X.attr(col, "Text")),
back: colorOf(X.attr(col, "Back")),
});
}
/** Window-level <Text> list entries: Format fields as attributes or a <Format> child, one Color. */
function drawTexts(ctx, doc, w, r) {
for (const t of X.childrenOf(w, "Text")) {
const fmt = X.child(t, "Format") || t;
const col = X.child(t, "Color") || t;
const body = X.child(t, "Text") || (t.children.length ? null : t);
const raw = body ? X.text(doc, body) : "";
if (!raw.trim()) continue;
drawText(ctx, UIText.resolve(doc, body, raw), {
x: r.x + X.num(fmt, "X"),
y: r.y + X.num(fmt, "Y"),
size: X.num(fmt, "Size", 12),
style: X.attr(fmt, "Style") || "Normal",
halign: low(X.attr(fmt, "HorzAlign") || "left"),
valign: low(X.attr(fmt, "VertAlign") || "top"),
gap: X.num(fmt, "VertGap", 0),
text: colorOf(X.attr(col, "Text") || X.attr(col, "Color")),
back: colorOf(X.attr(col, "Back")),
widthCut: X.num(fmt, "WidthCut", 0),
});
}
}
// ioRichLabel: <ExtraInfo><TextBlock Text Size Color Style/> … laid out one after another
function drawTextBlocks(ctx, doc, w, r) {
const extra = X.child(w, "ExtraInfo");
const blocks = X.childrenOf(extra, "TextBlock");
if (!blocks.length) return;
let x = r.x, y = r.y;
for (const b of blocks) {
const raw = X.attr(b, "Text") || "";
const str = /STR\(/i.test(raw) ? UIText.resolve(doc, b, raw) : LSF.decode(Uint8Array.from(raw, (c) => c.charCodeAt(0) & 255));
const size = X.num(b, "Size", 12);
const parts = str.split("#");
parts.forEach((p, i) => {
if (i) {
x = r.x;
y += size + 2;
}
const col = colorOf(X.attr(b, "Color") || "FFFFFFFF");
drawText(ctx, p, { x, y, size, style: X.attr(b, "Style") || "Normal", text: col, back: colorOf("FF000000") });
ctx.font = fontCss(size);
x += ctx.measureText(p).width;
});
}
}
// EX classes (ioLabelWndEX / ioButtonWndEX / ioFrameWndEX): ExtraInfo children that fill the window
const INT_H = ["left", "center", "right"];
const INT_V = ["top", "center", "bottom"];
function drawEx(ctx, doc, w, r, state) {
const extra = X.child(w, "ExtraInfo");
if (!extra) return;
const want = { Normal: ["NormalFrame", "NormalImage"], Over: ["OverFrame", "OverImage"], Push: ["PushFrame", "PushImage"], Disable: ["DisableFrame", "NormalImage"] }[state] || ["NormalFrame", "NormalImage"];
for (const c of extra.children) {
const tag = c.tag;
if (tag === "FrameInfo" || (tag.endsWith("Frame") && want.includes(tag)) || (tag === "OverAddFrame" && state === "Over")) {
drawFrame(ctx, X.attr(c, "Template") || X.attr(c, "Templete"), r, X.attr(c, "Color"), tag === "OverAddFrame" ? "Add" : null);
} else if (tag === "ImageInfo" || (tag.endsWith("Image") && want.includes(tag))) {
const im = UIRes.imageSync(X.attr(c, "Imageset"), X.attr(c, "Image"), X.attr(c, "File"));
if (im) blit(ctx, tinted(im, X.attr(c, "Color")), r.x, r.y, r.w || im.w, r.h || im.h);
} else if (tag === "LabelInfo") {
const h = INT_H[X.num(c, "HorzAlign", 0)] || "left";
const v = INT_V[X.num(c, "VertAlign", 0)] || "top";
const size = X.num(c, "Size", 12);
const raw = X.attr(c, "Text") || "";
drawText(ctx, LSF.decode(Uint8Array.from(raw, (ch) => ch.charCodeAt(0) & 255)), {
x: r.x + (h === "center" ? r.w / 2 : h === "right" ? r.w : 0) + X.num(extra, "LabelOffsetX"),
y: r.y + (v === "center" ? r.h / 2 : v === "bottom" ? r.h : 0) + X.num(extra, "LabelOffsetY"),
size,
halign: h,
valign: v,
text: colorOf(X.attr(c, state === "Over" ? "OverColor" : "NormalColor") || X.attr(c, "Color") || "FFFFFFFF"),
back: colorOf(X.attr(c, "BKColor")),
alpha: X.num(c, "Alpha", 255) / 255,
});
}
}
}
const FIRST = ["selected", "multiplyfrm", "selectedtitle"];
// engine classes (ioGUIManager::CreateWnd): they draw every image Type in their list; client
// classes claim their own Types and draw them in code, so an Area-less image with such a Type
// isn't at the window origin in the game
const ENGINE = new Set("ioWnd ioButton ioScreenButton ioConvexButton ioScaleButton ioCheckButton ioRadioButton ioStretchButton ioActiveScreenBtn ioEdit ioProgressBar ioScroll ioSlide ioToggleButton ioWndEX ioFrameWndEX ioImageWndEX ioLabelWndEX ioFrameButtonWndEX ioImageButtonWndEX ioCheckBoxEX ioFlashPlayer ioTabFrameButtonEX ioTabWndEX ioScrollBarEX ioRichLabel ioRadioWndEX ioRadioFrameButtonEX ioMovingWnd ioMovingBtn ioMovingNormalBtn ioCustomWnd".toLowerCase().split(" "));
const GENERIC_TYPES = new Set(["", "normal", "back", "background", "bg"]);
/** An image a client class draws itself (so the generic list skips it). */
const classDrawn = (w, e) => {
if (e.tag !== "Image" || X.child(e, "Area")) return false;
const cls = low(X.attr(w, "ClassName"));
if (ENGINE.has(cls)) return false;
const t = low(X.attr(e, "Type"));
return !GENERIC_TYPES.has(t) && !t.includes("#");
};
const BUTTON_STATES = ["normal", "over", "overadd", "push", "disable", "exdisablenor", "exdisablepush", "back", "normallist", "disablelist", "check"];
/** Draws one window (not its child windows) at r. opts: { state, text, images, preview } */
function window(ctx, doc, w, r, { state = "Normal", text = true, images = true, preview = false } = {}) {
const style = X.child(w, "Style") ? X.text(doc, X.child(w, "Style")) : "";
const inactive = /IWS_INACTIVE/.test(style);
const st = inactive ? "Disable" : state;
const items = X.childrenOf(w, "Imagery").flatMap((im) => im.children);
const cls = X.attr(w, "ClassName") || "";
const isButton = /button|btn/i.test(cls) || items.some((e) => ["over", "push"].includes(low(X.attr(e, "Type"))));
const isCheck = /check/i.test(cls);
const draw = (e) => {
if (e.tag === "DefaultFrameImagery" || e.tag === "FrameImagery") return drawFrameEl(ctx, e, r);
if (e.tag !== "Image") return;
if (classDrawn(w, e)) {
// placed by the class's code: hidden in the preview, faint at the origin while editing
if (preview) return;
ctx.save();
ctx.globalAlpha *= 0.35;
drawImageEl(ctx, e, r);
ctx.restore();
return;
}
drawImageEl(ctx, e, r);
};
ctx.save();
if (inactive) ctx.filter = "grayscale(1)";
if (images) {
// Selected / MultiplyFrm / SelectedTitle frames first (ioWnd::OnRender)
for (const e of items) if (FIRST.includes(low(X.attr(e, "Type")))) draw(e);
if (isButton) {
const of = (t) => items.filter((e) => low(X.attr(e, "Type")) === t);
of("back").forEach(draw);
const s = low(st);
const stateItems = of(s === "disable" ? "disable" : s);
(stateItems.length ? stateItems : of("normal")).forEach(draw);
if (s === "over") of("overadd").forEach((e) => withBlend(ctx, "Add", () => draw(e)));
if (isCheck) of("check").forEach(draw);
// NormalList / DisableList images (the "x" of a close button…) come after the state art
(s === "disable" && of("disablelist").length ? of("disablelist") : of("normallist")).forEach(draw);
for (const e of items) {
const t = low(X.attr(e, "Type"));
if (!BUTTON_STATES.includes(t) && !FIRST.includes(t)) draw(e);
}
} else {
for (const e of items) {
const t = low(X.attr(e, "Type"));
if (FIRST.includes(t) || ["over", "overadd", "push", "disable"].includes(t)) continue;
draw(e);
}
}
drawEx(ctx, doc, w, r, st);
}
if (text) {
drawTitle(ctx, doc, w, r, st);
drawTexts(ctx, doc, w, r);
drawTextBlocks(ctx, doc, w, r);
}
ctx.restore();
}
/** IWS_MODAL: a full-screen BaseImage001/strip_black behind the window (×3 for MODAL_EXTEND). */
function modalBack(ctx, doc, w, screen) {
const style = X.child(w, "Style") ? X.text(doc, X.child(w, "Style")) : "";
if (!/IWS_MODAL/.test(style)) return;
const im = UIRes.imageSync("BaseImage001", "strip_black");
const n = /IWS_MODAL_EXTEND/.test(style) ? 3 : 1;
for (let i = 0; i < n; i++) {
if (im) blit(ctx, im, 0, 0, screen[0], screen[1]);
else {
ctx.fillStyle = "rgba(0,0,0,.5)";
ctx.fillRect(0, 0, screen[0], screen[1]);
}
}
}
/** Every image name a document can draw (to load them before drawing). */
function wanted(doc) {
const out = [];
for (const e of doc.elements) {
const set = X.attr(e, "Imageset"), img = X.attr(e, "Image"), file = X.attr(e, "File");
if ((set && img) || (file && /\.(dds|png|tga|bmp|jpg)$/i.test(file))) out.push([set, img, file]);
const tpl = X.attr(e, "Templete") || X.attr(e, "Template");
if (tpl) for (const p of UIRes.frame(tpl)?.parts || []) out.push([p.set, p.image]);
}
out.push(["BaseImage001", "strip_black"]);
return out;
}
return { window, modalBack, drawFrame, drawText, colorOf, wanted };
})();
window.UIDraw = UIDraw;
const UIProps = (() => {
const X = XmlDoc;
const ENUMS = {
horzalign: ["Left", "Center", "Right", "Stretch", "Tile"],
vertalign: ["Top", "Center", "Bottom", "Stretch", "Tile"],
xalign: ["", "Left", "Center", "Right", "Rate"],
yalign: ["", "Top", "Center", "Bottom", "Rate"],
autoresize: ["True", "False"],
rendertype: ["", "Normal", "Screen", "Gray", "Add", "AddColor", "ColorAlpha", "Lighten", "Darken", "Multiply", "GrayLight", "AddAlpha"],
reverse: ["", "Horz", "Vert", "HorzVert"],
style: ["Normal", "Shadow", "Outline", "Outline_2x", "Outline_full", "Outline_full_2x", "Outline_full_3x", "Outline_full_4x", "Bold", "Bold_shadow", "Bold_outline", "Bold_outline_full", "Bold_outline_full_2x", "Bold_outline_full_3x", "Bold_outline_full_4x"],
disablestyle: ["Normal", "Shadow", "Outline", "Outline_2x", "Outline_full", "Outline_full_2x", "Outline_full_3x", "Bold", "Bold_shadow"],
};
/** An input for an attribute: colours get a picker, known enums a list, the rest text. */
function input(el, name, value, onChange) {
const n = name.toLowerCase();
const enumOk = ENUMS[n] && !(n === "style" && el.tag !== "Format");
if (enumOk) {
const sel = document.createElement("select");
for (const v of new Set([...ENUMS[n], value])) {
const o = document.createElement("option");
o.value = v;
o.textContent = v || "(none)";
o.selected = v === value;
sel.append(o);
}
sel.onchange = () => onChange(sel.value);
return sel;
}
if ((n === "templete" || n === "template") && UIRes.frameNames().length) return withList(value, onChange, "wnd-dl-frames", UIRes.frameNames());
if (n === "imageset" && UIRes.imagesets().length) return withList(value, onChange, "wnd-dl-sets", UIRes.imagesets());
const t = document.createElement("input");
t.type = "text";
t.value = value;
t.spellcheck = false;
t.addEventListener("change", () => onChange(t.value));
t.addEventListener("keydown", (e) => e.key === "Enter" && t.blur());
if (/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value) && /color|^text$|^back$|bkcolor/i.test(name)) {
const pick = document.createElement("input");
pick.type = "color";
pick.value = `#${value.slice(-6)}`;
pick.onchange = () => onChange((value.length === 8 ? value.slice(0, 2) : "") + pick.value.slice(1).toUpperCase());
const wrap = document.createElement("div");
wrap.className = "wnd-color";
wrap.append(pick, t);
return wrap;
}
return t;
}
function withList(value, onChange, id, names) {
if (!document.getElementById(id)) {
const dl = document.createElement("datalist");
dl.id = id;
for (const n of names) {
const o = document.createElement("option");
o.value = n;
dl.append(o);
}
document.body.append(dl);
}
const t = document.createElement("input");
t.type = "text";
t.value = value;
t.spellcheck = false;
t.setAttribute("list", id);
t.addEventListener("change", () => onChange(t.value));
return t;
}
/** The quick form of a window: position, size, alignment. */
function quick(doc, w, { edit }) {
const s = X.child(w, "Size");
const box = document.createElement("div");
box.className = "wnd-quick";
if (!s) return box;
for (const k of ["X", "Y", "Width", "Height", "XAlign", "YAlign"]) {
const lab = document.createElement("label");
lab.className = "fld";
const sp = document.createElement("span");
sp.textContent = k;
lab.append(sp, input(s, k, X.attr(s, k) ?? "", (v) => edit(s, (d, x) => (v === "" ? X.removeAttr(d, x, k) : X.setAttr(d, x, k, v)))));
box.append(lab);
}
return box;
}
return { input, quick };
})();
window.UIProps = UIProps;
+739
View File
@@ -0,0 +1,739 @@
"use strict";
// UI window editor (Wnd tab): the client's window layouts, rs.iop xml/*.xml (<Window> trees).
// Shows the window as the game draws it (frames from xml/defaultuiframe.xml, images from
// xml/uiimageset.xml, titles with their text), lets windows be moved / resized on the canvas and
// every element / attribute be edited in the side panel or as XML source. Edits touch only the
// characters they change (xmldoc.js), so a patch carries just what was edited.
(() => {
const WND = {
src: null,
rows: [],
doc: null,
where: null,
title: "",
dirty: false,
undo: [],
redo: [],
sel: null, // index of the selected <Window> in doc.elements
zoom: 1,
panX: 40,
panY: 40,
screen: [1024, 768],
state: "Normal",
showHidden: true,
showBounds: true,
tex: new Map(),
};
window.WND = WND;
const toggled = (id) => $(id).classList.contains("on");
const X = XmlDoc;
const baseName = (p) => p.replace(/\\/g, "/").split("/").pop();
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));
// =====================================================================
// source + list
// =====================================================================
async function setSource(src, { quiet = false } = {}) {
try {
const d = await call("mdl:describe", src);
WND.src = { ...src, label: d.label };
} catch (err) {
return toast(err.message, true);
}
$("wnd-src-label").textContent = WND.src.kind === "dir" ? "Extracted folder" : WND.src.kind === "packs" ? "Pack folder" : `Packs next to ${WND.src.name}`;
$("wnd-src").title = `Reading from ${WND.src.label}`;
WND.tex.clear();
UIRes.reset();
await loadList();
if (!quiet) toast(`Reading windows from ${WND.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 (xml/, texts), map.iop / map2.iop (textures)" },
{ value: "dir", title: "A folder of extracted files…", sub: "xml/…, 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: "wndsrc", 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 UI files?", ["Window layouts are in rs.iop (xml/…); their pictures in the textures of 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;
}
$("wnd-src").onclick = () => chooseSource();
const list = new VTable($("wnd-list"), [{ key: 1, label: "Window file", width: 220, grow: true, title: true }], { multi: false, id: "wnd" });
list.keyOf = (r) => r[0];
list.onOpen = (r) => openRow(r);
async function loadList() {
if (!WND.src) return;
try {
const rows = await call("xml:list", WND.src);
WND.rows = rows.map(([key, path, pack]) => [key, path.replace(/^.*?xml\//i, ""), pack, path]);
} catch (err) {
toast(err.message, true);
WND.rows = [];
}
filterList();
}
function filterList() {
const q = $("wnd-search").value.trim().toLowerCase();
const rows = WND.rows.filter((r) => !q || r[1].toLowerCase().includes(q));
list.setRows(rows);
$("wnd-count").textContent = rows.length.toLocaleString();
}
$("wnd-search").addEventListener("input", filterList);
async function confirmDiscard() {
if (!WND.dirty) return true;
return confirmBox("Unsaved changes", "This window file 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", WND.src, r[0]);
if (!hit) throw new Error("Not found in the packs any more.");
await openText(toLatin1(hit.bytes), hit.where, r[1]);
} catch (err) {
toast(err.message, true);
}
}
$("wnd-open-file").onclick = async () => {
if (!(await confirmDiscard())) return;
const file = await call("dialog:openXml");
if (!file) return;
try {
const hit = await call("mdl:readFile", file);
await openText(toLatin1(hit.bytes), hit.where, file);
} catch (err) {
toast(err.message, true);
}
};
async function openText(text, where, title) {
const doc = X.parse(text);
if (!doc.root || doc.root.tag !== "Window") throw new Error(`This isn't a window layout (root <${doc.root?.tag || "?"}>).`);
WND.doc = doc;
WND.where = where;
WND.title = title;
WND.dirty = false;
WND.undo = [];
WND.redo = [];
WND.sel = doc.elements.indexOf(doc.root);
UIText.setFile(title);
$("wnd-title").textContent = title;
$("wnd-dirty").hidden = true;
$("wnd-empty").hidden = true;
$("wnd-side").hidden = false;
if (doc.warnings?.length) toast(`Loaded with ${doc.warnings.length} XML quirk(s): ${doc.warnings[0]}`, true);
await UIRes.load(WND.src);
const tx = UIRes.texts();
$("wnd-textinfo").textContent = tx ? `${tx.count.toLocaleString()} UI texts · ${tx.encoding}` : "no text.txt";
$("wnd-textinfo").title = tx ? `STR(n) → ${UIText.key("n")} from ${tx.source}` : "";
await prefetch();
fit();
renderAll();
}
// =====================================================================
// edits (all through the source text, with undo)
// =====================================================================
function selEl() {
return WND.doc?.elements[WND.sel] || null;
}
/** Path of an element (tag + index among same-tag siblings) so it can be found again after a re-parse. */
function pathOf(el) {
const p = [];
for (let e = el; e && e.parent; e = e.parent) p.unshift([e.tag, e.parent.children.filter((c) => c.tag === e.tag).indexOf(e)]);
return p;
}
function byPath(doc, p) {
let e = doc.root;
for (const [tag, i] of p) {
e = e?.children.filter((c) => c.tag === tag)[i];
if (!e) return null;
}
return e;
}
function apply(newSrc, { keep = true } = {}) {
if (newSrc === WND.doc.src) return;
const selPath = keep && selEl() ? pathOf(selEl()) : null;
WND.undo.push(WND.doc.src);
if (WND.undo.length > 300) WND.undo.shift();
WND.redo = [];
reparse(newSrc, selPath);
}
function reparse(src, selPath) {
WND.doc = X.parse(src);
const s = selPath ? byPath(WND.doc, selPath) : null;
WND.sel = WND.doc.elements.indexOf(s || WND.doc.root);
WND.dirty = true;
$("wnd-dirty").hidden = false;
renderAll();
}
function undoRedo(from, to) {
const s = from.pop();
if (s === undefined) return;
const selPath = selEl() ? pathOf(selEl()) : null;
to.push(WND.doc.src);
reparse(s, selPath);
}
$("wnd-undo").onclick = () => undoRedo(WND.undo, WND.redo);
$("wnd-redo").onclick = () => undoRedo(WND.redo, WND.undo);
/** Edits one element: fn(doc, el) → new source. */
const editEl = (el, fn) => apply(fn(WND.doc, el));
async function save(as = false) {
if (!WND.doc) return;
let where = WND.where;
if (as || !where) {
const file = await call("dialog:saveXml", baseName(WND.title || "window.xml"));
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.`, "Save"))) return;
try {
await call("mdl:writeBack", where, fromLatin1(WND.doc.src));
WND.where = where;
WND.dirty = false;
$("wnd-dirty").hidden = true;
toast(`Saved ${where.kind === "pack" ? `${where.entry} into ${baseName(where.file)}` : where.file}.`);
} catch (err) {
toast(err.message, true);
}
}
$("wnd-save").onclick = (e) =>
showMenu(
[
{ label: WND.where?.kind === "pack" ? `Save into ${baseName(WND.where.file)}` : "Save", icon: WND.where?.kind === "pack" ? "box" : "save", hint: "Ctrl+S", disabled: !WND.doc, run: () => save() },
{ label: "Save as file…", icon: "download", disabled: !WND.doc, run: () => save(true) },
"-",
{ label: "Screenshot (PNG)…", icon: "camera", disabled: !WND.doc, run: screenshot },
],
{ anchor: e.currentTarget },
);
// =====================================================================
// layout (window rectangles in screen space)
// =====================================================================
const windows = () => (WND.doc ? WND.doc.elements.filter((e) => e.tag === "Window") : []);
const sizeOf = (w) => X.child(w, "Size");
const styleText = (w) => (X.child(w, "Style") ? X.text(WND.doc, X.child(w, "Style")) : "");
const isHidden = (w) => /IWS_START_HIDE/.test(styleText(w));
/** Screen rectangle of a window: its Size relative to the parent window (with X/Y align). */
function rectOf(w, cache = new Map()) {
if (cache.has(w)) return cache.get(w);
const s = sizeOf(w);
const pw = w.parent && w.parent.tag === "Window" ? w.parent : null;
const pr = pw ? rectOf(pw, cache) : { x: 0, y: 0, w: WND.screen[0], h: WND.screen[1] };
// ioWnd::SetSize: Width/Height "Parent" fill the parent; X/YAlign Center / Right / Bottom
// position against the parent, Rate scales by screen / 800×600
let x = X.num(s, "X"), y = X.num(s, "Y");
const wd = /^parent$/i.test(X.attr(s, "Width") || "") ? pr.w : X.num(s, "Width");
const ht = /^parent$/i.test(X.attr(s, "Height") || "") ? pr.h : X.num(s, "Height");
const xa = (X.attr(s, "XAlign") || "").toLowerCase();
const ya = (X.attr(s, "YAlign") || "").toLowerCase();
if (xa === "center") x = x + (pr.w - wd) / 2;
else if (xa === "right") x = x + pr.w - wd;
else if (xa === "rate") x = x * (WND.screen[0] / 800);
if (ya === "center") y = y + (pr.h - ht) / 2;
else if (ya === "bottom") y = y + pr.h - ht;
else if (ya === "rate") y = y * (WND.screen[1] / 600);
const r = { x: pr.x + x, y: pr.y + y, w: wd, h: ht, lx: x, ly: y };
cache.set(w, r);
return r;
}
// =====================================================================
// drawing (UIRes / UIDraw do the game-specific parts)
// =====================================================================
const canvas = $("wnd-canvas");
const ctx = canvas.getContext("2d");
function resize() {
const r = canvas.parentElement.getBoundingClientRect();
const dpr = devicePixelRatio || 1;
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
canvas.style.width = `${r.width}px`;
canvas.style.height = `${r.height}px`;
draw();
}
new ResizeObserver(resize).observe(canvas.parentElement);
function fit() {
const r = canvas.parentElement.getBoundingClientRect();
const root = WND.doc?.root;
const rr = root ? rectOf(root) : { x: 0, y: 0, w: WND.screen[0], h: WND.screen[1] };
const w = Math.max(rr.w, 50), h = Math.max(rr.h, 50);
WND.zoom = Math.max(0.1, Math.min(4, Math.min((r.width - 60) / w, (r.height - 60) / h)));
WND.panX = (r.width - w * WND.zoom) / 2 - rr.x * WND.zoom;
WND.panY = (r.height - h * WND.zoom) / 2 - rr.y * WND.zoom;
$("wnd-zoom").textContent = `${Math.round(WND.zoom * 100)}%`;
draw();
}
async function prefetch() {
// textures used by the file, so the first draw has them
await Promise.all(UIDraw.wanted(WND.doc).map(([s, i, f]) => UIRes.image(s, i, f)));
}
let drawQueued = false;
function draw() {
if (drawQueued) return;
drawQueued = true;
requestAnimationFrame(() => {
drawQueued = false;
drawNow();
});
}
function drawNow() {
const dpr = devicePixelRatio || 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!WND.doc) return;
ctx.setTransform(dpr * WND.zoom, 0, 0, dpr * WND.zoom, dpr * WND.panX, dpr * WND.panY);
// the game screen: an opaque, scene-like backdrop (Multiply / Add images need something under them)
backdrop(ctx);
ctx.strokeStyle = "rgba(255,255,255,0.12)";
ctx.lineWidth = 1 / WND.zoom;
ctx.strokeRect(0, 0, WND.screen[0], WND.screen[1]);
const cache = new Map();
const preview = toggled("wnd-t-preview");
const showHidden = WND.showHidden && !preview;
if (preview) UIDraw.modalBack(ctx, WND.doc, WND.doc.root, WND.screen);
const drawWin = (w) => {
if (!showHidden && isHidden(w) && w !== WND.doc.root) return;
const r = rectOf(w, cache);
UIDraw.window(ctx, WND.doc, w, r, { state: WND.state, text: toggled("wnd-t-text"), images: toggled("wnd-t-img"), preview });
for (const c of w.children) if (c.tag === "Window") drawWin(c);
};
drawWin(WND.doc.root);
// bounds + selection
const px = 1 / WND.zoom;
if (preview) return;
if (WND.showBounds) {
ctx.lineWidth = px;
for (const w of windows()) {
if (!WND.showHidden && isHidden(w) && w !== WND.doc.root) continue;
const r = rectOf(w, cache);
ctx.strokeStyle = isHidden(w) ? "rgba(150,150,255,.35)" : "rgba(233,180,60,.35)";
ctx.setLineDash(isHidden(w) ? [3 * px, 3 * px] : []);
ctx.strokeRect(r.x + px / 2, r.y + px / 2, Math.max(0, r.w - px), Math.max(0, r.h - px));
}
ctx.setLineDash([]);
}
const s = selWindow();
if (s) {
const r = rectOf(s, cache);
ctx.lineWidth = 2 * px;
ctx.strokeStyle = "#f4c95a";
ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.fillStyle = "#f4c95a";
for (const [hx, hy] of handles(r)) ctx.fillRect(hx - 3.5 * px, hy - 3.5 * px, 7 * px, 7 * px);
ctx.font = `${11 * px}px ${getComputedStyle(document.body).fontFamily}`;
const label = `${X.attr(s, "ClassName") || "Window"} #${X.attr(s, "Id") ?? ""} ${Math.round(r.lx)},${Math.round(r.ly)} ${r.w}×${r.h}`;
const tw = ctx.measureText(label).width + 8 * px;
ctx.fillStyle = "rgba(20,19,24,.9)";
ctx.fillRect(r.x, r.y - 16 * px, tw, 15 * px);
ctx.fillStyle = "#f4c95a";
ctx.fillText(label, r.x + 4 * px, r.y - 5 * px);
}
}
function backdrop(g) {
const grad = g.createLinearGradient(0, 0, 0, WND.screen[1]);
grad.addColorStop(0, "#3b4a5e");
grad.addColorStop(1, "#1b222c");
g.fillStyle = grad;
g.fillRect(0, 0, WND.screen[0], WND.screen[1]);
}
const handles = (r) => [
[r.x, r.y, "nw"], [r.x + r.w / 2, r.y, "n"], [r.x + r.w, r.y, "ne"], [r.x + r.w, r.y + r.h / 2, "e"],
[r.x + r.w, r.y + r.h, "se"], [r.x + r.w / 2, r.y + r.h, "s"], [r.x, r.y + r.h, "sw"], [r.x, r.y + r.h / 2, "w"],
];
const CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
function selWindow() {
let e = selEl();
while (e && e.tag !== "Window") e = e.parent;
return e;
}
// ---------- mouse: select, move, resize, pan, zoom ----------
const toScreen = (e) => {
const b = canvas.getBoundingClientRect();
return { x: (e.clientX - b.left - WND.panX) / WND.zoom, y: (e.clientY - b.top - WND.panY) / WND.zoom };
};
function hitWindow(p) {
const cache = new Map();
let best = null;
let area = Infinity;
for (const w of windows()) {
if (!WND.showHidden && isHidden(w) && w !== WND.doc.root) continue;
const r = rectOf(w, cache);
if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h && r.w * r.h <= area) {
best = w;
area = r.w * r.h;
}
}
return best;
}
function hitHandle(p) {
const s = selWindow();
if (!s) return null;
const r = rectOf(s);
const tol = 6 / WND.zoom;
return handles(r).find(([hx, hy]) => Math.abs(p.x - hx) <= tol && Math.abs(p.y - hy) <= tol)?.[2] || null;
}
let drag = null;
canvas.addEventListener("mousedown", (e) => {
if (!WND.doc) return;
canvas.focus();
const p = toScreen(e);
if (e.button !== 0 || e.altKey) {
drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: WND.panX, py: WND.panY };
return e.preventDefault();
}
const h = hitHandle(p);
const s = selWindow();
if (h && s) {
drag = { mode: "resize", h, start: p, w: s, s: sizeRect(s), before: WND.doc.src, path: pathOf(s) };
} else {
const w = hitWindow(p);
if (w) {
select(w);
drag = { mode: "move", start: p, w, s: sizeRect(w), before: WND.doc.src, path: pathOf(w), moved: false };
} else drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: WND.panX, py: WND.panY };
}
e.preventDefault();
});
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
const sizeRect = (w) => {
const s = sizeOf(w);
return { x: X.num(s, "X"), y: X.num(s, "Y"), w: X.num(s, "Width"), h: X.num(s, "Height"), xa: (X.attr(s, "XAlign") || "").toLowerCase(), ya: (X.attr(s, "YAlign") || "").toLowerCase() };
};
window.addEventListener("mousemove", (e) => {
if (!$("view-wnd").classList.contains("on") || !WND.doc) return;
if (!drag) {
if (e.target !== canvas) return;
const p = toScreen(e);
const h = hitHandle(p);
canvas.style.cursor = h ? CURSOR[h] : hitWindow(p) ? "move" : "grab";
$("wnd-hover").textContent = `${Math.floor(p.x)}, ${Math.floor(p.y)}`;
return;
}
if (drag.mode === "pan") {
WND.panX = drag.px + e.clientX - drag.sx;
WND.panY = drag.py + e.clientY - drag.sy;
return draw();
}
const p = toScreen(e);
const dx = Math.round(p.x - drag.start.x);
const dy = Math.round(p.y - drag.start.y);
const s0 = drag.s;
const sx = s0.xa === "rate" ? 800 / WND.screen[0] : 1;
const sy = s0.ya === "rate" ? 600 / WND.screen[1] : 1;
let { x, y, w, h } = s0;
if (drag.mode === "move") {
x += dx * sx;
y += dy * sy;
} else {
if (drag.h.includes("e")) w = Math.max(0, s0.w + dx);
if (drag.h.includes("s")) h = Math.max(0, s0.h + dy);
if (drag.h.includes("w")) {
w = Math.max(0, s0.w - dx);
x = s0.x + dx * sx * (s0.xa === "center" ? 0.5 : 1);
}
if (drag.h.includes("n")) {
h = Math.max(0, s0.h - dy);
y = s0.y + dy * sy * (s0.ya === "center" ? 0.5 : 1);
}
if (s0.xa === "center" && drag.h.includes("e")) x = s0.x + dx / 2;
if (s0.ya === "center" && drag.h.includes("s")) y = s0.y + dy / 2;
}
// live: write straight into the source (undo is added on mouse-up)
const doc = X.parse(drag.before);
const win = byPath(doc, drag.path);
let src = drag.before;
const set = (k, v) => {
const d = X.parse(src);
src = X.setAttr(d, X.child(byPath(d, drag.path), "Size"), k, String(Math.round(v)));
};
if (win && X.child(win, "Size")) {
if (Math.round(x) !== s0.x || drag.mode === "resize") set("X", x);
if (Math.round(y) !== s0.y || drag.mode === "resize") set("Y", y);
if (drag.mode === "resize") {
set("Width", w);
set("Height", h);
}
drag.moved = true;
WND.doc = X.parse(src);
WND.sel = WND.doc.elements.indexOf(byPath(WND.doc, drag.path));
draw();
$("wnd-hover").textContent = `X ${Math.round(x)} Y ${Math.round(y)} ${Math.round(w)} × ${Math.round(h)}`;
}
});
window.addEventListener("mouseup", () => {
if (!drag) return;
const d = drag;
drag = null;
if ((d.mode === "move" || d.mode === "resize") && d.moved && WND.doc.src !== d.before) {
WND.undo.push(d.before);
WND.redo = [];
WND.dirty = true;
$("wnd-dirty").hidden = false;
renderSide();
}
canvas.style.cursor = "";
});
canvas.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const b = canvas.getBoundingClientRect();
const cx = e.clientX - b.left, cy = e.clientY - b.top;
const nz = Math.max(0.1, Math.min(8, WND.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
WND.panX = cx - ((cx - WND.panX) * nz) / WND.zoom;
WND.panY = cy - ((cy - WND.panY) * nz) / WND.zoom;
WND.zoom = nz;
$("wnd-zoom").textContent = `${Math.round(nz * 100)}%`;
draw();
},
{ passive: false },
);
$("wnd-fit").onclick = fit;
$("wnd-zoom").onclick = () => {
WND.zoom = 1;
$("wnd-zoom").textContent = "100%";
draw();
};
$("wnd-screen").onchange = () => {
WND.screen = $("wnd-screen").value.split("x").map(Number);
fit();
};
$("wnd-state").onchange = () => {
WND.state = $("wnd-state").value;
draw();
};
for (const id of ["wnd-t-text", "wnd-t-img"]) $(id).onclick = () => ($(id).classList.toggle("on"), draw());
$("wnd-t-preview").onclick = () => {
$("wnd-t-preview").classList.toggle("on");
draw();
};
$("wnd-t-bounds").onclick = () => {
WND.showBounds = !WND.showBounds;
$("wnd-t-bounds").classList.toggle("on", WND.showBounds);
draw();
};
$("wnd-t-hidden").onclick = () => {
WND.showHidden = !WND.showHidden;
$("wnd-t-hidden").classList.toggle("on", WND.showHidden);
draw();
renderTree();
};
async function screenshot() {
const file = await call("dialog:savePng", `${baseName(WND.title).replace(/\.xml$/i, "")}.png`);
if (!file) return;
const root = rectOf(WND.doc.root);
const c = document.createElement("canvas");
c.width = Math.max(1, root.w);
c.height = Math.max(1, root.h);
const g = c.getContext("2d");
g.translate(-root.x, -root.y);
backdrop(g);
const cache = new Map();
const drawWin = (w) => {
if (isHidden(w) && w !== WND.doc.root) return; // the screenshot shows what the game shows
UIDraw.window(g, WND.doc, w, rectOf(w, cache), { state: WND.state, text: true, images: true, preview: true });
for (const ch of w.children) if (ch.tag === "Window") drawWin(ch);
};
drawWin(WND.doc.root);
await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]);
toast(`Saved ${file}.`);
}
// =====================================================================
// side panel: window tree, properties, source
// =====================================================================
function select(el) {
WND.sel = WND.doc.elements.indexOf(el);
renderSide();
draw();
}
function renderAll() {
renderSide();
draw();
$("wnd-undo").disabled = !WND.undo.length;
$("wnd-redo").disabled = !WND.redo.length;
}
function renderSide() {
renderTree();
renderProps();
$("wnd-undo").disabled = !WND.undo.length;
$("wnd-redo").disabled = !WND.redo.length;
}
function renderTree() {
const out = [];
const sw = selWindow();
const walk = (w, depth) => {
const hidden = isHidden(w);
if (!WND.showHidden && hidden && w !== WND.doc.root) return;
const title = UIText.titleOf(WND.doc, w);
out.push(
el("div", { class: `wnd-node${w === sw ? " on" : ""}${hidden ? " hid" : ""}`, style: `padding-left:${8 + depth * 14}px`, onclick: () => select(w) }, [
el("b", { text: X.attr(w, "ClassName") || "Window" }),
el("em", { text: `#${X.attr(w, "Id") ?? ""}${title ? ` ${title}` : ""}` }),
]),
);
for (const c of w.children) if (c.tag === "Window") walk(c, depth + 1);
};
if (WND.doc) walk(WND.doc.root, 0);
$("wnd-tree").replaceChildren(...out);
$("wnd-tree").querySelector(".on")?.scrollIntoView({ block: "nearest" });
$("wnd-count-win").textContent = String(windows().length);
}
/** One element as an editable card: its attributes, text, child elements (not child windows). */
function elementCard(e, depth = 0) {
const doc = WND.doc;
const rows = [];
for (const a of e.attrs) {
const input = UIProps.input(e, a.name, a.value, (v) => editEl(e, (d, x) => X.setAttr(d, byPath(d, pathOf(x)) || x, a.name, v)));
rows.push(
el("div", { class: "map-kv" }, [
el("span", { class: "k", text: a.name }),
input,
el("button", { class: "iconbtn only", title: "Remove this attribute", onclick: () => editEl(e, (d, x) => X.removeAttr(d, x, a.name)) }, [icon("minus")]),
]),
);
}
if (!e.children.length && (e.textStart !== undefined || ["Style", "ExStyle", "Text"].includes(e.tag))) {
const t = X.text(doc, e);
const input = el("input", { type: "text", value: t.trim(), spellcheck: "false" });
input.addEventListener("change", () => editEl(e, (d, x) => X.setText(d, x, input.value)));
const resolved = e.tag === "Text" ? UIText.resolve(doc, e, t) : "";
rows.push(el("div", { class: "map-kv" }, [el("span", { class: "k", text: "(text)" }), input, el("span")]));
if (resolved && resolved !== t.trim()) rows.push(el("div", { class: "wnd-resolved", text: `→ ${resolved}` }));
}
// add attribute
const nk = el("input", { type: "text", placeholder: "attribute", spellcheck: "false" });
const nv = el("input", { type: "text", placeholder: "value", spellcheck: "false" });
const add = () => nk.value.trim() && editEl(e, (d, x) => X.setAttr(d, x, nk.value.trim(), nv.value));
nv.addEventListener("keydown", (ev) => ev.key === "Enter" && add());
const kids = e.children.filter((c) => c.tag !== "Window");
const head = el("div", { class: "wnd-el-head" }, [
el("b", { text: `<${e.tag}>` }),
el("span", { class: "spacer" }),
el("button", { class: "iconbtn only", title: "Copy this element", onclick: () => editEl(e, (d, x) => X.insertChild(d, x.parent, X.markupOf(d, x), x)) }, [icon("copy")]),
el("button", { class: "iconbtn only danger", title: "Remove this element", onclick: () => editEl(e, (d, x) => X.removeElement(d, x)) }, [icon("trash")]),
]);
return el("div", { class: `wnd-el depth${Math.min(depth, 3)}` }, [head, ...rows, el("div", { class: "map-kv add" }, [nk, nv, el("button", { class: "iconbtn only", title: "Add attribute", onclick: add }, [icon("plus")])]), ...kids.map((k) => elementCard(k, depth + 1))]);
}
function renderProps() {
const w = selWindow();
$("wnd-props-card").hidden = !w;
if (!w) return;
$("wnd-props-name").textContent = `${X.attr(w, "ClassName") || "Window"} #${X.attr(w, "Id") ?? ""}`;
const doc = WND.doc;
const quick = UIProps.quick(doc, w, { edit: editEl, pathOf, byPath });
const tools = el("div", { class: "map-sec-tools" }, [
el("button", { class: "iconbtn", title: "Add a child window", onclick: () => addChild(w) }, [icon("plus"), el("span", { text: "Child" })]),
el("button", { class: "iconbtn", title: "Duplicate this window", disabled: w === doc.root, onclick: () => editEl(w, (d, x) => X.insertChild(d, x.parent, X.markupOf(d, x), x)) }, [icon("copy"), el("span", { text: "Copy" })]),
el("button", { class: "iconbtn", title: "Add an element (Title, Imagery, ExtraInfo…)", onclick: (ev) => addElementMenu(ev, w) }, [icon("fileplus"), el("span", { text: "Element" })]),
el("span", { class: "spacer" }),
el("button", { class: "iconbtn only", title: "Edit this window as XML", onclick: () => editSource(w) }, [icon("note")]),
el("button", { class: "iconbtn only danger", title: "Delete this window", disabled: w === doc.root, onclick: () => editEl(w, (d, x) => X.removeElement(d, x)) }, [icon("trash")]),
]);
$("wnd-props").replaceChildren(tools, quick, el("h5", { text: "Elements" }), ...w.children.filter((c) => c.tag !== "Window").map((c) => elementCard(c)));
}
function addChild(w) {
const ids = w.children.filter((c) => c.tag === "Window").map((c) => Number(X.attr(c, "Id")) || 0);
const id = (ids.length ? Math.max(...ids) : 0) + 1;
editEl(w, (d, x) => X.insertChild(d, x, `<Window ClassName="ioWnd" Id="${id}">\n\t<Size X="10" Y="10" Width="100" Height="30" />\n</Window>`));
}
function addElementMenu(ev, w) {
const snippets = {
Title: `<Title>\n\t<Format X="0" Y="0" Size="12" Style="Normal" HorzAlign="Left"/>\n\t<Color Type="Normal" Text="FFFFFFFF" Back="FF000000"/>\n\t<Text>Text</Text>\n</Title>`,
"Imagery (image)": `<Imagery>\n\t<Image Type="Normal" Imageset="BaseImage001" Image="none"/>\n</Imagery>`,
"Imagery (frame)": `<Imagery>\n\t<DefaultFrameImagery Type="Normal" Templete="slot_empty_white">\n\t\t<Area X="0" Y="0" Width="100" Height="30"/>\n\t</DefaultFrameImagery>\n</Imagery>`,
Style: `<Style>IWS_PROCESS</Style>`,
ExStyle: `<ExStyle>IWS_EX_PUSH_DOUBLE</ExStyle>`,
ExtraInfo: `<ExtraInfo />`,
};
showMenu(Object.entries(snippets).map(([label, s]) => ({ label, icon: "plus", run: () => editEl(w, (d, x) => X.insertChild(d, x, s)) })), { x: ev.clientX, y: ev.clientY });
}
async function editSource(w) {
const ta = el("textarea", { class: "mdl-mat", spellcheck: "false" });
ta.value = cp949(X.markupOf(WND.doc, w));
const ok = await modal(`XML — ${X.attr(w, "ClassName") || "Window"} #${X.attr(w, "Id") ?? ""}`, [el("p", { class: "muted", text: "The window and its children as XML. Korean text is shown decoded and written back as CP949." }), ta], [{ label: "Cancel", value: false }, { label: "Apply", kind: "primary", value: true }]);
if (!ok) return;
let bytes;
try {
bytes = UIText.encodeCp949(ta.value);
} catch (err) {
return toast(err.message, true);
}
const markup = toLatin1(bytes);
try {
X.parse(markup);
} catch (err) {
return toast(`Not valid XML: ${err.message}`, true);
}
editEl(w, (d, x) => d.src.slice(0, x.start) + markup + d.src.slice(x.end));
}
// keys
document.addEventListener("keydown", (e) => {
if (!$("view-wnd").classList.contains("on") || !WND.doc) return;
if (e.target.matches?.("input, textarea, select")) return;
const k = e.key.toLowerCase();
if (e.ctrlKey && k === "z") return e.preventDefault(), undoRedo(WND.undo, WND.redo);
if (e.ctrlKey && k === "y") return e.preventDefault(), undoRedo(WND.redo, WND.undo);
if (e.ctrlKey && k === "s") return e.preventDefault(), save();
const w = selWindow();
const mv = { arrowleft: [-1, 0], arrowright: [1, 0], arrowup: [0, -1], arrowdown: [0, 1] }[k];
if (w && mv && sizeOf(w)) {
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const s = sizeRect(w);
const sx = 1, sy = 1;
let src = WND.doc.src;
let d = WND.doc;
if (mv[0]) src = X.setAttr(d, sizeOf(w), "X", String(s.x + mv[0] * step * sx));
d = X.parse(src);
if (mv[1]) src = X.setAttr(d, X.child(byPath(d, pathOf(w)), "Size"), "Y", String(s.y + mv[1] * step * sy));
apply(src);
}
if (e.key === "Delete" && w && w !== WND.doc.root) editEl(w, (d, x) => X.removeElement(d, x));
});
document.querySelector('[data-view="wnd"]').addEventListener("click", async () => {
resize();
if (WND.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(WND, { setSource, openRow, openText, draw: drawNow, select, rectOf, windows, save, apply });
})();
+283
View File
@@ -0,0 +1,283 @@
"use strict";
// Virtual table: only the visible rows exist in the DOM, so 50 000+ pack entries scroll smoothly.
// columns: [{ key, label, width (px), grow, cls, format(row) → text|Node, sort(row) → value }]
// Column widths can be dragged at the header's right edge; the `grow` column takes the free width.
// The table scrolls sideways when the columns are wider than the view.
class VTable {
constructor(host, columns, { rowHeight = 28, multi = true, id = null } = {}) {
this.host = host;
this.columns = columns;
this.rh = rowHeight;
this.multi = multi;
this.id = id; // remembers column widths per table
this.rows = [];
this.view = [];
this.sel = new Set();
this.anchor = -1;
this.sortKey = null;
this.sortDesc = false;
this.sortFirst = null; // (row) → rank, applied before the column sort (folders first)
this.onSelect = () => {};
this.onOpen = () => {};
this.keyOf = (r) => r.key ?? r[0];
this.loadWidths();
host.innerHTML = "";
host.classList.add("vtable");
this.head = document.createElement("div");
this.head.className = "vt-head";
this.body = document.createElement("div");
this.body.className = "vt-rows";
host.append(this.head, this.body);
for (const c of columns) {
const h = document.createElement("div");
h.className = "vt-h";
const label = document.createElement("span");
label.textContent = c.label;
h.append(label);
if (c.cls?.includes("num")) h.style.justifyContent = "flex-end";
h.onclick = (e) => !e.target.classList.contains("vt-resize") && this.sortBy(c.key);
const grip = document.createElement("i");
grip.className = "vt-resize";
grip.onmousedown = (e) => this.startResize(e, c);
grip.ondblclick = () => this.autoFit(c);
h.append(grip);
c.headEl = h;
this.head.append(h);
}
host.addEventListener("scroll", () => this.draw());
// Double-click is caught here: rows are re-created while scrolling, so a per-row handler
// could miss the second click
host.addEventListener("dblclick", (e) => {
const row = e.target.closest(".vt-row");
if (row) this.onOpen(this.view[Number(row.dataset.i)]);
});
new ResizeObserver(() => this.layout()).observe(host);
host.tabIndex = 0;
host.addEventListener("keydown", (e) => this.key(e));
this.layout();
}
// ---------- column widths ----------
loadWidths() {
if (!this.id) return;
try {
const saved = JSON.parse(localStorage.getItem(`vt:${this.id}`) || "{}");
for (const c of this.columns) if (saved[c.key] > 20) c.width = saved[c.key];
} catch {}
}
saveWidths() {
if (!this.id) return;
try {
localStorage.setItem(`vt:${this.id}`, JSON.stringify(Object.fromEntries(this.columns.map((c) => [c.key, c.width]))));
} catch {}
}
/** Column widths in px, the grow column stretched to fill the view. */
widths() {
const base = this.columns.map((c) => c.width);
const total = base.reduce((a, b) => a + b, 0);
const free = Math.max(0, this.host.clientWidth - total);
const g = this.columns.findIndex((c) => c.grow);
if (g >= 0) base[g] += free;
return base;
}
layout() {
const w = this.widths();
this.totalWidth = w.reduce((a, b) => a + b, 0);
this.head.style.width = this.body.style.width = `${this.totalWidth}px`;
this.columns.forEach((c, i) => (c.headEl.style.width = `${w[i]}px`));
this.draw();
}
startResize(e, col) {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const start = col.width;
document.body.classList.add("col-resizing");
const move = (ev) => {
col.width = Math.max(40, Math.round(start + ev.clientX - startX));
this.layout();
};
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
document.body.classList.remove("col-resizing");
this.saveWidths();
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
}
/** Double-click on a column edge: fit the widest visible value. */
autoFit(col) {
const probe = document.createElement("span");
probe.className = "vt-measure";
document.body.append(probe);
let max = col.label.length * 8 + 30;
for (const r of this.view.slice(0, 2000)) {
const v = col.format ? col.format(r) : r[col.key];
probe.textContent = v instanceof Node ? v.textContent : String(v ?? "");
max = Math.max(max, probe.offsetWidth + 34);
}
probe.remove();
col.width = Math.min(1200, max);
this.layout();
this.saveWidths();
}
// ---------- data ----------
setRows(rows, { keepSelection = false, animate = null } = {}) {
this.rows = rows;
if (!keepSelection) {
this.sel.clear();
this.anchor = -1;
} else {
const keys = new Set(rows.map(this.keyOf));
for (const k of [...this.sel]) if (!keys.has(k)) this.sel.delete(k);
}
if (animate) {
this.host.scrollTop = 0;
this.body.classList.remove("in-left", "in-right", "in-fade");
void this.body.offsetWidth;
this.body.classList.add(`in-${animate}`);
}
this.applySort();
}
setView(filterFn) {
this.filter = filterFn;
this.applySort();
}
sortBy(key) {
if (this.sortKey === key) this.sortDesc = !this.sortDesc;
else {
this.sortKey = key;
this.sortDesc = false;
}
for (const c of this.columns) {
c.headEl.classList.toggle("sorted", c.key === this.sortKey);
c.headEl.classList.toggle("desc", c.key === this.sortKey && this.sortDesc);
}
this.applySort();
}
applySort() {
const v = this.filter ? this.rows.filter(this.filter) : this.rows.slice();
const col = this.columns.find((c) => c.key === this.sortKey);
const first = this.sortFirst;
if (col || first) {
const get = col ? col.sort || ((r) => r[col.key]) : null;
v.sort((a, b) => {
if (first) {
const f = first(a) - first(b);
if (f) return f;
}
if (!get) return 0;
const x = get(a);
const y = get(b);
const d = typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y));
return this.sortDesc ? -d : d;
});
}
this.view = v;
this.body.style.height = `${v.length * this.rh}px`;
this.draw();
}
draw() {
if (!this.totalWidth) return;
const w = this.widths();
const top = this.host.scrollTop;
const height = this.host.clientHeight;
const first = Math.max(0, Math.floor(top / this.rh) - 8);
const last = Math.min(this.view.length, Math.ceil((top + height) / this.rh) + 8);
const frag = document.createDocumentFragment();
for (let i = first; i < last; i++) {
const r = this.view[i];
const row = document.createElement("div");
row.className = "vt-row" + (this.sel.has(this.keyOf(r)) ? " sel" : "") + (this.rowClass ? ` ${this.rowClass(r)}` : "");
row.style.top = `${i * this.rh}px`;
row.style.width = `${this.totalWidth}px`;
row.dataset.i = i;
this.columns.forEach((c, ci) => {
const cell = document.createElement("div");
cell.style.width = `${w[ci]}px`;
if (c.cls) cell.className = c.cls;
const v = c.format ? c.format(r) : r[c.key];
if (v instanceof Node) cell.append(v);
else {
cell.textContent = v ?? "";
if (c.title) cell.title = cell.textContent;
}
row.append(cell);
});
row.onmousedown = (e) => {
if (e.button !== 0) return;
// e.g. a folder: one click opens it (Ctrl/Shift still select)
if (!e.ctrlKey && !e.shiftKey && this.onActivate?.(r, e)) return;
this.click(i, e);
};
frag.append(row);
}
this.body.replaceChildren(frag);
}
// ---------- selection / keys ----------
click(i, e) {
const key = this.keyOf(this.view[i]);
if (this.multi && e.shiftKey && this.anchor >= 0) {
if (!e.ctrlKey) this.sel.clear();
const [a, b] = [Math.min(this.anchor, i), Math.max(this.anchor, i)];
for (let k = a; k <= b; k++) this.sel.add(this.keyOf(this.view[k]));
} else if (this.multi && e.ctrlKey) {
if (this.sel.has(key)) this.sel.delete(key);
else this.sel.add(key);
this.anchor = i;
} else {
this.sel.clear();
this.sel.add(key);
this.anchor = i;
}
this.markSelection();
this.onSelect(this.selected(), this.view[i]);
}
/** Updates the highlight on the rows already on screen (keeps the elements, so double-click works). */
markSelection() {
for (const row of this.body.children) row.classList.toggle("sel", this.sel.has(this.keyOf(this.view[Number(row.dataset.i)])));
}
key(e) {
if (e.ctrlKey && e.key.toLowerCase() === "a" && this.multi) {
e.preventDefault();
for (const r of this.view) this.sel.add(this.keyOf(r));
this.markSelection();
this.onSelect(this.selected());
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!this.view.length) return;
const i = Math.max(0, Math.min(this.view.length - 1, this.anchor + (e.key === "ArrowDown" ? 1 : -1)));
this.click(i, { shiftKey: e.shiftKey, ctrlKey: false });
const y = i * this.rh;
if (y < this.host.scrollTop + 30) this.host.scrollTop = y - 30;
else if (y > this.host.scrollTop + this.host.clientHeight - this.rh * 2) this.host.scrollTop = y - this.host.clientHeight + this.rh * 2;
} else if (e.key === "Enter" && this.anchor >= 0) this.onOpen(this.view[this.anchor]);
else if (e.key === "Backspace") this.onBack?.();
}
selected() {
return this.rows.filter((r) => this.sel.has(this.keyOf(r)));
}
clearSelection() {
this.sel.clear();
this.draw();
}
}
window.VTable = VTable;
+227
View File
@@ -0,0 +1,227 @@
"use strict";
// Source-preserving XML: parses into elements that remember where every attribute value and text
// sits in the original string, so edits replace just those characters (comments, spacing, line
// breaks, CP949 bytes — the text travels as latin1 — all stay as they were). After an edit the
// document is re-parsed (UI files are small).
const XmlDoc = (() => {
const isName = (c) => /[A-Za-z0-9_:.\-]/.test(c);
/** → { src, root, elements } ; element = { tag, attrs: [{ name, value, vs, ve, ns }], start, openEnd, selfClose, end, children, parent, textStart, textEnd } */
function parse(src) {
const els = [];
const stack = [];
const warnings = [];
let root = null;
let i = 0;
const n = src.length;
const err = (msg) => {
const line = src.slice(0, i).split("\n").length;
throw new Error(`XML line ${line}: ${msg}`);
};
while (i < n) {
const lt = src.indexOf("<", i);
if (lt < 0) break;
const top = stack[stack.length - 1];
if (top && top.textStart === undefined && lt > i) {
// text between tags (only kept for leaf elements such as <Style>, <Text>)
top._text = (top._text || []).concat([[i, lt]]);
}
i = lt;
if (src.startsWith("<!--", i)) {
const e = src.indexOf("-->", i + 4);
i = e < 0 ? n : e + 3;
continue;
}
if (src.startsWith("<?", i)) {
const e = src.indexOf("?>", i + 2);
i = e < 0 ? n : e + 2;
continue;
}
if (src.startsWith("<![CDATA[", i)) {
const e = src.indexOf("]]>", i);
i = e < 0 ? n : e + 3;
continue;
}
if (src.startsWith("<!", i)) {
const e = src.indexOf(">", i);
i = e < 0 ? n : e + 1;
continue;
}
if (src[i + 1] === "/") {
// closing tag
let j = i + 2;
while (j < n && isName(src[j])) j++;
const tag = src.slice(i + 2, j);
const gt = src.indexOf(">", j);
if (gt < 0) err("unclosed end tag");
// a stray / mismatched end tag: close up to the matching element, or ignore it
const at = stack.map((e) => e.tag.toLowerCase()).lastIndexOf(tag.toLowerCase());
if (at < 0) {
warnings.push(`line ${src.slice(0, i).split("\n").length}: stray </${tag}> ignored`);
i = gt + 1;
continue;
}
while (stack.length - 1 > at) {
const open = stack.pop();
delete open._text;
open.closeStart = i;
open.end = i;
open.unclosed = true;
warnings.push(`<${open.tag}> closed by </${tag}>`);
}
const el = stack.pop();
if (!el.children.length && el._text) {
el.textStart = el._text[0][0];
el.textEnd = el._text[el._text.length - 1][1];
}
delete el._text;
el.closeStart = i;
el.end = gt + 1;
i = gt + 1;
continue;
}
// opening tag
let j = i + 1;
while (j < n && isName(src[j])) j++;
const el = { tag: src.slice(i + 1, j), attrs: [], start: i, children: [], parent: stack[stack.length - 1] || null };
for (;;) {
while (j < n && /\s/.test(src[j])) j++;
if (src[j] === "/" && src[j + 1] === ">") {
el.selfClose = true;
el.openEnd = j;
el.end = j + 2;
j += 2;
break;
}
if (src[j] === ">") {
el.openEnd = j;
j += 1;
break;
}
const ns = j;
while (j < n && isName(src[j])) j++;
if (j === ns) {
warnings.push(`line ${src.slice(0, j).split("\n").length}: stray “${src[j]}” in <${el.tag}>`);
j++;
continue;
}
const name = src.slice(ns, j);
while (j < n && /\s/.test(src[j])) j++;
if (src[j] !== "=") {
el.attrs.push({ name, value: "", vs: j, ve: j, ns, bare: true });
continue;
}
j++;
while (j < n && /\s/.test(src[j])) j++;
const q = src[j];
if (q !== '"' && q !== "'") {
// unquoted value (a few game files have them; the client's parser takes them)
const vs = j;
while (j < n && !/[\s>]/.test(src[j]) && !(src[j] === "/" && src[j + 1] === ">")) j++;
el.attrs.push({ name, value: src.slice(vs, j), vs, ve: j, ns, unquoted: true });
continue;
}
const ve = src.indexOf(q, j + 1);
if (ve < 0) err(`unclosed attribute ${name}`);
el.attrs.push({ name, value: src.slice(j + 1, ve), vs: j + 1, ve, ns });
j = ve + 1;
}
els.push(el);
if (el.parent) el.parent.children.push(el);
else if (!root) root = el;
if (!el.selfClose) stack.push(el);
i = j;
}
// unclosed elements at the end (the client tolerates them): closed at the end of the file
const unclosed = stack.map((e) => e.tag);
while (stack.length) {
const el = stack.pop();
delete el._text;
el.closeStart = n;
el.end = n;
el.unclosed = true;
}
if (unclosed.length) warnings.push(`never closed: <${unclosed.join("> <")}>`);
return { src, root, elements: els, warnings };
}
const attr = (el, name) => el?.attrs.find((a) => a.name.toLowerCase() === name.toLowerCase())?.value;
const num = (el, name, d = 0) => {
const v = parseFloat(attr(el, name));
return isFinite(v) ? v : d;
};
const text = (doc, el) => (el && el.textStart !== undefined ? doc.src.slice(el.textStart, el.textEnd) : "");
const child = (el, tag) => el?.children.find((c) => c.tag.toLowerCase() === tag.toLowerCase()) || null;
const childrenOf = (el, tag) => (el ? el.children.filter((c) => c.tag.toLowerCase() === tag.toLowerCase()) : []);
const escAttr = (v) => String(v).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
/** The edits: each returns the new source string. */
function setAttr(doc, el, name, value) {
const a = el.attrs.find((x) => x.name.toLowerCase() === name.toLowerCase());
const v = escAttr(value);
if (a && a.unquoted) return doc.src.slice(0, a.vs) + `"${v}"` + doc.src.slice(a.ve);
if (a && !a.bare) return doc.src.slice(0, a.vs) + v + doc.src.slice(a.ve);
const at = el.openEnd;
const before = doc.src.slice(0, at).replace(/\s*$/, "");
const cut = before.length;
return doc.src.slice(0, cut) + ` ${name}="${v}"` + (el.selfClose ? " " : "") + doc.src.slice(el.selfClose ? at : at);
}
function removeAttr(doc, el, name) {
const a = el.attrs.find((x) => x.name.toLowerCase() === name.toLowerCase());
if (!a) return doc.src;
let s = a.ns;
while (s > 0 && /[ \t]/.test(doc.src[s - 1])) s--;
const e = a.bare || a.unquoted ? a.ve : a.ve + 1;
return doc.src.slice(0, s) + doc.src.slice(e);
}
function setText(doc, el, value) {
if (el.textStart !== undefined) return doc.src.slice(0, el.textStart) + value + doc.src.slice(el.textEnd);
if (el.selfClose) return doc.src.slice(0, el.openEnd).replace(/\s*$/, "") + `>${value}</${el.tag}>` + doc.src.slice(el.end);
return doc.src.slice(0, el.openEnd + 1) + value + doc.src.slice(el.openEnd + 1);
}
/** Line break + indentation used around an element. */
function indentOf(doc, el) {
const ls = doc.src.lastIndexOf("\n", el.start - 1);
const ws = doc.src.slice(ls + 1, el.start);
return /^[ \t]*$/.test(ws) ? ws : "";
}
const eolOf = (doc) => (doc.src.includes("\r\n") ? "\r\n" : "\n");
/** Inserts markup as the last child of `parent` (or after `after`). */
function insertChild(doc, parent, markup, after = null) {
const eol = eolOf(doc);
const base = indentOf(doc, parent);
const step = parent.children[0] ? indentOf(doc, parent.children[0]).slice(base.length) || "\t" : "\t";
const ind = base + step;
const body = markup.split(/\r?\n/).map((l, k) => (k ? ind + l : l)).join(eol);
if (after) return doc.src.slice(0, after.end) + eol + ind + body + doc.src.slice(after.end);
if (parent.selfClose) {
const open = doc.src.slice(parent.start, parent.openEnd).replace(/\s*$/, "");
return doc.src.slice(0, parent.start) + open + ">" + eol + ind + body + eol + base + `</${parent.tag}>` + doc.src.slice(parent.end);
}
// before the closing tag, on its own line
let cs = parent.closeStart;
let ls = cs;
while (ls > 0 && /[ \t]/.test(doc.src[ls - 1])) ls--;
return doc.src.slice(0, ls) + ind + body + eol + doc.src.slice(ls);
}
function removeElement(doc, el) {
let s = el.start;
let e = el.end;
// take the whole line when the element sits alone on it
let ls = s;
while (ls > 0 && /[ \t]/.test(doc.src[ls - 1])) ls--;
let le = e;
while (le < doc.src.length && /[ \t]/.test(doc.src[le])) le++;
if ((ls === 0 || doc.src[ls - 1] === "\n") && (doc.src[le] === "\r" || doc.src[le] === "\n")) {
s = ls;
e = le + (doc.src[le] === "\r" && doc.src[le + 1] === "\n" ? 2 : 1);
}
return doc.src.slice(0, s) + doc.src.slice(e);
}
const markupOf = (doc, el) => doc.src.slice(el.start, el.end);
return { parse, attr, num, text, child, childrenOf, setAttr, removeAttr, setText, insertChild, removeElement, markupOf, indentOf };
})();
window.XmlDoc = XmlDoc;
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
{
"order": [
"vtable.js",
"dds.js",
"imageset.js",
"lsformats.js",
"gl3d.js",
"meshkit.js",
"skin.js",
"effects.js",
"effect-editor.js",
"fxlab.js",
"model.js",
"animkit.js",
"anim-studio.js",
"costumekit.js",
"costume.js",
"texkit.js",
"texture.js",
"hero-ini.js",
"hero.js",
"mapedit.js",
"xmldoc.js",
"uires.js",
"uiwnd.js",
"items.js",
"quests.js",
"tables.js"
],
"views": [
"ui",
"wnd",
"mdl",
"ans",
"cos",
"fx",
"fxlab",
"hero",
"tex",
"map",
"items",
"quests",
"tables"
]
}
+110
View File
@@ -0,0 +1,110 @@
<section id="view-ans" class="view">
<header class="bar top">
<button id="ans-src" class="iconbtn" title="Where skeletons, meshes and animations are read from"><i data-icon="box"></i><span id="ans-src-label">Choose packs…</span></button>
<button id="ans-new" class="iconbtn" title="A new empty animation on the current character"><i data-icon="newfile"></i><span>New</span></button>
<button id="ans-open-file" class="iconbtn" title="Open an .ani from disk (e.g. exported from Blender)"><i data-icon="folder"></i><span>Open .ani…</span></button>
<div class="ims-title"><span id="ans-title" class="muted"></span><span id="ans-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
<div class="spacer"></div>
<button id="ans-undo" class="iconbtn only" title="Undo (Ctrl+Z)" disabled><i data-icon="undo"></i></button>
<button id="ans-redo" class="iconbtn only" title="Redo (Ctrl+Y)" disabled><i data-icon="redo"></i></button>
<button id="ans-export" class="iconbtn" title="Export"><i data-icon="download"></i><span>Export</span></button>
<button id="ans-save" class="btn primary" disabled><i data-icon="save"></i>Save</button>
</header>
<div class="split">
<aside id="ans-left" class="ims-left">
<div class="mdl-kinds">
<div id="ans-kinds" class="seg">
<button data-kind="model" class="on" title="Model configs: use as the character (skeleton + meshes)">Models</button>
<button data-kind="skl" title="Skeletons: use as the character">Skel</button>
<button data-kind="msh" title="Meshes: add to the character">Meshes</button>
<button data-kind="ani" title="Animations: open, join, retarget">Anims</button>
</div>
</div>
<div class="ims-head">
<div class="search"><i data-icon="search"></i><input id="ans-search" type="search" placeholder="Search" spellcheck="false" /></div>
<span id="ans-count" class="muted"></span>
</div>
<div id="ans-list" class="mdl-list"></div>
<div class="ims-foot muted mdl-hint">Double-click uses it · right-click for more</div>
</aside>
<div class="splitter" data-target="ans-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<div id="ans-tools" class="seg">
<button data-tool="view" class="on" title="View: drag turns the camera, click picks a bone (Esc)">View</button>
<button data-tool="rotate" title="Rotate the selected bone: drag (Ctrl = twist around the view) (R)">Rotate</button>
<button data-tool="move" title="Move the selected bone (mostly the root, Bip01): drag (G)">Move</button>
</div>
<button id="ans-t-mesh" class="iconbtn toggle on" title="Meshes"><i data-icon="cube"></i><span>Mesh</span></button>
<button id="ans-t-bones" class="iconbtn toggle on" title="Bones"><i data-icon="bone"></i><span>Bones</span></button>
<button id="ans-t-ghost" class="iconbtn toggle on" title="The source skeleton of an import / retarget, drawn where it lands"><i data-icon="swap"></i><span>Source</span></button>
<button id="ans-t-grid" class="iconbtn toggle on only" title="Floor grid (y = 0)"><i data-icon="floor"></i></button>
<div class="spacer"></div>
<span id="ans-info" class="muted ims-texinfo"></span>
<button id="ans-reset" class="iconbtn only" title="Reset the view"><i data-icon="focus"></i></button>
</div>
<div class="mdl-stage">
<canvas id="ans-canvas" tabindex="0"></canvas>
<div id="ans-labels" class="ans-labels"></div>
<div id="ans-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
</div>
<div id="ans-transport" class="ans-transport">
<button id="ans-play" class="iconbtn only" title="Play / pause (Space)"><i data-icon="play"></i></button>
<span id="ans-clock" class="mono"></span>
<select id="ans-speed" title="Playback speed">
<option value="0.1">0.1×</option><option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option>
</select>
<button id="ans-loop" class="iconbtn toggle on only" title="Loop"><i data-icon="refresh"></i></button>
<span class="ans-sep"></span>
<button id="ans-key" class="iconbtn" title="Key the selected bone at the playhead (K)"><i data-icon="plus"></i><span>Key</span></button>
<button id="ans-key-all" class="iconbtn" title="Key every bone at the playhead (Shift+K)"><i data-icon="plus"></i><span>Key all</span></button>
<button id="ans-del-keys" class="iconbtn" title="Delete the selected keys (Del)"><i data-icon="trash"></i></button>
<div class="spacer"></div>
<span id="ans-range-label" class="muted"></span>
</div>
<div id="ans-dope" class="ans-dope"><canvas id="ans-dope-canvas" tabindex="0"></canvas></div>
<div id="ans-empty" class="empty">
<div class="empty-card">
<i data-icon="bone" class="big"></i>
<h2>Anim Studio</h2>
<p>Pick the packs, then double-click a <b>model</b> or <b>skeleton</b> for the character (heroes: <b>human_man</b> / <b>human_woman</b>) and an <b>animation</b> to edit — or <b>New</b> for an empty one.</p>
<p class="muted">Click a bone, then Rotate / Move and drag; every change keys the bone at the playhead. Shift-drag the ruler for a time range.</p>
</div>
</div>
</div>
<div class="splitter" data-target="ans-side" data-edge="right" title="Drag to resize"></div>
<aside id="ans-side" class="ims-side ans-side">
<section class="ims-card">
<h4>Character <span id="ans-char-name" class="muted"></span></h4>
<div id="ans-char"></div>
</section>
<section class="ims-card">
<h4>Clip <span id="ans-clip-name" class="muted"></span></h4>
<div id="ans-clip"></div>
</section>
<section class="ims-card">
<h4>Bone <span id="ans-bone-name" class="muted"></span></h4>
<div id="ans-bone"></div>
</section>
<section class="ims-card">
<h4>Cut &amp; join</h4>
<div id="ans-ops"></div>
</section>
<section class="ims-card">
<h4>Events <span id="ans-ev-count" class="muted"></span></h4>
<div id="ans-events"></div>
</section>
<section class="ims-card">
<h4>Hitbox</h4>
<div id="ans-hit"></div>
</section>
<section class="ims-card">
<h4>Blender</h4>
<div id="ans-blender"></div>
</section>
</aside>
</div>
</section>
+86
View File
@@ -0,0 +1,86 @@
<section id="view-cos" class="view">
<header class="bar top">
<button id="cos-src" class="iconbtn" title="Where the hero body, meshes and animations are read from"><i data-icon="box"></i><span id="cos-src-label">Choose packs…</span></button>
<button id="cos-import" class="iconbtn" title="Import a 3D model as the costume: .glb / .gltf (Blender: File → Export → glTF 2.0, with its armature if it has one), .fbx (binary) or .obj"><i data-icon="import"></i><span>Import 3D…</span></button>
<button id="cos-blender" class="iconbtn" title="Save the body you see (skeleton, weights, textures) as a .glb to model on in Blender"><i data-icon="cube"></i><span>Body for Blender…</span></button>
<div class="ims-title"><span id="cos-title" class="muted"></span><span id="cos-dirty" class="dot bad" title="Not saved yet" hidden></span></div>
<div class="spacer"></div>
<button id="cos-save" class="btn primary" disabled><i data-icon="save"></i>Save</button>
</header>
<div class="split">
<aside id="cos-left" class="ims-left">
<div class="mdl-kinds">
<div id="cos-kinds" class="seg">
<button data-kind="msh" class="on" title="Meshes: double-click puts it on the hero (costume bodies, hands, helmets, cloaks…)">Meshes</button>
<button data-kind="ani" title="Animations: double-click plays it on the hero">Anims</button>
</div>
</div>
<div class="ims-head">
<div class="search"><i data-icon="search"></i><input id="cos-search" type="search" placeholder="Search (e.g. _body_)" spellcheck="false" /></div>
<span id="cos-count" class="muted"></span>
</div>
<div id="cos-list" class="mdl-list"></div>
<div class="ims-foot muted mdl-hint">Double-click: wear the mesh / play the animation</div>
</aside>
<div class="splitter" data-target="cos-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<div id="cos-sex" class="seg">
<button data-sex="man" class="on" title="sp2_human_man: human_body, default_hand, face, hair">Man</button>
<button data-sex="woman" title="sp2_human_woman: the _hw meshes (same skeleton)">Woman</button>
</div>
<button id="cos-t-bones" class="iconbtn toggle only" title="Bones"><i data-icon="bone"></i></button>
<button id="cos-t-grid" class="iconbtn toggle on only" title="Floor grid (y = 0)"><i data-icon="floor"></i></button>
<button id="cos-t-wire" class="iconbtn toggle only" title="Wireframe"><i data-icon="grid"></i></button>
<div class="spacer"></div>
<span id="cos-info" class="muted ims-texinfo"></span>
<button id="cos-reset" class="iconbtn only" title="Reset the view"><i data-icon="focus"></i></button>
</div>
<div class="mdl-stage">
<canvas id="cos-canvas" tabindex="0"></canvas>
<div id="cos-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
</div>
<div class="ans-transport">
<button id="cos-play" class="iconbtn only" title="Play / pause (Space)"><i data-icon="play"></i></button>
<select id="cos-anim" title="Animation"></select>
<span id="cos-clock" class="mono"></span>
<input id="cos-scrub" type="range" min="0" max="1000" value="0" class="grow" title="Time" />
<button id="cos-tpose" class="iconbtn" title="Stop and show the rest pose (T-pose: where the costume is modelled)"><i data-icon="bone"></i><span>T-pose</span></button>
</div>
<div id="cos-empty" class="empty">
<div class="empty-card">
<i data-icon="shirt" class="big"></i>
<h2>Costume Studio</h2>
<p>Pick the packs: the hero body appears in its T-pose. <b>Import 3D…</b> a .glb / .gltf / .obj — it is fitted to the body, takes its bones' weights (or the body's) and moves with every animation.</p>
<p class="muted">For an exact fit: <b>Body for Blender…</b>, model the clothes on that body in Blender (parent them to its armature with weights, or leave that to this tool), export glTF 2.0 and import it here. Save writes the .msh, its material and the .dds textures.</p>
</div>
</div>
</div>
<div class="splitter" data-target="cos-side" data-edge="right" title="Drag to resize"></div>
<aside id="cos-side" class="ims-side ans-side">
<section class="ims-card">
<h4>Hero <span id="cos-char-name" class="muted"></span></h4>
<div id="cos-char"></div>
</section>
<section class="ims-card">
<h4>Costume <span id="cos-file-name" class="muted"></span></h4>
<div id="cos-opts"></div>
</section>
<section class="ims-card">
<h4>Materials</h4>
<div id="cos-mats"></div>
</section>
<section class="ims-card">
<h4>Result</h4>
<div id="cos-report"></div>
</section>
<section class="ims-card">
<h4>Register in game <span id="cos-reg-file" class="muted"></span></h4>
<div id="cos-reg"></div>
</section>
</aside>
</div>
</section>
+38
View File
@@ -0,0 +1,38 @@
<section id="view-fx" class="view">
<header class="bar top">
<button id="fx-src" class="iconbtn" title="Choose packs or extracted resources"><i data-icon="box"></i><span id="fx-src-label">Choose packs…</span></button>
<button id="fx-open-file" class="iconbtn" title="Open an effect script from disk"><i data-icon="folder"></i><span>Open file…</span></button>
<button id="fx-new" class="iconbtn" title="Create a new effect"><i data-icon="plus"></i><span>New</span></button>
<div class="ims-title"><span id="fx-title" class="muted"></span><span id="fx-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
<div class="spacer"></div>
<button id="fx-to-lab" class="iconbtn" title="Edit this effect in FX Lab (sliders, curves, hero preview)"><i data-icon="brush"></i><span>Edit in FX Lab</span></button>
<button id="fx-save-as" class="iconbtn" disabled><i data-icon="download"></i><span>Save As…</span></button>
<button id="fx-save" class="btn primary" disabled><i data-icon="save"></i>Save</button>
</header>
<div class="split">
<aside id="fx-left" class="ims-left">
<div class="ims-head"><div class="search"><i data-icon="search"></i><input id="fx-search" type="search" placeholder="Search effects" spellcheck="false" /></div><span id="fx-count" class="muted"></span></div>
<div id="fx-list" class="mdl-list"></div>
<div class="ims-foot muted mdl-hint">Double-click an effect to open it</div>
</aside>
<div class="splitter" data-target="fx-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<button id="fx-play" class="iconbtn toggle on" title="Play or pause"><i data-icon="play"></i><span>Play</span></button>
<button id="fx-restart" class="iconbtn" title="Restart preview"><i data-icon="refresh"></i><span>Restart</span></button>
<button id="fx-grid" class="iconbtn toggle on" title="Floor grid"><i data-icon="floor"></i><span>Grid</span></button>
<div class="spacer"></div><span id="fx-stats" class="mono muted"></span>
<button id="fx-reset" class="iconbtn only" title="Reset camera"><i data-icon="focus"></i></button>
</div>
<div class="mdl-stage fx-stage"><canvas id="fx-canvas" tabindex="0"></canvas></div>
<div id="fx-empty" class="empty"><div class="empty-card"><i data-icon="zap" class="big"></i><h2>Effect editor</h2><p>Choose the game's packs or an extracted resource folder, then open an effect. Drag to orbit, right-drag to pan, wheel to zoom.</p></div></div>
<div class="ims-help muted">Select a system in the hierarchy to set each particle or model separately. Particle preview uses the Map editor simulator; model and light preview is not available yet.</div>
</div>
<div class="splitter" data-target="fx-side" data-edge="right" title="Drag to resize"></div>
<aside id="fx-side" class="ims-side fx-side">
<section class="ims-card"><h4>Hierarchy</h4><div id="fx-hierarchy" class="fx-hierarchy"></div><div class="fx-actions"><button id="fx-add-particle" class="iconbtn"><i data-icon="plus"></i>Particle</button><button id="fx-add-model" class="iconbtn"><i data-icon="plus"></i>Model</button><button id="fx-add-emitter" class="iconbtn"><i data-icon="plus"></i>Emitter</button><button id="fx-add-affector" class="iconbtn"><i data-icon="plus"></i>Affector</button><button id="fx-duplicate-block" class="iconbtn" title="Duplicate selected particle or model"><i data-icon="copy"></i></button><button id="fx-move-up" class="iconbtn" title="Move selected particle or model up"><i data-icon="up"></i></button><button id="fx-move-down" class="iconbtn" title="Move selected particle or model down"><i data-icon="down"></i></button><button id="fx-delete-block" class="iconbtn danger" title="Remove selected block"><i data-icon="trash"></i></button></div></section>
<section class="ims-card"><h4>Properties <span id="fx-selection" class="muted"></span></h4><div id="fx-properties" class="fx-properties"></div><div class="fx-actions"><button id="fx-add-field" class="iconbtn"><i data-icon="plus"></i>Field</button></div></section>
<section class="ims-card fx-script-card"><h4>Effect script <span id="fx-parse-status" class="muted"></span></h4><textarea id="fx-script" class="fx-script" spellcheck="false" aria-label="Effect script"></textarea></section>
</aside>
</div>
</section>
+50
View File
@@ -0,0 +1,50 @@
<section id="view-fxlab" class="view">
<header class="bar top">
<button id="fxl-src" class="iconbtn" title="Game packs used for pack textures and the hero preview"><i data-icon="box"></i><span id="fxl-src-label">Choose packs…</span></button>
<button id="fxl-new" class="iconbtn" title="Start an empty effect"><i data-icon="plus"></i><span>New</span></button>
<input id="fxl-name" class="fxl-name" type="text" spellcheck="false" value="my_effect" title="Effect name (file name of the export)" />
<button id="fxl-save-preset" class="iconbtn" title="Save as a preset (FX Lab library)"><i data-icon="save"></i><span>Save preset</span></button>
<button id="fxl-open" class="iconbtn" title="Open an existing Lost Saga effect (packs or file) or a preset JSON"><i data-icon="folder"></i><span>Open…</span></button>
<button id="fxl-export-json" class="iconbtn" title="Export the preset as JSON"><i data-icon="download"></i><span>JSON</span></button>
<div class="spacer"></div>
<button id="fxl-export-sheet" class="iconbtn" title="Render the free preview to a sprite sheet PNG + Aseprite-style JSON"><i data-icon="image"></i><span>Sprite sheet…</span></button>
<button id="fxl-save" class="iconbtn" title="Save the script back to the effect it was opened from" hidden><i data-icon="save"></i><span id="fxl-save-label">Save</span></button>
<button id="fxl-export-ls" class="btn primary" title="Write resource/effect/&lt;name&gt;.txt and its textures (DDS) to a folder"><i data-icon="upload"></i>Export LS effect…</button>
</header>
<div class="split">
<aside id="fxl-left" class="ims-left">
<div class="ims-head"><div class="search"><i data-icon="search"></i><input id="fxl-search" type="search" placeholder="Search presets" spellcheck="false" /></div></div>
<div id="fxl-presets" class="ims-list fxl-presets"></div>
<div class="ims-foot muted mdl-hint">Click a preset to load it</div>
</aside>
<div class="splitter" data-target="fxl-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<button id="fxl-play" class="iconbtn toggle on" title="Play or pause"><i data-icon="play"></i><span>Play</span></button>
<button id="fxl-restart" class="iconbtn only" title="Restart (fire the effect again)"><i data-icon="refresh"></i></button>
<button id="fxl-grid" class="iconbtn only toggle on" title="Floor grid"><i data-icon="floor"></i></button>
<select id="fxl-mode" title="Where the effect plays">
<option value="free">Free (no hero)</option>
<option value="bone">On hero (bone)</option>
<option value="hit">Hit target</option>
</select>
<select id="fxl-anim" title="Hero animation played with the effect" hidden></select>
<select id="fxl-bg" title="Background">
<option value="gray">Gray</option><option value="dark">Dark</option><option value="black">Black</option>
</select>
<div class="spacer"></div><span id="fxl-stats" class="mono muted"></span>
<button id="fxl-reset" class="iconbtn only" title="Reset camera"><i data-icon="focus"></i></button>
</div>
<div id="fxl-stage" class="mdl-stage fx-stage"><canvas id="fxl-canvas" tabindex="0"></canvas></div>
<div class="ims-help muted">Pick a preset, then tune the selected layer on the right. Drag to orbit, right-drag to pan, wheel to zoom. Curves: drag points, double-click to add, right-click to remove.</div>
</div>
<div class="splitter" data-target="fxl-side" data-edge="right" title="Drag to resize"></div>
<aside id="fxl-side" class="ims-side fx-side fxl-side">
<section class="ims-card"><h4>Layers</h4><div id="fxl-layers" class="fxl-layers"></div>
<details id="fxl-notes" class="fxl-notes" hidden></details>
<div class="fx-actions"><button id="fxl-add-layer" class="iconbtn"><i data-icon="plus"></i>Layer</button><button id="fxl-dup-layer" class="iconbtn" title="Duplicate layer"><i data-icon="copy"></i></button><button id="fxl-up-layer" class="iconbtn" title="Move up"><i data-icon="up"></i></button><button id="fxl-del-layer" class="iconbtn danger" title="Remove layer"><i data-icon="trash"></i></button></div></section>
<section class="ims-card fxl-props-card"><h4>Layer <span id="fxl-layer-name" class="muted"></span></h4><div id="fxl-props" class="fxl-props"></div></section>
<section class="ims-card"><details><summary class="muted">Lost Saga script (generated)</summary><textarea id="fxl-script" class="fx-script fxl-script" spellcheck="false" readonly></textarea></details></section>
</aside>
</div>
</section>
+38
View File
@@ -0,0 +1,38 @@
<section id="view-hero" class="view">
<header class="bar top">
<button id="hero-src" class="iconbtn"><i data-icon="box"></i><span id="hero-src-label">Choose packs…</span></button>
<div class="ims-title"><span id="hero-title" class="muted">Hero Creator · experimental</span><span id="hero-dirty" class="dot bad" hidden></span></div>
<div class="spacer"></div>
<button id="hero-export" class="btn primary" disabled><i data-icon="download"></i>Export config…</button>
</header>
<div class="split">
<aside id="hero-left" class="ims-left">
<div class="ims-head"><div class="search"><i data-icon="search"></i><input id="hero-search" type="search" placeholder="Search heroes" spellcheck="false" /></div><span id="hero-count" class="muted"></span></div>
<div id="hero-list" class="hero-list"></div>
<div class="ims-foot muted mdl-hint">Select a hero as the base</div>
</aside>
<div class="splitter" data-target="hero-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col hero-main">
<div id="hero-empty" class="empty"><div class="empty-card"><i data-icon="cube" class="big"></i><h2>Hero Creator</h2><p>Choose the client packs and a hero. Customize gear meshes, moves, skills and effects, then export changed INI files for testing.</p></div></div>
<div id="hero-work" hidden>
<div class="hero-summary"><h2 id="hero-name"></h2><p id="hero-summary" class="muted"></p></div>
<div class="hero-studio">
<div id="hero-stage" class="hero-stage" tabindex="0"><div id="hero-action-label" class="hero-action-label">Select an action to preview</div><div id="hero-practice-hud" class="hero-practice-hud" hidden><b>TRAINING</b><span id="hero-dummy-health">Dummy 100 / 100</span></div></div>
<div class="hero-playbar"><button id="hero-idle" class="iconbtn">Idle pose</button><button id="hero-stop" class="iconbtn">Stop</button><button id="hero-practice" class="iconbtn" disabled>Training mode</button><span id="hero-play-status" class="muted"></span></div>
<div id="hero-practice-help" class="hero-practice-help" hidden>↑ ↓ ← → move · double tap or Shift dash · A jump · S guard · D attack / hold D half attack · SD / ASD / AD / AS skills · 1–4 skill shortcuts · Esc exit</div>
<div id="hero-combos" class="hero-combos"></div>
<div id="hero-skills" class="hero-skills"></div>
<div id="hero-action-info" class="hero-action-info muted"></div>
</div>
<div class="hero-tabs seg"><button data-hero-tab="gear" class="on">Gear</button><button data-hero-tab="moves">Moveset</button><button data-hero-tab="skills">Skills</button><button data-hero-tab="effects">Effects</button><button data-hero-tab="flow" title="Every attack: input → animation events → hits → attack attributes → effects / sounds / buffs, with the checks">Flow</button></div>
<div id="hero-content" class="hero-content"></div>
</div>
</div>
<div class="splitter" data-target="hero-side" data-edge="right" title="Drag to resize"></div>
<aside id="hero-side" class="ims-side hero-side">
<section class="ims-card"><h4>Preview</h4><p class="muted">Play combos and skills here using the animations and effect references in the hero config.</p><button id="hero-preview" class="btn" disabled>Reload hero preview</button><div id="hero-preview-status" class="muted"></div></section>
<section class="ims-card"><h4>Selected field</h4><div id="hero-field-detail" class="muted">Select a field to edit its exact INI value.</div></section>
<section class="ims-card"><h4>Export status</h4><div id="hero-export-status" class="muted">Only changed INI files will be exported. The game packs remain untouched.</div></section>
</aside>
</div>
</section>
+16
View File
@@ -0,0 +1,16 @@
<section id="view-items" class="view">
<header class="bar top">
<div class="zone-title"><h2>Items</h2><span id="it-source" class="muted"></span></div>
<div class="spacer"></div>
<div class="search"><i data-icon="search"></i><input id="it-search" type="search" placeholder="Name, ID, icon or section" spellcheck="false" /></div>
<label class="chip"><input id="it-hide" type="checkbox" checked /><span>Hide unnamed</span></label>
<select id="it-text" title="Text table (language) the names come from"></select>
<button id="it-folder" class="iconbtn" title="Read the items from another game folder"><i data-icon="folder"></i><span>Folder</span></button>
<button id="it-reload" class="iconbtn" title="Read the packs again (after a patch or an edit)"><i data-icon="refresh"></i><span>Reload</span></button>
</header>
<div class="it-body">
<nav id="it-cats" class="it-cats"></nav>
<div class="it-list"><div id="it-table" class="vtable"></div><div id="it-count" class="muted it-count"></div></div>
<aside id="it-detail" class="it-detail"><div class="it-empty muted">Pick an item.</div></aside>
</div>
</section>
+87
View File
@@ -0,0 +1,87 @@
<section id="view-map" class="view">
<header class="bar top">
<button id="map-src" class="iconbtn" title="Where the maps, meshes and textures are read from"><i data-icon="box"></i><span id="map-src-label">Choose packs…</span></button>
<button id="map-open-file" class="iconbtn" title="Open a map .ini from disk"><i data-icon="folder"></i><span>Open file…</span></button>
<div class="ims-title"><span id="map-title" class="muted"></span><span id="map-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
<div class="spacer"></div>
<button id="map-add" class="iconbtn" title="Add an object, effect or point"><i data-icon="plus"></i><span>Add</span></button>
<button id="map-undo" class="iconbtn only" title="Undo (Ctrl+Z)" disabled><i data-icon="undo"></i></button>
<button id="map-redo" class="iconbtn only" title="Redo (Ctrl+Y)" disabled><i data-icon="redo"></i></button>
<button id="map-save" class="btn primary"><i data-icon="save"></i>Save</button>
</header>
<div class="split">
<aside id="map-left" class="ims-left">
<div class="ims-head map-head">
<select id="map-mode" title="Mode"><option value="">All modes</option></select>
</div>
<div class="ims-head">
<div class="search"><i data-icon="search"></i><input id="map-search" type="search" placeholder="Search maps / models" spellcheck="false" /></div>
<span id="map-count" class="muted"></span>
</div>
<div id="map-list" class="mdl-list"></div>
<div class="ims-foot muted mdl-hint">Double-click a map to open it</div>
</aside>
<div class="splitter" data-target="map-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<button id="map-t-anim" class="iconbtn toggle on" title="Map animation: moving textures (water, sky, glow), animated objects, elevators — off shows the map at rest"><i data-icon="play"></i><span>Anim</span></button>
<button id="map-t-light" class="iconbtn toggle on" title="Lighting, fog and background from the config"><i data-icon="zap"></i><span>Light</span></button>
<button id="map-t-tex" class="iconbtn toggle on only" title="Textures"><i data-icon="image"></i></button>
<button id="map-t-wire" class="iconbtn toggle only" title="Wireframe"><i data-icon="grid"></i></button>
<button id="map-t-col" class="iconbtn toggle" title="Collision mesh (.cms): where players can stand"><i data-icon="shield"></i><span>Collision</span></button>
<button id="map-t-outline" class="iconbtn toggle on" title="Outline edges (.edg)"><i data-icon="draw"></i><span>Outline</span></button>
<button id="map-t-objects" class="iconbtn toggle on" title="Objects placed by the config"><i data-icon="cube"></i><span>Objects</span></button>
<button id="map-t-labels" class="iconbtn toggle" title="Names of every point"><i data-icon="note"></i><span>Labels</span></button>
<button id="map-t-grid" class="iconbtn toggle only" title="Floor grid"><i data-icon="floor"></i></button>
<label class="pill" title="Snap dragged points to a grid"><i data-icon="target"></i>
<select id="map-snap"><option value="0">No snap</option><option value="10">10</option><option value="25">25</option><option value="50">50</option><option value="100">100</option></select>
</label>
<div class="spacer"></div>
<span id="map-hover" class="mono ims-texinfo"></span>
<button id="map-top" class="iconbtn only" title="Top view (T)"><i data-icon="up"></i></button>
<button id="map-reset" class="iconbtn only" title="Reset the view"><i data-icon="focus"></i></button>
</div>
<div class="mdl-stage">
<canvas id="map-canvas" tabindex="0"></canvas>
<div id="map-labels" class="map-labels"></div>
<div id="map-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
</div>
<div id="map-empty" class="empty">
<div class="empty-card">
<i data-icon="map" class="big"></i>
<h2>Map editor</h2>
<p>Pick the packs (<b>Choose packs…</b>: the client folder with rs.iop and map.iop), then double-click a map. Select an object and use <b>G</b> to move, <b>R</b> to rotate its editor preview, or <b>S</b> to scale. Drag points to move; Alt-drag changes height. Editor rotation is saved in a comment so it returns when reopened, but the game ignores it because map files have no rotation field.</p>
</div>
</div>
<div class="ims-help muted">Click a point to select · P move · R rotate preview · S scale · Snap base to map floor · Alt-drag: height · arrows nudge · Ctrl+D copy · Del remove · F focus · T top view</div>
</div>
<div class="splitter" data-target="map-side" data-edge="right" title="Drag to resize"></div>
<aside id="map-side" class="ims-side map-side" hidden>
<section class="ims-card">
<h4>Map</h4>
<div id="map-info" class="ims-form"></div>
</section>
<section class="ims-card">
<h4>Layers</h4>
<div id="map-layers" class="map-layers"></div>
</section>
<section class="ims-card">
<h4>Objects <span id="map-object-count" class="muted">0</span></h4>
<div class="search"><i data-icon="search"></i><input id="map-object-search" type="search" placeholder="Search objects / models" spellcheck="false" /></div>
<div id="map-object-list" class="map-object-list"></div>
</section>
<section class="ims-card">
<h4>Sections <span id="map-sec-count" class="muted"></span></h4>
<div class="search"><i data-icon="search"></i><input id="map-sec-search" type="search" placeholder="Filter sections / values" spellcheck="false" /></div>
<div id="map-sections" class="map-sections"></div>
</section>
<section id="map-sec-card" class="ims-card grow" hidden>
<h4><span id="map-sec-name"></span></h4>
<div id="map-sec"></div>
</section>
</aside>
</div>
</section>
+85
View File
@@ -0,0 +1,85 @@
<section id="view-mdl" class="view">
<header class="bar top">
<button id="mdl-src" class="iconbtn" title="Where models, textures and animations are read from"><i data-icon="box"></i><span id="mdl-src-label">Choose packs…</span></button>
<button id="mdl-open-file" class="iconbtn" title="Open a .msh, .ani, .skl or model .txt from disk"><i data-icon="folder"></i><span>Open file…</span></button>
<div class="ims-title"><span id="mdl-title" class="muted"></span><span id="mdl-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
<div class="spacer"></div>
<button id="mdl-export" class="iconbtn" title="Export the model or a screenshot"><i data-icon="download"></i><span>Export</span></button>
<button id="mdl-save" class="btn primary" disabled><i data-icon="save"></i>Save</button>
</header>
<div class="split">
<aside id="mdl-left" class="ims-left">
<div class="mdl-kinds">
<div id="mdl-kinds" class="seg">
<button data-kind="model" class="on" title="Model configs (resource/model): skeleton + meshes + animations">Models</button>
<button data-kind="msh" title="Meshes (.msh)">Meshes</button>
<button data-kind="ani" title="Animations (.ani)">Anims</button>
<button data-kind="skl" title="Skeletons (.skl)">Skeletons</button>
</div>
</div>
<div class="ims-head">
<div class="search"><i data-icon="search"></i><input id="mdl-search" type="search" placeholder="Search" spellcheck="false" /></div>
<span id="mdl-count" class="muted"></span>
</div>
<div id="mdl-list" class="mdl-list"></div>
<div class="ims-foot muted mdl-hint">Double-click opens · right-click for more</div>
</aside>
<div class="splitter" data-target="mdl-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<button id="mdl-t-tex" class="iconbtn toggle on" title="Textures"><i data-icon="image"></i><span>Textures</span></button>
<button id="mdl-t-wire" class="iconbtn toggle" title="Wireframe"><i data-icon="grid"></i><span>Wire</span></button>
<button id="mdl-t-bones" class="iconbtn toggle" title="Bones"><i data-icon="bone"></i><span>Bones</span></button>
<button id="mdl-t-points" class="iconbtn toggle on" title="Control points (Type_Index)"><i data-icon="target"></i><span>Points</span></button>
<button id="mdl-t-grid" class="iconbtn toggle on only" title="Floor grid"><i data-icon="floor"></i></button>
<button id="mdl-t-box" class="iconbtn toggle only" title="Bounding box"><i data-icon="fit"></i></button>
<button id="mdl-t-texanim" class="iconbtn toggle on only" title="Texture animations from the material scripts (scroll, rotate, wave, frames…)"><i data-icon="zap"></i></button>
<div class="spacer"></div>
<span id="mdl-info" class="muted ims-texinfo"></span>
<button id="mdl-reset" class="iconbtn only" title="Reset the view (double-click the view)"><i data-icon="focus"></i></button>
</div>
<div class="mdl-stage">
<canvas id="mdl-canvas" tabindex="0"></canvas>
<div id="mdl-labels"></div>
<div id="mdl-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
</div>
<div id="mdl-empty" class="empty">
<div class="empty-card">
<i data-icon="cube" class="big"></i>
<h2>Model viewer</h2>
<p>Pick the packs (<b>Choose packs…</b>, e.g. the client folder with map.iop and rs.iop), then double-click a model, mesh, animation or skeleton. Drag to turn, right-drag to pan, wheel to zoom.</p>
</div>
</div>
<div id="mdl-timeline" class="mdl-timeline" hidden>
<button id="mdl-play" class="iconbtn only" title="Play / pause (Space)"><i data-icon="play"></i></button>
<div class="mdl-track">
<div id="mdl-events" class="mdl-events"></div>
<input id="mdl-time" type="range" min="0" max="1000" step="1" value="0" />
</div>
<span id="mdl-clock" class="mono"></span>
<select id="mdl-speed" title="Speed">
<option value="0.1">0.1×</option><option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="1.5">1.5×</option><option value="2">2×</option>
</select>
<button id="mdl-loop" class="iconbtn toggle on only" title="Loop"><i data-icon="refresh"></i></button>
</div>
</div>
<div class="splitter" data-target="mdl-side" data-edge="right" title="Drag to resize"></div>
<aside id="mdl-side" class="ims-side" hidden>
<section class="ims-card">
<h4>Scene <span id="mdl-scene-count" class="muted"></span></h4>
<div id="mdl-scene" class="mdl-rows"></div>
</section>
<section id="mdl-mesh-card" class="ims-card" hidden>
<h4>Mesh <span id="mdl-mesh-name" class="muted"></span></h4>
<div id="mdl-mesh"></div>
</section>
<section id="mdl-ani-card" class="ims-card grow" hidden>
<h4>Animation <span id="mdl-ani-name" class="muted"></span></h4>
<div id="mdl-ani" class="mdl-ani"></div>
</section>
</aside>
</div>
</section>
+84
View File
@@ -0,0 +1,84 @@
<section id="view-quests" class="view">
<header class="bar top">
<div class="zone-title"><h2>Quests</h2><span id="q-source" class="muted"></span></div>
<div class="spacer"></div>
<nav class="seg" id="q-subtabs">
<button data-sub="quests" class="on"><i data-icon="scroll"></i>Quests</button>
<button data-sub="rewards"><i data-icon="gem"></i>Rewards</button>
<button data-sub="missions"><i data-icon="layout"></i>Missions</button>
<button data-sub="checks"><i data-icon="check"></i>Checks</button>
</nav>
<select id="q-text" title="Text table (language) the texts come from and new texts go into"></select>
<button id="q-export" class="iconbtn" title="Write the quest, reward and mission INIs as they are now into a folder, for the game server's config folder"><i data-icon="download"></i><span>Export for server</span></button>
<button id="q-folder" class="iconbtn" title="Read the quests from another game folder"><i data-icon="folder"></i><span>Folder</span></button>
<button id="q-reload" class="iconbtn" title="Read the packs again"><i data-icon="refresh"></i><span>Reload</span></button>
</header>
<div class="sub on q-sub" data-sub="quests">
<div class="q-body">
<div class="q-list">
<div class="bar nav">
<div class="search"><i data-icon="search"></i><input id="q-search" type="search" placeholder="Title, main index, section, class" spellcheck="false" /></div>
<select id="q-class" title="Quest class"></select>
<select id="q-filter" title="Show">
<option value="">All</option>
<option value="event">Events (dates)</option>
<option value="active">Events running now</option>
<option value="daily">Daily</option>
<option value="repeat">Repeatable</option>
<option value="problems">With problems</option>
</select>
<button id="q-new" class="iconbtn" title="New quest"><i data-icon="plus"></i><span>New</span></button>
</div>
<div id="q-table" class="vtable"></div>
<div id="q-count" class="muted it-count"></div>
</div>
<div id="q-detail" class="q-detail"><div class="it-empty muted">Pick a quest.</div></div>
</div>
</div>
<div class="sub q-sub" data-sub="rewards">
<div class="q-body two">
<div class="q-list">
<div class="bar nav">
<div class="search"><i data-icon="search"></i><input id="qr-search" type="search" placeholder="RewardIndex, name, type" spellcheck="false" /></div>
<select id="qr-type" title="Present type"></select>
<label class="chip"><input id="qr-unused" type="checkbox" /><span>Unused only</span></label>
<button id="qr-new" class="iconbtn" title="New reward"><i data-icon="plus"></i><span>New</span></button>
</div>
<div id="qr-table" class="vtable"></div>
<div id="qr-count" class="muted it-count"></div>
</div>
<div id="qr-detail" class="q-detail"><div class="it-empty muted">Pick a reward.</div></div>
</div>
</div>
<div class="sub q-sub" data-sub="missions">
<div class="q-body">
<div class="q-list">
<div class="bar nav">
<nav class="seg" id="qm-kind">
<button data-kind="daily" class="on">Daily</button>
<button data-kind="weekly">Weekly</button>
<button data-kind="monthly">Monthly</button>
</nav>
<div class="search"><i data-icon="search"></i><input id="qm-search" type="search" placeholder="Date, code, title" spellcheck="false" /></div>
<button id="qm-new" class="iconbtn" title="New date (a copy of the selected one)"><i data-icon="plus"></i><span>New</span></button>
</div>
<div id="qm-table" class="vtable"></div>
<div id="qm-count" class="muted it-count"></div>
</div>
<div id="qm-detail" class="q-detail"><div class="it-empty muted">Pick a date.</div></div>
</div>
</div>
<div class="sub q-sub" data-sub="checks">
<div class="bar nav">
<button id="qc-run" class="iconbtn"><i data-icon="refresh"></i><span>Check again</span></button>
<select id="qc-sev"><option value="">Errors and warnings</option><option value="error">Errors only</option></select>
<div class="spacer"></div>
<span id="qc-count" class="muted"></span>
</div>
<div id="qc-table" class="vtable"></div>
</div>
</section>
+38
View File
@@ -0,0 +1,38 @@
<section id="view-tables" class="view">
<header class="bar top">
<div class="zone-title"><h2>Tables</h2><span id="dt-source" class="muted"></span></div>
<div class="spacer"></div>
<button id="dt-client" class="iconbtn" title="Read the client tables from another game folder (default: this profile's)"><i data-icon="box"></i><span>Game folder</span></button>
<button id="dt-server" class="iconbtn" title="The game server's tables folder (ls_gamesvr\config\tables)"><i data-icon="server"></i><span>Server folder</span></button>
<button id="dt-file" class="iconbtn" title="Open any .dat table file"><i data-icon="folder"></i><span>Open file</span></button>
<button id="dt-maker" class="iconbtn" title="ioDataMaker folder (Config.xlsx + ExcelData): column descriptions and enum names"><i data-icon="gear"></i><span>ioDataMaker</span></button>
<button id="dt-reload" class="iconbtn" title="List the tables again"><i data-icon="refresh"></i><span>Reload</span></button>
</header>
<div class="dt-body">
<nav id="dt-list" class="dt-list"></nav>
<div class="dt-main">
<div class="bar nav" id="dt-tools">
<div class="search"><i data-icon="search"></i><input id="dt-search" type="search" placeholder="Filter rows (any cell)" spellcheck="false" /></div>
<select id="dt-enc" title="Code page of the text columns">
<option value="euc-kr">Korean (CP949)</option>
<option value="big5">Trad. Chinese (Big5)</option>
<option value="windows-874">Thai (874)</option>
<option value="gbk">Simpl. Chinese (GBK)</option>
<option value="shift_jis">Japanese</option>
<option value="windows-1252">Western (1252)</option>
</select>
<button id="dt-add" class="iconbtn" title="Add a row after the selection (Ctrl+Enter)"><i data-icon="plus"></i><span>Row</span></button>
<button id="dt-dup" class="iconbtn" title="Duplicate the selected rows (Ctrl+D)"><i data-icon="copy"></i><span>Duplicate</span></button>
<button id="dt-del" class="iconbtn danger" title="Delete the selected rows (Ctrl+Delete)"><i data-icon="trash"></i><span>Delete</span></button>
<button id="dt-undo" class="iconbtn only" title="Undo (Ctrl+Z)"><i data-icon="undo"></i></button>
<button id="dt-redo" class="iconbtn only" title="Redo (Ctrl+Y)"><i data-icon="redo"></i></button>
<div class="spacer"></div>
<button id="dt-import" class="iconbtn" title="Replace the rows with an .xlsx / .csv whose first row names the columns (an ioDataMaker sheet works too)"><i data-icon="upload"></i><span>Import</span></button>
<button id="dt-export" class="iconbtn" title="Export as .xlsx (or .csv)"><i data-icon="download"></i><span>Export</span></button>
<button id="dt-save" class="btn primary" disabled title="Write the table back (Ctrl+S)"><i data-icon="save"></i>Save</button>
</div>
<div id="dt-grid" class="dt-grid"><div class="it-empty muted">Pick a table.</div></div>
<div id="dt-status" class="muted dt-status"></div>
</div>
</div>
</section>
+53
View File
@@ -0,0 +1,53 @@
<section id="view-tex" class="view">
<header class="bar top">
<button id="tex-open" class="iconbtn" title="Open a folder of textures (.png .jpg .tga .bmp .dds)"><i data-icon="folder"></i><span>Open folder…</span></button>
<button id="tex-openfile" class="iconbtn" title="Open one picture (or drop it on the view)"><i data-icon="image"></i><span>Open file…</span></button>
<button id="tex-convert" class="iconbtn" title="Every .png / .jpg / .tga / .bmp of the folder → .dds (the format in Save as .dds)"><i data-icon="swap"></i><span>Convert folder to .dds…</span></button>
<div class="ims-title"><span id="tex-title" class="muted"></span><span id="tex-dirty" class="dot bad" title="Not saved yet" hidden></span></div>
<div class="spacer"></div>
<button id="tex-saveas" class="btn" disabled><i data-icon="download"></i>Save as…</button>
<button id="tex-save" class="btn primary" disabled title="&lt;name&gt;.dds next to the picture (Ctrl+S)"><i data-icon="save"></i>Save .dds</button>
</header>
<div class="split">
<aside id="tex-left" class="ims-left">
<div class="ims-head">
<div class="search"><i data-icon="search"></i><input id="tex-search" type="search" placeholder="Search" spellcheck="false" /></div>
<span id="tex-count" class="muted"></span>
<button id="tex-refresh" class="iconbtn only" title="Read the folder again"><i data-icon="refresh"></i></button>
</div>
<div id="tex-list" class="mdl-list tex-list"></div>
<div id="tex-dir" class="ims-foot muted mdl-hint tex-dir"></div>
</aside>
<div class="splitter" data-target="tex-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<div id="tex-tools" class="seg"></div>
<div class="spacer"></div>
<span id="tex-zoom" class="muted mono"></span>
<button id="tex-100" class="iconbtn" title="Actual size (100%)"><span>1:1</span></button>
<button id="tex-fit" class="iconbtn only" title="Fit the picture (0)"><i data-icon="fit"></i></button>
</div>
<div class="mdl-stage tex-stage">
<canvas id="tex-canvas" tabindex="0"></canvas>
<div id="tex-loading" class="mdl-loading" hidden><i></i><span>Loading…</span></div>
<div id="tex-empty" class="empty">
<div class="empty-card">
<i data-icon="brush" class="big"></i>
<h2>Texture Studio</h2>
<p><b>Open folder…</b> and click a picture. Select what to change — <b>Magic wand</b> / <b>Colour range</b> (click a colour), <b>Lasso</b>, <b>Rectangle</b>; Shift adds, Alt takes away — then <b>Recolour</b> it and <b>Apply</b>.</p>
<p class="muted"><b>Save .dds</b> writes the game's format (DXT1 / DXT5 with mip maps, like its own textures); <b>Convert folder to .dds…</b> does every .png of the folder at once. Wheel: zoom, middle / right drag or Space: move.</p>
</div>
</div>
</div>
</div>
<div class="splitter" data-target="tex-side" data-edge="right" title="Drag to resize"></div>
<aside id="tex-side" class="ims-side ans-side">
<section class="ims-card"><h4>Selection</h4><div id="tex-sel"></div></section>
<section class="ims-card"><h4>Recolour</h4><div id="tex-rc"></div></section>
<section class="ims-card"><h4>Save as .dds</h4><div id="tex-exp"></div></section>
</aside>
</div>
</section>
+80
View File
@@ -0,0 +1,80 @@
<section id="view-ui" class="view">
<header class="bar top">
<label class="pill" title="Pack the XML (and textures) come from — open it in IOP Manager first">
<i data-icon="box"></i>
<select id="ims-session" disabled></select>
</label>
<input id="ims-entry" type="text" value="xml/uiimageset.xml" spellcheck="false" title="Entry inside the pack" />
<button id="ims-load" class="iconbtn" disabled><i data-icon="refresh"></i><span>Load</span></button>
<button id="ims-open-file" class="iconbtn" title="Open an imageset XML from disk"><i data-icon="folder"></i><span>Open file…</span></button>
<div class="ims-title"><span id="ims-title" class="muted"></span><span id="ims-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
<button id="ims-texsrc" class="iconbtn" title="Where the textures come from"><i data-icon="image"></i><span id="ims-texsrc-label">Textures</span></button>
<div class="spacer"></div>
<button id="ims-undo" class="iconbtn only" title="Undo (Ctrl+Z)"><i data-icon="undo"></i></button>
<button id="ims-redo" class="iconbtn only" title="Redo (Ctrl+Y)"><i data-icon="redo"></i></button>
<button id="ims-save" class="btn primary"><i data-icon="save"></i>Save</button>
</header>
<div class="split">
<aside id="ims-left" class="ims-left">
<div class="ims-head">
<div class="search"><i data-icon="search"></i><input id="ims-search" type="search" placeholder="Search imagesets" spellcheck="false" /></div>
<span id="ims-set-count" class="muted"></span>
</div>
<div id="ims-sets" class="ims-list"></div>
<div class="ims-foot">
<button id="ims-add-set" class="iconbtn" title="New imageset"><i data-icon="plus"></i><span>Imageset</span></button>
<button id="ims-del-set" class="iconbtn only danger" title="Delete this imageset"><i data-icon="trash"></i></button>
</div>
</aside>
<div class="splitter" data-target="ims-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<div class="seg">
<button id="ims-zoom-out" title="Zoom out"><i data-icon="minus"></i></button>
<button id="ims-zoom" title="100 %">100%</button>
<button id="ims-zoom-in" title="Zoom in"><i data-icon="plus"></i></button>
</div>
<button id="ims-fit" class="iconbtn" title="Fit the texture"><i data-icon="fit"></i><span>Fit</span></button>
<button id="ims-showall" class="iconbtn toggle on" title="Show every box"><i data-icon="grid"></i><span>All boxes</span></button>
<button id="ims-drawmode" class="iconbtn toggle" title="Drag on the texture to add an image"><i data-icon="draw"></i><span>Draw</span></button>
<div class="spacer"></div>
<span id="ims-hover" class="mono"></span>
<span id="ims-texinfo" class="muted ims-texinfo"></span>
</div>
<div class="ims-stage"><canvas id="ims-canvas" tabindex="0"></canvas></div>
<div id="ims-empty" class="empty">
<div class="empty-card">
<i data-icon="image" class="big"></i>
<h2>UI Image Set editor</h2>
<p>Open <b>rs.iop</b> in IOP Manager, then press <b>Load</b> to edit <code>xml/uiimageset.xml</code>. Textures are read from the packs next to it (map.iop, map2.iop, sakura.iop, …).</p>
</div>
</div>
<div class="ims-help muted">Drag a box to move it, its handles to resize · Alt/right-drag or drag empty space to pan · wheel to zoom · arrows nudge (Shift ×10) · Delete removes</div>
</div>
<div class="splitter" data-target="ims-side" data-edge="right" title="Drag to resize"></div>
<aside id="ims-side" class="ims-side" hidden>
<section class="ims-card">
<h4>Imageset</h4>
<div id="ims-setform" class="ims-form"></div>
</section>
<section class="ims-card grow">
<h4>Images <span id="ims-img-count" class="muted"></span></h4>
<div class="search"><i data-icon="search"></i><input id="ims-img-search" type="search" placeholder="Search images" spellcheck="false" /></div>
<div id="ims-images" class="ims-list small"></div>
<div class="ims-foot">
<button id="ims-add-img" class="iconbtn" title="New image (or use Draw)"><i data-icon="plus"></i><span>Image</span></button>
<button id="ims-dup-img" class="iconbtn only" title="Duplicate"><i data-icon="copy"></i></button>
<button id="ims-del-img" class="iconbtn only danger" title="Delete (Del)"><i data-icon="trash"></i></button>
</div>
</section>
<section id="ims-imgbox" class="ims-card">
<h4>Selected image</h4>
<div id="ims-imgform" class="ims-form"></div>
<div class="ims-cropwrap"><canvas id="ims-crop"></canvas></div>
</section>
</aside>
</div>
</section>
+62
View File
@@ -0,0 +1,62 @@
<section id="view-wnd" class="view">
<header class="bar top">
<button id="wnd-src" class="iconbtn" title="Where the UI files are read from"><i data-icon="box"></i><span id="wnd-src-label">Choose packs…</span></button>
<button id="wnd-open-file" class="iconbtn" title="Open a window .xml from disk"><i data-icon="folder"></i><span>Open file…</span></button>
<div class="ims-title"><span id="wnd-title" class="muted"></span><span id="wnd-dirty" class="dot bad" title="Unsaved changes" hidden></span></div>
<div class="spacer"></div>
<button id="wnd-undo" class="iconbtn only" title="Undo (Ctrl+Z)" disabled><i data-icon="undo"></i></button>
<button id="wnd-redo" class="iconbtn only" title="Redo (Ctrl+Y)" disabled><i data-icon="redo"></i></button>
<button id="wnd-save" class="btn primary"><i data-icon="save"></i>Save</button>
</header>
<div class="split">
<aside id="wnd-left" class="ims-left">
<div class="ims-head">
<div class="search"><i data-icon="search"></i><input id="wnd-search" type="search" placeholder="Search window files" spellcheck="false" /></div>
<span id="wnd-count" class="muted"></span>
</div>
<div id="wnd-list" class="mdl-list"></div>
<div class="ims-foot muted mdl-hint">Double-click a file to open it</div>
</aside>
<div class="splitter" data-target="wnd-left" data-edge="left" title="Drag to resize"></div>
<div class="main-col">
<div class="bar nav">
<div class="seg"><button id="wnd-zoom" title="100 %">100%</button></div>
<button id="wnd-fit" class="iconbtn" title="Fit the window"><i data-icon="fit"></i><span>Fit</span></button>
<label class="pill" title="Game screen size (for aligned windows)"><i data-icon="grid"></i>
<select id="wnd-screen"><option value="800x600">800×600</option><option value="1024x768" selected>1024×768</option><option value="1280x720">1280×720</option><option value="1920x1080">1920×1080</option></select>
</label>
<label class="pill" title="Button state to show"><i data-icon="target"></i>
<select id="wnd-state"><option>Normal</option><option>Over</option><option>Push</option><option>Disable</option></select>
</label>
<button id="wnd-t-preview" class="iconbtn toggle" title="Preview: the window as the game shows it (no outlines, no hidden windows)"><i data-icon="play"></i><span>Preview</span></button>
<button id="wnd-t-img" class="iconbtn toggle on only" title="Images and frames"><i data-icon="image"></i></button>
<button id="wnd-t-text" class="iconbtn toggle on only" title="Texts"><i data-icon="note"></i></button>
<button id="wnd-t-bounds" class="iconbtn toggle on" title="Window outlines"><i data-icon="draw"></i><span>Bounds</span></button>
<button id="wnd-t-hidden" class="iconbtn toggle on" title="Windows that start hidden (IWS_START_HIDE)"><i data-icon="copy"></i><span>Hidden</span></button>
<div class="spacer"></div>
<span id="wnd-textinfo" class="muted ims-texinfo"></span>
<span id="wnd-hover" class="mono ims-texinfo"></span>
</div>
<div class="ims-stage"><canvas id="wnd-canvas" tabindex="0"></canvas></div>
<div id="wnd-empty" class="empty">
<div class="empty-card">
<i data-icon="layout" class="big"></i>
<h2>UI window editor</h2>
<p>Pick the packs, then double-click a window file (xml/…wnd.xml). Click a window to select it, drag to move, handles to resize; every element and attribute can be edited on the right.</p>
</div>
</div>
<div class="ims-help muted">Click selects · drag moves · handles resize · arrows nudge (Shift ×10) · Alt/right-drag pans · wheel zooms · Del removes a child window</div>
</div>
<div class="splitter" data-target="wnd-side" data-edge="right" title="Drag to resize"></div>
<aside id="wnd-side" class="ims-side map-side" hidden>
<section class="ims-card">
<h4>Windows <span id="wnd-count-win" class="muted"></span></h4>
<div id="wnd-tree" class="wnd-tree"></div>
</section>
<section id="wnd-props-card" class="ims-card grow" hidden>
<h4><span id="wnd-props-name"></span></h4>
<div id="wnd-props" class="wnd-props"></div>
</section>
</aside>
</div>
</section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB