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>
155 lines
7.6 KiB
C#
155 lines
7.6 KiB
C#
#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
|