Files
losadev-tools/app/public/legacy/js/anim-studio.js
T

1955 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Anim Studio (Anim tab): a character (model config or skeleton + meshes) and one animation clip to
// make or change — pose bones (every change keys the bone at the playhead), move / delete keys on
// the dope sheet, cut / join / speed / reverse / loop / mirror time ranges, retarget animations from
// other skeletons, and re-load .ani files Blender exports. The maths is in animkit.js; saves go
// through mdl:writeBack.
(() => {
const K = AnimKit;
const { Q, V } = K;
const ANS = {
src: null,
rows: [],
kind: "model",
char: null, // { name, sk, skName, meshes: [] }
clip: null, // AnimKit clip + { where, source, dirty, file }
map: [],
time: 0,
playing: false,
speed: 1,
loop: true,
bone: -1,
keys: new Set(), // selected key objects
event: -1,
range: null, // [a, b] ms
tool: "view",
undo: [],
redo: [],
ghost: null, // { rig, info } of the last import / retarget
watch: null, // { file, mtime }
step: 1000 / 30,
};
window.ANS = ANS;
let view = null;
let kit = null;
const stem = (p) => p.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
const kindOf = (p) => {
const l = p.toLowerCase();
if (l.endsWith(".msh")) return "msh";
if (l.endsWith(".ani")) return "ani";
if (l.endsWith(".skl")) return "skl";
if (l.endsWith(".txt") && /(^|\/)model\//.test(l)) return "model";
return null;
};
const fmt = (ms) => `${(ms / 1000).toFixed(2)} s`;
const num = (v, d = 2) => (Math.abs(v) < 1e-9 ? 0 : v).toFixed(d);
const visible = () => $("view-ans").classList.contains("on");
function ensureView() {
if (view) return view;
try {
view = new GL3D.View($("ans-canvas"));
} catch (err) {
toast(`3D view unavailable: ${err.message}`, true);
return null;
}
kit = MeshKit.create(view, () => ANS.src);
ANS.view = view;
view.onPointerDown = pointerDown;
view.onCamera = () => requestAnimationFrame(drawOverlays);
return view;
}
function loading(on, text = "Loading…") {
$("ans-loading").hidden = !on;
$("ans-loading").querySelector("span").textContent = text;
}
// =====================================================================
// source + browser
// =====================================================================
async function setSource(src, { quiet = false } = {}) {
try {
const d = await call("mdl:describe", src);
ANS.src = { ...src, label: d.label };
} catch (err) {
return toast(err.message, true);
}
$("ans-src-label").textContent = ANS.src.kind === "dir" ? "Extracted folder" : ANS.src.kind === "packs" ? "Pack folder" : `Packs next to ${ANS.src.name}`;
$("ans-src").title = `Reading from ${ANS.src.label}`;
kit?.clearTextures();
await loadList();
if (!quiet) toast(`Reading from ${ANS.src.label}.`);
}
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.replace(/[\\/][^\\/]+$/, "") })),
{ value: "packs", title: "A folder with .iop packs…", sub: "the client folder: map.iop, map2.iop, rs.iop…" },
{ value: "dir", title: "A folder of extracted files…", sub: "resource/skeleton, resource/animation, resource/mesh…" },
];
let choice = options[0].value;
const list = el(
"div",
{ class: "choice-list" },
options.map((o, i) =>
el("label", { class: "choice" }, [el("input", { type: "radio", name: "anssrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })])]),
),
);
if (!(await modal("Where are the animations?", ["Skeletons, meshes and animations are spread over several packs. Pick the folder they are in.", 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;
}
$("ans-src").onclick = () => chooseSource();
const list = new VTable($("ans-list"), [{ key: 1, label: "Name", width: 200, grow: true, title: true }, { key: 2, label: "Pack", width: 70, cls: "mono dim" }], { multi: false, id: "ans" });
list.keyOf = (r) => r[0];
list.sortBy(1);
async function loadList() {
if (!ANS.src) return;
loading(true, "Reading the pack index…");
try {
const rows = await call("mdl:list", ANS.src);
ANS.rows = rows.map(([k, path, pack]) => {
const kind = kindOf(path);
return [k, kind === "model" ? path.replace(/^.*?model\//i, "").replace(/\.txt$/i, "") : stem(path), pack || path.split("/").slice(0, -1).join("/"), kind, path];
});
} catch (err) {
toast(err.message, true);
ANS.rows = [];
} finally {
loading(false);
}
filterList();
}
function filterList() {
const q = $("ans-search").value.trim().toLowerCase();
const rows = ANS.rows.filter((r) => r[3] === ANS.kind && (!q || r[1].toLowerCase().includes(q)));
list.setRows(rows);
$("ans-count").textContent = rows.length.toLocaleString();
}
let searchTimer = 0;
$("ans-search").addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(filterList, 120);
});
for (const b of $("ans-kinds").children)
b.onclick = () => {
ANS.kind = b.dataset.kind;
for (const x of $("ans-kinds").children) x.classList.toggle("on", x === b);
filterList();
};
list.onOpen = (r) => openRow(r);
$("ans-list").addEventListener("contextmenu", (e) => {
const rowEl = e.target.closest(".vt-row");
if (!rowEl) return;
e.preventDefault();
const i = Number(rowEl.dataset.i);
const r = list.view[i];
list.click(i, {});
const items = [];
if (r[3] === "model" || r[3] === "skl") items.push({ label: "Use as the character", icon: "bone", run: () => openRow(r) });
if (r[3] === "msh") items.push({ label: "Add to the character", icon: "plus", disabled: !ANS.char, run: () => openRow(r) });
if (r[3] === "ani") {
items.push(
{ label: "Open (edit this animation)", icon: "external", hint: "Double-click", run: () => openRow(r) },
"-",
{ label: "Append to the end of the clip", icon: "plus", disabled: !ANS.clip, run: () => joinRow(r, "append") },
{ label: "Insert at the playhead…", icon: "plus", disabled: !ANS.clip, run: () => joinRow(r, "insert") },
{ label: "Retarget onto this character…", icon: "swap", disabled: !ANS.char, run: () => retargetRow(r) },
);
}
items.push("-", { label: "Copy name", icon: "copy", run: () => navigator.clipboard.writeText(r[4].split("/").pop()) });
showMenu(items, { x: e.clientX, y: e.clientY });
});
async function readKey(k) {
const r = await call("mdl:readKey", ANS.src, k);
if (!r) throw new Error("Not found in the packs any more.");
return r;
}
const readNamed = (name, folder) => (ANS.src ? call("mdl:read", ANS.src, name, folder, false) : null);
async function openRow(r) {
if (!r) return;
try {
if (r[3] === "model") await useModel(await readKey(r[0]), r[1]);
else if (r[3] === "skl") await useSkeleton(await readKey(r[0]), `${r[1]}.skl`);
else if (r[3] === "msh") await addMesh(await readKey(r[0]), r[1]);
else if (r[3] === "ani") await openAni(await readKey(r[0]), `${r[1]}.ani`);
} catch (err) {
toast(err.message, true);
} finally {
loading(false);
}
}
// =====================================================================
// character
// =====================================================================
function newCharacter(name) {
if (!ensureView()) return null;
view.clear();
ANS.char = { name, sk: null, skName: "", meshes: [] };
ANS.bone = -1;
$("ans-empty").hidden = true;
return ANS.char;
}
async function prepareSkeleton(data, name) {
const sk = SkinKit.prepare(data);
if (data.linked) {
const h = await readNamed(data.linked, "skeleton");
if (!h || !SkinKit.link(sk, LSF.parseSkl(h.bytes))) toast(`${name} links to ${data.linked}, which isn't in these packs (or has other bones); positions may be off.`, true);
}
sk.name = name;
return sk;
}
async function useSkeleton(hit, name, { keepMeshes = false } = {}) {
const old = ANS.char;
const c = keepMeshes && old ? old : newCharacter(stem(name));
if (!c) return;
c.sk = await prepareSkeleton(LSF.parseSkl(hit.bytes), name);
c.skName = name;
for (const m of c.meshes) m.palette = SkinKit.palette(c.sk, m.data);
remap();
frame();
if (!ANS.clip) newClip("new_animation", { quiet: true });
refresh();
}
async function useModel(hit, name) {
loading(true, "Loading the model…");
const cfg = LSF.parseModelCfg(LSF.decode(hit.bytes));
if (!cfg.skeleton) throw new Error("This model has no skeleton (#SKELETON); it can't be animated.");
const sk = await readNamed(cfg.skeleton, "skeleton");
if (!sk) throw new Error(`${cfg.skeleton} isn't in these packs.`);
const c = newCharacter(name);
c.sk = await prepareSkeleton(LSF.parseSkl(sk.bytes), cfg.skeleton);
c.skName = cfg.skeleton;
c.animList = cfg.animations;
const missing = [];
for (const n of cfg.meshes) {
const h = await readNamed(`${n}.msh`, "mesh");
if (h) await addMesh(h, n, { quiet: true });
else missing.push(n);
}
remap();
frame();
if (!ANS.clip && cfg.animations.length) {
const first = cfg.animations.find((a) => /delay/i.test(a)) || cfg.animations[0];
const h = await readNamed(first, "animation");
if (h) await openAni(h, first, { keepChar: true });
}
if (!ANS.clip) newClip("new_animation", { quiet: true });
if (missing.length) toast(`Not in these packs: ${missing.join(", ")}`, true);
refresh();
}
async function addMesh(hit, name, { quiet = false } = {}) {
if (!ANS.char) throw new Error("Pick a character (model or skeleton) first.");
const m = await kit.loadMesh(hit, name);
m.palette = ANS.char.sk ? SkinKit.palette(ANS.char.sk, m.data) : null;
ANS.char.meshes.push(m);
kit.loadTextures(m).then(() => (view.dirty = true));
if (!quiet) {
frame();
refresh();
}
return m;
}
/** human_man.skl (the heroes' skeleton) when nothing is loaded yet. */
async function ensureCharacter() {
if (ANS.char?.sk) return true;
if (!ANS.src && !(await chooseSource())) return false;
const h = await readNamed("human_man.skl", "skeleton");
if (!h) {
toast("Pick a character first: double-click a model or a skeleton (heroes use human_man.skl).", true);
return false;
}
await useSkeleton(h, "human_man.skl");
return true;
}
// =====================================================================
// clip
// =====================================================================
function remap() {
ANS.map = ANS.char?.sk && ANS.clip ? K.trackMap(ANS.char.sk, ANS.clip) : [];
}
function setClip(clip, { where = null, source = "", file = null, dirty = false } = {}) {
stop();
ANS.clip = Object.assign(clip, { where, source, file, dirty });
ANS.time = 0;
ANS.keys.clear();
ANS.event = -1;
ANS.range = null;
ANS.undo = [];
ANS.redo = [];
ANS.watch = file ? { file, mtime: null } : null;
if (file) call("anim:mtime", file).then((t) => ANS.watch && (ANS.watch.mtime = t));
remap();
dope.fit();
refresh();
}
function newClip(name, { quiet = false } = {}) {
if (!ANS.char?.sk) return toast("Pick a character first (a model or a skeleton).", true);
setClip(K.emptyClip(name, 4002, 1000), { dirty: !quiet });
}
$("ans-new").onclick = async () => {
if (!(await ensureCharacter()) || !(await confirmDiscard())) return;
const name = await prompt("New animation", "Name (saved as resource/animation/<name>.ani)", "new_animation");
if (!name) return;
newClip(name.replace(/\.ani$/i, "").trim());
toast("Empty clip: click a bone, Rotate / Move and drag, or Key all to start from the rest pose.");
};
async function openAni(hit, name, { keepChar = false } = {}) {
if (!keepChar && !(await confirmDiscard())) return;
const data = LSF.parseAni(hit.bytes);
const clip = K.fromLsf(data, stem(name));
const names = data.tracks.map((t) => t.name.text.toLowerCase());
const fits = (sk) => sk && names.filter((n) => sk.byName.has(n)).length >= Math.max(1, names.length * 0.5);
if (!keepChar && !fits(ANS.char?.sk) && ANS.src) {
// a model that plays it, else the best skeleton
loading(true, "Finding a skeleton for this animation…");
const used = await call("mdl:usedBy", ANS.src, { ani: `${stem(name)}.ani` });
const modelRow = used.length && ANS.rows.find((x) => x[3] === "model" && x[4].toLowerCase().endsWith("/" + used[0]));
if (modelRow) {
ANS.clip = null;
await useModel(await readKey(modelRow[0]), modelRow[1]);
} else {
const c = await call("mdl:findSkeleton", ANS.src, { ani: `${stem(name)}.ani`, bones: names });
const best = c.find((x) => /^human_man\.skl$/.test(x.name) && x.matched === x.total) || c[0];
const sk = best && (await readNamed(best.name, "skeleton"));
if (sk) await useSkeleton(sk, best.name);
}
}
if (!ANS.char?.sk) return toast("No skeleton for this animation; pick a character first.", true);
setClip(clip, { where: hit.where, source: hit.source, file: hit.where?.kind === "file" ? hit.where.file : null });
play();
}
$("ans-open-file").onclick = async () => {
const file = await call("dialog:openExt", "Open an animation", "Animations", ["ani"]);
if (!file) return;
try {
if (!(await ensureCharacter())) return;
await openAni(await call("mdl:readFile", file), file.split(/[\\/]/).pop());
} catch (err) {
toast(err.message, true);
} finally {
loading(false);
}
};
function anyDirty() {
return !!ANS.clip?.dirty;
}
async function confirmDiscard() {
if (!anyDirty()) return true;
return confirmBox("Unsaved changes", `${ANS.clip.name} has changes that aren't saved. Discard them?`, "Discard", "danger");
}
// ---------- undo ----------
function snapshot() {
const { where, source, file, ...c } = ANS.clip;
return { clip: structuredClone(c), where, source, file, time: ANS.time };
}
function pushUndo() {
if (!ANS.clip) return;
ANS.undo.push(snapshot());
if (ANS.undo.length > 80) ANS.undo.shift();
ANS.redo = [];
updateUndo();
}
function restore(from, to) {
const s = from.pop();
if (!s) return;
to.push(snapshot());
ANS.clip = Object.assign(s.clip, { where: s.where, source: s.source, file: s.file });
ANS.keys.clear();
remap();
refresh();
}
function updateUndo() {
$("ans-undo").disabled = !ANS.undo.length;
$("ans-redo").disabled = !ANS.redo.length;
}
$("ans-undo").onclick = () => restore(ANS.undo, ANS.redo);
$("ans-redo").onclick = () => restore(ANS.redo, ANS.undo);
/** After any edit of the clip. */
function changed({ side = true } = {}) {
ANS.clip.dirty = true;
remap();
applyPose();
dope.draw();
if (side) renderSide();
else renderHead();
}
// =====================================================================
// pose + drawing
// =====================================================================
let pose = null;
function computePose() {
const sk = ANS.char?.sk;
return sk ? K.lsPose(sk, ANS.clip, ANS.map, ANS.time) : null;
}
function applyPose() {
if (!view || !ANS.char) return;
pose = computePose();
if (pose) {
const sk = ANS.char.sk;
const skin = pose.wr.map((q, i) => SkinKit.M4.mul(sk.bst[i], SkinKit.M4.fromQT(q, pose.wp[i])));
const show = toggled("ans-t-mesh");
for (const m of ANS.char.meshes) {
view.setVisible(m.id, show && !m.hidden);
if (m.palette && show && !m.hidden) {
SkinKit.skin(m, m.palette, { skin });
view.updateMesh(m.id, m.skinPos, m.skinNrm);
}
}
}
drawOverlays();
renderClock();
}
const C = { bone: [0.96, 0.79, 0.35, 1], joint: [1, 0.9, 0.55, 1], root: [1, 0.42, 0.37, 1], sel: [0.35, 0.85, 1, 1], ghost: [0.45, 0.65, 1, 0.8], grid: [1, 1, 1, 0.16], axis: [1, 1, 1, 0.4] };
const push = (a, p, c) => a.push(p[0], p[1], p[2], c[0], c[1], c[2], c[3]);
function drawOverlays() {
if (!view) return;
const labels = $("ans-labels");
labels.replaceChildren();
const sk = ANS.char?.sk;
if (sk && pose) {
const l = [], pts = [], sel = [];
sk.parent.forEach((pi, i) => {
push(i === ANS.bone ? sel : pts, pose.wp[i], pi < 0 ? C.root : i === ANS.bone ? C.sel : C.joint);
if (pi >= 0) {
const c = i === ANS.bone || pi === ANS.bone ? C.sel : C.bone;
push(l, pose.wp[pi], c);
push(l, pose.wp[i], c);
}
});
const on = toggled("ans-t-bones");
view.setOverlay("bones", new Float32Array(l), { depth: false });
view.setOverlay("joints", new Float32Array(pts), { mode: "points", depth: false, point: 5 });
view.setOverlay("sel", sel.length ? new Float32Array(sel) : null, { mode: "points", depth: false, point: 10 });
view.showOverlay("bones", on);
view.showOverlay("joints", on);
view.showOverlay("sel", true);
if (ANS.bone >= 0) {
const sp = view.project(pose.wp[ANS.bone]);
if (sp) labels.append(el("span", { class: "mdl-tag", text: sk.data.bones[ANS.bone].name.text, style: `left:${sp[0]}px;top:${sp[1]}px` }));
}
} else {
for (const o of ["bones", "joints", "sel"]) view.setOverlay(o, null);
}
// the source skeleton of the last import / retarget, where it lands on this character
const g = ANS.ghost;
if (g && toggled("ans-t-ghost") && ANS.clip === g.clip) {
const S = g.rig.pose(ANS.time + g.start);
const at = (p) => V.add(g.info.restRoot, V.scale(V.sub(p, g.info.srcRoot), g.info.scale));
const gl = [];
g.rig.parent.forEach((pi, i) => {
if (pi < 0 || /IK|全ての親|操作中心/.test(g.rig.names[i])) return;
push(gl, at(S.wp[pi]), C.ghost);
push(gl, at(S.wp[i]), C.ghost);
});
view.setOverlay("ghost", new Float32Array(gl), { depth: false });
} else view.setOverlay("ghost", null);
drawHitboxes();
updateFace();
view.dirty = true;
}
function frame() {
const sk = ANS.char?.sk;
let min = [-50, 0, -50], max = [50, 150, 50];
if (sk) {
const p = K.lsPose(sk, null, null, 0).wp;
min = [0, 1, 2].map((i) => Math.min(0, ...p.map((v) => v[i])));
max = [0, 1, 2].map((i) => Math.max(...p.map((v) => v[i])));
}
for (const m of ANS.char?.meshes || []) for (let i = 0; i < 3; i++) {
min[i] = Math.min(min[i], m.data.aabbMin[i]);
max[i] = Math.max(max[i], m.data.aabbMax[i]);
}
view.frame(min, max);
const r = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2], 40) * 1.6;
const step = 10 ** Math.floor(Math.log10(r / 4));
const half = Math.ceil(r / step) * step;
const g = [];
for (let v = -half; v <= half + 1e-6; v += step) {
const c = Math.abs(v) < step / 2 ? C.axis : C.grid;
push(g, [v, 0, -half], c);
push(g, [v, 0, half], c);
push(g, [-half, 0, v], c);
push(g, [half, 0, v], c);
}
view.setOverlay("grid", new Float32Array(g));
view.showOverlay("grid", toggled("ans-t-grid"));
}
const toggled = (id) => $(id).classList.contains("on");
for (const id of ["ans-t-mesh", "ans-t-bones", "ans-t-ghost", "ans-t-grid"])
$(id).onclick = () => {
$(id).classList.toggle("on");
view?.showOverlay("grid", toggled("ans-t-grid"));
applyPose();
};
$("ans-reset").onclick = () => view && frame();
for (const b of $("ans-tools").children) b.onclick = () => setTool(b.dataset.tool);
function setTool(t) {
ANS.tool = t;
for (const b of $("ans-tools").children) b.classList.toggle("on", b.dataset.tool === t);
$("ans-canvas").classList.toggle("manip", t !== "view");
}
// =====================================================================
// picking + manipulating bones in the view
// =====================================================================
function pickBone(e) {
if (!pose || !view) return -1;
const b = $("ans-canvas").getBoundingClientRect();
const x = e.clientX - b.left, y = e.clientY - b.top;
let best = -1, bd = 14;
pose.wp.forEach((p, i) => {
const s = view.project(p);
if (!s) return;
const d = Math.hypot(s[0] - x, s[1] - y);
if (d < bd) {
bd = d;
best = i;
}
});
return best;
}
// a plain click (no drag) in View mode picks a bone
let clickAt = null;
$("ans-canvas").addEventListener("mousedown", (e) => (clickAt = e.button === 0 ? [e.clientX, e.clientY] : null));
$("ans-canvas").addEventListener("mouseup", (e) => {
if (!clickAt || ANS.tool !== "view") return;
if (Math.hypot(e.clientX - clickAt[0], e.clientY - clickAt[1]) < 4) selectBone(pickBone(e));
clickAt = null;
});
function pointerDown(e) {
if (ANS.tool === "view" || e.button !== 0 || !pose || !ANS.clip) return false;
const hit = pickBone(e);
if (hit >= 0 && hit !== ANS.bone) selectBone(hit);
if (ANS.bone < 0) return false;
startManip(e);
return true;
}
function startManip(e) {
const sk = ANS.char.sk;
const b = ANS.bone;
const pi = sk.parent[b];
const P = pose;
const Wp = pi >= 0 ? P.wr[pi] : Q.id();
const W0 = P.wr[b];
const pivot = P.wp[b];
const p0 = V.scale(P.lp[b], 1 / sk.rate[b]);
const q0 = P.lr[b];
const ax = view.cameraAxes();
const scr = (p) => view.project(p) || [0, 0];
const ps = scr(pivot);
const s = Math.max(5, view.cam.dist * 0.05);
// which way a positive rotation / move shows on screen (so dragging follows the mouse)
const front = V.sub(pivot, V.scale(ax.dir, s));
const turn = (axis, a, p) => V.add(pivot, Q.rot(Q.axis(axis, a), V.sub(p, pivot)));
const sx = Math.sign(scr(turn(ax.up, 0.05, front))[0] - scr(front)[0]) || 1;
const sy = Math.sign(scr(turn(ax.right, 0.05, front))[1] - scr(front)[1]) || 1;
const side = V.add(pivot, V.scale(ax.right, s));
const ang = (p) => Math.atan2(p[1] - ps[1], p[0] - ps[0]);
const st = Math.sign(ang(scr(turn(ax.dir, 0.05, side))) - ang(scr(side))) || 1;
const mr = Math.sign(scr(V.add(pivot, V.scale(ax.right, s)))[0] - ps[0]) || 1;
const mu = Math.sign(ps[1] - scr(V.add(pivot, V.scale(ax.up, s)))[1]) || 1;
const unit = (view.cam.dist * Math.tan(view.cam.fov / 2) * 2) / $("ans-canvas").clientHeight;
const rect = $("ans-canvas").getBoundingClientRect();
const start = [e.clientX, e.clientY];
const a0 = Math.atan2(e.clientY - rect.top - ps[1], e.clientX - rect.left - ps[0]);
const tr = trackOf(b, true);
pushUndo();
stop();
const t = Math.round(ANS.time);
const move = (ev) => {
const dx = ev.clientX - start[0], dy = ev.clientY - start[1];
if (ANS.tool === "rotate") {
let D;
if (ev.ctrlKey) {
const a = Math.atan2(ev.clientY - rect.top - ps[1], ev.clientX - rect.left - ps[0]) - a0;
D = Q.axis(ax.dir, st * a);
} else D = Q.mul(Q.axis(ax.up, sx * dx * 0.01), Q.axis(ax.right, sy * dy * 0.01));
K.setKey(tr, t, p0, Q.mul(Q.conj(Wp), Q.mul(D, W0)));
} else {
const d = V.add(V.scale(ax.right, mr * dx * unit), V.scale(ax.up, mu * -dy * unit));
K.setKey(tr, t, V.add(p0, V.scale(Q.rot(Q.conj(Wp), d), 1 / sk.rate[b])), q0);
}
ANS.clip.dirty = true;
applyPose();
dope.draw();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
changed();
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
/** The clip's track of a bone (made when `create`). */
function trackOf(b, create = false) {
const i = ANS.map.indexOf(b);
if (i >= 0) return ANS.clip.tracks[i];
if (!create) return null;
const tr = { name: K.S(ANS.char.sk.data.bones[b].name.text), weight: 1, keys: [] };
ANS.clip.tracks.push(tr);
remap();
return tr;
}
function selectBone(b) {
ANS.bone = b;
applyPose();
renderBone();
dope.reveal(b);
dope.draw();
}
/** Keys bones at the playhead with their current pose. */
function keyBones(bones) {
if (!ANS.clip || !pose) return;
pushUndo();
const sk = ANS.char.sk;
for (const b of bones) K.setKey(trackOf(b, true), ANS.time, V.scale(pose.lp[b], 1 / sk.rate[b]), pose.lr[b]);
changed();
}
$("ans-key").onclick = () => (ANS.bone >= 0 ? keyBones([ANS.bone]) : toast("Pick a bone first.", true));
$("ans-key-all").onclick = () => ANS.char?.sk && keyBones(ANS.char.sk.data.bones.map((_, i) => i));
function deleteKeys() {
if (!ANS.keys.size) return toast("Select keys on the dope sheet first (click, Shift-click, drag a box).", true);
pushUndo();
for (const tr of ANS.clip.tracks) tr.keys = tr.keys.filter((k) => !ANS.keys.has(k));
ANS.clip.tracks = ANS.clip.tracks.filter((t) => t.keys.length);
ANS.keys.clear();
changed();
}
$("ans-del-keys").onclick = deleteKeys;
// =====================================================================
// transport
// =====================================================================
let raf = 0, last = 0;
function play() {
if (!ANS.clip || ANS.clip.total <= 0) return;
const [a, b] = ANS.range && ANS.loop ? ANS.range : [0, ANS.clip.total];
if (ANS.time >= b) ANS.time = a;
ANS.playing = true;
$("ans-play").replaceChildren(icon("pause"));
last = performance.now();
cancelAnimationFrame(raf);
soundEventsBetween(ANS.time - 1e-6, ANS.time);
const tick = (now) => {
if (!ANS.playing) return;
const [a, b] = ANS.range && ANS.loop ? ANS.range : [0, ANS.clip.total];
const prev = ANS.time;
ANS.time += (now - last) * ANS.speed;
last = now;
if (ANS.time > b) {
soundEventsBetween(prev, b);
if (ANS.loop) {
ANS.time = a + ((ANS.time - a) % Math.max(1, b - a));
soundEventsBetween(a - 1e-6, ANS.time);
} else {
ANS.time = b;
stop();
}
} else soundEventsBetween(prev, ANS.time);
if (visible()) {
applyPose();
dope.draw();
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
}
function stop() {
ANS.playing = false;
cancelAnimationFrame(raf);
$("ans-play").replaceChildren(icon("play"));
}
function seek(t) {
ANS.time = Math.max(0, Math.min(ANS.clip?.total || 0, t));
applyPose();
dope.draw();
renderBone();
}
$("ans-play").onclick = () => (ANS.playing ? (stop(), renderBone()) : play());
$("ans-speed").onchange = () => (ANS.speed = Number($("ans-speed").value) || 1);
$("ans-loop").onclick = () => {
ANS.loop = !ANS.loop;
$("ans-loop").classList.toggle("on", ANS.loop);
};
function renderClock() {
const c = ANS.clip;
$("ans-clock").textContent = c ? `${fmt(ANS.time)} / ${fmt(c.total)} · f${Math.round(ANS.time / ANS.step)}` : "";
$("ans-range-label").textContent = ANS.range ? `Range ${fmt(ANS.range[0])} – ${fmt(ANS.range[1])}` : "Shift-drag the ruler for a range";
}
// =====================================================================
// dope sheet (canvas): ruler, events row, a row per bone (tree order)
// =====================================================================
const dope = (() => {
const cv = $("ans-dope-canvas");
const g = cv.getContext("2d");
const RULER = 22, EVROW = 16, ROW = 18, NAMEW = 180;
let rows = []; // [{ bone, depth, track }]
let scroll = 0;
let v0 = 0, v1 = 1000; // visible time range
function build() {
const sk = ANS.char?.sk;
rows = [];
if (sk) {
const kids = sk.parent.map(() => []);
sk.parent.forEach((p, i) => p >= 0 && kids[p].push(i));
const walk = (i, d) => {
rows.push({ bone: i, depth: d });
kids[i].forEach((c) => walk(c, d + 1));
};
sk.parent.forEach((p, i) => p < 0 && walk(i, 0));
}
// tracks for bones this skeleton doesn't have
ANS.map.forEach((b, ti) => b < 0 && rows.push({ bone: -1, depth: 0, ti }));
}
const W = () => cv.clientWidth, H = () => cv.clientHeight;
const tx = (t) => NAMEW + ((t - v0) / Math.max(1, v1 - v0)) * (W() - NAMEW - 12);
const xt = (x) => v0 + ((x - NAMEW) / Math.max(1, W() - NAMEW - 12)) * (v1 - v0);
const keysOfRow = (r) => (r.bone >= 0 ? trackOf(r.bone)?.keys : ANS.clip.tracks[r.ti]?.keys) || [];
function fit() {
v0 = 0;
v1 = Math.max(100, ANS.clip?.total || 1000);
scroll = 0;
build();
}
function draw() {
const dpr = devicePixelRatio || 1;
if (cv.width !== Math.round(W() * dpr) || cv.height !== Math.round(H() * dpr)) {
cv.width = Math.round(W() * dpr);
cv.height = Math.round(H() * dpr);
}
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, W(), H());
g.font = "11px Segoe UI, sans-serif";
const c = ANS.clip;
if (!c) return;
if (rows.length === 0 || rows.filter((r) => r.bone < 0).length !== ANS.map.filter((b) => b < 0).length) build();
// range shade
if (ANS.range) {
g.fillStyle = "rgba(233,180,60,0.08)";
g.fillRect(tx(ANS.range[0]), 0, tx(ANS.range[1]) - tx(ANS.range[0]), H());
}
// rows
const top = RULER + EVROW;
g.save();
g.beginPath();
g.rect(0, top, W(), H() - top);
g.clip();
rows.forEach((r, i) => {
const y = top + i * ROW - scroll;
if (y < top - ROW || y > H()) return;
if (r.bone === ANS.bone && r.bone >= 0) {
g.fillStyle = "rgba(90,200,255,0.10)";
g.fillRect(0, y, W(), ROW);
} else if (i % 2) {
g.fillStyle = "rgba(255,255,255,0.02)";
g.fillRect(0, y, W(), ROW);
}
const name = r.bone >= 0 ? ANS.char.sk.data.bones[r.bone].name.text.replace(/^Bip01 /, "") : K.nameOf(c.tracks[r.ti].name);
const keys = keysOfRow(r);
g.fillStyle = r.bone < 0 ? "#ff8f84" : keys.length ? "#ecebef" : "#6f6b77";
g.fillText(name, 6 + r.depth * 8, y + 13, NAMEW - 10 - r.depth * 8);
for (const k of keys) {
const x = tx(k.t);
if (x < NAMEW - 4 || x > W()) continue;
const on = ANS.keys.has(k);
g.fillStyle = on ? "#5ad2ff" : k.raw && k.rv === c.version ? "#e9b43c" : "#ffd98a";
g.beginPath();
g.moveTo(x, y + 4);
g.lineTo(x + 4, y + 9);
g.lineTo(x, y + 14);
g.lineTo(x - 4, y + 9);
g.fill();
}
});
g.restore();
// name column edge
g.fillStyle = "rgba(255,255,255,0.07)";
g.fillRect(NAMEW - 1, 0, 1, H());
// ruler
g.fillStyle = "#16151a";
g.fillRect(0, 0, W(), RULER + EVROW);
const span = v1 - v0;
const step = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 30000].find((s) => (s / span) * (W() - NAMEW) > 60) || 60000;
g.fillStyle = "#a6a2ad";
g.strokeStyle = "rgba(255,255,255,0.12)";
for (let t = Math.ceil(v0 / step) * step; t <= v1; t += step) {
const x = tx(t);
g.fillRect(x, RULER - 6, 1, 6);
g.fillText(step < 1000 ? `${(t / 1000).toFixed(2)}` : `${t / 1000}s`, x + 3, 12);
}
// end of clip
g.fillStyle = "rgba(255,107,94,0.5)";
g.fillRect(tx(c.total), 0, 1, H());
// events
g.fillStyle = "#6f6b77";
g.fillText("events", 6, RULER + 12);
c.events.forEach((e, i) => {
const x = tx(e.time);
g.fillStyle = i === ANS.event ? "#5ad2ff" : "#ff6b5e";
g.fillRect(x - 3, RULER + 3, 7, 10);
});
// playhead
const px = tx(ANS.time);
g.fillStyle = "#e9b43c";
g.fillRect(px - 0.5, 0, 1.5, H());
g.beginPath();
g.moveTo(px - 5, 0);
g.lineTo(px + 5, 0);
g.lineTo(px, 7);
g.fill();
// box select
if (box) {
g.strokeStyle = "#5ad2ff";
g.strokeRect(Math.min(box.x0, box.x1), Math.min(box.y0, box.y1), Math.abs(box.x1 - box.x0), Math.abs(box.y1 - box.y0));
}
}
function reveal(b) {
const i = rows.findIndex((r) => r.bone === b);
if (i < 0) return;
const y = i * ROW;
const h = H() - RULER - EVROW;
if (y < scroll) scroll = y;
else if (y + ROW > scroll + h) scroll = y + ROW - h;
}
let box = null;
const pos = (e) => {
const b = cv.getBoundingClientRect();
return [e.clientX - b.left, e.clientY - b.top];
};
const rowAt = (y) => rows[Math.floor((y - RULER - EVROW + scroll) / ROW)];
function keyAt(x, y) {
const r = rowAt(y);
if (!r || y < RULER + EVROW) return null;
let best = null, bd = 6;
for (const k of keysOfRow(r)) {
const d = Math.abs(tx(k.t) - x);
if (d < bd) {
bd = d;
best = k;
}
}
return best;
}
cv.addEventListener("mousedown", (e) => {
if (!ANS.clip) return;
cv.focus();
const [x, y] = pos(e);
const snap = (t) => Math.round(Math.max(0, Math.min(ANS.clip.total, t)));
// ruler: scrub, Shift = range
if (y < RULER) {
stop();
const t0 = snap(xt(x));
if (e.shiftKey) ANS.range = null;
const mv = (ev) => {
const t = snap(xt(pos(ev)[0]));
if (e.shiftKey) ANS.range = t === t0 ? null : [Math.min(t0, t), Math.max(t0, t)];
else seek(t);
draw();
renderClock();
renderOps();
};
mv(e);
drag(mv);
return;
}
// events row: pick / move an event
if (y < RULER + EVROW) {
const i = ANS.clip.events.findIndex((ev) => Math.abs(tx(ev.time) - x) < 6);
ANS.event = i;
renderEvents();
draw();
if (i < 0) return seek(snap(xt(x)));
const t0 = ANS.clip.events[i].time;
let pushed = false;
drag((ev) => {
const t = snap(t0 + xt(pos(ev)[0]) - xt(x));
if (t === ANS.clip.events[i].time) return;
if (!pushed) (pushUndo(), (pushed = true));
ANS.clip.events[i].time = t;
ANS.clip.dirty = true;
draw();
}, () => pushed && changed());
return;
}
const r = rowAt(y);
if (x < NAMEW) {
if (r?.bone >= 0) selectBone(r.bone);
return;
}
const k = keyAt(x, y);
if (k) {
if (e.shiftKey) ANS.keys.has(k) ? ANS.keys.delete(k) : ANS.keys.add(k);
else if (!ANS.keys.has(k)) {
ANS.keys.clear();
ANS.keys.add(k);
}
if (r?.bone >= 0 && r.bone !== ANS.bone) selectBone(r.bone);
draw();
// drag the selected keys in time
const sel = [...ANS.keys].map((key) => [key, key.t]);
let pushed = false;
drag((ev) => {
const dt = Math.round(xt(pos(ev)[0]) - xt(x));
if (!dt && !pushed) return;
if (!pushed) {
pushUndo();
pushed = true;
}
for (const [key, t0] of sel) key.t = Math.max(0, Math.min(ANS.clip.total, t0 + dt));
for (const tr of ANS.clip.tracks) K.sortKeys(tr);
ANS.clip.dirty = true;
applyPose();
draw();
}, () => {
if (!pushed) return seek(k.t);
// two keys on the same ms in a track: the moved one wins
for (const tr of ANS.clip.tracks) tr.keys = tr.keys.filter((a) => ANS.keys.has(a) || !tr.keys.some((b) => b !== a && ANS.keys.has(b) && Math.round(b.t) === Math.round(a.t)));
changed();
});
return;
}
// empty: playhead + bone, drag = box select
if (!e.shiftKey) ANS.keys.clear();
if (r?.bone >= 0 && r.bone !== ANS.bone) selectBone(r.bone);
seek(snap(xt(x)));
box = { x0: x, y0: y, x1: x, y1: y };
drag(
(ev) => {
[box.x1, box.y1] = pos(ev);
draw();
},
() => {
if (Math.abs(box.x1 - box.x0) > 3 || Math.abs(box.y1 - box.y0) > 3) {
const [xa, xb] = [Math.min(box.x0, box.x1), Math.max(box.x0, box.x1)];
const [ya, yb] = [Math.min(box.y0, box.y1), Math.max(box.y0, box.y1)];
rows.forEach((row, i) => {
const yy = RULER + EVROW + i * ROW - scroll + ROW / 2;
if (yy < ya || yy > yb) return;
for (const key of keysOfRow(row)) if (tx(key.t) >= xa && tx(key.t) <= xb) ANS.keys.add(key);
});
}
box = null;
draw();
},
);
});
cv.addEventListener("dblclick", (e) => {
const [x, y] = pos(e);
const k = keyAt(x, y);
if (k) seek(k.t);
});
cv.addEventListener(
"wheel",
(e) => {
e.preventDefault();
if (!ANS.clip) return;
if (e.ctrlKey) {
const [x] = pos(e);
const t = xt(x);
const f = e.deltaY < 0 ? 0.8 : 1.25;
v0 = Math.max(-50, t - (t - v0) * f);
v1 = Math.max(v0 + 50, t + (v1 - t) * f);
} else if (e.shiftKey) {
const d = ((v1 - v0) * Math.sign(e.deltaY)) / 10;
v0 += d;
v1 += d;
} else {
scroll = Math.max(0, Math.min(Math.max(0, rows.length * ROW - (H() - RULER - EVROW)), scroll + e.deltaY));
}
draw();
},
{ passive: false },
);
function drag(move, end) {
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
end?.();
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
new ResizeObserver(() => draw()).observe(cv.parentElement);
return { draw, fit, reveal, build, rows: () => rows };
})();
ANS.dope = dope;
// =====================================================================
// side panel
// =====================================================================
function renderHead() {
const c = ANS.clip;
$("ans-title").textContent = c ? `${ANS.char?.name || ""} · ${c.name}.ani` : "";
$("ans-dirty").hidden = !c?.dirty;
$("ans-save").disabled = !c;
$("ans-clip-name").textContent = c ? `${c.name}${c.dirty ? " •" : ""}` : "";
$("ans-info").textContent = ANS.char?.sk ? `${ANS.char.skName} · ${ANS.char.sk.data.bones.length} bones${ANS.char.meshes.length ? ` · ${ANS.char.meshes.length} meshes` : ""}` : "";
updateUndo();
}
function renderSide() {
renderHead();
renderChar();
renderClip();
renderBone();
renderOps();
renderEvents();
renderHit();
renderBlender();
}
function refresh() {
dope.build();
applyPose();
dope.draw();
renderSide();
}
const field = (label, input) => [el("label", { text: label }), input];
const numInput = (value, onchange, { step = "any", w } = {}) => {
const i = el("input", { type: "number", step, value: String(value), onchange: () => onchange(Number(i.value)) });
if (w) i.style.width = w;
return i;
};
// ---------- character: the meshes it wears ----------
function removeMeshes(list) {
for (const m of list) {
view.removeMesh(m.id);
ANS.char.meshes.splice(ANS.char.meshes.indexOf(m), 1);
}
applyPose();
renderSide();
}
// ---------- character presets: a skeleton + the meshes worn, one click to dress again ----------
function charPresets() { try { return JSON.parse(lsGet("ans-char-presets") || "[]"); } catch { return []; } }
function storePresets(list) { lsSet("ans-char-presets", JSON.stringify(list)); }
async function saveCharPreset() {
const c = ANS.char;
if (!c?.meshes.length) return toast("Dress the character first (double-click meshes).", true);
const guess = (c.meshes.find((m) => /body/i.test(m.name)) || c.meshes[0]).name.replace(/_bodyd*/i, "").replace(/_hw$/i, " (woman)");
const name = await prompt("Save character preset", "Name", guess);
if (!name?.trim()) return;
const list = charPresets().filter((p) => p.name.toLowerCase() !== name.trim().toLowerCase());
list.push({ name: name.trim(), skeleton: c.skName, meshes: c.meshes.map((m) => m.name), hidden: c.meshes.filter((m) => m.hidden).map((m) => m.name) });
list.sort((a, b) => a.name.localeCompare(b.name));
storePresets(list);
toast(`Saved preset ${name.trim()} (${c.meshes.length} meshes).`);
renderChar();
}
async function loadCharPreset(p) {
try {
if (!ANS.src && !(await chooseSource())) return;
loading(true, `Dressing ${p.name}…`);
if (!ANS.char?.sk || ANS.char.skName.toLowerCase() !== p.skeleton.toLowerCase()) {
const h = await readNamed(p.skeleton, "skeleton");
if (!h) throw new Error(`${p.skeleton} isn't in these packs.`);
await useSkeleton(h, p.skeleton);
}
if (ANS.char.meshes.length) removeMeshes(ANS.char.meshes.slice());
const missing = [];
for (const n of p.meshes) {
const h = await readNamed(`${n}.msh`, "mesh");
if (!h) { missing.push(n); continue; }
const m = await addMesh(h, n, { quiet: true });
if ((p.hidden || []).includes(n)) m.hidden = true;
}
frame();
refresh();
applyPose();
if (missing.length) toast(`Not in these packs: ${missing.join(", ")}`, true);
} catch (err) {
toast(err.message, true);
} finally {
loading(false);
}
}
ANS.loadCharPreset = (name) => { const p = charPresets().find((x) => x.name === name); return p ? loadCharPreset(p) : null; };
ANS.charPresets = charPresets;
function presetRow() {
const list = charPresets();
return el("div", { class: "ans-presets" }, [
...list.map((p) => el("span", { class: "ans-preset", title: `${p.skeleton}: ${p.meshes.join(", ")}` }, [
el("button", { class: "ans-preset-load", text: p.name, onclick: () => loadCharPreset(p) }),
el("button", { class: "ans-preset-del", title: "Delete this preset", text: "×", onclick: async () => {
if (!(await confirmBox("Delete preset", `Delete the character preset "${p.name}"?`, "Delete", "danger"))) return;
storePresets(charPresets().filter((x) => x.name !== p.name));
renderChar();
} }),
])),
ANS.char?.meshes.length ? el("button", { class: "iconbtn", title: "Save this skeleton + the meshes worn as a preset", onclick: saveCharPreset }, [icon("save"), "Save preset"]) : null,
]);
}
function renderChar() {
const box = $("ans-char");
const c = ANS.char;
$("ans-char-name").textContent = c?.skName || "";
if (!c) return box.replaceChildren(el("div", { class: "ans-note", text: "Double-click a model or a skeleton, or load a preset." }), presetRow());
const rows = c.meshes.map((m) =>
el("div", { class: "ans-mesh" }, [
el("input", { type: "checkbox", checked: !m.hidden, title: "Show / hide", onchange: (e) => ((m.hidden = !e.target.checked), applyPose()) }),
el("span", { class: "mono grow", text: m.name, title: m.name }),
el("button", { class: "iconbtn only danger", title: "Take it off (unequip)", onclick: () => removeMeshes([m]) }, [icon("trash")]),
]),
);
box.replaceChildren(
...(rows.length ? rows : [el("div", { class: "ans-note", text: "No meshes: bones only. Double-click meshes in the list (Meshes) to dress it." })]),
c.meshes.length ? el("div", { class: "ans-row" }, [el("button", { class: "iconbtn", title: "Take every mesh off", onclick: () => removeMeshes(c.meshes.slice()) }, [icon("trash"), "Remove all"])]) : null,
presetRow(),
);
}
function renderClip() {
const box = $("ans-clip");
const c = ANS.clip;
if (!c) return box.replaceChildren(el("div", { class: "ans-note", text: "No clip. Open an animation or start a New one." }));
const keys = c.tracks.reduce((s, t) => s + t.keys.length, 0);
const name = el("input", { type: "text", value: c.name, spellcheck: "false", onchange: () => {
const v = name.value.replace(/\.ani$/i, "").trim();
if (!/^[\x21-\x7e]+$/.test(v)) return toast("Use ASCII letters, digits, _ and - (the client's file names).", true);
pushUndo();
c.name = v;
changed();
} });
const ver = el("select", { onchange: () => {
pushUndo();
c.version = Number(ver.value);
changed();
toast(`Saved as ${LSF.ANI_VERSIONS[c.version]}: every rotation is written in the new format.`);
} }, Object.entries(LSF.ANI_VERSIONS).map(([v, t]) => el("option", { value: v, selected: Number(v) === c.version, text: `${v} — ${t}` })));
let stretch = true;
const dur = numInput(c.total, (v) => {
if (!(v > 0)) return;
pushUndo();
if (stretch && c.total > 0) K.speed(c, 0, c.total, c.total / v);
c.total = Math.round(v);
changed();
dope.fit();
dope.draw();
}, { step: "1" });
const fps = el("select", { onchange: () => ((ANS.step = 1000 / Number(fps.value)), renderClock()) }, [24, 30, 60].map((f) => el("option", { value: f, selected: Math.abs(1000 / f - ANS.step) < 0.01, text: `${f} fps` })));
box.replaceChildren(
el("div", { class: "ans-grid" }, [
...field("Name", name),
...field("Format", ver),
...field("Length ms", el("div", { class: "ans-vec" }, [dur, el("label", { class: "ans-check", title: "Move the keys and events with the new length" }, [el("input", { type: "checkbox", checked: true, onchange: (e) => (stretch = e.target.checked) }), "stretch"])])),
...field("Frame step", fps),
]),
el("div", { class: "ans-note", text: `${c.tracks.length} tracks · ${keys.toLocaleString()} keys · gold keys keep their stored bytes, light ones are new or edited` }),
);
}
function renderBone() {
const box = $("ans-bone");
const sk = ANS.char?.sk;
const b = ANS.bone;
$("ans-bone-name").textContent = sk && b >= 0 ? sk.data.bones[b].name.text : "";
if (!sk || b < 0 || !pose || !ANS.clip) return box.replaceChildren(el("div", { class: "ans-note", text: "Click a joint in the view or a name on the dope sheet." }));
const tr = trackOf(b);
const lp = V.scale(pose.lp[b], 1 / sk.rate[b]);
const e = Q.toEuler(pose.lr[b]);
const setRot = (i, v) => {
const ne = e.slice();
ne[i] = v;
pushUndo();
K.setKey(trackOf(b, true), ANS.time, lp, Q.fromEuler(ne));
changed();
};
const setPos = (i, v) => {
const np = lp.slice();
np[i] = v;
pushUndo();
K.setKey(trackOf(b, true), ANS.time, np, pose.lr[b]);
changed();
};
const parent = sk.parent[b] >= 0 ? sk.data.bones[sk.parent[b]].name.text : "—";
const rest = sk.data.bones[b];
box.replaceChildren(
el("div", { class: "ans-grid" }, [
...field("Parent", el("span", { class: "mono", text: parent })),
...field("Rotate °", el("div", { class: "ans-vec" }, e.map((v, i) => numInput(num(v, 1), (x) => setRot(i, x), { step: "1" })))),
...field("Position", el("div", { class: "ans-vec" }, lp.map((v, i) => numInput(num(v, 2), (x) => setPos(i, x), { step: "0.5" })))),
...field("Weight", tr ? numInput(tr.weight, (v) => (pushUndo(), (tr.weight = v), changed()), { step: "0.1" }) : el("span", { class: "muted", text: "no track (rest pose)" })),
]),
el("div", { class: "ans-row" }, [
el("button", { class: "iconbtn", title: "Key this bone at the playhead (K)", onclick: () => keyBones([b]) }, [icon("plus"), "Key"]),
el("button", { class: "iconbtn", title: "Key the skeleton's rest pose for this bone", onclick: () => {
pushUndo();
K.setKey(trackOf(b, true), ANS.time, V.scale(rest.localPos, 1 / sk.rate[b]), rest.localRot);
changed();
} }, [icon("undo"), "Rest"]),
el("button", { class: "iconbtn", title: "Select this bone's keys", disabled: !tr, onclick: () => {
for (const k of tr.keys) ANS.keys.add(k);
dope.draw();
} }, [icon("check"), "Keys"]),
el("button", { class: "iconbtn danger", title: "Remove this bone's track (it goes back to the rest pose)", disabled: !tr, onclick: () => {
pushUndo();
ANS.clip.tracks.splice(ANS.clip.tracks.indexOf(tr), 1);
changed();
} }, [icon("trash")]),
]),
el("div", { class: "ans-note", text: `${tr ? `${tr.keys.length} keys` : "No keys yet"} · rotation is local (relative to the parent), XYZ degrees` }),
);
}
// ---------- cut & join ----------
function renderOps() {
const box = $("ans-ops");
const c = ANS.clip;
if (!c) return box.replaceChildren();
const r = ANS.range || [0, c.total];
const a = numInput(Math.round(r[0]), (v) => setRange(v, b.valueAsNumber), { step: "1", w: "80px" });
const b = numInput(Math.round(r[1]), (v) => setRange(a.valueAsNumber, v), { step: "1", w: "80px" });
const sp = numInput(2, () => {}, { step: "0.1", w: "60px" });
const op = (label, ic, title, fn, need = true) =>
el("button", { class: "iconbtn", title, disabled: need && !ANS.range, onclick: () => {
pushUndo();
fn(...(ANS.range || [0, c.total]));
ANS.keys.clear();
changed();
dope.fit();
dope.draw();
} }, [icon(ic), label]);
box.replaceChildren(
el("div", { class: "ans-row" }, [el("span", { class: "muted", text: "Range ms" }), a, "–", b, el("button", { class: "iconbtn only", title: "Clear the range", onclick: () => setRange(null) }, [icon("minus")])]),
el("div", { class: "ans-row" }, [
op("Trim", "fit", "Keep only the range", (x, y) => {
K.trim(c, x, y);
ANS.range = null;
ANS.time = 0;
}),
op("Cut", "trash", "Remove the range (the rest closes up)", (x, y) => {
K.cut(c, x, y);
ANS.range = null;
}),
op("Reverse", "swap", "Play the range backwards", (x, y) => K.reverse(c, x, y)),
]),
el("div", { class: "ans-row" }, [
el("span", { class: "muted", text: "Speed ×" }),
sp,
op("Apply", "play", "Faster (> 1) or slower (< 1); the range or the whole clip", (x, y) => {
if (!(sp.valueAsNumber > 0)) return;
K.speed(c, x, y, sp.valueAsNumber);
if (ANS.range) ANS.range = [x, x + (y - x) / sp.valueAsNumber];
}, false),
]),
el("div", { class: "ans-row" }, [
op("Make loop", "refresh", "The last pose becomes the first one (no jump when it repeats)", () => K.makeLoop(c), false),
op("Mirror L↔R", "swap", "Mirror the whole clip left / right", () => K.mirror(c, ANS.char.sk, "x"), false),
]),
el("div", { class: "ans-note", text: "Join: right-click an animation in the list → Append / Insert at the playhead. Retarget: right-click → Retarget onto this character." }),
);
}
function setRange(x, y) {
if (x === null || !ANS.clip) ANS.range = null;
else {
const a = Math.max(0, Math.min(x, y)), b = Math.min(ANS.clip.total, Math.max(x, y));
ANS.range = b > a ? [a, b] : null;
}
renderOps();
renderClock();
dope.draw();
}
async function joinRow(r, how) {
if (!ANS.clip) return;
try {
const hit = await readKey(r[0]);
const other = K.fromLsf(LSF.parseAni(hit.bytes), r[1]);
const gapIn = el("input", { type: "number", value: how === "append" ? "0" : "100", step: "1" });
const at = how === "append" ? ANS.clip.total : Math.round(ANS.time);
const ok = await modal(`${how === "append" ? "Append" : "Insert"} ${r[1]}.ani`, [
`${r[1]} (${fmt(other.total)}, ${other.tracks.length} tracks) goes in at ${fmt(at)}. Keys keep their stored bytes when both use the same format.`,
el("label", { class: "fld" }, [el("span", { text: "Blend time (ms) into and out of it — 0 = hard cut" }), gapIn]),
], [{ label: "Cancel", value: false }, { label: how === "append" ? "Append" : "Insert", kind: "primary", value: true }]);
if (!ok) return;
pushUndo();
const sk = ANS.char.sk;
const bind = (name) => {
const b = sk.byName.get(name.toLowerCase());
return b === undefined ? null : { p: V.scale(sk.data.bones[b].localPos, 1 / sk.rate[b]), q: sk.data.bones[b].localRot };
};
if (other.version !== ANS.clip.version) toast(`${r[1]} is ${other.version}, this clip ${ANS.clip.version}: its rotations are re-encoded.`);
K.insert(ANS.clip, other, at, Math.max(0, gapIn.valueAsNumber || 0), bind);
changed();
dope.fit();
dope.draw();
} catch (err) {
toast(err.message, true);
}
}
// ---------- events ----------
// ---------- event types (ioClientAniEventHandler / ioAniEventHandler / ioBaseChar) ----------
var EVENT_TYPES = [
["auto_sound", "Sound: resource/wave/<path>", "sound"],
["voice_sound", "Character voice: path without _1 / _hw (the game adds the voice type and _hw for women)", "voice"],
["motion_sound", "Footstep / motion sound (… 1 = the map's floor sound)", "sound"],
["shop_sound", "Sound only in the shop / lobby preview", "sound"],
["auto_effect", "Effect on the character: resource/effect/<file>.txt", "effect"],
["shop_effect", "Effect only in the shop / lobby preview", "effect"],
["face_change", "Face expression: <face>.dds <ms> (the game adds the face number and _hw)", "face"],
["fire_s", "Hit starts: uses attack_type n (box: r_weapon r_hand l_foot … special <box>)", "hit"],
["fire_e", "Hit ends", ""],
["trail_s", "Weapon trail on: <texture.dds> <blend> …", ""],
["trail_e", "Weapon trail off", ""],
["auto_shake", "Camera shake", ""],
["key_reserve", "Next input is taken from here (combo)", ""],
["fire_sliding", "Sliding attack movement", ""],
["tracking_e", "Stops turning to the target", ""],
["hide_weapon", "Hide the weapon mesh", ""], ["show_weapon", "Show the weapon mesh", ""],
["hide_helm", "Hide the helmet", ""], ["show_helm", "Show the helmet", ""],
["hide_cloak", "Hide the cloak", ""], ["show_cloak", "Show the cloak", ""],
["hide_armor", "Hide the armor", ""], ["show_armor", "Show the armor", ""],
["hide_hair", "Hide the hair", ""], ["hide_mesh", "Hide a mesh", ""], ["show_mesh", "Show a mesh", ""],
["scale_s", "Scale starts", ""], ["scale_e", "Scale ends", ""],
["air_jump", "Air jump", ""], ["input_jump_s", "Jump input accepted", ""], ["input_dash_s", "Dash input accepted", ""],
["cmd_action", "Command action", ""], ["cmd_end", "Command ends", ""], ["attack_end", "Attack ends", ""], ["end_animation", "Animation end", ""],
];
var EVENT_KIND = Object.fromEntries(EVENT_TYPES.map(([t, , k]) => [t, k]));
const eventTypeList = () => $("ans-ev-types") || document.body.appendChild(el("datalist", { id: "ans-ev-types" }, EVENT_TYPES.map(([t, d]) => el("option", { value: t, label: d }))));
const packNames = new Map(); // folder → Promise<names>
function namesOf(folder) {
if (!ANS.src) return Promise.resolve([]);
const key = `${folder}|${ANS.src.folder || ANS.src.sessionId || ""}`;
if (!packNames.has(key)) packNames.set(key, call("mdl:names", ANS.src, folder).catch(() => []));
return packNames.get(key);
}
/** Searchable list modal → the picked row (rows: [{ value, label, sub }]), with an optional ▶ per row. */
async function listPicker(title, rows, { listen = null, extra = [], initial = "" } = {}) {
const search = el("input", { type: "search", placeholder: `Search ${rows.length.toLocaleString()}…`, spellcheck: "false", value: initial });
const listBox = el("div", { class: "fx-resource-list" });
let choice = null;
const fill = () => {
const words = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const hits = rows.filter((r) => words.every((w) => r.value.toLowerCase().includes(w))).slice(0, 400);
listBox.replaceChildren(...hits.map((r) => el("div", { class: `ans-snd${choice === r ? " on" : ""}` }, [
listen ? el("button", { class: "iconbtn only", title: "Listen / preview", onclick: () => listen(r) }, [icon("play")]) : null,
el("button", { class: `fx-resource${choice === r ? " on" : ""}`, text: r.label || r.value, title: r.sub || r.value, onclick: () => { choice = r; fill(); listen?.(r); }, ondblclick: () => { choice = r; $("modal-actions").lastChild.click(); } }),
])));
};
search.oninput = fill;
fill();
const ok = await modal(title, [search, ...extra, listBox], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }]);
return ok ? choice : null;
}
async function pickPackSound(kind) {
const names = (await namesOf("wave")).filter((n) => /\.(wav|ogg)$/i.test(n) && !/^bgm\//i.test(n));
let rows;
if (kind === "voice") {
// voice_sound names are written without the voice type / woman tail: voice/attack_22.wav
const base = new Map();
for (const n of names) { const m = /^(.*?)_\d+(_hw)?(\.\w+)$/i.exec(n); if (m && /voice|_voice/i.test(n)) base.set(`${m[1]}${m[3]}`, (base.get(`${m[1]}${m[3]}`) || 0) + 1); }
rows = [...base].sort().map(([v, n]) => ({ value: v, sub: `${n} variants (_1, _1_hw …)` }));
} else rows = names.map((v) => ({ value: v }));
const r = await listPicker(kind === "voice" ? "Voice (resource/wave)" : "Sound (resource/wave)", rows, { listen: (x) => playSound(x.value, kind === "voice") });
return r?.value || null;
}
async function pickPackEffect() {
const names = (await namesOf("effect")).filter((n) => /\.txt$/i.test(n));
const r = await listPicker("Effect (resource/effect)", names.map((v) => ({ value: v })));
return r?.value || null;
}
/** Face expression bases: textures <base><n>(_hw).dds with several face numbers. */
async function pickFace(current = "") {
const groups = new Map();
for (const n of await namesOf("texture")) {
const m = /^([a-z_][\w-]*?)(\d*)(_hw)?\.dds$/i.exec(n.split("/").pop());
if (!m || !/face/i.test(m[1]) || /\//.test(n)) continue;
const g = groups.get(m[1].toLowerCase()) || { base: `${m[1]}.dds`, n: 0 };
g.n++;
groups.set(m[1].toLowerCase(), g);
}
const rows = [...groups.values()].filter((g) => g.n >= 3).sort((a, b) => a.base.localeCompare(b.base)).map((g) => ({ value: g.base, sub: `${g.n} face variants` }));
const [cur = "", curMs = "500"] = current.split(/\s+/);
const ms = el("input", { type: "number", value: curMs || 500, min: 0, step: 50, title: "How long the face stays (ms)" });
const r = await listPicker("Face expression (face_change)", rows, { initial: cur.replace(/\.dds$/i, ""), extra: [el("label", { class: "fxl-row" }, ["Duration ms ", ms])], listen: (x) => previewFace(x.value, 1500) });
return r ? `${r.value} ${Number(ms.value) || 500}` : null;
}
async function pickInfo(kind, current) {
if (kind === "sound" || kind === "voice") return pickPackSound(kind);
if (kind === "effect") return pickPackEffect();
if (kind === "face") return pickFace(current);
if (kind === "hit") {
const r = await listPicker("Hit box (fire_s)", ["r_weapon", "l_weapon", "r_hand", "l_hand", "r_foot", "l_foot", "special"].map((v) => ({ value: v })));
return r?.value || null;
}
return null;
}
async function addEventWith(type) {
const c = ANS.clip;
if (!c) return;
const info = await pickInfo(EVENT_KIND[type], "");
if (info === null) return;
pushUndo();
c.events.push({ type: K.S(type), sub: K.S(info), time: Math.round(ANS.time) });
ANS.event = c.events.length - 1;
changed();
}
// ---------- face expression preview: face_XX(_hw) mesh, its color texture swapped like ioBaseChar::FaceChange ----------
let faceShown = "", facePreviewUntil = 0, facePreviewName = "";
function faceMesh() { return ANS.char?.meshes.find((m) => /^face_?\d*(_hw)?$/i.test(m.name) && !m.hidden); }
function faceTexName(base, m) {
const fm = /^face_?(\d*)(_hw)?$/i.exec(m.name), type = Number(fm[1]) || 1, woman = !!fm[2];
const stem = base.replace(/\.dds$/i, "");
const male = type > 1 ? `${stem}${type}.dds` : `${stem}.dds`;
return woman ? [male.replace(/\.dds$/i, "_hw.dds"), male] : [male];
}
function previewFace(base, ms) { facePreviewName = base; facePreviewUntil = performance.now() + ms; updateFace(); setTimeout(updateFace, ms + 20); }
async function updateFace() {
const m = faceMesh();
if (!m || !kit) return;
let want = "";
if (facePreviewUntil > performance.now()) want = facePreviewName;
else for (const e of ANS.clip?.events || []) {
if (K.nameOf(e.type).toLowerCase() !== "face_change") continue;
const [base, d] = K.nameOf(e.sub).split(/\s+/);
if (ANS.time >= e.time && ANS.time < e.time + (Number(d) || 0)) want = base;
}
const key = want ? `${m.id}:${want}` : "";
if (key === faceShown) return;
faceShown = key;
let tex = null;
if (want) for (const n of faceTexName(want, m)) { const t = await kit.texture(n); if (t.tex) { tex = t.tex; break; } }
if (faceShown !== key) return;
m.groups.forEach((g, gi) => view.setGroupTexture(m.id, gi, tex || g.texInfo?.tex || null));
view.dirty = true;
}
ANS.faceState = () => faceShown;
ANS.pickInfoFor = (kind) => pickInfo(kind, "");
function renderEvents() {
const box = $("ans-events");
const c = ANS.clip;
$("ans-ev-count").textContent = c ? String(c.events.length) : "";
if (!c || !EVENT_TYPES) return box.replaceChildren();
eventTypeList();
const rows = c.events.map((e, i) => {
const t = el("input", { type: "number", step: "1", value: String(Math.round(e.time)), onchange: () => (pushUndo(), (e.time = Number(t.value) || 0), changed()) });
const ascii = (v) => /^[\x20-\x7e]*$/.test(v) || (toast("Event texts must be ASCII.", true), false);
const type = K.nameOf(e.type), kind = EVENT_KIND[type.toLowerCase()] || "";
const ty = el("input", { type: "text", value: type, list: "ans-ev-types", spellcheck: "false", title: EVENT_TYPES.find(([x]) => x === type.toLowerCase())?.[1] || "Event type (pick from the list or type one)", onchange: () => ascii(ty.value) && (pushUndo(), (e.type = K.S(ty.value.trim())), changed()) });
const su = el("input", { type: "text", value: K.nameOf(e.sub), spellcheck: "false", onchange: () => ascii(su.value) && (pushUndo(), (e.sub = K.S(su.value)), changed()) });
const pick = kind ? el("button", { class: "iconbtn only", title: "Pick from the packs", onclick: async () => {
const v = await pickInfo(kind, K.nameOf(e.sub));
if (v === null) return;
pushUndo(); e.sub = K.S(v); changed();
} }, [icon("search")]) : el("span");
const isSound = SOUND_EVENT.test(type);
return el("div", { class: `ans-ev${i === ANS.event ? " on" : ""}` }, [
t, ty, su, pick,
isSound ? el("button", { class: "iconbtn only", title: "Listen", onclick: async () => { if (!(await playSound(K.nameOf(e.sub), /voice/i.test(type)))) toast(`${K.nameOf(e.sub)}: not in the packs`, true); } }, [icon("play")])
: kind === "face" ? el("button", { class: "iconbtn only", title: "Preview on the face mesh", onclick: () => { const [b, d] = K.nameOf(e.sub).split(/\s+/); previewFace(b, Math.max(800, Number(d) || 500)); } }, [icon("play")]) : el("span"),
el("button", { class: "iconbtn only", title: "Go to it", onclick: () => seek(e.time) }, [icon("target")]),
el("button", { class: "iconbtn only danger", title: "Remove", onclick: () => (pushUndo(), c.events.splice(i, 1), (ANS.event = -1), changed()) }, [icon("trash")]),
]);
});
const typeSel = el("select", { title: "Type of the new event" }, EVENT_TYPES.map(([t, d]) => el("option", { value: t, text: t, title: d })));
typeSel.value = lsGet("ans-ev-type") || "auto_sound";
box.replaceChildren(
...rows,
el("div", { class: "ans-row" }, [
typeSel,
el("button", { class: "iconbtn", title: "Add an event of this type at the playhead (sounds, effects, faces and hit boxes open a list from the packs)", onclick: async () => {
lsSet("ans-ev-type", typeSel.value);
if (EVENT_KIND[typeSel.value]) return addEventWith(typeSel.value);
pushUndo();
c.events.push({ type: K.S(typeSel.value), sub: K.S(""), time: Math.round(ANS.time) });
ANS.event = c.events.length - 1;
changed();
} }, [icon("plus"), "Event at playhead"]),
]),
el("div", { class: "ans-row" }, [
el("button", { class: "iconbtn", title: "auto_sound from the packs (resource/wave)", onclick: () => addEventWith("auto_sound") }, [icon("play"), "Sound…"]),
el("button", { class: "iconbtn", title: "voice_sound from the packs", onclick: () => addEventWith("voice_sound") }, [icon("play"), "Voice…"]),
el("button", { class: "iconbtn", title: "auto_effect from the packs (resource/effect)", onclick: () => addEventWith("auto_effect") }, [icon("zap"), "Effect…"]),
el("button", { class: "iconbtn", title: "face_change: a face expression for some ms", onclick: () => addEventWith("face_change") }, [icon("image"), "Face…"]),
el("label", { class: "ans-check", title: "Play sound events (auto_sound, voice_sound, motion_sound …) during playback" }, [el("input", { type: "checkbox", checked: ANS.soundOn, onchange: (ev) => { ANS.soundOn = ev.target.checked; lsSet("ans-sound", ANS.soundOn ? "1" : "0"); } }), "Play sounds"]),
]),
el("div", { class: "ans-note", text: "time ms · type · info. Sounds, voices, effects, faces and hit boxes: 🔍 picks from the packs, ▶ plays / previews. face_change swaps the face mesh's texture (face_26_hw → <face>26_hw.dds) while it lasts." }),
);
}
// =====================================================================
// event sounds: preview while playing
// =====================================================================
function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
var SOUND_EVENT = /sound|dound/i; // var: renderEvents may run before this line
let actx = null;
const sndCache = new Map();
ANS.soundOn = lsGet("ans-sound") !== "0";
/** Event path → decoded audio from the packs' resource/wave/<path>. */
function soundBuffer(info, voice = false) {
const key = `${String(info || "").toLowerCase()}${voice ? "|v" : ""}`;
if (!key) return Promise.resolve(null);
if (!sndCache.has(key)) {
sndCache.set(key, (async () => {
let bytes = null;
const wpath = String(info).replace(/\\/g, "/").split(/\s+/)[0];
if (!bytes && ANS.src) bytes = (await call("mdl:readPath", ANS.src, `wave/${wpath}`).catch(() => null))?.bytes;
// voice_sound: the game adds the voice type (_1) to the name
if (!bytes && ANS.src && voice) bytes = (await call("mdl:readPath", ANS.src, `wave/${wpath.replace(/(\.\w+)$/, "_1$1")}`).catch(() => null))?.bytes;
if (!bytes) return null;
actx ??= new AudioContext();
return actx.decodeAudioData(bytes.slice().buffer).catch(() => null);
})());
}
return sndCache.get(key);
}
async function playSound(info, voice = false) {
const b = await soundBuffer(info, voice);
if (!b) return false;
if (actx.state === "suspended") await actx.resume().catch(() => {});
const s = actx.createBufferSource();
s.buffer = b;
s.connect(actx.destination);
s.start();
return true;
}
ANS.playSound = playSound;
/** Sound events whose time lies in (t0, t1] while playing. */
function soundEventsBetween(t0, t1) {
if (!ANS.soundOn || !ANS.clip) return;
for (const e of ANS.clip.events) if (SOUND_EVENT.test(K.nameOf(e.type)) && e.time > t0 && e.time <= t1) playSound(K.nameOf(e.sub), /voice/i.test(K.nameOf(e.type)));
}
// =====================================================================
// hitboxes: fire_s / fire_e windows, the .cms boxes they use, the hero config's type counts
// (ioBaseChar::RefreshFireTimeListWithBase: the i-th fire_s uses attack_type{i}, lasts until the
// i-th fire_e; its info picks the box: l_hand r_hand l_foot r_foot l_weapon r_weapon special)
// =====================================================================
var HIT_TYPES = ["l_hand", "r_hand", "l_foot", "r_foot", "l_weapon", "r_weapon", "none", "special"]; // AniCollisionType order
var HIT_COLORS = { r_weapon: [1, 0.25, 0.2], l_weapon: [1, 0.55, 0.2], r_hand: [1, 0.85, 0.2], l_hand: [0.95, 0.95, 0.35], r_foot: [0.35, 0.8, 1], l_foot: [0.55, 0.6, 1], special: [0.85, 0.4, 1], none: [0.8, 0.8, 0.8] };
ANS.hit = { on: lsGet("ans-hit") !== "0", weapon: lsGet("ans-hit-cms") || "", scale: Number(lsGet("ans-hit-scale")) || 1, offset: Number(lsGet("ans-hit-offset")) || 0, hero: lsGet("ans-hit-hero") || "", check: null, groups: new Map() };
/** CMS version 5500: character attack boxes [{ type, center, axis[3], ext[3], bone }] (ioAniCollisionBoxGrp::InitBoxList) */
function parseAniCms(bytes) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (String.fromCharCode(bytes[0], bytes[1], bytes[2]) !== "CMS") throw new Error("Not a .cms file");
const version = dv.getInt32(4, true);
if (version !== 5500) throw new Error(`.cms version ${version}: not an attack box group (5500)`);
let o = 12;
const f = () => { const v = dv.getFloat32(o, true); o += 4; return v; };
const boxes = [];
for (let n = dv.getInt32(8, true); n-- > 0;) {
const type = HIT_TYPES[dv.getInt32(o, true)] || "none"; o += 4;
const center = [f(), f(), f()], axis = [[f(), f(), f()], [f(), f(), f()], [f(), f(), f()]], ext = [f(), f(), f()];
const len = dv.getInt32(o, true); o += 4;
const bone = String.fromCharCode(...bytes.subarray(o, o + len)); o += len;
boxes.push({ type, center, axis, ext, bone });
}
return boxes;
}
async function hitGroup(name) {
if (!name || !ANS.src) return null;
const key = name.toLowerCase();
if (!ANS.hit.groups.has(key)) {
ANS.hit.groups.set(key, null);
try {
const h = await readNamed(name.replace(/^.*[\\/]/, ""), "mesh");
ANS.hit.groups.set(key, h ? parseAniCms(h.bytes) : null);
if (!h) toast(`${name} is not in the packs`, true);
} catch (err) { toast(`${name}: ${err.message}`, true); }
drawOverlays();
renderHit();
}
return ANS.hit.groups.get(key);
}
/** The clip's hit windows: i-th fire_s with the i-th fire_e (times in the clip, ms). */
function hitWindows() {
const ev = (ANS.clip?.events || []).map((e) => ({ t: e.time, type: K.nameOf(e.type).toLowerCase(), info: K.nameOf(e.sub).trim() })).sort((a, b) => a.t - b.t);
const starts = ev.filter((e) => e.type === "fire_s"), ends = ev.filter((e) => e.type === "fire_e");
return starts.map((s, i) => {
const [kind = "", special = ""] = s.info.split(/\s+/);
const type = HIT_TYPES.includes(kind.toLowerCase()) ? kind.toLowerCase() : "none";
return { i, start: s.t, end: ends[i] ? ends[i].t : s.t, hasEnd: !!ends[i], type, special, raw: s.info };
});
}
/** Box for a window like ioBaseChar: weapon .cms (scaled / offset) → character default .cms. */
function hitBoxFor(type) {
const weapon = ANS.hit.groups.get(ANS.hit.weapon.toLowerCase());
const w = type !== "special" && weapon?.find((b) => b.type === type);
if (w) return { box: w, scale: ANS.hit.scale, offset: ANS.hit.offset, from: ANS.hit.weapon };
const def = ANS.hit.groups.get("collision_char_default.cms");
const d = def?.find((b) => b.type === type);
return d ? { box: d, scale: 1, offset: 0, from: "collision_char_default.cms" } : null;
}
function drawHitboxes() {
const sk = ANS.char?.sk;
if (!view) return;
if (!ANS.hit?.on || !sk || !pose || !ANS.clip) return view.setOverlay("hitbox", null);
hitGroup("collision_char_default.cms");
if (ANS.hit.weapon) hitGroup(ANS.hit.weapon);
const lines = [];
for (const w of hitWindows()) {
const hb = hitBoxFor(w.type);
if (!hb) continue;
const b = sk.byName.get(hb.box.bone.toLowerCase());
if (b === undefined) continue;
const active = ANS.time >= w.start && ANS.time <= Math.max(w.end, w.start + 16);
const near = !active && ANS.time >= w.start - 150 && ANS.time <= w.end + 150;
if (!active && !near) continue;
const c = HIT_COLORS[w.type] || HIT_COLORS.none, col = [...c, active ? 1 : 0.28];
const { center, axis, ext } = hb.box;
const e = ext.map((x) => x * hb.scale);
const cen = V.sub(center, V.scale(axis[2], hb.offset));
// the box is in the model's bind space, like the mesh: moved by the bone's skinning matrix
// (ioBiped::GetBipedSpaceFullXform = biped space TM × full transform), row vectors as in D3D
const M = SkinKit.M4.mul(sk.bst[b], SkinKit.M4.fromQT(pose.wr[b], pose.wp[b]));
const xf = (p) => [0, 1, 2].map((c) => p[0] * M[c] + p[1] * M[4 + c] + p[2] * M[8 + c] + M[12 + c]);
const corner = (sx, sy, sz) => xf(V.add(cen, V.add(V.scale(axis[0], e[0] * sx), V.add(V.scale(axis[1], e[1] * sy), V.scale(axis[2], e[2] * sz)))));
const P = [-1, 1].flatMap((x) => [-1, 1].flatMap((y) => [-1, 1].map((z) => corner(x, y, z))));
for (const [i, j] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) push(lines, P[i], col), push(lines, P[j], col);
if (active) {
const sp = view.project(corner(0, 0, 0));
if (sp) $("ans-labels").append(el("span", { class: "mdl-tag ans-hit-tag", text: `hit ${w.i + 1} · ${w.type}`, style: `left:${sp[0]}px;top:${sp[1]}px` }));
}
}
view.setOverlay("hitbox", lines.length ? new Float32Array(lines) : null, { depth: false });
}
/** Hero config entries that play this clip, with their attack type counts. */
async function checkHero() {
const id = ANS.hit.hero.trim();
if (!id || !ANS.clip || !ANS.src) return;
const want = `${ANS.clip.name}.ani`.toLowerCase();
const found = [];
for (const kind of ["item", "skill", "extraitem", "powerup_item", "powerup_extraitem"]) {
const h = await call("mdl:readPath", ANS.src, `config/mercenary/${id}/${id}_${kind}.ini`).catch(() => null);
if (!h) continue;
let text = "";
for (let i = 0; i < h.bytes.length; i += 8192) text += String.fromCharCode(...h.bytes.subarray(i, i + 8192));
const doc = HeroIni.parse(text);
for (const s of doc.sections) {
const get = (k) => s.fields.findLast((f) => f.key.toLowerCase() === k.toLowerCase())?.value.replace(/\s*;.*$/, "").trim();
for (const f of s.fields) {
if (f.value.replace(/\s*;.*$/, "").trim().toLowerCase() !== want) continue;
const k = f.key, stem = k.replace(/_(motion|animation|ani)$/i, "");
const countKey = [`${k}_type_count`, `${stem}_type_count`, /^skill_motion$/i.test(k) ? "attack_type_count" : "", /skill_motion$/i.test(k) ? k.replace(/skill_motion$/i, "attack_type_count") : ""].find((c) => c && get(c) !== undefined);
const rateKey = [`${k}_time_rate`, `${stem}_time_rate`, /skill_motion$/i.test(k) ? k.replace(/skill_motion$/i, "animation_time_rate") : "", "animation_time_rate"].find((c) => c && get(c) !== undefined);
const types = countKey ? Array.from({ length: Number(get(countKey)) || 0 }, (_, n) => get(countKey.replace(/_count$/i, `${n + 1}`)) || "?") : [];
found.push({ file: `${id}_${kind}.ini`, section: s.name, key: k, countKey, count: countKey ? Number(get(countKey)) || 0 : null, rate: rateKey ? Number.parseFloat(get(rateKey)) || 1 : 1, types,
cms: get("collision_mesh"), cmsScale: Number.parseFloat(get("collision_mesh_scale") || "") || null, cmsOffset: Number.parseFloat(get("collision_mesh_offset") || "") || 0 });
}
}
}
ANS.hit.check = { id, clip: ANS.clip.name, found };
// the weapon's own box group, as the game uses it
const withCms = found.find((x) => x.cms);
if (withCms && !ANS.hit.weapon) { ANS.hit.weapon = withCms.cms; ANS.hit.scale = withCms.cmsScale || 1; ANS.hit.offset = withCms.cmsOffset; }
renderHit();
drawOverlays();
}
async function pickCms() {
if (!ANS.src) return;
const names = (await call("mdl:names", ANS.src, "mesh").catch(() => [])).filter((n) => /\.cms$/i.test(n) && !/_c\.cms$|^\d|building|map|stage/i.test(n));
const search = el("input", { type: "search", placeholder: "Search collision meshes (sword, collision_…)", spellcheck: "false" });
const listBox = el("div", { class: "fx-resource-list" });
let choice = null;
const fill = () => {
const q = search.value.trim().toLowerCase();
listBox.replaceChildren(...names.filter((n) => !q || n.toLowerCase().includes(q)).slice(0, 300).map((n) => el("button", { class: `fx-resource${choice === n ? " on" : ""}`, text: n, onclick: () => { choice = n; fill(); }, ondblclick: () => { choice = n; $("modal-actions").lastChild.click(); } })));
};
search.oninput = fill; fill();
if (!(await modal("Weapon collision mesh (collision_mesh)", [search, listBox], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }])) || !choice) return;
setHit({ weapon: choice.split("/").pop() });
}
function setHit(p) {
Object.assign(ANS.hit, p);
lsSet("ans-hit", ANS.hit.on ? "1" : "0"); lsSet("ans-hit-cms", ANS.hit.weapon); lsSet("ans-hit-scale", String(ANS.hit.scale)); lsSet("ans-hit-offset", String(ANS.hit.offset)); lsSet("ans-hit-hero", ANS.hit.hero);
renderHit();
drawOverlays();
}
function addHitWindow(type) {
const c = ANS.clip;
if (!c) return;
pushUndo();
const t = Math.round(ANS.time);
c.events.push({ type: K.S("fire_s"), sub: K.S(type), time: t }, { type: K.S("fire_e"), sub: K.S(""), time: Math.min(c.total, t + 80) });
changed();
}
function renderHit() {
const box = $("ans-hit");
if (!box) return;
const c = ANS.clip;
if (!c) return box.replaceChildren(el("small", { class: "muted", text: "Open or import an animation." }));
const wins = hitWindows();
const ev = c.events.map((e) => K.nameOf(e.type).toLowerCase());
const nS = ev.filter((t) => t === "fire_s").length, nE = ev.filter((t) => t === "fire_e").length;
const typeSel = el("select", {}, ["r_weapon", "l_weapon", "r_hand", "l_hand", "r_foot", "l_foot", "special"].map((t) => el("option", { value: t, text: t })));
const rows = wins.map((w) => {
const hb = hitBoxFor(w.type);
return el("div", { class: `ans-hit-row${ANS.time >= w.start && ANS.time <= w.end ? " on" : ""}` }, [
el("b", { text: `hit ${w.i + 1}` }),
el("span", { class: "mono", text: `${w.start}–${w.end} ms` }),
el("span", { text: w.raw || "(no box: front of the body)" }),
el("span", { class: "muted", text: hb ? hb.from : w.type === "none" ? "" : "no such box" }),
el("button", { class: "iconbtn only", title: "Go to it", onclick: () => seek(w.start) }, [icon("target")]),
]);
});
const warn = [];
if (nS !== nE) warn.push(`${nS} fire_s but ${nE} fire_e: a fire_s without its fire_e hits for one frame only.`);
for (const w of wins) if (w.end < w.start) warn.push(`hit ${w.i + 1}: fire_e before fire_s.`);
const ch = ANS.hit.check;
const checkRows = ch && ch.clip === c.name ? (ch.found.length ? ch.found.map((x) => {
const ok = x.count === null ? null : x.count === nS;
return el("div", { class: `ans-hit-check ${ok === false ? "bad" : ok ? "ok" : ""}` }, [
el("b", { text: ok === false ? "✗" : ok ? "✓" : "?" }),
el("span", { text: `${x.file} [${x.section}] ${x.key}` }),
el("span", { class: "muted", text: x.count === null ? "no type count found" : `${x.countKey} = ${x.count} (${x.types.join(", ")}) · ${nS} fire_s${x.rate !== 1 ? ` · time rate ×${x.rate}` : ""}` }),
]);
}) : [el("small", { class: "muted", text: `No entry of hero ${ch.id} plays ${c.name}.ani.` })]) : [];
box.replaceChildren(
el("div", { class: "ans-row" }, [
el("label", { class: "ans-check", title: "Draw the box each fire_s window uses (bright while it hits)" }, [el("input", { type: "checkbox", checked: ANS.hit.on, onchange: (e) => setHit({ on: e.target.checked }) }), "Show hitboxes"]),
typeSel,
el("button", { class: "iconbtn", title: "Add fire_s (this box) at the playhead and fire_e 80 ms later", onclick: () => addHitWindow(typeSel.value) }, [icon("plus"), "Hit window"]),
]),
el("div", { class: "ans-grid" }, [
...field("Weapon .cms", el("div", { class: "ans-vec" }, [
el("span", { class: "mono", text: ANS.hit.weapon || "none (body boxes)", title: "collision_mesh of the weapon item" }),
el("button", { class: "iconbtn", onclick: pickCms }, ["Pick…"]),
ANS.hit.weapon ? el("button", { class: "iconbtn only", title: "No weapon boxes", onclick: () => setHit({ weapon: "" }) }, [icon("minus")]) : null,
])),
...field("Scale", numInput(ANS.hit.scale, (v) => setHit({ scale: v > 0 ? v : 1 }), { step: "0.05" })),
...field("Offset", numInput(ANS.hit.offset, (v) => setHit({ offset: v || 0 }), { step: "1" })),
...field("Hero ID", el("div", { class: "ans-vec" }, [
el("input", { type: "text", value: ANS.hit.hero, placeholder: "001", spellcheck: "false", onchange: (e) => setHit({ hero: e.target.value.trim() }) }),
el("button", { class: "iconbtn", title: "Find this animation in config/mercenary/<id>/*.ini and compare its attack type count with the fire_s count", onclick: checkHero }, ["Check"]),
])),
]),
...rows,
...warn.map((t) => el("div", { class: "ans-hit-check bad" }, [el("b", { text: "!" }), el("span", { text: t })])),
...checkRows,
el("div", { class: "ans-note", text: "fire_s n uses attack_type n of the config entry that plays this animation; the counts must match or the attack never fires. Box: weapon .cms (× scale, offset) → collision_char_default.cms; an empty info uses the area in front of the body." }),
);
}
ANS.setSource = setSource;
ANS.openRow = (r) => openRow(r);
ANS.checkHero = () => checkHero();
ANS.setHit = (p) => setHit(p);
ANS.hitWindows = () => hitWindows();
ANS.selectBone = (b) => selectBone(b);
ANS.seek = (t) => (stop(), seek(t));
ANS.bytes = () => LSF.writeAni(K.toLsf(ANS.clip));
// ---------- retarget another Lost Saga animation ----------
async function retargetRow(r) {
try {
if (!(await confirmDiscard())) return;
loading(true, "Finding the animation's skeleton…");
const hit = await readKey(r[0]);
const data = LSF.parseAni(hit.bytes);
const src = K.fromLsf(data, r[1]);
const c = await call("mdl:findSkeleton", ANS.src, { ani: `${r[1]}.ani`, bones: data.tracks.map((t) => t.name.text) });
loading(false);
if (!c.length) throw new Error("No skeleton in these packs fits this animation.");
const pick = el("select", {}, c.slice(0, 15).map((x, i) => el("option", { value: i, text: `${x.name} (${x.matched}/${x.total} bones${x.fromModel ? ", used by a model" : ""})` })));
const ok = await modal(`Retarget ${r[1]}.ani`, [`Played on its own skeleton, then moved onto ${ANS.char.skName} by bone names, with the rest poses aligned and the feet kept on the floor.`, el("label", { class: "fld" }, [el("span", { text: "Source skeleton" }), pick])], [{ label: "Cancel", value: false }, { label: "Retarget", kind: "primary", value: true }]);
if (!ok) return;
loading(true, "Retargeting…");
const sh = await readNamed(c[Number(pick.value)].name, "skeleton");
const ssk = await prepareSkeleton(LSF.parseSkl(sh.bytes), c[Number(pick.value)].name);
const rig = K.lsRig(ssk, src);
const { clip, info } = K.retarget(rig, ANS.char.sk, { name: `${r[1]}_rt`, version: data.version, fps: 30 });
setClip(clip, { dirty: true });
ANS.ghost = { rig, info, clip, start: 0 };
toast(`Retargeted: ${info.mapped}/${info.bones} bones matched, scale ×${info.scale.toFixed(2)}.`);
play();
} catch (err) {
toast(err.message, true);
} finally {
loading(false);
}
}
// =====================================================================
// Blender round trip: export .ani + .skl, watch an .ani for re-exports
// =====================================================================
function renderBlender() {
const box = $("ans-blender");
const w = ANS.watch;
box.replaceChildren(
el("div", { class: "ans-row" }, [
el("button", { class: "iconbtn", title: "Write the clip (.ani) and the character's skeleton (.skl) into a folder for the io_scene_lostsaga add-on", disabled: !ANS.clip, onclick: exportForBlender }, [icon("download"), "Export for Blender…"]),
]),
el("div", { class: "ans-row" }, [
el("label", { class: "ans-check", title: "Reload the opened .ani file whenever it changes on disk (re-export from Blender)" }, [
el("input", { type: "checkbox", checked: !!w?.on, disabled: !w, onchange: (e) => (ANS.watch.on = e.target.checked) }),
w ? `Reload ${w.file.split(/[\\/]/).pop()} on change` : "Open an .ani file to watch it",
]),
]),
el("div", { class: "ans-note", text: "In Blender: File → Import → Lost Saga the .skl, then the .ani on the armature; edit; Export Lost Saga (Action → .ani) over the same file. The tool reloads it here; Save puts it in the pack." }),
);
}
async function exportForBlender() {
const folder = await call("dialog:folder", "Folder for the Blender files");
if (!folder) return;
const files = [{ name: `${ANS.clip.name}.ani`, data: LSF.writeAni(K.toLsf(ANS.clip)) }];
const sh = await readNamed(ANS.char.skName, "skeleton");
if (sh) files.push({ name: ANS.char.skName, data: sh.bytes });
await call("mdl:writeFiles", folder, files);
toast(`Wrote ${files.map((f) => f.name).join(" + ")} to ${folder}.`);
}
setInterval(async () => {
const w = ANS.watch;
if (!w?.on || !ANS.clip) return;
const t = await call("anim:mtime", w.file);
if (t === null || t === w.mtime) return;
w.mtime = t;
try {
const hit = await call("mdl:readFile", w.file);
const clip = K.fromLsf(LSF.parseAni(hit.bytes), stem(w.file));
pushUndo();
const time = ANS.time;
Object.assign(ANS.clip, clip, { dirty: false });
ANS.time = Math.min(time, clip.total);
ANS.keys.clear();
remap();
refresh();
toast(`Reloaded ${w.file.split(/[\\/]/).pop()}.`);
} catch (err) {
toast(`Reload failed: ${err.message}`, true);
}
}, 1200);
// =====================================================================
// save / export
// =====================================================================
const bytes = () => LSF.writeAni(K.toLsf(ANS.clip));
async function save(where, label) {
try {
await call("mdl:writeBack", where, bytes());
ANS.clip.where = { ...where, create: undefined };
ANS.clip.dirty = false;
if (where.kind === "file") {
ANS.watch = { file: where.file, mtime: await call("anim:mtime", where.file), on: ANS.watch?.on };
} else kit?.clearTextures();
toast(`Saved ${label}.`);
renderSide();
if (where.kind === "pack") loadList();
} catch (err) {
toast(err.message, true);
}
}
async function saveAsFile() {
const file = await call("dialog:saveAs", `${ANS.clip.name}.ani`, "ani");
if (file) await save({ kind: "file", file }, `to ${file}`);
}
/** Into a pack as resource/animation/<name>.ani (replacing an entry of that name). */
async function saveIntoPack() {
const packs = [...new Set(ANS.rows.filter((r) => r[3] === "ani").map((r) => r[2]))];
if (!packs.length) return toast("These packs have no animations to go next to; choose the game's packs first.", true);
let pick = packs.includes("rs.iop") ? "rs.iop" : packs[0];
const sel = el("select", { onchange: () => (pick = sel.value) }, packs.map((p) => el("option", { value: p, selected: p === pick, text: p })));
const name = `${ANS.clip.name}.ani`;
const exists = ANS.rows.find((r) => r[3] === "ani" && r[4].toLowerCase().endsWith(`/${name.toLowerCase()}`));
const ok = await modal("Save into a pack", [
`${name} goes into the pack as resource/animation/${name}.${exists ? ` It replaces the ${name} in ${exists[2]} if you pick that pack — the game loads it for every model that plays it.` : " It's a new file: add it to a model config's #ANIMATION_LIST or a hero / skill INI to use it in the game."}`,
el("label", { class: "fld" }, [el("span", { text: "Pack" }), sel]),
], [{ label: "Cancel", value: false }, { label: "Save", kind: "primary", value: true }]);
if (!ok) return;
const row = ANS.rows.find((r) => r[3] === "ani" && r[2] === pick);
const hit = await readKey(row[0]);
if (hit.where?.kind !== "pack") return toast("That source isn't a pack (extracted folder): use Save as file.", true);
const dir = hit.where.entry.replace(/[^/\\]+$/, "");
await save({ kind: "pack", file: hit.where.file, entry: `${dir}${name}`, create: true }, `into ${pick}`);
}
$("ans-save").onclick = (e) => {
if (!ANS.clip) return;
const w = ANS.clip.where;
const items = [];
if (w?.kind === "pack") items.push({ label: `Save into ${w.file.split(/[\\/]/).pop()} (${w.entry.split("/").pop()})`, icon: "box", run: () => confirmBox("Save into the pack", `Replace ${w.entry} in ${w.file.split(/[\\/]/).pop()}?`, "Save").then((ok) => ok && save(w, `into ${w.file.split(/[\\/]/).pop()}`)) });
if (w?.kind === "file") items.push({ label: `Save ${w.file.split(/[\\/]/).pop()}`, icon: "save", run: () => save(w, `to ${w.file}`) });
items.push({ label: `Save into a pack as ${ANS.clip.name}.ani…`, icon: "box", run: saveIntoPack }, { label: "Save as file…", icon: "download", run: saveAsFile });
showMenu(items, { anchor: e.currentTarget });
};
$("ans-export").onclick = (e) =>
showMenu(
[
{ label: "Save the clip as .ani file…", icon: "download", disabled: !ANS.clip, run: saveAsFile },
{ label: "Export for Blender (.ani + .skl)…", icon: "cube", disabled: !ANS.clip, run: exportForBlender },
],
{ anchor: e.currentTarget },
);
// =====================================================================
// keys
// =====================================================================
let clipboard = null;
document.addEventListener("keydown", (e) => {
if (!visible() || !ANS.clip) return;
if (e.target.closest?.("input, textarea, select") || !$("modal").hidden) return;
const k = e.key;
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && k.toLowerCase() === "z") restore(ANS.undo, ANS.redo);
else if (ctrl && k.toLowerCase() === "y") restore(ANS.redo, ANS.undo);
else if (ctrl && k.toLowerCase() === "c" && ANS.bone >= 0 && pose) clipboard = { p: V.scale(pose.lp[ANS.bone], 1 / ANS.char.sk.rate[ANS.bone]), q: pose.lr[ANS.bone] };
else if (ctrl && k.toLowerCase() === "v" && ANS.bone >= 0 && clipboard) {
pushUndo();
K.setKey(trackOf(ANS.bone, true), ANS.time, clipboard.p, clipboard.q);
changed();
} else if (k === " ") ANS.playing ? (stop(), renderBone()) : play();
else if (k === "ArrowLeft" || k === "ArrowRight") {
stop();
const dir = k === "ArrowLeft" ? -1 : 1;
if (e.altKey) {
// previous / next key of the selected bone (or any)
const ts = (ANS.bone >= 0 && trackOf(ANS.bone) ? trackOf(ANS.bone).keys : ANS.clip.tracks.flatMap((t) => t.keys)).map((x) => x.t);
const t = dir < 0 ? Math.max(...ts.filter((x) => x < ANS.time - 0.5), 0) : Math.min(...ts.filter((x) => x > ANS.time + 0.5), ANS.clip.total);
seek(t);
} else seek(Math.round((ANS.time + dir * (e.shiftKey ? 10 : 1) * ANS.step) / 1) );
} else if (k === "Home") seek(0);
else if (k === "End") seek(ANS.clip.total);
else if (k.toLowerCase() === "k") e.shiftKey ? $("ans-key-all").onclick() : $("ans-key").onclick();
else if (k === "Delete") ANS.event >= 0 && !ANS.keys.size ? (pushUndo(), ANS.clip.events.splice(ANS.event, 1), (ANS.event = -1), changed()) : deleteKeys();
else if (k.toLowerCase() === "r" && !ctrl) setTool("rotate");
else if (k.toLowerCase() === "g" && !ctrl) setTool("move");
else if (k === "Escape") setTool("view");
else return;
e.preventDefault();
});
// first visit: the profile's packs
new MutationObserver(async () => {
if (!visible() || ANS.src) return;
const s = profileSource();
if (s) await setSource(s, { quiet: true });
}).observe($("view-ans"), { attributes: true, attributeFilter: ["class"] });
window.addEventListener("beforeunload", (e) => {
if (anyDirty()) e.returnValue = "";
});
renderSide();
})();