using System.Text; using ProjectM.Simulation; using UnityEditor; using UnityEditor.Animations; using UnityEngine; namespace ProjectM.EditorTools { /// /// 07-20 data-driven tuning: derive the melee timing TRUTH from the assets themselves instead of hand-copied /// constants — clip WindUp lengths / wired state speeds → the real per-step contact seconds/ticks, compared /// against what MeleeTiming + the MeleeContactTicks knob actually produce; the coupling invariants the design /// review flagged as un-clamped (contact < recover, contact ≤ CastFacingTicks); cadence/DPS math; and the /// weapon-vs-cone reach-honesty ratio measured off the BAKED weapon mesh. Run after any clip re-speed, /// wire-tool re-run, or knob-default change — drift shows up here before it shows up as jank. /// public static class TuningAuditTools { const string Controller = "Assets/_Project/Animation/AC_PlayerTopDown.controller"; const string PackDir = "Assets/Synty/AnimationSwordCombat/Animations/Polygon/"; const string WeaponMesh = "Assets/_Project/Art/Models/Rebased_SM_Wep_Melee.asset"; [MenuItem("ProjectM/Tuning/Audit Melee Timing + Reach")] public static void AuditMeleeTimingMenu() => AuditMeleeTiming(); public static string AuditMeleeTiming() { var sb = new StringBuilder(); sb.AppendLine("==== MELEE TUNING AUDIT (derived from assets vs configured) ===="); var t = TuningConfig.Defaults(); float contactKnob = t.MeleeContactTicks; uint recover = (uint)Mathf.Max(1f, t.MeleeRecoverTicks); uint finRecover = (uint)Mathf.Max(1f, Mathf.Round(t.MeleeRecoverTicks * Mathf.Max(1f, t.MeleeFinisherMult))); var ac = AssetDatabase.LoadAssetAtPath(Controller); if (ac == null) { Debug.LogError("[TuningAudit] Controller missing."); return "controller missing"; } var sm = ac.layers[0].stateMachine; // (state, windup-take fbx, windup clip name, combo step, lock that gates this step's chain) var rows = new (string state, string fbx, string clip, byte step, uint stepRecover)[] { ("Swing1", "Attack/LightCombo01/A_Attack_LightCombo01A_Sword", "A_Attack_LightCombo01A_WindUp_Sword", 1, recover), ("Swing2", "Attack/LightCombo01/A_Attack_LightCombo01B_Sword", "A_Attack_LightCombo01B_WindUp_Sword", 2, recover), ("Swing3", "Attack/LightCombo01/A_Attack_LightCombo01C_Sword", "A_Attack_LightCombo01C_WindUp_Sword", 3, finRecover), }; bool anyDrift = false; foreach (var r in rows) { AnimatorState st = null; foreach (var cs in sm.states) if (cs.state.name == r.state) st = cs.state; if (st == null) { sb.AppendLine($" {r.state}: STATE MISSING"); continue; } AnimationClip windup = null; foreach (var o in AssetDatabase.LoadAllAssetsAtPath(PackDir + r.fbx + ".fbx")) if (o is AnimationClip c && c.name == r.clip) windup = c; if (windup == null) { sb.AppendLine($" {r.state}: windup take '{r.clip}' MISSING"); continue; } float speed = Mathf.Max(0.01f, st.speed); float derivedSec = windup.length / speed; // the blade lands when the WindUp take ends uint derivedTicks = (uint)Mathf.Round(derivedSec * 60f); uint configured = MeleeTiming.ContactTicks(r.step, contactKnob); bool drift = derivedTicks != configured; anyDrift |= drift; sb.AppendLine($" {r.state}: clip windup {windup.length:F3}s @ speed {speed:F2} -> TRUE contact {derivedSec:F3}s = {derivedTicks}t | configured {configured}t {(drift ? " << DRIFT" : " OK")}"); if (configured >= r.stepRecover) sb.AppendLine($" !! contact {configured}t >= step lock {r.stepRecover}t (flush-at-overwrite will fire it EARLY every chain)"); if (configured > PlayerAimSystem.CastFacingTicks) sb.AppendLine($" !! contact {configured}t > CastFacingTicks {PlayerAimSystem.CastFacingTicks} (aim-steer + anim pulse end BEFORE the blade lands)"); } if (anyDrift) sb.AppendLine(" -> DRIFT fix: retune MeleeContactTicks (base = Swing1's true ticks) and/or MeleeTiming's step fractions."); // Cadence / DPS at the configured knobs (chain = light, light, finisher). float dmg = t.MeleeDamage; float chainSec = (2f * recover + finRecover) / 60f; float chainDmg = 2f * dmg + dmg * Mathf.Max(1f, t.MeleeFinisherMult); sb.AppendLine($" Cadence: light lock {recover}t ({recover / 60f:F2}s), finisher lock {finRecover}t ({finRecover / 60f:F2}s); full chain {chainSec:F2}s"); sb.AppendLine($" DPS: per-light {dmg / (recover / 60f):F1} | full-chain {chainDmg / chainSec:F1} (dmg {dmg}, finisher x{t.MeleeFinisherMult})"); sb.AppendLine($" Buffer: {t.MeleeBufferTicks}t window, unlock slack {MeleeTiming.BatchSlackTicks}t (batch-proof)"); // Reach honesty: the BAKED weapon mesh's max planar radius (rest pose, feet-at-0 space) vs the cone. var mesh = AssetDatabase.LoadAssetAtPath(WeaponMesh); if (mesh != null) { float maxR = 0f; var verts = mesh.vertices; for (int i = 0; i < verts.Length; i++) { float rr = new Vector2(verts[i].x, verts[i].z).magnitude; if (rr > maxR) maxR = rr; } float swingReach = maxR + 0.45f; // rest radius + typical arm extension at full swing (approx) float range = t.MeleeRange; float finRange = range * Mathf.Max(0.01f, t.MeleeFinisherRangeMult); sb.AppendLine($" Reach: weapon rest radius {maxR:F2}m (~{swingReach:F2}m swept) vs cone {range:F2}m (x{range / swingReach:F2}) | finisher {finRange:F2}m (x{finRange / swingReach:F2})"); sb.AppendLine($" honesty budget: keep the ratio <= ~1.25x (guidelines G2; >1.4x reads as the old 'arc lies' complaint)"); } else sb.AppendLine(" Reach: weapon mesh not found (run Attach Melee Weapon first)"); // 07-21 G6 (review wf_98bf1268): the cone socket (SpecialSlam) lands at ConeContactTicks via // ConeContactPending (0 = legacy at-fire). Visual contact estimate ~0.35s (21t) into the slam anim. uint coneContact = (uint)Mathf.Max(0f, t.ConeContactTicks); sb.AppendLine(coneContact == 0 ? " Cone (SpecialSlam): knob 32 = 0 -> LEGACY at-fire damage (~0.35s before the visual contact)" : $" Cone (SpecialSlam): contact {coneContact}t ({coneContact / 60f:F2}s after fire; visual estimate ~21t)"); if (coneContact > PlayerAimSystem.CastFacingTicks) sb.AppendLine($" !! cone contact {coneContact}t > CastFacingTicks {PlayerAimSystem.CastFacingTicks} (a resting gamepad stick resolves a MOVE-facing slam)"); Debug.Log(sb.ToString()); return sb.ToString(); } } }