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