Files
Project-M/Assets/_Project/Scripts/Editor/PlayerRigTools.cs
T
kronic 7b65912f30 Melee feel overhaul: SwordCombat anims at heavy cadence, salvage axe, damage-at-contact + buffer + dash law (guidelines forks 1-6)
07-18/19/20 arc: LightCombo01/HeavyCombo01 clips onto Swing1-3/Slam at
~natural speed (26-tick window, 30-tick recover, DPS-held damage retune),
Menacing01 combat idle (InCombat), SM_Wep_Axe_Large_01 rigid-skinned to
Hand_R at x1.25, LANTERN FX retone + blade-smear ribbon. Forks 1-6 per
Combat_Attack_Feel_Guidelines (design review wf_000bc247): cleave resolves
at the CONTACT tick (MeleeCleavePending schedule-and-consume, knob 31,
0=legacy; connect cues moved to contact), MeleeRange 2.2 + reach-only
finisher mult 1.25 (knob 30), BufferedAttackTick GhostField input buffer
(knob 29, unlock-edge validity), dash refused pre-contact (lookup-based).
Tests: MeleeComboTests pin legacy knobs; +3 fork tests (414 green); live
server proof: HP drop exactly at swing+16 under tick batching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:03:18 -07:00

846 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace ProjectM.EditorTools
{
/// <summary>
/// MC-4 — builds the player's MELEE SWING clip + adds the IsAttacking param + MeleeSwing state to AC_PlayerTopDown.
/// The swing clip is built FROM the full idle pose (every bone keyed) + a Root YAW twist on top. A clip that keys
/// ONLY the Root makes Rukhanka collapse every un-keyed bone (Hips/Spine/legs) to identity for the state's
/// duration -> the body sinks into the floor; writeDefaultValues does NOT prevent it (2026-06-11 fix). Basing on
/// idle leaves nothing un-keyed; the Root yaw is a vertical-axis (height-preserving) twist that reads as a
/// horizontal slash, paired with the CombatFeedbackSystem slash-arc VFX. PlayerAnimationDriveSystem drives
/// IsAttacking from MeleeCombo. Idempotent / re-runnable (menu: ProjectM/Animation).
/// </summary>
public static class PlayerRigTools
{
const string SwingClip = "Assets/_Project/Animation/PlayerMeleeSwing.anim";
const string PlayerController = "Assets/_Project/Animation/AC_PlayerTopDown.controller";
[MenuItem("ProjectM/Animation/Player - Build Melee Swing")]
public static void BuildPlayerMeleeSwing()
{
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
var idle = FindIdleClip(ac);
if (idle == null) { Debug.LogError("[PlayerRigTools] No Idle-state clip to base the swing on."); return; }
// Full idle pose (every bone keyed) + a Root yaw twist. See the class summary for why a Root-only clip sinks.
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(SwingClip);
if (clip == null) { clip = new AnimationClip { frameRate = 30f }; AssetDatabase.CreateAsset(clip, SwingClip); }
var yaw = new AnimationCurve(
new Keyframe(0f, 0f), new Keyframe(0.06f, -25f), new Keyframe(0.16f, 48f), new Keyframe(0.30f, 0f));
AnimRigUtil.BuildRootYawOverlayClip(clip, idle, yaw);
if (!AnimRigUtil.HasParam(ac, "IsAttacking"))
ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool);
var sm = ac.layers[0].stateMachine;
var swing = FindState(sm, "MeleeSwing");
if (swing == null)
{
swing = sm.AddState("MeleeSwing");
swing.writeDefaultValues = false;
var toSwing = sm.AddAnyStateTransition(swing);
toSwing.hasExitTime = false;
toSwing.duration = 0.05f;
toSwing.canTransitionToSelf = false;
toSwing.AddCondition(AnimatorConditionMode.If, 0f, "IsAttacking");
var fromSwing = swing.AddExitTransition();
fromSwing.hasExitTime = false;
fromSwing.duration = 0.10f;
fromSwing.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsAttacking");
}
swing.motion = clip;
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] AC_PlayerTopDown: IsAttacking + idle-based MeleeSwing built (no un-keyed-bone collapse).");
}
/// <summary>LANTERN P5 (realignment): swap the Bathynaut suit body onto Player.prefab IN PLACE (GUID
/// preserved — the subscene PlayerSpawner refs never break). Mirrors the proven EnemyRigTools.BuildOne
/// recipe: strip the old Synty-soldier skeleton + SMR children (every ROOT component — ghost/authoring/
/// CC/Animator/RigDefinitionAuthoring — stays), flatten the suit body's skeleton + SMRs under the player
/// root, keep the feet-on-ground Root offset, re-slot the deformation material, and adopt the clone's
/// avatar (same shared humanoid Characters avatar — AC_PlayerTopDown's muscle clips retarget). Both
/// frames share this visual until per-frame player models land. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Build Bathynaut Player (LANTERN)")]
public static void BuildBathynautPlayer()
{
const string synty = "Assets/Synty/PolygonSciFiSpace/Prefabs/Characters/SM_Chr_SpaceSoldier_HelmetArmor_Male_01.prefab";
const string matPath = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
const string output = "Assets/_Project/Prefabs/Player.prefab";
const float rootY = -0.90f; // the player's existing feet-on-ground offset (entity origin = capsule center)
var syntyAsset = AssetDatabase.LoadAssetAtPath<GameObject>(synty);
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (syntyAsset == null) { Debug.LogError($"[PlayerRigTools] Suit body prefab missing: {synty}"); return; }
if (mat == null) { Debug.LogError($"[PlayerRigTools] Deformation material missing: {matPath}"); return; }
var root = PrefabUtility.LoadPrefabContents(output);
try
{
// Strip ONLY the old visual (skeleton + SMR children); root components stay untouched.
var kids = new System.Collections.Generic.List<Transform>();
foreach (Transform c in root.transform) kids.Add(c);
foreach (var c in kids) Object.DestroyImmediate(c.gameObject);
// Clone the suit body (unlinked) and flatten its skeleton + SMRs under the player root.
var clone = (GameObject)Object.Instantiate(syntyAsset);
var srcAnimator = clone.GetComponentInChildren<Animator>();
var avatar = srcAnimator != null ? srcAnimator.avatar : null;
var children = new System.Collections.Generic.List<Transform>();
foreach (Transform c in clone.transform) children.Add(c);
foreach (var c in children) c.SetParent(root.transform, false); // keep local transforms
Object.DestroyImmediate(clone);
// Feet-on-ground: offset the un-keyed Root bone.
var rootBone = root.transform.Find("Root");
if (rootBone != null) { var lp = rootBone.localPosition; lp.y = rootY; rootBone.localPosition = lp; }
else Debug.LogWarning("[PlayerRigTools] No 'Root' bone after the swap; feet offset skipped.");
// Deformation material on every SMR slot (else unskinned-static under Entities Graphics).
foreach (var smr in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
var mats = smr.sharedMaterials;
for (int i = 0; i < mats.Length; i++) mats[i] = mat;
smr.sharedMaterials = mats;
}
var anim = root.GetComponent<Animator>();
if (anim != null && avatar != null) anim.avatar = avatar; // controller (AC_PlayerTopDown) stays
PrefabUtility.SaveAsPrefabAsset(root, output);
Debug.Log("[PlayerRigTools] Bathynaut suit body swapped onto Player.prefab (in place).");
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
/// <summary>LANTERN suit-attachment follow-up (DR-051): graft the Bathynaut kitbash attachments (brass
/// diving-bell dome + porthole, back tank pack, shoulder dive-lamp — exported rigid-skinned to
/// Head/Spine_03/Clavicle_L in Suit_Bathynaut_Kitbash.blend) onto Player.prefab IN PLACE (GUID preserved,
/// visual-only: ghost surface unchanged, same class of edit as the P5 body swap). The kit FBX's two SMRs
/// are re-bound onto the player's existing flattened skeleton by bone NAME. Brass rides the shared
/// M_Skinned_Palette (AnimatedLitShader + PaletteAtlas); glow rides M_EmissiveGloam_WarmSkinned
/// (steady warm = true light). Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Attach Bathynaut Kit (LANTERN)")]
public static void AttachBathynautKit()
{
const string kitFbx = "Assets/_Project/Art/Models/SM_Suit_BathynautKit.fbx";
const string brassMat = "Assets/_Project/Art/Materials/M_Skinned_Palette.mat";
const string glowMat = "Assets/_Project/Art/Materials/M_EmissiveGloam_WarmSkinned.mat";
const string bodyMat = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
const string barePrefab = "Assets/Synty/PolygonSciFiSpace/Prefabs/Characters/SM_Chr_SpaceSoldier_Male_01.prefab";
const string output = "Assets/_Project/Prefabs/Player.prefab";
var kit = AssetDatabase.LoadAssetAtPath<GameObject>(kitFbx);
var mBrass = AssetDatabase.LoadAssetAtPath<Material>(brassMat);
var mGlow = AssetDatabase.LoadAssetAtPath<Material>(glowMat);
var mBody = AssetDatabase.LoadAssetAtPath<Material>(bodyMat);
if (kit == null) { Debug.LogError($"[PlayerRigTools] Kit FBX missing: {kitFbx}"); return; }
if (mBrass == null) { Debug.LogError($"[PlayerRigTools] Brass material missing: {brassMat}"); return; }
if (mGlow == null) { Debug.LogError($"[PlayerRigTools] Glow material missing: {glowMat}"); return; }
var root = PrefabUtility.LoadPrefabContents(output);
try
{
// Bone map: every transform under the player root by name (the flattened Synty skeleton).
var bones = new System.Collections.Generic.Dictionary<string, Transform>();
foreach (var t in root.GetComponentsInChildren<Transform>(true))
if (!bones.ContainsKey(t.name)) bones.Add(t.name, t);
foreach (var src in kit.GetComponentsInChildren<SkinnedMeshRenderer>(true))
GraftSmr(root, bones, src, src.name.Contains("Glow") ? mGlow : mBrass);
// 07-15 operator fork: brass dome + BARE head (the kitbash look) — drop the Synty sci-fi helmet
// and graft the bare head SMR so the face reads through the amber porthole up close.
var helmet = root.transform.Find("SM_Chr_Attach_SpaceSoldier_Male_Helmet_01");
if (helmet != null) Object.DestroyImmediate(helmet.gameObject);
// Purge any wrong-variant head graft (the Synty container holds Female/Male heads) before re-grafting.
for (int i = root.transform.childCount - 1; i >= 0; i--)
{
var c = root.transform.GetChild(i);
if (c.name.StartsWith("SM_Chr_SpaceSoldier_Head_") && c.name != "SM_Chr_SpaceSoldier_Head_Male_01")
Object.DestroyImmediate(c.gameObject);
}
if (root.transform.Find("SM_Chr_SpaceSoldier_Head_Male_01") == null)
{
var bare = AssetDatabase.LoadAssetAtPath<GameObject>(barePrefab);
SkinnedMeshRenderer headSrc = null;
if (bare != null)
foreach (var s in bare.GetComponentsInChildren<SkinnedMeshRenderer>(true))
if (s.name == "SM_Chr_SpaceSoldier_Head_Male_01") { headSrc = s; break; }
if (headSrc != null && mBody != null) GraftSmr(root, bones, headSrc, mBody, rebase: false); // meter-scale Synty source + blend-skinned neck: native bindposes are correct
else Debug.LogWarning("[PlayerRigTools] Bare head SMR (or body material) not found — helmet removed without a head.");
}
PrefabUtility.SaveAsPrefabAsset(root, output);
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[PlayerRigTools] Bathynaut kit attached to Player.prefab (in place).");
}
/// <summary>Re-bind a source SkinnedMeshRenderer onto the player's existing flattened skeleton by bone
/// NAME and drop it in as a fresh child (idempotent: replaces a same-named child). Blender dedup
/// suffixes ("Finger_01.001" — the Synty rig duplicates L/R finger names) fall back to the base name;
/// safe because the kit meshes only WEIGHT Head/Spine_03/Clavicle_L.</summary>
static void GraftSmr(GameObject root, System.Collections.Generic.Dictionary<string, Transform> bones,
SkinnedMeshRenderer src, Material material, bool rebase = true)
{
// Resolve the bone rebind FIRST (07-16 review): destroying the old child before validation meant a
// bone-name mismatch on a re-run silently stripped the piece from the prefab.
var srcBones = src.bones;
var dst = new Transform[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
string bn = srcBones[i] != null ? srcBones[i].name : null;
Transform t = null;
if (bn != null && !bones.TryGetValue(bn, out t))
{
// Blender dedup suffix ("Finger_01.001" — the Synty rig duplicates L/R finger names): strip
// and take the first base-name match. Safe: the kit only WEIGHTS Head/Spine_03/Clavicle_L.
int dot = bn.LastIndexOf('.');
if (dot > 0) bones.TryGetValue(bn.Substring(0, dot), out t);
}
if (t == null)
{
Debug.LogError($"[PlayerRigTools] Player skeleton missing bone '{bn ?? "<null>"}' for {src.name}; skipped (existing graft left untouched).");
return;
}
dst[i] = t;
}
Mesh mesh;
if (!rebase)
{
// Source skeleton already matches the player's conventions (meter-scale Synty prefab, e.g. the
// bare head): keep the native mesh + bindposes; only the bones array is remapped.
mesh = src.sharedMesh;
}
else
{
// REBASE (07-15/16): a Blender FBX roundtrip imports cm bones under a 0.01 armature (Synty
// prefabs are meter bones) so raw bindpose reuse explodes ×100 on rebind. Bake the SOURCE rest
// pose into world-space vertices and compute bindposes as the inverse of the RIGID
// (scale-stripped) rest matrices — self-consistent feet-at-0 rest space regardless of exporter
// scaling; the player's Root feet-offset applies at runtime exactly like the body meshes.
var srcMesh = src.sharedMesh;
var srcBind = srcMesh.bindposes;
var restWorld = new Matrix4x4[srcBones.Length];
var skinRest = new Matrix4x4[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
restWorld[i] = srcBones[i].localToWorldMatrix;
skinRest[i] = restWorld[i] * srcBind[i];
}
var verts = srcMesh.vertices;
var norms = srcMesh.normals;
var weights = srcMesh.boneWeights;
var newVerts = new Vector3[verts.Length];
var newNorms = new Vector3[norms.Length];
for (int v = 0; v < verts.Length; v++)
{
// The rigid rebase is only valid for 100%-single-bone skins (07-16 review guard): a
// blend-skinned mesh (e.g. the bare head's neck weights) would distort — graft it rebase:false.
if (weights[v].weight0 < 0.999f)
{
Debug.LogError($"[PlayerRigTools] {src.name} vertex {v} is blend-skinned (w0={weights[v].weight0:0.###}); rigid rebase would distort — skipped (graft with rebase:false instead).");
return;
}
int b = weights[v].boneIndex0;
newVerts[v] = skinRest[b].MultiplyPoint3x4(verts[v]);
newNorms[v] = skinRest[b].rotation * norms[v];
}
var newBind = new Matrix4x4[srcBones.Length];
for (int i = 0; i < srcBones.Length; i++)
{
var m = restWorld[i];
newBind[i] = Matrix4x4.TRS((Vector3)m.GetColumn(3), m.rotation, Vector3.one).inverse;
}
// Persist the rebased mesh GUID-stably (Clear+refill keeps an existing asset's GUID on re-runs).
string meshPath = $"Assets/_Project/Art/Models/Rebased_{src.name}.asset";
mesh = AssetDatabase.LoadAssetAtPath<Mesh>(meshPath);
bool fresh = mesh == null;
if (fresh) mesh = new Mesh();
else mesh.Clear();
mesh.name = "Rebased_" + src.name;
mesh.vertices = newVerts;
mesh.normals = newNorms;
mesh.uv = srcMesh.uv;
mesh.subMeshCount = srcMesh.subMeshCount;
for (int s = 0; s < srcMesh.subMeshCount; s++) mesh.SetTriangles(srcMesh.GetTriangles(s), s);
mesh.boneWeights = weights;
mesh.bindposes = newBind;
mesh.RecalculateBounds();
mesh.RecalculateTangents(); // Rukhanka/BRG registration fails on a tangent-less mesh (BatchMeshID missing)
if (fresh) AssetDatabase.CreateAsset(mesh, meshPath);
else EditorUtility.SetDirty(mesh);
}
var old = root.transform.Find(src.name);
if (old != null) Object.DestroyImmediate(old.gameObject);
var go = new GameObject(src.name);
go.transform.SetParent(root.transform, false);
var smr = go.AddComponent<SkinnedMeshRenderer>();
smr.sharedMesh = mesh;
smr.bones = dst;
smr.rootBone = src.rootBone != null && bones.TryGetValue(src.rootBone.name, out var rb) ? rb : dst[0];
if (!rebase)
{
smr.localBounds = src.localBounds;
}
else
{
// Bounds in rootBone space: rebased (feet-at-0 world) verts through the RIGID root rest inverse.
var rootRest = src.rootBone != null ? src.rootBone.localToWorldMatrix : srcBones[0].localToWorldMatrix;
var rootRestInv = Matrix4x4.TRS((Vector3)rootRest.GetColumn(3), rootRest.rotation, Vector3.one).inverse;
var mv = mesh.vertices;
var bmin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var bmax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
for (int v = 0; v < mv.Length; v++)
{
var p = rootRestInv.MultiplyPoint3x4(mv[v]);
bmin = Vector3.Min(bmin, p); bmax = Vector3.Max(bmax, p);
}
var lb = new Bounds(); lb.SetMinMax(bmin, bmax);
smr.localBounds = lb;
}
smr.sharedMaterials = new[] { material };
Debug.Log($"[PlayerRigTools] Grafted {src.name} ({mesh.vertexCount}v, {dst.Length} bones{(rebase ? ", rebased" : "")}).");
}
/// <summary>07-15 underwater feel: heavier gait — slow the Locomotion blend-state playback and lengthen
/// the Idle⇄Locomotion transition blends (weightier starts/stops). The velocity-side drift (sharpness
/// 15→6) already slows the Speed/MoveX/MoveZ ramps; this makes the cycle itself read heavy. Uses the
/// AnimatorController API per the manage_animation gotcha. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Retime Locomotion Gait (LANTERN)")]
public static void RetimeLocomotionGait()
{
const float gaitSpeed = 0.85f;
const float blend = 0.25f;
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
var sm = ac.layers[0].stateMachine;
int retimed = 0, blends = 0;
foreach (var cs in sm.states)
{
if (cs.state.name == "Locomotion" && !Mathf.Approximately(cs.state.speed, gaitSpeed))
{
cs.state.speed = gaitSpeed;
retimed++;
}
foreach (var tr in cs.state.transitions)
{
if (tr.destinationState == null) continue;
bool idleLoco = (cs.state.name == "Idle" && tr.destinationState.name == "Locomotion")
|| (cs.state.name == "Locomotion" && tr.destinationState.name == "Idle");
if (idleLoco && !Mathf.Approximately(tr.duration, blend)) { tr.duration = blend; blends++; }
}
}
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log($"[PlayerRigTools] Locomotion gait retimed (speed {gaitSpeed}: {retimed} state(s); idle⇄locomotion blends {blend}s: {blends} transition(s)).");
}
/// <summary>07-16 gap-list: configure the four Blender underwater-feel clips (A_Idle_Sway, A_Walk_Trudge,
/// A_Bank_L/R) — HUMANOID + CreateFromThisModel (CopyFromOther fails on Blender's Armature node), root
/// motion baked into pose (lock+keepOriginal ×3 — the CC owns transforms), loops; the bank poses import
/// as ADDITIVE (playback frames 8-12 held pose, additive reference = frame 1's base stance) for the
/// Posture layer. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - 1 Import Underwater Clips (LANTERN)")]
public static void ImportUnderwaterClips()
{
var specs = new (string file, float first, float last, bool additive)[]
{
("A_Idle_Sway", 1f, 121f, false),
("A_Walk_Trudge", 1f, 33f, false), // v2: 32f long-stride cycle (foot-skate fix)
("A_Bank_L", 8f, 12f, true),
("A_Bank_R", 8f, 12f, true),
};
foreach (var s in specs)
{
string path = $"Assets/_Project/Animation/Authored/{s.file}.fbx";
var imp = AssetImporter.GetAtPath(path) as ModelImporter;
if (imp == null) { Debug.LogError($"[PlayerRigTools] Missing clip fbx: {path}"); continue; }
imp.animationType = ModelImporterAnimationType.Human;
imp.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
imp.importAnimation = true;
imp.SaveAndReimport(); // two-pass: takes only enumerate AFTER the rig import runs
var clips = imp.defaultClipAnimations;
if (clips.Length == 0) { Debug.LogError($"[PlayerRigTools] No takes in {path}"); continue; }
var c = clips[0];
c.name = s.file;
c.firstFrame = s.first;
c.lastFrame = s.last;
c.loopTime = true;
c.lockRootRotation = true; c.lockRootHeightY = true; c.lockRootPositionXZ = true;
c.keepOriginalOrientation = true; c.keepOriginalPositionY = true; c.keepOriginalPositionXZ = true;
if (s.additive)
{
c.hasAdditiveReferencePose = true;
c.additiveReferencePoseFrame = 1f; // the base stance key — the layer plays bank-minus-stance
}
imp.clipAnimations = new[] { c };
imp.SaveAndReimport();
Debug.Log($"[PlayerRigTools] Imported {s.file} (frames {s.first}-{s.last}{(s.additive ? ", additive" : "")}).");
}
// A_Lean_Fwd: ONE take -> TWO additive clips (the held lean + a zero-delta companion so the Lead
// layer's 1D Speed tree has a neutral end at 0).
{
string path = "Assets/_Project/Animation/Authored/A_Lean_Fwd.fbx";
var imp = AssetImporter.GetAtPath(path) as ModelImporter;
if (imp != null)
{
imp.animationType = ModelImporterAnimationType.Human;
imp.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
imp.importAnimation = true;
imp.SaveAndReimport();
var takes = imp.defaultClipAnimations;
if (takes.Length > 0)
{
ModelImporterClipAnimation Mk(string n, float f0, float f1)
{
var c = imp.defaultClipAnimations[0];
c.name = n; c.firstFrame = f0; c.lastFrame = f1; c.loopTime = true;
c.lockRootRotation = true; c.lockRootHeightY = true; c.lockRootPositionXZ = true;
c.keepOriginalOrientation = true; c.keepOriginalPositionY = true; c.keepOriginalPositionXZ = true;
c.hasAdditiveReferencePose = true; c.additiveReferencePoseFrame = 1f;
return c;
}
imp.clipAnimations = new[] { Mk("A_Lean_Fwd", 8f, 12f), Mk("A_Lean_Zero", 1f, 2f) };
imp.SaveAndReimport();
Debug.Log("[PlayerRigTools] Imported A_Lean_Fwd + A_Lean_Zero (additive pair).");
}
else Debug.LogError("[PlayerRigTools] No takes in A_Lean_Fwd.fbx");
}
}
}
static AnimationClip LoadAuthoredClip(string file)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath($"Assets/_Project/Animation/Authored/{file}.fbx"))
if (o is AnimationClip clip && !clip.name.StartsWith("__preview")) return clip;
return null;
}
/// <summary>07-16 gap-list: wire the underwater clips into AC_PlayerTopDown — Idle state → A_Idle_Sway
/// (buoyant sway replaces the static idle); the Locomotion 2D tree's FORWARD motion → A_Walk_Trudge
/// (chest-lead heavy walk; strafe/back clips stay Synty — they mostly show mid-cast); plus an ADDITIVE
/// "Posture" layer with a 1D Bank tree (A_Bank_L @ -1 … A_Bank_R @ +1, symmetric so Bank=0 cancels to
/// neutral) driven by PlayerAnimationDriveSystem's turn-rate Bank param. Idempotent.</summary>
[MenuItem("ProjectM/Animation/Player - 2 Wire Underwater Clips (LANTERN)")]
public static void WireUnderwaterFeelClips()
{
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
var bankL = LoadAuthoredClip("A_Bank_L");
var bankR = LoadAuthoredClip("A_Bank_R");
if (bankL == null || bankR == null)
{ Debug.LogError("[PlayerRigTools] Underwater clips not found — run Import Underwater Clips first."); return; }
// 07-16d (operator): idles come from the AnimationIdles pack (professionally-authored base loop).
AnimationClip packIdle = null;
foreach (var o in AssetDatabase.LoadAllAssetsAtPath("Assets/Synty/AnimationIdles/Animations/Polygon/Masculine/Base/Stances/A_POLY_IDL_Base_Masc.fbx"))
if (o is AnimationClip pcl && !pcl.name.StartsWith("__preview")) packIdle = pcl;
if (packIdle == null) Debug.LogError("[PlayerRigTools] AnimationIdles base idle not found — Idle keeps its current motion.");
const string locoDir = "Assets/Synty/AnimationBaseLocomotion/Animations/Polygon/Masculine/Locomotion/";
AnimationClip Clip(string sub, string file)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(locoDir + sub + "/" + file + ".fbx"))
if (o is AnimationClip cl && !cl.name.StartsWith("__preview")) return cl;
Debug.LogError($"[PlayerRigTools] Locomotion clip missing: {sub}/{file}");
return null;
}
// 07-16e TWO-RING gait tree: MoveX/MoveZ magnitude = speed/MoveSpeed, so a WALK ring at the walk
// clip's natural fraction (1.46/4.2 MoveSpeed ≈ 0.35) and a RUN ring at full deflection blend
// walk→run as the underwater accel ramps velocity — "walk first, run when held". StrideScale
// fine-corrects residual cadence against the blended natural (PlayerAnimationDriveSystem).
const float walkRing = 0.35f; // = FeelConfig.TrudgeNaturalSpeed / Character_Default MoveSpeed (4.2)
var dirs = new (Vector2 dir, string walk, string run)[]
{
(new Vector2(0f, 1f), "A_Walk_FwdStrafeF_Masc", "A_Run_FwdStrafeF_Masc"),
(new Vector2(0.7f, 0.7f), "A_Walk_FwdStrafeFR_Masc", "A_Run_FwdStrafeFR_Masc"),
(new Vector2(1f, 0f), "A_Walk_FwdStrafeR_Masc", "A_Run_FwdStrafeR_Masc"),
(new Vector2(0.7f, -0.7f), "A_Walk_BckStrafeBR_Masc", "A_Run_FwdStrafeBR_Masc"),
(new Vector2(0f, -1f), "A_Walk_BckStrafeB_Masc", "A_Run_BckStrafeB_Masc"),
(new Vector2(-0.7f, -0.7f), "A_Walk_BckStrafeBL_Masc", "A_Run_BckStrafeBL_Masc"),
(new Vector2(-1f, 0f), "A_Walk_FwdStrafeL_Masc", "A_Run_FwdStrafeL_Masc"),
(new Vector2(-0.7f, 0.7f), "A_Walk_FwdStrafeFL_Masc", "A_Run_FwdStrafeFL_Masc"),
};
var sm = ac.layers[0].stateMachine;
foreach (var cs in sm.states)
{
if (cs.state.name == "Idle")
{
if (packIdle != null) cs.state.motion = packIdle;
}
else if (cs.state.name == "Locomotion" && cs.state.motion is BlendTree tree)
{
cs.state.speed = 1f;
cs.state.speedParameterActive = true;
cs.state.speedParameter = "StrideScale";
tree.useAutomaticThresholds = false;
tree.children = new UnityEditor.Animations.ChildMotion[0]; // full rebuild (idempotent)
if (packIdle != null) tree.AddChild(packIdle, Vector2.zero);
foreach (var d in dirs)
{
var w = Clip("Walk", d.walk);
var r = Clip("Run", d.run);
if (w != null) tree.AddChild(w, d.dir.normalized * walkRing);
if (r != null) tree.AddChild(r, d.dir);
}
Debug.Log($"[PlayerRigTools] Locomotion rebuilt: idle center + walk ring @{walkRing} + run ring @1.0 ({tree.children.Length} children).");
}
}
if (!AnimRigUtil.HasParam(ac, "Bank"))
ac.AddParameter("Bank", AnimatorControllerParameterType.Float);
if (!AnimRigUtil.HasParam(ac, "StrideScale"))
{
ac.AddParameter("StrideScale", AnimatorControllerParameterType.Float);
var ps = ac.parameters;
for (int i = 0; i < ps.Length; i++) if (ps[i].name == "StrideScale") ps[i].defaultFloat = 1f;
ac.parameters = ps;
}
// Additive Posture layer (Bank: symmetric poses cancel at 0).
int postureIdx = EnsureAdditiveLayer(ac, "Posture");
var psm = ac.layers[postureIdx].stateMachine;
AnimatorState bankState = null;
foreach (var cs in psm.states) if (cs.state.name == "Bank") bankState = cs.state;
if (bankState == null) bankState = psm.AddState("Bank");
psm.defaultState = bankState;
if (!(bankState.motion is BlendTree))
{
var bankTree = new BlendTree { name = "BankTree", blendType = BlendTreeType.Simple1D, blendParameter = "Bank", useAutomaticThresholds = false, hideFlags = HideFlags.HideInHierarchy };
AssetDatabase.AddObjectToAsset(bankTree, ac);
bankTree.AddChild(bankL, -1f);
bankTree.AddChild(bankR, 1f);
bankState.motion = bankTree;
}
bankState.writeDefaultValues = false;
// Additive Lead layer (underwater chest-lead scales with Speed).
var leanFwd = LoadAuthoredClip("A_Lean_Fwd");
AnimationClip leanZero = null;
foreach (var o in AssetDatabase.LoadAllAssetsAtPath("Assets/_Project/Animation/Authored/A_Lean_Fwd.fbx"))
if (o is AnimationClip cl && cl.name == "A_Lean_Zero") leanZero = cl;
if (leanFwd != null && leanZero != null)
{
int leadIdx = EnsureAdditiveLayer(ac, "Lead");
var lsm = ac.layers[leadIdx].stateMachine;
AnimatorState leadState = null;
foreach (var cs in lsm.states) if (cs.state.name == "Lean") leadState = cs.state;
if (leadState == null) leadState = lsm.AddState("Lean");
lsm.defaultState = leadState;
if (!(leadState.motion is BlendTree))
{
var leadTree = new BlendTree { name = "LeadTree", blendType = BlendTreeType.Simple1D, blendParameter = "Speed", useAutomaticThresholds = false, hideFlags = HideFlags.HideInHierarchy };
AssetDatabase.AddObjectToAsset(leadTree, ac);
leadTree.AddChild(leanZero, 0.1f);
leadTree.AddChild(leanFwd, 0.85f);
leadState.motion = leadTree;
}
leadState.writeDefaultValues = false;
}
else Debug.LogError("[PlayerRigTools] Lean clips missing — run Import Underwater Clips first.");
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] Underwater clips wired v5 (pack idle, walk+run rings + StrideScale, Bank + Lead additive layers).");
}
static int EnsureAdditiveLayer(AnimatorController ac, string name)
{
int idx = -1;
for (int i = 0; i < ac.layers.Length; i++) if (ac.layers[i].name == name) idx = i;
if (idx < 0)
{
ac.AddLayer(name);
idx = ac.layers.Length - 1;
}
var layers = ac.layers; // array copy — modify + assign back
layers[idx].blendingMode = UnityEditor.Animations.AnimatorLayerBlendingMode.Additive;
layers[idx].defaultWeight = 1f;
ac.layers = layers;
return idx;
}
static AnimationClip FindIdleClip(AnimatorController ac)
{
foreach (var c in ac.layers[0].stateMachine.states)
if (c.state.name == "Idle") return c.state.motion as AnimationClip;
return null;
}
static AnimatorState FindState(AnimatorStateMachine sm, string name)
{
foreach (var c in sm.states) if (c.state.name == name) return c.state;
return null;
}
/// <summary>07-18 melee-anim pass: swap the attack states onto AnimationSwordCombat pack clips
/// (LightCombo01 A/B/C -> Swing1/2/3, HeavyCombo01A -> SpecialSlam, Death_B_01 -> Death) and add the
/// Menacing01 combat idle (CombatIdle state + InCombat param; PlayerAnimationDriveSystem holds it for
/// FeelConfig.CombatIdleHoldSec after a swing). State speeds land each clip's HIT take inside the
/// 13-tick swing-anim window (CastFacingTicks ~0.22s + 0.12s exit blend). Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Wire Sword Combat Attacks (LANTERN)")]
public static void WireSwordCombatAttackClips()
{
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
const string packDir = "Assets/Synty/AnimationSwordCombat/Animations/Polygon/";
AnimationClip PackClip(string fbxSub, string clipName)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(packDir + fbxSub + ".fbx"))
if (o is AnimationClip c && c.name == clipName) return c;
Debug.LogError($"[PlayerRigTools] SwordCombat clip missing: {fbxSub} -> {clipName}");
return null;
}
// Menacing01: the pack take ships loopTime=false -- flip it on the pack importer once (idempotent).
const string menacingFbx = "Idle/Menacing01/A_Idle_Menacing01_Sword";
var menacing = PackClip(menacingFbx, "A_Idle_Menacing01_Sword");
if (menacing != null && !menacing.isLooping)
{
var imp = AssetImporter.GetAtPath(packDir + menacingFbx + ".fbx") as ModelImporter;
var takes = (imp.clipAnimations != null && imp.clipAnimations.Length > 0)
? imp.clipAnimations : imp.defaultClipAnimations;
foreach (var t in takes) t.loopTime = true;
imp.clipAnimations = takes;
imp.SaveAndReimport();
menacing = PackClip(menacingFbx, "A_Idle_Menacing01_Sword");
}
// 07-19 retime: a custom SLAM take on the HeavyCombo01A fbx -- the stock windup is ~1s of the 2s clip,
// so the take starts ~22% in (leaves ~0.5s clip-time of windup before the hit; at speed 1.5 the strike
// lands ~0.35s into the 26-tick window) instead of comic 5x compression. Idempotent by take name.
{
string fbx = packDir + "Attack/HeavyCombo01/A_Attack_HeavyCombo01A_Sword.fbx";
var imp = AssetImporter.GetAtPath(fbx) as ModelImporter;
var takes = (imp.clipAnimations != null && imp.clipAnimations.Length > 0) ? imp.clipAnimations : imp.defaultClipAnimations;
bool hasSlam = false;
ModelImporterClipAnimation full = null;
foreach (var tk in takes)
{
if (tk.name == "A_Attack_HeavyCombo01A_Slam_Sword") hasSlam = true;
if (tk.name == "A_Attack_HeavyCombo01A_Sword") full = tk;
}
if (!hasSlam && full != null)
{
var slam = imp.defaultClipAnimations[0]; // fresh instance (never mutate an existing take entry)
slam.name = "A_Attack_HeavyCombo01A_Slam_Sword";
slam.firstFrame = Mathf.Lerp(full.firstFrame, full.lastFrame, 0.22f);
slam.lastFrame = full.lastFrame;
slam.loopTime = false;
var list = new System.Collections.Generic.List<ModelImporterClipAnimation>(takes) { slam };
imp.clipAnimations = list.ToArray();
imp.SaveAndReimport();
}
else if (full == null) Debug.LogError("[PlayerRigTools] HeavyCombo01A full take not found -- slam take skipped.");
}
var sm = ac.layers[0].stateMachine;
// Attack/death states: full pack clip + a state speed that lands the HIT take inside the swing
// window. Hit-start seconds measured off the pack takes (= the WindUp take length).
var swaps = new (string state, string fbxSub, string clip, float speed)[]
{
("Swing1", "Attack/LightCombo01/A_Attack_LightCombo01A_Sword", "A_Attack_LightCombo01A_Sword", 1.25f), // hit @0.33s -> ~0.27s (07-19 retime: 26-tick window)
("Swing2", "Attack/LightCombo01/A_Attack_LightCombo01B_Sword", "A_Attack_LightCombo01B_Sword", 1.0f), // hit @0.17s natural speed
("Swing3", "Attack/LightCombo01/A_Attack_LightCombo01C_Sword", "A_Attack_LightCombo01C_Sword", 1.1f), // finisher: hit @0.33s, heaviest of the chain
("SpecialSlam", "Attack/HeavyCombo01/A_Attack_HeavyCombo01A_Sword", "A_Attack_HeavyCombo01A_Slam_Sword", 1.5f), // custom take (skips ~2/3 of the windup); hit ~0.35s
("Death", "Death/A_Death_B_01_Sword", "A_Death_B_01_Sword", 1.0f), // backward collapse, holds last frame
};
foreach (var s in swaps)
{
var st = FindState(sm, s.state);
var clip = PackClip(s.fbxSub, s.clip);
if (st == null || clip == null) { Debug.LogError($"[PlayerRigTools] Skipped {s.state} (state or clip missing)."); continue; }
st.motion = clip;
st.speed = s.speed;
}
// 07-19 retime: soften the swing entries (0.03 read as a snap-cut) + exits, matching the heavy cadence.
foreach (var tr in sm.anyStateTransitions)
{
if (tr.destinationState == null) continue;
string dn = tr.destinationState.name;
if (dn == "Swing1" || dn == "Swing2" || dn == "Swing3") tr.duration = 0.06f;
else if (dn == "SpecialSlam") tr.duration = 0.07f;
}
foreach (var sn in new[] { "Swing1", "Swing2", "Swing3", "SpecialSlam" })
{
var st2 = FindState(sm, sn);
if (st2 == null) continue;
foreach (var tr in st2.transitions) if (tr.isExit) tr.duration = 0.15f;
}
// InCombat param + the CombatIdle (Menacing01) state. Idle -> CombatIdle is appended AFTER the
// existing Idle -> Locomotion transition, so movement keeps priority while InCombat holds.
bool hasInCombat = false;
foreach (var p in ac.parameters) if (p.name == "InCombat") hasInCombat = true;
if (!hasInCombat) ac.AddParameter("InCombat", AnimatorControllerParameterType.Bool);
var idle = FindState(sm, "Idle");
var loco = FindState(sm, "Locomotion");
var combatIdle = FindState(sm, "CombatIdle");
if (combatIdle == null && menacing != null && idle != null && loco != null)
{
combatIdle = sm.AddState("CombatIdle");
var toCombat = idle.AddTransition(combatIdle);
toCombat.hasExitTime = false; toCombat.duration = 0.25f;
toCombat.AddCondition(AnimatorConditionMode.If, 0f, "InCombat");
var toIdle = combatIdle.AddTransition(idle);
toIdle.hasExitTime = false; toIdle.duration = 0.4f;
toIdle.AddCondition(AnimatorConditionMode.IfNot, 0f, "InCombat");
var toLoco = combatIdle.AddTransition(loco);
toLoco.hasExitTime = false; toLoco.duration = 0.25f;
toLoco.AddCondition(AnimatorConditionMode.Greater, 0.1f, "Speed");
}
if (combatIdle != null && menacing != null) { combatIdle.motion = menacing; combatIdle.speed = 1f; }
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[PlayerRigTools] SwordCombat wired: Swing1/2/3 = LightCombo01 A/B/C, SpecialSlam = HeavyCombo01A, Death = Death_B_01, CombatIdle = Menacing01.");
}
// 07-19 grip offset (Hand_R bone-local). Iterate these + re-run Attach Melee Weapon if the grip reads wrong.
static readonly Vector3 WeaponGripPos = new Vector3(0f, 0.06f, 0.02f);
static readonly Vector3 WeaponGripEuler = new Vector3(0f, 0f, -90f);
const float WeaponScale = 1.25f; // 07-20 G2.3 (reach honesty): the Synty-proportion exaggeration that sells the 2.2m cone
/// <summary>07-19 melee feel: rigid-skin a bulky Synty salvage axe into the RIGHT hand (100% Hand_R;
/// GraftSmr's rebase conventions: world-rest verts + rigid inverse bindpose; RecalculateTangents or
/// Rukhanka/BRG registration fails and the whole rig vanishes). An SMR so Rukhanka + Entities-Graphics
/// deform it with the rig like the suit kit. Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Player - Attach Melee Weapon (LANTERN)")]
public static void AttachMeleeWeapon()
{
const string weaponFbx = "Assets/Synty/PolygonDungeonRealms/Models/SM_Wep_Axe_Large_01.fbx";
const string matPath = "Assets/_Project/Materials/M_Weapon_Animated.mat";
const string bodyMat = "Assets/_Project/Materials/M_SuitFrame_Bathynaut_Animated.mat";
const string output = "Assets/_Project/Prefabs/Player.prefab";
const string childName = "SM_Wep_Melee";
const string meshPath = "Assets/_Project/Art/Models/Rebased_SM_Wep_Melee.asset";
var src = AssetDatabase.LoadAssetAtPath<GameObject>(weaponFbx);
if (src == null) { Debug.LogError($"[PlayerRigTools] Weapon fbx missing: {weaponFbx}"); return; }
var srcMf = src.GetComponentInChildren<MeshFilter>(true);
if (srcMf == null || srcMf.sharedMesh == null) { Debug.LogError("[PlayerRigTools] Weapon mesh missing."); return; }
var srcMesh = srcMf.sharedMesh;
// Deformation-aware material: clone the suit's AnimatedLit material, swap the atlas to the weapon pack's.
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (mat == null)
{
var body = AssetDatabase.LoadAssetAtPath<Material>(bodyMat);
if (body == null) { Debug.LogError($"[PlayerRigTools] Body material missing: {bodyMat}"); return; }
mat = new Material(body) { name = "M_Weapon_Animated" };
// EXPLICIT pack atlas (07-19 fix): the FBX's importer material carries NO texture, so a mainTexture
// fallback silently keeps the cloned suit atlas (caught by the material-VALUES check, not the render).
var atlas = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Synty/PolygonDungeonRealms/Textures/Dungeons_2_Texture_01_A.png");
if (atlas == null) { var srcMr = srcMf.GetComponent<MeshRenderer>(); atlas = srcMr != null && srcMr.sharedMaterial != null ? srcMr.sharedMaterial.mainTexture : null; }
if (atlas != null && mat.HasProperty("_BaseColorMap")) mat.SetTexture("_BaseColorMap", atlas);
else Debug.LogWarning("[PlayerRigTools] Weapon atlas not resolved -- material keeps the cloned suit atlas.");
AssetDatabase.CreateAsset(mat, matPath);
}
var root = PrefabUtility.LoadPrefabContents(output);
try
{
Transform hand = null;
foreach (var t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "Hand_R") { hand = t; break; }
if (hand == null) { Debug.LogError("[PlayerRigTools] Hand_R not found on Player.prefab."); return; }
// Rigid rest matrices (GraftSmr conventions: scale-stripped rest, feet-at-0 prefab space).
var hm = hand.localToWorldMatrix;
var handRigid = Matrix4x4.TRS((Vector3)hm.GetColumn(3), hm.rotation, Vector3.one);
var grip = Matrix4x4.TRS(WeaponGripPos, Quaternion.Euler(WeaponGripEuler), Vector3.one * WeaponScale); // uniform scale: normals keep their directions
var toWorld = handRigid * grip;
var verts = srcMesh.vertices;
var norms = srcMesh.normals;
var newVerts = new Vector3[verts.Length];
var newNorms = new Vector3[norms.Length];
for (int v = 0; v < verts.Length; v++)
{
newVerts[v] = toWorld.MultiplyPoint3x4(verts[v]);
newNorms[v] = toWorld.rotation * norms[v];
}
var weights = new BoneWeight[verts.Length];
for (int v = 0; v < verts.Length; v++) weights[v] = new BoneWeight { boneIndex0 = 0, weight0 = 1f };
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>(meshPath);
bool fresh = mesh == null;
if (fresh) mesh = new Mesh(); else mesh.Clear();
mesh.name = "Rebased_" + childName;
mesh.vertices = newVerts;
mesh.normals = newNorms;
mesh.uv = srcMesh.uv;
mesh.subMeshCount = srcMesh.subMeshCount;
for (int s = 0; s < srcMesh.subMeshCount; s++) mesh.SetTriangles(srcMesh.GetTriangles(s), s);
mesh.boneWeights = weights;
mesh.bindposes = new[] { handRigid.inverse };
mesh.RecalculateBounds();
mesh.RecalculateTangents(); // Rukhanka/BRG registration fails on a tangent-less mesh
if (fresh) AssetDatabase.CreateAsset(mesh, meshPath); else EditorUtility.SetDirty(mesh);
var old = root.transform.Find(childName);
if (old != null) Object.DestroyImmediate(old.gameObject);
var go = new GameObject(childName);
go.transform.SetParent(root.transform, false);
var smr = go.AddComponent<SkinnedMeshRenderer>();
smr.sharedMesh = mesh;
smr.bones = new[] { hand };
smr.rootBone = hand;
// Bounds in rootBone (hand) space = the grip-transformed source verts.
var bmin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var bmax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
for (int v = 0; v < verts.Length; v++)
{
var p = grip.MultiplyPoint3x4(verts[v]);
bmin = Vector3.Min(bmin, p); bmax = Vector3.Max(bmax, p);
}
var lb = new Bounds(); lb.SetMinMax(bmin, bmax);
smr.localBounds = lb;
smr.sharedMaterials = new[] { mat };
PrefabUtility.SaveAsPrefabAsset(root, output);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets();
Debug.Log($"[PlayerRigTools] Melee weapon attached to Hand_R ({srcMesh.vertexCount}v, grip pos {WeaponGripPos} euler {WeaponGripEuler}).");
}
}
}