diff --git a/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs b/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs new file mode 100644 index 000000000..0f54e962c --- /dev/null +++ b/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs @@ -0,0 +1,153 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Text; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// 07-20 data-driven tuning: a MEASUREMENT probe for melee feel. In Play, spawns a huge-HP dummy in front of + /// the local player, holds the (injected) Attack for a scripted chain, and samples the SERVER truth every + /// editor frame: swing-start ticks, scheduled contact ticks, actual damage ticks + amounts. The report is + /// MEASURED cadence / contact latency / DPS / buffer usage — objective numbers to tune against instead of + /// eyeballs, valid under real tick-batching. Editor-only diagnostics (cross-world reads); drive from the + /// DebugOverlay button or execute_code: ProjectM.Client.CombatProbe.Run(6);, read the console. + /// + public static class CombatProbe + { + public static bool Running { get; private set; } + + struct SwingRec { public uint Start; public uint Scheduled; public uint DamageTickObserved; public float Damage; } + + public static void Run(int swings = 6, float aimX = 1f, float aimZ = 0f) + { + if (!Application.isPlaying) { Debug.LogWarning("[CombatProbe] Play mode only."); return; } + if (Running) { Debug.LogWarning("[CombatProbe] Already running."); return; } + + World server = null; + foreach (var w in World.All) if (w.Name == "ServerWorld") server = w; + if (server == null) { Debug.LogWarning("[CombatProbe] No ServerWorld."); return; } + var sem = server.EntityManager; + + // The local player's server twin (the entity that carries the scheduled-cleave slot). + var pq = sem.CreateEntityQuery(typeof(MeleeCleavePending), typeof(MeleeCombo), typeof(LocalTransform)); + var players = pq.ToEntityArray(Allocator.Temp); + if (players.Length == 0) { players.Dispose(); Debug.LogWarning("[CombatProbe] No server player."); return; } + var player = players[0]; + players.Dispose(); + var playerPos = sem.GetComponentData(player).Position; + + // Dummy: any enemy prefab, parked inside the cone, HP no swing chain can finish. + var prefQ = sem.CreateEntityQuery(new EntityQueryDesc + { + All = new ComponentType[] { typeof(EnemyTag), typeof(Prefab) }, + Options = EntityQueryOptions.IncludePrefab, + }); + var prefabs = prefQ.ToEntityArray(Allocator.Temp); + if (prefabs.Length == 0) { prefabs.Dispose(); Debug.LogWarning("[CombatProbe] No enemy prefab."); return; } + var dummy = sem.Instantiate(prefabs[0]); + prefabs.Dispose(); + var baked = sem.GetComponentData(dummy); + var aim = math.normalizesafe(new float2(aimX, aimZ), new float2(1f, 0f)); + sem.SetComponentData(dummy, baked.WithPosition(playerPos + new float3(aim.x, 0f, aim.y) * 1.6f)); + sem.SetComponentData(dummy, new Health { Current = 100000f, Max = 100000f }); + + Running = true; + int frame = 0; + var recs = new List(); + uint lastSwing = 0; float lastHp = 100000f; int buffered = 0; uint firstTick = 0, lastTick = 0; + UnityEditor.EditorApplication.CallbackFunction cb = null; + cb = () => + { + try + { + frame++; + World sw = null; + foreach (var w2 in World.All) if (w2.Name == "ServerWorld") sw = w2; + if (sw == null) { Finish("server world gone"); return; } + var em = sw.EntityManager; + if (!em.Exists(player) || !em.Exists(dummy)) { Finish("probe entity despawned"); return; } + + uint tick = 0; + var ntq = em.CreateEntityQuery(typeof(NetworkTime)); + if (!ntq.IsEmpty) { var nt = ntq.GetSingleton(); if (nt.ServerTick.IsValid) tick = nt.ServerTick.TickIndexForValidTick; } + if (firstTick == 0) firstTick = tick; + lastTick = tick; + + var mc = em.GetComponentData(player); + var pend = em.GetComponentData(player); + float hp = em.GetComponentData(dummy).Current; + + if (mc.SwingStartTick != 0 && mc.SwingStartTick != lastSwing) + { + recs.Add(new SwingRec { Start = mc.SwingStartTick, Scheduled = pend.ResolveTick }); + lastSwing = mc.SwingStartTick; + } + else if (recs.Count > 0 && pend.ResolveTick != 0) + { + var r0 = recs[recs.Count - 1]; + if (r0.Scheduled == 0) { r0.Scheduled = pend.ResolveTick; recs[recs.Count - 1] = r0; } + } + if (hp < lastHp && recs.Count > 0) + { + var r1 = recs[recs.Count - 1]; + if (r1.DamageTickObserved == 0) { r1.DamageTickObserved = tick; r1.Damage = lastHp - hp; recs[recs.Count - 1] = r1; } + } + if (mc.BufferedAttackTick != 0) buffered++; // frames a buffer was armed (usage signal) + lastHp = hp; + + // Drive: hold the attack (re-issued) until the target swing count is reached — a mashing player. + if (recs.Count < swings) { DebugInputInjectionSystem.SetAim(aim.x, aim.y); DebugInputInjectionSystem.Attack(3); } + + bool lastResolved = recs.Count == 0 || recs[recs.Count - 1].DamageTickObserved != 0; // wait for the FINAL contact (a whiff falls through to the timeout) + if ((recs.Count >= swings && lastResolved) || frame > 900) Finish(null); + } + catch (System.Exception ex) { Finish("exception: " + ex.Message); } + }; + UnityEditor.EditorApplication.update += cb; + Debug.Log($"[CombatProbe] Running: {swings} swings vs a 100k-HP dummy at +{1.6f:F1}m (aim {aim.x:F1},{aim.y:F1})."); + + void Finish(string abort) + { + UnityEditor.EditorApplication.update -= cb; + DebugInputInjectionSystem.Stop(); + Running = false; + World sw2 = null; + foreach (var w3 in World.All) if (w3.Name == "ServerWorld") sw2 = w3; + if (sw2 != null && sw2.EntityManager.Exists(dummy)) sw2.EntityManager.DestroyEntity(dummy); + + var sb = new StringBuilder(); + sb.AppendLine("==== COMBAT PROBE REPORT (server-measured) ===="); + if (abort != null) sb.AppendLine(" ABORTED: " + abort); + float totalDmg = 0f; + for (int i = 0; i < recs.Count; i++) + { + var r = recs[i]; + long contact = r.Scheduled != 0 ? (long)r.Scheduled - r.Start : -1; + long observed = r.DamageTickObserved != 0 ? (long)r.DamageTickObserved - r.Start : -1; + long gap = i > 0 ? (long)r.Start - recs[i - 1].Start : 0; + totalDmg += r.Damage; + sb.AppendLine($" swing {i + 1}: start {r.Start}" + + (i > 0 ? $" (gap {gap}t = {gap / 60f:F2}s)" : "") + + $" | scheduled contact +{contact}t | damage {r.Damage:F0} observed +{observed}t (frame-sampled)"); + } + if (recs.Count > 1) + { + float span = (recs[recs.Count - 1].Start - recs[0].Start) / 60f; + sb.AppendLine($" MEASURED cadence: {span / (recs.Count - 1):F2}s/swing over {recs.Count} swings"); + } + float dur = (lastTick - firstTick) / 60f; + if (dur > 0.5f) sb.AppendLine($" MEASURED DPS vs dummy: {totalDmg / dur:F1} over {dur:F1}s (total {totalDmg:F0})"); + sb.AppendLine($" buffer armed on {buffered} sampled frames (mashing exercises the unlock-edge path)"); + Debug.Log(sb.ToString()); + } + } + } +} +#endif diff --git a/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs.meta b/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs.meta new file mode 100644 index 000000000..1cbe42ab6 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Debug/CombatProbe.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c20d0a512cd41f64191bc2457e059350 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs index d8bc7d5b3..305d0ec13 100644 --- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs +++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs @@ -117,6 +117,20 @@ namespace ProjectM.Client TuningRow("Cast turn deg", TuningKnob.CastTurnRateDeg, 60f, "0"); TuningRow("Move sharp", TuningKnob.MoveSharpness, 0.5f, "0.0"); + // 07-20 data-driven tuning: whole-gestalt FEEL PROFILES (sim knobs + FeelConfig in one JSON) -- + // A/B a feel with one click instead of dialing 30 scalars; Save captures the hand-dialed state. + GUILayout.Space(6); + GUILayout.Label("- Feel Profiles -"); + foreach (var prof in FeelProfileService.ListProfiles()) + if (GUILayout.Button(System.IO.Path.GetFileNameWithoutExtension(prof))) + FeelProfileService.Apply(prof); + GUILayout.BeginHorizontal(); + if (GUILayout.Button("Save current")) FeelProfileService.SaveCurrent(); + if (GUILayout.Button("Rescan")) FeelProfileService.Rescan(); + GUILayout.EndHorizontal(); + if (GUILayout.Button(CombatProbe.Running ? "Probe running..." : "Combat probe (6 swings)") && !CombatProbe.Running) + CombatProbe.Run(6); // measured cadence/contact/DPS -> console + } diff --git a/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs b/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs new file mode 100644 index 000000000..9e58b4a55 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs @@ -0,0 +1,154 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Reflection; +using ProjectM.Simulation; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// 07-20 data-driven tuning: a FEEL PROFILE is one JSON file holding a whole gestalt — sim knobs + /// ( names → values, applied through the authoritative SetTuning path exactly like an + /// overlay nudge) + client feel statics ( field names → values, set via reflection). + /// Profiles are PARTIAL: only the keys a profile lists are touched, so a profile can be a 4-line experiment. + /// A/B whole feels with one overlay click instead of twiddling 30 scalars; "save current" captures whatever + /// was hand-dialed. EDITOR-ONLY (same family as DebugOverlay / DebugInputInjectionSystem); profiles live in + /// Assets/_Project/Tuning/Profiles/*.json. When a profile wins, fold it into TuningConfig.Defaults() / + /// FeelConfig.ResetDefaults() (code stays the single shipped truth; profiles are experiments). + /// + public static class FeelProfileService + { + public const string ProfileDir = "Assets/_Project/Tuning/Profiles"; + + [Serializable] public class Entry { public string k; public string v; } + + [Serializable] + public class ProfileData + { + public string name; + public string notes; + public List sim = new List(); + public List feel = new List(); + } + + static string[] _cachedPaths; + + /// Profile file paths (cached; after adding files). + public static string[] ListProfiles() + { + if (_cachedPaths == null) Rescan(); + return _cachedPaths; + } + + public static void Rescan() + { + _cachedPaths = Directory.Exists(ProfileDir) + ? Directory.GetFiles(ProfileDir, "*.json") + : Array.Empty(); + Array.Sort(_cachedPaths); + } + + /// Apply a profile: sim knobs ride the authoritative SetTuning RPC + the optimistic local copy + /// (identical to an overlay nudge; server clamps); feel fields are set by name. Play mode only for sim. + public static void Apply(string path) + { + var data = Load(path); + if (data == null) return; + int simApplied = 0, feelApplied = 0; + var knobByName = KnobByName(); + foreach (var e in data.sim) + { + if (!knobByName.TryGetValue(e.k, out byte knob)) + { Debug.LogWarning($"[FeelProfile] Unknown sim knob '{e.k}' — skipped."); continue; } + if (!float.TryParse(e.v, NumberStyles.Float, CultureInfo.InvariantCulture, out float val)) + { Debug.LogWarning($"[FeelProfile] Bad value for {e.k}: '{e.v}'"); continue; } + if (Application.isPlaying) + { + DebugCommandSendSystem.SetTuning(knob, val); // authoritative (server applies + broadcasts; clamped) + TuningReadout.SetLocal(knob, val); // optimistic local (instant feel; clamped) + } + simApplied++; + } + foreach (var e in data.feel) + { + var f = typeof(FeelConfig).GetField(e.k, BindingFlags.Public | BindingFlags.Static); + if (f == null) { Debug.LogWarning($"[FeelProfile] Unknown FeelConfig field '{e.k}' — skipped."); continue; } + try { f.SetValue(null, ParseFeel(f.FieldType, e.v)); feelApplied++; } + catch (Exception ex) { Debug.LogWarning($"[FeelProfile] {e.k}: {ex.Message}"); } + } + string simNote = Application.isPlaying ? simApplied.ToString() : simApplied + " (LISTED ONLY — sim knobs need Play mode)"; + Debug.Log($"[FeelProfile] Applied '{data.name}' ({Path.GetFileName(path)}): sim {simNote}, feel {feelApplied}. {data.notes}"); + } + + /// Snapshot the CURRENT tuning (live knob readout + every FeelConfig field) as a new profile. + /// Outside Play the feel statics are re-stamped to defaults first (they are zero after a domain reload). + public static string SaveCurrent(string profileName = null) + { + if (!Application.isPlaying) FeelConfig.ResetDefaults(); + var data = new ProfileData + { + name = string.IsNullOrEmpty(profileName) ? "captured-" + DateTime.Now.ToString("MMdd-HHmm") : profileName, + notes = "Captured from the live session " + DateTime.Now.ToString("yyyy-MM-dd HH:mm"), + }; + foreach (var kv in KnobByName()) + data.sim.Add(new Entry { k = kv.Key, v = TuningConfig.Get(TuningReadout.Current, kv.Value).ToString(CultureInfo.InvariantCulture) }); + foreach (var f in typeof(FeelConfig).GetFields(BindingFlags.Public | BindingFlags.Static)) + data.feel.Add(new Entry { k = f.Name, v = FormatFeel(f.GetValue(null)) }); + Directory.CreateDirectory(ProfileDir); + string path = Path.Combine(ProfileDir, data.name + ".json"); + File.WriteAllText(path, JsonUtility.ToJson(data, prettyPrint: true)); + UnityEditor.AssetDatabase.Refresh(); + Rescan(); + Debug.Log($"[FeelProfile] Saved current tuning to {path} ({data.sim.Count} sim, {data.feel.Count} feel)."); + return path; + } + + static ProfileData Load(string path) + { + try { return JsonUtility.FromJson(File.ReadAllText(path)); } + catch (Exception ex) { Debug.LogError($"[FeelProfile] Failed to read {path}: {ex.Message}"); return null; } + } + + static Dictionary KnobByName() + { + var map = new Dictionary(); + foreach (var f in typeof(TuningKnob).GetFields(BindingFlags.Public | BindingFlags.Static)) + if (f.IsLiteral && f.FieldType == typeof(byte) && f.Name != "Count") + map[f.Name] = (byte)f.GetRawConstantValue(); + return map; + } + + static object ParseFeel(Type t, string v) + { + if (t == typeof(float)) return float.Parse(v, CultureInfo.InvariantCulture); + if (t == typeof(int)) return int.Parse(v, CultureInfo.InvariantCulture); + if (t == typeof(bool)) return bool.Parse(v); + if (t == typeof(Color)) + { + var p = v.Split(','); + return new Color( + float.Parse(p[0], CultureInfo.InvariantCulture), float.Parse(p[1], CultureInfo.InvariantCulture), + float.Parse(p[2], CultureInfo.InvariantCulture), p.Length > 3 ? float.Parse(p[3], CultureInfo.InvariantCulture) : 1f); + } + throw new NotSupportedException($"unsupported field type {t.Name}"); + } + + static string FormatFeel(object val) + { + switch (val) + { + case float f: return f.ToString(CultureInfo.InvariantCulture); + case int i: return i.ToString(CultureInfo.InvariantCulture); + case bool b: return b.ToString(); + case Color c: return string.Join(",", + c.r.ToString(CultureInfo.InvariantCulture), c.g.ToString(CultureInfo.InvariantCulture), + c.b.ToString(CultureInfo.InvariantCulture), c.a.ToString(CultureInfo.InvariantCulture)); + default: return val?.ToString() ?? ""; + } + } + } +} +#endif diff --git a/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs.meta b/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs.meta new file mode 100644 index 000000000..4262f1148 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 696cc12527a12a74fbc5758a3e949bd5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Editor/TuningAuditTools.cs b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs new file mode 100644 index 000000000..e66f694e7 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs @@ -0,0 +1,102 @@ +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(); + } + } +} diff --git a/Assets/_Project/Scripts/Editor/TuningAuditTools.cs.meta b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs.meta new file mode 100644 index 000000000..5be4b9038 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/TuningAuditTools.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f9457f271f6d69e4c92aa4a273af46fc \ No newline at end of file diff --git a/Assets/_Project/Tuning.meta b/Assets/_Project/Tuning.meta new file mode 100644 index 000000000..8cba56243 --- /dev/null +++ b/Assets/_Project/Tuning.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dc29876a7efaad54da85c8c668fd3215 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tuning/Profiles.meta b/Assets/_Project/Tuning/Profiles.meta new file mode 100644 index 000000000..617eb0be0 --- /dev/null +++ b/Assets/_Project/Tuning/Profiles.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c9422a862aa42ce49be71d2cab58f0b8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tuning/Profiles/baseline-0720.json b/Assets/_Project/Tuning/Profiles/baseline-0720.json new file mode 100644 index 000000000..00e8fbd7b --- /dev/null +++ b/Assets/_Project/Tuning/Profiles/baseline-0720.json @@ -0,0 +1,20 @@ +{ + "name": "baseline-0720", + "notes": "The shipped 07-20 defaults (forks 1-6). Apply to RESET after experiments. DPS ~60 held: dmg = recover ticks.", + "sim": [ + { "k": "MeleeDamage", "v": "30" }, + { "k": "MeleeRange", "v": "2.2" }, + { "k": "MeleeRecoverTicks", "v": "30" }, + { "k": "MeleeChainGraceTicks", "v": "24" }, + { "k": "MeleeFinisherMult", "v": "1.5" }, + { "k": "MeleeFinisherRangeMult", "v": "1.25" }, + { "k": "MeleeBufferTicks", "v": "8" }, + { "k": "MeleeContactTicks", "v": "16" } + ], + "feel": [ + { "k": "MeleeArcIntensity", "v": "1" }, + { "k": "MeleeArcBubbles", "v": "6" }, + { "k": "FinisherHoldFrames", "v": "5" }, + { "k": "CombatIdleHoldSec", "v": "4" } + ] +} diff --git a/Assets/_Project/Tuning/Profiles/baseline-0720.json.meta b/Assets/_Project/Tuning/Profiles/baseline-0720.json.meta new file mode 100644 index 000000000..d43969c83 --- /dev/null +++ b/Assets/_Project/Tuning/Profiles/baseline-0720.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d5f21d9955bf61d4ca0e4543712e8b46 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tuning/Profiles/heavy-committed.json b/Assets/_Project/Tuning/Profiles/heavy-committed.json new file mode 100644 index 000000000..37a23ca8f --- /dev/null +++ b/Assets/_Project/Tuning/Profiles/heavy-committed.json @@ -0,0 +1,17 @@ +{ + "name": "heavy-committed", + "notes": "Slower, weightier: MonHun-lean. Longer lock + contact, bigger hit + hold, dimmer arc (the weapon is the read). DPS ~60 held.", + "sim": [ + { "k": "MeleeDamage", "v": "36" }, + { "k": "MeleeRecoverTicks", "v": "36" }, + { "k": "MeleeChainGraceTicks", "v": "28" }, + { "k": "MeleeContactTicks", "v": "20" }, + { "k": "MeleeBufferTicks", "v": "10" }, + { "k": "MeleeSwingMoveScale", "v": "0.25" } + ], + "feel": [ + { "k": "MeleeArcIntensity", "v": "0.8" }, + { "k": "FinisherHoldFrames", "v": "7" }, + { "k": "MeleeConnectFovKick", "v": "1.1" } + ] +} diff --git a/Assets/_Project/Tuning/Profiles/heavy-committed.json.meta b/Assets/_Project/Tuning/Profiles/heavy-committed.json.meta new file mode 100644 index 000000000..66a1423e5 --- /dev/null +++ b/Assets/_Project/Tuning/Profiles/heavy-committed.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2ed6c17ceb8dc044f885d858b99e9a21 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Tuning/Profiles/snappier.json b/Assets/_Project/Tuning/Profiles/snappier.json new file mode 100644 index 000000000..5ebcfdddc --- /dev/null +++ b/Assets/_Project/Tuning/Profiles/snappier.json @@ -0,0 +1,17 @@ +{ + "name": "snappier", + "notes": "Faster, Hades-lean: shorter lock + contact, lighter hits, freer movement mid-swing. DPS ~60 held. Contact 12 keeps step-3 (15t) under CastFacingTicks 26.", + "sim": [ + { "k": "MeleeDamage", "v": "24" }, + { "k": "MeleeRecoverTicks", "v": "24" }, + { "k": "MeleeChainGraceTicks", "v": "20" }, + { "k": "MeleeContactTicks", "v": "12" }, + { "k": "MeleeBufferTicks", "v": "8" }, + { "k": "MeleeSwingMoveScale", "v": "0.45" } + ], + "feel": [ + { "k": "MeleeArcIntensity", "v": "1.1" }, + { "k": "FinisherHoldFrames", "v": "4" }, + { "k": "MeleeConnectFovKick", "v": "0.7" } + ] +} diff --git a/Assets/_Project/Tuning/Profiles/snappier.json.meta b/Assets/_Project/Tuning/Profiles/snappier.json.meta new file mode 100644 index 000000000..c568ee06f --- /dev/null +++ b/Assets/_Project/Tuning/Profiles/snappier.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b58b377cf031b0b489bd1e1234a1f3ec +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: