From 448a49217c91c3522ca3e4a8050201eff6141fff Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Sat, 28 Mar 2026 08:24:54 -0500 Subject: [PATCH] feat(sdk): add BasisParameterDriver StateMachineBehaviour and editor Adds a new SDK component that allows avatar creators to drive animator parameters via state machine transitions, supporting Set, Add, Random, and Copy operations with optional range remapping. --- .../Scripts/BasisParameterDriver.cs | 210 +++++++++++++++ .../Scripts/BasisParameterDriver.cs.meta | 2 + .../Editor/BasisParameterDriverEditor.cs | 255 ++++++++++++++++++ .../Editor/BasisParameterDriverEditor.cs.meta | 2 + 4 files changed, 469 insertions(+) create mode 100644 Basis/Packages/com.basis.sdk/Scripts/BasisParameterDriver.cs create mode 100644 Basis/Packages/com.basis.sdk/Scripts/BasisParameterDriver.cs.meta create mode 100644 Basis/Packages/com.basis.sdk/Scripts/Editor/BasisParameterDriverEditor.cs create mode 100644 Basis/Packages/com.basis.sdk/Scripts/Editor/BasisParameterDriverEditor.cs.meta diff --git a/Basis/Packages/com.basis.sdk/Scripts/BasisParameterDriver.cs b/Basis/Packages/com.basis.sdk/Scripts/BasisParameterDriver.cs new file mode 100644 index 000000000..21087b24e --- /dev/null +++ b/Basis/Packages/com.basis.sdk/Scripts/BasisParameterDriver.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// +/// Executes a list of parameter operations when the state is entered. +/// +public class BasisParameterDriver : StateMachineBehaviour +{ + [Tooltip("When true, operations only run on the local instance (recommended for Add and Random).")] + public bool localOnly = true; + + [Tooltip("Optional label shown in the editor for identification purposes.")] + public string debugString; + + public Operation[] operations = Array.Empty(); + + // ------------------------------------------------------------------ + // Data model + // ------------------------------------------------------------------ + + [Serializable] + public class Operation + { + public enum OperationType { Set, Add, Random, Copy } + + public OperationType type = OperationType.Set; + + [Tooltip("Animator parameter to write to.")] + public string destination; + + // ---- Set / Add ---- + public float value; + + // ---- Random (float / int) ---- + public float minValue; + public float maxValue = 1f; + + // ---- Random (bool only) ---- + [Range(0f, 1f), Tooltip("Probability that the bool is set to true.")] + public float chance = 0.5f; + + // ---- Random (int only) ---- + [Tooltip("Prevents the same integer value from being chosen twice in a row.")] + public bool preventRepeats; + + // ---- Copy ---- + [Tooltip("Animator parameter to read from.")] + public string source; + + [Tooltip("Remap the source range to a different destination range.")] + public bool remapRange; + public float sourceMin = 0f; + public float sourceMax = 1f; + public float destMin = 0f; + public float destMax = 1f; + } + + // ------------------------------------------------------------------ + // Runtime state + // ------------------------------------------------------------------ + + // Tracks previous int values per parameter for preventRepeats. + private readonly Dictionary _lastIntValues = new Dictionary(); + + // ------------------------------------------------------------------ + // StateMachineBehaviour callbacks + // ------------------------------------------------------------------ + + public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) + { + foreach (Operation op in operations) + Execute(animator, op); + } + + // ------------------------------------------------------------------ + // Execution + // ------------------------------------------------------------------ + + private void Execute(Animator animator, Operation op) + { + if (string.IsNullOrEmpty(op.destination)) + return; + + AnimatorControllerParameterType destType = GetParamType(animator, op.destination); + if (destType == 0) + { + Debug.LogWarning($"[RNGParamDriver] Destination parameter '{op.destination}' not found on animator.", animator); + return; + } + + switch (op.type) + { + case Operation.OperationType.Set: + WriteParam(animator, op.destination, destType, op.value); + break; + + case Operation.OperationType.Add: + { + float current = ReadParamAsFloat(animator, op.destination, destType); + WriteParam(animator, op.destination, destType, current + op.value); + break; + } + + case Operation.OperationType.Random: + ExecuteRandom(animator, op, destType); + break; + + case Operation.OperationType.Copy: + ExecuteCopy(animator, op, destType); + break; + } + } + + private void ExecuteRandom(Animator animator, Operation op, AnimatorControllerParameterType destType) + { + switch (destType) + { + case AnimatorControllerParameterType.Bool: + animator.SetBool(op.destination, UnityEngine.Random.value < op.chance); + break; + + case AnimatorControllerParameterType.Int: + { + int min = Mathf.RoundToInt(op.minValue); + int max = Mathf.RoundToInt(op.maxValue); + int range = max - min + 1; + int result = UnityEngine.Random.Range(min, max + 1); + + if (op.preventRepeats && range > 1 && + _lastIntValues.TryGetValue(op.destination, out int last) && last == result) + { + result = min + ((result - min + 1) % range); + } + + _lastIntValues[op.destination] = result; + animator.SetInteger(op.destination, result); + break; + } + + default: // Float + animator.SetFloat(op.destination, UnityEngine.Random.Range(op.minValue, op.maxValue)); + break; + } + } + + private void ExecuteCopy(Animator animator, Operation op, AnimatorControllerParameterType destType) + { + if (string.IsNullOrEmpty(op.source)) + return; + + AnimatorControllerParameterType srcType = GetParamType(animator, op.source); + if (srcType == 0) + { + Debug.LogWarning($"[RNGParamDriver] Source parameter '{op.source}' not found on animator.", animator); + return; + } + + float srcVal = ReadParamAsFloat(animator, op.source, srcType); + + if (op.remapRange) + srcVal = Remap(srcVal, op.sourceMin, op.sourceMax, op.destMin, op.destMax); + + WriteParam(animator, op.destination, destType, srcVal); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static float ReadParamAsFloat(Animator animator, string name, AnimatorControllerParameterType type) + { + switch (type) + { + case AnimatorControllerParameterType.Float: return animator.GetFloat(name); + case AnimatorControllerParameterType.Int: return animator.GetInteger(name); + case AnimatorControllerParameterType.Bool: return animator.GetBool(name) ? 1f : 0f; + default: return 0f; + } + } + + private static void WriteParam(Animator animator, string name, AnimatorControllerParameterType type, float value) + { + switch (type) + { + case AnimatorControllerParameterType.Float: + animator.SetFloat(name, value); + break; + case AnimatorControllerParameterType.Int: + animator.SetInteger(name, Mathf.RoundToInt(value)); + break; + case AnimatorControllerParameterType.Bool: + animator.SetBool(name, value >= 0.5f); + break; + } + } + + private static float Remap(float value, float inMin, float inMax, float outMin, float outMax) + { + if (Mathf.Approximately(inMin, inMax)) return outMin; + return Mathf.Lerp(outMin, outMax, Mathf.InverseLerp(inMin, inMax, value)); + } + + private static AnimatorControllerParameterType GetParamType(Animator animator, string name) + { + foreach (AnimatorControllerParameter p in animator.parameters) + if (p.name == name) return p.type; + return (AnimatorControllerParameterType)0; + } +} diff --git a/Basis/Packages/com.basis.sdk/Scripts/BasisParameterDriver.cs.meta b/Basis/Packages/com.basis.sdk/Scripts/BasisParameterDriver.cs.meta new file mode 100644 index 000000000..0b027b18a --- /dev/null +++ b/Basis/Packages/com.basis.sdk/Scripts/BasisParameterDriver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 452585efa8cd90f48b5e9838afe0adf1 \ No newline at end of file diff --git a/Basis/Packages/com.basis.sdk/Scripts/Editor/BasisParameterDriverEditor.cs b/Basis/Packages/com.basis.sdk/Scripts/Editor/BasisParameterDriverEditor.cs new file mode 100644 index 000000000..d0a5ee22f --- /dev/null +++ b/Basis/Packages/com.basis.sdk/Scripts/Editor/BasisParameterDriverEditor.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using static BasisParameterDriver; +using static BasisParameterDriver.Operation; + +[CustomEditor(typeof(BasisParameterDriver))] +public class BasisParameterDriverEditor : Editor +{ + private readonly List _foldouts = new List(); + + public override void OnInspectorGUI() + { + var driver = (BasisParameterDriver)target; + serializedObject.Update(); + + // ── Settings panel ──────────────────────────────────────────────── + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUI.BeginChangeCheck(); + bool localOnly = EditorGUILayout.Toggle( + new GUIContent("Local Only", + "When true, operations only run on the local instance (recommended for Add and Random)."), + driver.localOnly); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Change Local Only"); + driver.localOnly = localOnly; + EditorUtility.SetDirty(driver); + } + + EditorGUI.BeginChangeCheck(); + string debugString = EditorGUILayout.TextField( + new GUIContent("Debug Label", + "Optional label shown in the editor for identification."), + driver.debugString ?? ""); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Change Debug Label"); + driver.debugString = debugString; + EditorUtility.SetDirty(driver); + } + + EditorGUILayout.EndVertical(); + + // ── Operations header ───────────────────────────────────────────── + if (driver.operations == null) driver.operations = Array.Empty(); + EditorGUILayout.LabelField($"Operations ({driver.operations.Length})", EditorStyles.boldLabel); + + // Sync foldout list + while (_foldouts.Count < driver.operations.Length) _foldouts.Add(true); + while (_foldouts.Count > driver.operations.Length) _foldouts.RemoveAt(_foldouts.Count - 1); + + // ── Operations list ─────────────────────────────────────────────── + if (driver.operations.Length == 0) + { + GUIStyle emptyStyle = new GUIStyle(EditorStyles.miniLabel) + { + fontStyle = FontStyle.Bold, + wordWrap = true + }; + emptyStyle.normal.textColor = new Color(0.6f, 0.6f, 0.6f); + GUILayout.Label("No operations — click '+ Add Operation' below.", emptyStyle); + GUILayout.Space(4); + } + + int removeIndex = -1; + int swapA = -1, swapB = -1; + + for (int i = 0; i < driver.operations.Length; i++) + DrawOperationCard(driver, i, ref removeIndex, ref swapA, ref swapB); + + // Apply deferred mutations + if (swapA >= 0) + { + Undo.RecordObject(driver, "Reorder Operation"); + (driver.operations[swapA], driver.operations[swapB]) = (driver.operations[swapB], driver.operations[swapA]); + EditorUtility.SetDirty(driver); + } + if (removeIndex >= 0) + { + Undo.RecordObject(driver, "Remove Operation"); + var list = new List(driver.operations); + list.RemoveAt(removeIndex); + driver.operations = list.ToArray(); + EditorUtility.SetDirty(driver); + } + + // ── Add button ──────────────────────────────────────────────────── + GUILayout.Space(4); + if (GUILayout.Button("+ Add Operation", GUILayout.Height(30))) + { + Undo.RecordObject(driver, "Add Operation"); + var list = new List(driver.operations) { new Operation() }; + driver.operations = list.ToArray(); + _foldouts.Add(true); + EditorUtility.SetDirty(driver); + } + + serializedObject.ApplyModifiedProperties(); + } + + // ───────────────────────────────────────────────────────────────────── + // Operation card + // ───────────────────────────────────────────────────────────────────── + + private void DrawOperationCard(BasisParameterDriver driver, int i, + ref int removeIndex, ref int swapA, ref int swapB) + { + var op = driver.operations[i]; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // ── Header row ──────────────────────────────────────────────────── + EditorGUILayout.BeginHorizontal(); + + string destLabel = string.IsNullOrEmpty(op.destination) ? "" : op.destination; + _foldouts[i] = EditorGUILayout.Foldout(_foldouts[i], $" [{i}] {destLabel}", true); + + GUILayout.Label(op.type.ToString().ToUpper(), EditorStyles.miniLabel, GUILayout.Width(58)); + + GUI.enabled = i > 0; + if (GUILayout.Button("▲", GUILayout.Width(22), GUILayout.Height(18))) { swapA = i - 1; swapB = i; } + + GUI.enabled = i < driver.operations.Length - 1; + if (GUILayout.Button("▼", GUILayout.Width(22), GUILayout.Height(18))) { swapA = i; swapB = i + 1; } + + GUI.enabled = true; + if (GUILayout.Button("✕", GUILayout.Width(22), GUILayout.Height(18))) removeIndex = i; + + EditorGUILayout.EndHorizontal(); + + // ── Body ────────────────────────────────────────────────────────── + if (_foldouts[i]) + { + GUILayout.Space(2); + EditorGUI.indentLevel += 2; + + // Type + EditorGUI.BeginChangeCheck(); + var newType = (OperationType)EditorGUILayout.EnumPopup("Type", op.type); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Change Operation Type"); + driver.operations[i].type = newType; + EditorUtility.SetDirty(driver); + } + + // Destination + EditorGUI.BeginChangeCheck(); + string newDest = EditorGUILayout.TextField("Destination", op.destination ?? ""); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Change Destination"); + driver.operations[i].destination = newDest; + EditorUtility.SetDirty(driver); + } + + // Type-specific fields + switch (op.type) + { + case OperationType.Set: + case OperationType.Add: + EditorGUI.BeginChangeCheck(); + float val = EditorGUILayout.FloatField("Value", op.value); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Change Value"); + driver.operations[i].value = val; + EditorUtility.SetDirty(driver); + } + break; + + case OperationType.Random: + EditorGUI.BeginChangeCheck(); + float minV = EditorGUILayout.FloatField("Min Value", op.minValue); + float maxV = EditorGUILayout.FloatField("Max Value", op.maxValue); + float chance = EditorGUILayout.Slider( + new GUIContent("Chance (bool)", "Probability the bool is set to true."), + op.chance, 0f, 1f); + bool noRepeat = EditorGUILayout.Toggle( + new GUIContent("Prevent Repeats (int)", "Prevents the same int from being chosen twice in a row."), + op.preventRepeats); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Change Random Operation"); + driver.operations[i].minValue = minV; + driver.operations[i].maxValue = maxV; + driver.operations[i].chance = chance; + driver.operations[i].preventRepeats = noRepeat; + EditorUtility.SetDirty(driver); + } + break; + + case OperationType.Copy: + EditorGUI.BeginChangeCheck(); + string src = EditorGUILayout.TextField( + new GUIContent("Source", "Animator parameter to read from."), + op.source ?? ""); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Change Source"); + driver.operations[i].source = src; + EditorUtility.SetDirty(driver); + } + + EditorGUILayout.Space(2); + + EditorGUI.BeginChangeCheck(); + bool remap = EditorGUILayout.Toggle( + new GUIContent("Remap Range", "Remap the source range to a different destination range."), + op.remapRange); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Toggle Remap Range"); + driver.operations[i].remapRange = remap; + EditorUtility.SetDirty(driver); + } + + if (op.remapRange) + { + EditorGUI.indentLevel++; + EditorGUI.BeginChangeCheck(); + float srcMin = EditorGUILayout.FloatField("Source Min", op.sourceMin); + float srcMax = EditorGUILayout.FloatField("Source Max", op.sourceMax); + float dstMin = EditorGUILayout.FloatField("Dest Min", op.destMin); + float dstMax = EditorGUILayout.FloatField("Dest Max", op.destMax); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(driver, "Change Remap Range"); + driver.operations[i].sourceMin = srcMin; + driver.operations[i].sourceMax = srcMax; + driver.operations[i].destMin = dstMin; + driver.operations[i].destMax = dstMax; + EditorUtility.SetDirty(driver); + } + EditorGUI.indentLevel--; + } + break; + } + + EditorGUI.indentLevel -= 2; + GUILayout.Space(5); + } + else + { + GUILayout.Space(3); + } + + EditorGUILayout.EndVertical(); + GUILayout.Space(3); + } + +} diff --git a/Basis/Packages/com.basis.sdk/Scripts/Editor/BasisParameterDriverEditor.cs.meta b/Basis/Packages/com.basis.sdk/Scripts/Editor/BasisParameterDriverEditor.cs.meta new file mode 100644 index 000000000..66fb79d49 --- /dev/null +++ b/Basis/Packages/com.basis.sdk/Scripts/Editor/BasisParameterDriverEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e791397e8c7c6824490842bdd84d7250 \ No newline at end of file