730bad3d74
FeelProfileService: partial-apply profiles ride the authoritative SetTuning path + FeelConfig reflection; Save-current captures hand-dialed state; 3 starter profiles (baseline-0720 / heavy-committed / snappier, DPS-held). TuningAuditTools: contact truth derived from clip WindUp takes / state speeds vs MeleeTiming (drift detector), coupling invariants, cadence/DPS, reach-honesty ratio off the baked weapon mesh (now x0.93/x1.16, was >2x). CombatProbe: dummy + injected mash chain, per-swing server-measured start/contact/damage + cadence/DPS report. Verified live: snappier profile apply -> measured 24/24/36 gaps, +12/+8/+15 contacts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
154 lines
8.2 KiB
C#
154 lines
8.2 KiB
C#
#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
|
|
{
|
|
/// <summary>
|
|
/// 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: <c>ProjectM.Client.CombatProbe.Run(6);</c>, read the console.
|
|
/// </summary>
|
|
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<LocalTransform>(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<LocalTransform>(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<SwingRec>();
|
|
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<NetworkTime>(); if (nt.ServerTick.IsValid) tick = nt.ServerTick.TickIndexForValidTick; }
|
|
if (firstTick == 0) firstTick = tick;
|
|
lastTick = tick;
|
|
|
|
var mc = em.GetComponentData<MeleeCombo>(player);
|
|
var pend = em.GetComponentData<MeleeCleavePending>(player);
|
|
float hp = em.GetComponentData<Health>(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
|