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:
@@ -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}).");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user