2aebc37115
Cone/SpecialSlam damage lands at its visual contact via ConeContactPending (knob 32, 0=legacy; early-flush + resolve re-validate; death + BOTH class-swap paths drop armed pendings — fixes the shipped melee death-strand in the same stroke). Zones: [GhostField] Caster/Radius/NextTick + ZoneTelegraphSystem (Geyser latch contract on InterpolationTick; rim = true radius; arm grows, persistent phase drains). Cone cues latch to contact (FireStartRaw, C14); TuningConfig.Defaults() fallback at client cue sites (release-build timing fix). G4: SaturationMath ally-FX degrade (living-enemy census, solo-exempt; enemy telegraphs structurally exempt) + CombatStressDebug + overlay saturation rows. Reviews wf_98bf1268 (13 confirmed folded) / wf_9757d214 (5 confirmed fixed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
109 lines
7.2 KiB
C#
109 lines
7.2 KiB
C#
using System.Text;
|
|
using ProjectM.Simulation;
|
|
using UnityEditor;
|
|
using UnityEditor.Animations;
|
|
using UnityEngine;
|
|
|
|
namespace ProjectM.EditorTools
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<AnimatorController>(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<Mesh>(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();
|
|
}
|
|
}
|
|
}
|