Data-driven tuning bench: feel profiles (JSON gestalts + overlay A/B), asset-derived timing audit, server-measured combat probe
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>
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 07-20 data-driven tuning: a FEEL PROFILE is one JSON file holding a whole gestalt — sim knobs
|
||||
/// (<see cref="TuningKnob"/> names → values, applied through the authoritative SetTuning path exactly like an
|
||||
/// overlay nudge) + client feel statics (<see cref="FeelConfig"/> 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).
|
||||
/// </summary>
|
||||
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<Entry> sim = new List<Entry>();
|
||||
public List<Entry> feel = new List<Entry>();
|
||||
}
|
||||
|
||||
static string[] _cachedPaths;
|
||||
|
||||
/// <summary>Profile file paths (cached; <see cref="Rescan"/> after adding files).</summary>
|
||||
public static string[] ListProfiles()
|
||||
{
|
||||
if (_cachedPaths == null) Rescan();
|
||||
return _cachedPaths;
|
||||
}
|
||||
|
||||
public static void Rescan()
|
||||
{
|
||||
_cachedPaths = Directory.Exists(ProfileDir)
|
||||
? Directory.GetFiles(ProfileDir, "*.json")
|
||||
: Array.Empty<string>();
|
||||
Array.Sort(_cachedPaths);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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<ProfileData>(File.ReadAllText(path)); }
|
||||
catch (Exception ex) { Debug.LogError($"[FeelProfile] Failed to read {path}: {ex.Message}"); return null; }
|
||||
}
|
||||
|
||||
static Dictionary<string, byte> KnobByName()
|
||||
{
|
||||
var map = new Dictionary<string, byte>();
|
||||
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
|
||||
Reference in New Issue
Block a user