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>
This commit is contained in:
2026-07-20 21:03:18 -07:00
parent 2c187a17a9
commit 7b65912f30
22 changed files with 1327 additions and 112 deletions
@@ -604,5 +604,242 @@ namespace ProjectM.EditorTools
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}).");
}
}
}