#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