Fix: attack animation sank the model into the floor (Rukhanka partial-clip collapse)

An attack clip that keys ONLY the Root bone makes Rukhanka collapse every un-keyed bone (Hips/Spine/legs) to identity for the duration of the state -> the body folds halfway into the floor (player MeleeSwing + enemy Attack; writeDefaultValues does NOT prevent it -- confirmed since even a pure yaw, which is height-preserving, still sank). Fix: build the attack clips FROM the full idle pose (every bone keyed) + a Root YAW twist on top, so nothing is un-keyed. Applied to both runtime clips (PlayerMeleeSwing/EnemyAttackWindup) and both editor recipes (PlayerRigTools/EnemyRigTools) so rebuilds stay correct. Operator-verified in Play.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:35:39 -07:00
parent 12a18cc41c
commit 1224fd97f8
4 changed files with 261589 additions and 90 deletions
@@ -118,17 +118,25 @@ namespace ProjectM.EditorTools
[MenuItem("ProjectM/Animation/Enemy Rigs - 2 Build Controller")]
public static void BuildControllerAndClip()
{
// Attack wind-up: pitch the Root bone forward (whole-body lunge) and back, non-looping. Root is the
// top of every Synty Generic rig, so one clip fits all variants; the locomotion clips don't key Root,
// so no conflict and Root returns to its authored Y-offset when the state exits.
var clip = new AnimationClip { frameRate = 30f };
var pitch = new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(0.15f, 30f), new Keyframe(0.40f, 0f));
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.x"), pitch);
// Attack tell: built FROM the full idle pose (every bone keyed) + a Root YAW coil. A Root-ONLY clip makes
// Rukhanka collapse every un-keyed bone to identity for the state's duration -> the body sinks into the
// floor (writeDefaultValues does NOT prevent it; 2026-06-11 fix). Idle base = nothing un-keyed; the yaw is
// height-preserving. Based on the player controller's Idle clip (the enemy controller is forked below).
var srcAc = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
AnimationClip idle = null;
if (srcAc != null) foreach (var st in srcAc.layers[0].stateMachine.states) if (st.state.name == "Idle") idle = st.state.motion as AnimationClip;
if (idle == null) { Debug.LogError("[EnemyRigTools] No Idle clip to base the attack on."); return; }
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(AttackClip);
if (clip == null) { clip = new AnimationClip { frameRate = 30f }; AssetDatabase.CreateAsset(clip, AttackClip); }
clip.ClearCurves();
foreach (var cb in AnimationUtility.GetCurveBindings(idle))
{ if (cb.path == "Root") continue; AnimationUtility.SetEditorCurve(clip, cb, AnimationUtility.GetEditorCurve(idle, cb)); }
var yaw = new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(0.15f, -38f), new Keyframe(0.40f, 0f));
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.y"), yaw);
var s = AnimationUtility.GetAnimationClipSettings(clip);
s.loopTime = false;
AnimationUtility.SetAnimationClipSettings(clip, s);
AssetDatabase.DeleteAsset(AttackClip);
AssetDatabase.CreateAsset(clip, AttackClip);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
if (AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController) == null)