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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -118,17 +118,25 @@ namespace ProjectM.EditorTools
|
|||||||
[MenuItem("ProjectM/Animation/Enemy Rigs - 2 Build Controller")]
|
[MenuItem("ProjectM/Animation/Enemy Rigs - 2 Build Controller")]
|
||||||
public static void BuildControllerAndClip()
|
public static void BuildControllerAndClip()
|
||||||
{
|
{
|
||||||
// Attack wind-up: pitch the Root bone forward (whole-body lunge) and back, non-looping. Root is the
|
// Attack tell: built FROM the full idle pose (every bone keyed) + a Root YAW coil. A Root-ONLY clip makes
|
||||||
// top of every Synty Generic rig, so one clip fits all variants; the locomotion clips don't key Root,
|
// Rukhanka collapse every un-keyed bone to identity for the state's duration -> the body sinks into the
|
||||||
// so no conflict and Root returns to its authored Y-offset when the state exits.
|
// floor (writeDefaultValues does NOT prevent it; 2026-06-11 fix). Idle base = nothing un-keyed; the yaw is
|
||||||
var clip = new AnimationClip { frameRate = 30f };
|
// height-preserving. Based on the player controller's Idle clip (the enemy controller is forked below).
|
||||||
var pitch = new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(0.15f, 30f), new Keyframe(0.40f, 0f));
|
var srcAc = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
|
||||||
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.x"), pitch);
|
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);
|
var s = AnimationUtility.GetAnimationClipSettings(clip);
|
||||||
s.loopTime = false;
|
s.loopTime = false;
|
||||||
AnimationUtility.SetAnimationClipSettings(clip, s);
|
AnimationUtility.SetAnimationClipSettings(clip, s);
|
||||||
AssetDatabase.DeleteAsset(AttackClip);
|
EditorUtility.SetDirty(clip);
|
||||||
AssetDatabase.CreateAsset(clip, AttackClip);
|
|
||||||
AssetDatabase.SaveAssets();
|
AssetDatabase.SaveAssets();
|
||||||
|
|
||||||
if (AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController) == null)
|
if (AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController) == null)
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ using UnityEngine;
|
|||||||
namespace ProjectM.EditorTools
|
namespace ProjectM.EditorTools
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MC-4 — builds the player's procedural MELEE SWING clip and adds the IsAttacking param + MeleeSwing state to
|
/// MC-4 — builds the player's MELEE SWING clip + adds the IsAttacking param + MeleeSwing state to AC_PlayerTopDown.
|
||||||
/// AC_PlayerTopDown. Mirrors EnemyRigTools' attack recipe (a Root-bone procedural clip — no authored Synty
|
/// The swing clip is built FROM the full idle pose (every bone keyed) + a Root YAW twist on top. A clip that keys
|
||||||
/// Generic melee-swing clip exists; the slash-arc VFX in CombatFeedbackSystem carries the blade read). The clip
|
/// ONLY the Root makes Rukhanka collapse every un-keyed bone (Hips/Spine/legs) to identity for the state's
|
||||||
/// keys ONLY Root rotation (a yaw twist into the swing + a slight forward pitch), leaving the rig's authored
|
/// duration -> the body sinks into the floor; writeDefaultValues does NOT prevent it (2026-06-11 fix). Basing on
|
||||||
/// Root Y offset (feet-on-ground) untouched. PlayerAnimationDriveSystem drives IsAttacking from MeleeCombo.
|
/// idle leaves nothing un-keyed; the Root yaw is a vertical-axis (height-preserving) twist that reads as a
|
||||||
/// Idempotent / re-runnable (menu: ProjectM/Animation).
|
/// horizontal slash, paired with the CombatFeedbackSystem slash-arc VFX. PlayerAnimationDriveSystem drives
|
||||||
|
/// IsAttacking from MeleeCombo. Idempotent / re-runnable (menu: ProjectM/Animation).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PlayerRigTools
|
public static class PlayerRigTools
|
||||||
{
|
{
|
||||||
@@ -20,37 +21,39 @@ namespace ProjectM.EditorTools
|
|||||||
[MenuItem("ProjectM/Animation/Player - Build Melee Swing")]
|
[MenuItem("ProjectM/Animation/Player - Build Melee Swing")]
|
||||||
public static void BuildPlayerMeleeSwing()
|
public static void BuildPlayerMeleeSwing()
|
||||||
{
|
{
|
||||||
// Horizontal-cleave read: yaw the Root (wind back -> swing across -> recover) + a slight forward pitch.
|
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
|
||||||
// Root is the top of the Synty Generic rig; locomotion clips don't key Root, so no conflict and it
|
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
|
||||||
// returns to the authored pose on exit. Rotation-only (no position) so the baked Root Y offset stays.
|
|
||||||
var clip = new AnimationClip { frameRate = 30f };
|
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); }
|
||||||
|
clip.ClearCurves();
|
||||||
|
foreach (var b in AnimationUtility.GetCurveBindings(idle))
|
||||||
|
{
|
||||||
|
if (b.path == "Root") continue; // the Root is driven below
|
||||||
|
AnimationUtility.SetEditorCurve(clip, b, AnimationUtility.GetEditorCurve(idle, b));
|
||||||
|
}
|
||||||
var yaw = new AnimationCurve(
|
var yaw = new AnimationCurve(
|
||||||
new Keyframe(0f, 0f), new Keyframe(0.06f, -22f), new Keyframe(0.16f, 34f), new Keyframe(0.32f, 0f));
|
new Keyframe(0f, 0f), new Keyframe(0.06f, -25f), new Keyframe(0.16f, 48f), new Keyframe(0.30f, 0f));
|
||||||
var pitch = new AnimationCurve(
|
|
||||||
new Keyframe(0f, 0f), new Keyframe(0.14f, 16f), new Keyframe(0.32f, 0f));
|
|
||||||
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.y"), yaw);
|
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.y"), yaw);
|
||||||
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.x"), pitch);
|
|
||||||
var s = AnimationUtility.GetAnimationClipSettings(clip);
|
var s = AnimationUtility.GetAnimationClipSettings(clip);
|
||||||
s.loopTime = false;
|
s.loopTime = false;
|
||||||
AnimationUtility.SetAnimationClipSettings(clip, s);
|
AnimationUtility.SetAnimationClipSettings(clip, s);
|
||||||
AssetDatabase.DeleteAsset(SwingClip);
|
EditorUtility.SetDirty(clip);
|
||||||
AssetDatabase.CreateAsset(clip, SwingClip);
|
|
||||||
AssetDatabase.SaveAssets();
|
AssetDatabase.SaveAssets();
|
||||||
|
|
||||||
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController);
|
|
||||||
if (ac == null) { Debug.LogError($"[PlayerRigTools] Controller missing: {PlayerController}"); return; }
|
|
||||||
|
|
||||||
if (!HasParam(ac, "IsAttacking"))
|
if (!HasParam(ac, "IsAttacking"))
|
||||||
ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool);
|
ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool);
|
||||||
|
|
||||||
var sm = ac.layers[0].stateMachine;
|
var sm = ac.layers[0].stateMachine;
|
||||||
var swingClipAsset = AssetDatabase.LoadAssetAtPath<AnimationClip>(SwingClip);
|
|
||||||
|
|
||||||
var swing = FindState(sm, "MeleeSwing");
|
var swing = FindState(sm, "MeleeSwing");
|
||||||
if (swing == null)
|
if (swing == null)
|
||||||
{
|
{
|
||||||
swing = sm.AddState("MeleeSwing");
|
swing = sm.AddState("MeleeSwing");
|
||||||
swing.writeDefaultValues = false; // partial (Root-only) clip: MUST be false, else WDV resets every un-keyed bone (Hips/Spine/legs) to identity and the body collapses into the floor
|
swing.writeDefaultValues = false;
|
||||||
|
|
||||||
var toSwing = sm.AddAnyStateTransition(swing);
|
var toSwing = sm.AddAnyStateTransition(swing);
|
||||||
toSwing.hasExitTime = false;
|
toSwing.hasExitTime = false;
|
||||||
@@ -63,11 +66,18 @@ namespace ProjectM.EditorTools
|
|||||||
fromSwing.duration = 0.10f;
|
fromSwing.duration = 0.10f;
|
||||||
fromSwing.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsAttacking");
|
fromSwing.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsAttacking");
|
||||||
}
|
}
|
||||||
swing.motion = swingClipAsset; // (re)point at the freshly-built clip
|
swing.motion = clip;
|
||||||
|
|
||||||
EditorUtility.SetDirty(ac);
|
EditorUtility.SetDirty(ac);
|
||||||
AssetDatabase.SaveAssets();
|
AssetDatabase.SaveAssets();
|
||||||
Debug.Log("[PlayerRigTools] AC_PlayerTopDown: IsAttacking + MeleeSwing state built; PlayerMeleeSwing.anim created.");
|
Debug.Log("[PlayerRigTools] AC_PlayerTopDown: IsAttacking + idle-based MeleeSwing built (no un-keyed-bone collapse).");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bool HasParam(AnimatorController ac, string name)
|
static bool HasParam(AnimatorController ac, string name)
|
||||||
|
|||||||
Reference in New Issue
Block a user