Files
Project-M/Assets/_Project/Scripts/Client/Debug/FeelProfileService.cs
T
kronic 730bad3d74 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>
2026-07-20 21:28:33 -07:00

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