// engine/anim/clip-map.js - written by Muse Glimmer (Phase 31, part A, 27 Sep 2026; raw output in game/muse-authored/phase31/raw/), // reviewed by Grok Bot (coach) with game/muse-authored/phase31/harness/. Coach fixes are marked [coach fix P31-n], see FIXES-P31.md. (function () { function splitCamel(s) { // [coach fix P31-1] also split letter/digit boundaries ("sprint01" -> sprint 01, "Character1" -> character 1) and acronym runs ("IDLERifle") return s .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") .replace(/([A-Za-z])([0-9])/g, "$1 $2") .replace(/([0-9])([A-Za-z])/g, "$1 $2"); } function tokenize(name) { name = String(name == null ? "" : name); // [coach fix P31-2a] tolerate null/undefined clip names var seg = ""; // [coach fix P31-2] when every segment is a junk take name ("Take 001"), there are no tokens (was: the whole name) var parts = name.split("|"); for (var i = parts.length - 1; i >= 0; i--) { var p = parts[i].trim(); if (!p) continue; var low = p.toLowerCase(); if (low === "mixamo.com" || low === "layer0" || low === "baselayer" || low === "default") continue; if (/^take\s*\d+$/.test(low) || /^take\d+$/.test(low)) continue; seg = p; break; } var colon = seg.lastIndexOf(":"); if (colon !== -1) seg = seg.slice(colon + 1); seg = splitCamel(seg); var raw = seg.split(/[\s_\-\.]+/); var out = []; for (var t of raw) { if (!t) continue; var tt = t.toLowerCase(); if (/^\d+$/.test(tt)) continue; out.push(tt); } return out; } function stem(w) { if (w === "dying") return "die"; if (w === "running") return "run"; if (w === "firing") return "fire"; if (w.endsWith("ing") && w.length > 4) return w.slice(0, -3); if (w.endsWith("ed") && w.length > 3) return w.slice(0, -2); if (w.endsWith("s") && w.length > 3) return w.slice(0, -1); return w; } var ROLES = ["idle", "walk", "run", "shoot", "death"]; var SYN = { idle: ["idle", "stand", "standing", "breathe", "breathing", "rest"], walk: ["walk", "stroll"], run: ["run", "jog", "sprint"], shoot: ["shoot", "shot", "fire", "attack", "gunshot"], death: ["death", "die", "dead", "dying"], }; var DIR = ["back", "backward", "backwards", "left", "right", "strafe", "turn", "side"]; var PEN = ["hit", "react", "reaction", "hurt", "start", "end", "stop", "to"]; function scoreClip(tokens, role) { var syn = SYN[role]; var hasSyn = false; for (var t of tokens) { var s = stem(t); for (var k of syn) { if (s === k || t === k) { hasSyn = true; break; } } if (hasSyn) break; } if (!hasSyn) return -Infinity; var sc = 10; var other = 0; for (var r of ROLES) { if (r === role) continue; for (var t of tokens) { var s = stem(t); for (var k of SYN[r]) { if (s === k || t === k) { other++; break; } } } } if (other > 0) sc -= 6; for (var t of tokens) { if (DIR.includes(t)) sc -= 4; } for (var t of tokens) { if (PEN.includes(t)) sc -= 3; } if (tokens.length > 1) sc -= tokens.length - 1; if (role === "walk" || role === "run") { for (var t of tokens) { if (t === "loop" || t === "forward" || t === "fwd") sc += 1; } } return sc; } function match(names, opts) { opts = opts || {}; var overrides = opts.overrides || {}; var n = names.length; var tokenLists = names.map(tokenize); var scores = {}; for (var r of ROLES) { scores[r] = new Array(n).fill(-Infinity); } for (var i = 0; i < n; i++) { var toks = tokenLists[i]; for (var ri = 0; ri < ROLES.length; ri++) { var r = ROLES[ri]; var sc = scoreClip(toks, r); if (sc >= 5) scores[r][i] = sc; } } var roleIdx = {}; for (var i = 0; i < ROLES.length; i++) roleIdx[ROLES[i]] = i; var assignedClip = new Array(n).fill(false); var roleMap = {}; for (var r of ROLES) roleMap[r] = -1; for (var r of ROLES) { var idx = overrides[r]; if (idx !== undefined) { if (idx >= 0 && idx < n) { roleMap[r] = idx; assignedClip[idx] = true; } else { roleMap[r] = -1; } } } // [coach fix P31-3] roles given in overrides (including -1 = "force none") are not auto-assigned afterwards var remainingRoles = ROLES.filter(function (r) { return roleMap[r] === -1 && overrides[r] === undefined; }); while (remainingRoles.length) { var best = { score: -Infinity, ri: -1, ci: -1 }; for (var ri = 0; ri < remainingRoles.length; ri++) { var r = remainingRoles[ri]; for (var i = 0; i < n; i++) { if (assignedClip[i]) continue; var sc = scores[r][i]; if (sc > best.score || (sc === best.score && (i < best.ci || (i === best.ci && roleIdx[r] < roleIdx[ROLES[best.ri]])))) { best = { score: sc, ri: ri, ci: i }; } } } if (best.score === -Infinity || best.score < 5) break; var r = remainingRoles[best.ri]; roleMap[r] = best.ci; assignedClip[best.ci] = true; remainingRoles.splice(best.ri, 1); } var used = new Set(); for (var r of ROLES) { if (roleMap[r] >= 0) used.add(roleMap[r]); } var unmatched = []; for (var i = 0; i < n; i++) if (!used.has(i)) unmatched.push(i); return { roles: { idle: roleMap.idle, walk: roleMap.walk, run: roleMap.run, shoot: roleMap.shoot, death: roleMap.death }, // [coach fix P31-3b] spec: scores = {role: score of the chosen clip} (was the full per-clip arrays); null when unassigned, 'override' when forced scores: ROLES.reduce(function (o, r) { o[r] = overrides[r] !== undefined ? "override" : roleMap[r] >= 0 ? scores[r][roleMap[r]] : null; return o; }, {}), unmatched: unmatched, }; } function boneKey(name) { var s = String(name == null ? "" : name); // [coach fix P31-2a] var colon = s.lastIndexOf(":"); var pipe = s.lastIndexOf("|"); var cut = Math.max(colon, pipe); if (cut !== -1) s = s.slice(cut + 1); s = s.toLowerCase().trim(); s = s .replace(/^mixamorig/, "") .replace(/^bip01/, "") .replace(/^bip001/, ""); s = s.replace(/^character1_/, ""); s = s.replace(/[\s_\.]+/g, ""); return s; } window.MuseClipMap = { ROLES: ROLES, tokens: tokenize, match: match, boneKey: boneKey }; })();