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