"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/.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/", "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/.txt", "effect"], ["shop_effect", "Effect only in the shop / lobby preview", "effect"], ["face_change", "Face expression: .dds (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 )", "hit"], ["fire_e", "Hit ends", ""], ["trail_s", "Weapon trail on: …", ""], ["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 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 (_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 → 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/. */ 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//*.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/.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(); })();