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)");
sb.AppendLine(" NOTE: SpecialSlam (cone socket) still damages AT FIRE, ~0.35s before its visual contact — the");
sb.AppendLine(" same dishonesty melee just fixed; it rides the SOCKET pipeline (guidelines G6 follow-up).");
Debug.Log(sb.ToString());
return sb.ToString();
}
}
}