Enemy hit-reacts: React/Stagger anim tiers off Health-drop edges (the Rukhanka-safe flinch)

SwordCombat React (1.4x, 0.3s pulse) + Stagger (1.2x, 0.55s) as Any-State
states on AC_EnemyTopDown via EnemyRigTools.WireEnemyHitReacts; driven by
EnemyAnimationDriveSystem's cache (now Pos/Hp/ReactUntil/Heavy) off
replicated Health drops. Windup-honesty gate: light reacts require
!IsAttacking (a sub-poise hit never visually cancels a live windup);
the heavy tier tracks the server's B2 poise break (stagger threshold 50
= finisher staggers, light flinches at the locked tuning). Knobs:
HitReactSeconds (0=off) / HitStaggerSeconds / HitReactStaggerDamage.
Replaces the review-cut positional vibrate (Rukhanka inverse-fold no-op).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:28:11 -07:00
parent 4e795a38e9
commit 0ff11fe2a9
4 changed files with 325 additions and 8 deletions
@@ -26,6 +26,8 @@ namespace ProjectM.EditorTools
const string PlayerController = "Assets/_Project/Animation/AC_PlayerTopDown.controller";
const string EnemyController = "Assets/_Project/Animation/AC_EnemyTopDown.controller";
const string AttackClip = "Assets/_Project/Animation/EnemyAttackWindup.anim";
const string HitReactFbx = "Assets/Synty/AnimationSwordCombat/Animations/Polygon/Hit/HitReact/A_Hit_F_React_Sword.fbx";
const string HitStaggerFbx = "Assets/Synty/AnimationSwordCombat/Animations/Polygon/Hit/HitStagger/A_Hit_F_Stagger_Sword.fbx";
const string WerewolfAtlas = "Assets/Synty/PolygonWerewolf/Textures/PolygonWerewolf_01_A.png";
const string KaijuAtlas = "Assets/Synty/PolygonKaiju/Textures/PolygonKaiju_01.png";
@@ -364,5 +366,83 @@ namespace ProjectM.EditorTools
}
Debug.LogWarning($"[EnemyRigTools] Field/prop '{name}' not found on {o.GetType().Name}.");
}
}
/// <summary>07-21 hit-react pass (guidelines G5; the Rukhanka-safe flinch replacing the cut vibrate):
/// adds IsHit/IsHitHeavy params + HitReact (light flinch) and HitStagger (heavy) Any-State states to
/// AC_EnemyTopDown, pulsed by EnemyAnimationDriveSystem off replicated Health-drop edges. HONESTY GATE:
/// the light react requires IsAttacking == false so a sub-poise hit never visually cancels a windup the
/// server is still running (the telegraph must not lie); a HEAVY hit staggers unconditionally, which
/// aligns with the server's B2 poise break (finisher knockback >= the stagger threshold zeroes the
/// windup). Idempotent / re-runnable.</summary>
[MenuItem("ProjectM/Animation/Enemy - Wire Hit Reacts (LANTERN)")]
public static void WireEnemyHitReacts()
{
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(EnemyController);
if (ac == null) { Debug.LogError($"[EnemyRigTools] Controller missing: {EnemyController}"); return; }
AnimationClip Clip(string fbxPath, string clipName)
{
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(fbxPath))
if (o is AnimationClip c && c.name == clipName) return c;
Debug.LogError($"[EnemyRigTools] Clip missing: {fbxPath} -> {clipName}");
return null;
}
var react = Clip(HitReactFbx, "A_Hit_F_React_Sword");
var stagger = Clip(HitStaggerFbx, "A_Hit_F_Stagger_Sword");
if (react == null || stagger == null) return;
void EnsureParam(string n)
{
foreach (var p in ac.parameters) if (p.name == n) return;
ac.AddParameter(n, AnimatorControllerParameterType.Bool);
}
EnsureParam("IsHit");
EnsureParam("IsHitHeavy");
var sm = ac.layers[0].stateMachine;
AnimatorState Find(string n)
{
foreach (var cs in sm.states) if (cs.state.name == n) return cs.state;
return null;
}
var hitReact = Find("HitReact");
if (hitReact == null)
{
hitReact = sm.AddState("HitReact");
var to = sm.AddAnyStateTransition(hitReact);
to.hasExitTime = false; to.duration = 0.05f; to.canTransitionToSelf = false;
to.AddCondition(AnimatorConditionMode.If, 0f, "IsHit");
to.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsHitHeavy");
to.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsAttacking"); // honesty: never cancel a live windup visual
to.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsDead");
var ex = hitReact.AddExitTransition();
ex.hasExitTime = false; ex.duration = 0.10f;
ex.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsHit");
}
hitReact.motion = react;
hitReact.speed = 1.4f; // 0.83s clip -> the flinch peak inside the ~0.3s pulse
var hitStagger = Find("HitStagger");
if (hitStagger == null)
{
hitStagger = sm.AddState("HitStagger");
var to = sm.AddAnyStateTransition(hitStagger);
to.hasExitTime = false; to.duration = 0.05f; to.canTransitionToSelf = false;
to.AddCondition(AnimatorConditionMode.If, 0f, "IsHit");
to.AddCondition(AnimatorConditionMode.If, 0f, "IsHitHeavy"); // heavy overrides even a windup (server poise broke it)
to.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsDead");
var ex = hitStagger.AddExitTransition();
ex.hasExitTime = false; ex.duration = 0.12f;
ex.AddCondition(AnimatorConditionMode.IfNot, 0f, "IsHit");
}
hitStagger.motion = stagger;
hitStagger.speed = 1.2f; // 1.07s clip -> the lurch inside the ~0.55s pulse
EditorUtility.SetDirty(ac);
AssetDatabase.SaveAssets();
Debug.Log("[EnemyRigTools] Hit reacts wired: HitReact (light, windup-honest) + HitStagger (heavy) on AC_EnemyTopDown.");
}
}
}