diff --git a/Editor/Inspector/Menu/MenuItemGUI.cs b/Editor/Inspector/Menu/MenuItemGUI.cs index 2c053362..67458e40 100644 --- a/Editor/Inspector/Menu/MenuItemGUI.cs +++ b/Editor/Inspector/Menu/MenuItemGUI.cs @@ -54,6 +54,7 @@ namespace nadena.dev.modular_avatar.core.editor private readonly SerializedProperty _prop_isSynced; private readonly SerializedProperty _prop_isSaved; private readonly SerializedProperty _prop_isDefault; + private readonly SerializedProperty _prop_automaticValue; public bool AlwaysExpandContents = false; public bool ExpandContents = false; @@ -105,6 +106,7 @@ namespace nadena.dev.modular_avatar.core.editor _prop_isSynced = obj.FindProperty(nameof(ModularAvatarMenuItem.isSynced)); _prop_isSaved = obj.FindProperty(nameof(ModularAvatarMenuItem.isSaved)); _prop_isDefault = obj.FindProperty(nameof(ModularAvatarMenuItem.isDefault)); + _prop_automaticValue = obj.FindProperty(nameof(ModularAvatarMenuItem.automaticValue)); _previewGUI = new MenuPreviewGUI(redraw); } @@ -180,6 +182,7 @@ namespace nadena.dev.modular_avatar.core.editor _prop_isSynced = _control.FindPropertyRelative(nameof(ModularAvatarMenuItem.isSynced)); _prop_isSaved = _control.FindPropertyRelative(nameof(ModularAvatarMenuItem.isSaved)); _prop_isDefault = _control.FindPropertyRelative(nameof(ModularAvatarMenuItem.isDefault)); + _prop_automaticValue = null; _prop_submenuSource = null; _prop_otherObjSource = null; @@ -225,7 +228,7 @@ namespace nadena.dev.modular_avatar.core.editor EditorGUILayout.PropertyField(_texture, G("menuitem.prop.icon")); EditorGUILayout.PropertyField(_type, G("menuitem.prop.type")); - EditorGUILayout.PropertyField(_value, G("menuitem.prop.value")); + DoValueField(); _parameterGUI.DoGUI(true); @@ -462,6 +465,8 @@ namespace nadena.dev.modular_avatar.core.editor if (knownParameter != null && knownParameter.Source is ModularAvatarMenuItem) isDefaultByKnownParam = null; + if (_prop_automaticValue?.boolValue == true) isDefaultByKnownParam = null; + Object controller = knownParameter?.Source; // If we can't figure out what to reference the parameter names to, or if they're controlled by something @@ -549,6 +554,55 @@ namespace nadena.dev.modular_avatar.core.editor EditorGUILayout.EndHorizontal(); } + private void DoValueField() + { + var value_label = G("menuitem.prop.value"); + var auto_label = G("menuitem.prop.automatic_value"); + + if (_prop_automaticValue == null) + { + EditorGUILayout.PropertyField(_value, value_label); + return; + } + + var toggleSize = EditorStyles.toggle.CalcSize(new GUIContent()); + var autoLabelSize = EditorStyles.label.CalcSize(auto_label); + + var style = EditorStyles.numberField; + var rect = EditorGUILayout.GetControlRect(true, EditorGUIUtility.singleLineHeight, style); + + var valueRect = rect; + valueRect.xMax -= toggleSize.x + autoLabelSize.x + 4; + + var autoRect = rect; + autoRect.xMin = valueRect.xMax + 4; + + var suppressValue = _prop_automaticValue.boolValue || _prop_automaticValue.hasMultipleDifferentValues; + + using (new EditorGUI.DisabledScope(suppressValue)) + { + if (suppressValue) + { + EditorGUI.TextField(valueRect, value_label, "", style); + } + else + { + EditorGUI.BeginChangeCheck(); + EditorGUI.PropertyField(valueRect, _value, value_label); + if (EditorGUI.EndChangeCheck()) _prop_automaticValue.boolValue = false; + } + } + + EditorGUI.BeginProperty(autoRect, auto_label, _prop_automaticValue); + EditorGUI.BeginChangeCheck(); + + EditorGUI.showMixedValue = _prop_automaticValue.hasMultipleDifferentValues; + var autoValue = EditorGUI.ToggleLeft(autoRect, auto_label, _prop_automaticValue.boolValue); + + if (EditorGUI.EndChangeCheck()) _prop_automaticValue.boolValue = autoValue; + EditorGUI.EndProperty(); + } + private List FindSiblingMenuItems(SerializedObject serializedObject) { if (serializedObject == null || serializedObject.isEditingMultipleObjects) return null; diff --git a/Editor/Localization/en-US.json b/Editor/Localization/en-US.json index 63e3e35b..812970c0 100644 --- a/Editor/Localization/en-US.json +++ b/Editor/Localization/en-US.json @@ -187,6 +187,8 @@ "menuitem.prop.type.tooltip": "The type of this item", "menuitem.prop.value": "Value", "menuitem.prop.value.tooltip": "The value to set the parameter to when this control is used", + "menuitem.prop.automatic_value": "Auto", + "menuitem.prop.automatic_value.tooltip": "Automatically set this control to a unique value", "menuitem.prop.parameter": "Parameter", "menuitem.prop.label": "Label", "menuitem.prop.submenu_asset": "Submenu Asset", diff --git a/Editor/Localization/ja-JP.json b/Editor/Localization/ja-JP.json index f891e835..b00c5c40 100644 --- a/Editor/Localization/ja-JP.json +++ b/Editor/Localization/ja-JP.json @@ -197,6 +197,8 @@ "menuitem.prop.is_saved.tooltip": "有効になっていると、アバター変更やワールド移動するときこの設定が保持されます。", "menuitem.prop.is_synced": "同期する", "menuitem.prop.is_synced.tooltip": "有効の場合はほかのプレイヤーに同期されます。", + "menuitem.prop.automatic_value": "自動", + "menuitem.prop.automatic_value.tooltip": "かぶらない値を自動的に割り振る", "menuitem.param.rotation": "回転パラメーター名", "menuitem.param.rotation.tooltip": "このメニューアイテムの回転に連動するべきパラメーター", "menuitem.param.horizontal": "横パラメーター名", diff --git a/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectAnalyzer.LocateReactions.cs b/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectAnalyzer.LocateReactions.cs index 6edb6834..1aeb0f1b 100644 --- a/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectAnalyzer.LocateReactions.cs +++ b/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectAnalyzer.LocateReactions.cs @@ -63,6 +63,17 @@ namespace nadena.dev.modular_avatar.core.editor _computeContext.Observe(mami, c => (c.Control?.parameter, c.Control?.type, c.Control?.value, c.isDefault)); var mami_condition = ParameterAssignerPass.AssignMenuItemParameter(mami, _simulationInitialStates); + + if (mami_condition != null && + ForceMenuItems.TryGetValue(mami_condition.Parameter, out var forcedMenuItem)) + { + var enable = forcedMenuItem == mami; + mami_condition.InitialValue = 0.5f; + mami_condition.ParameterValueLo = enable ? 0 : 999f; + mami_condition.ParameterValueHi = 1000; + mami_condition.IsConstant = true; + } + if (mami_condition != null) conditions.Add(mami_condition); } diff --git a/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectAnalyzer.cs b/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectAnalyzer.cs index 1f087949..096c3e94 100644 --- a/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectAnalyzer.cs +++ b/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectAnalyzer.cs @@ -20,6 +20,9 @@ namespace nadena.dev.modular_avatar.core.editor private Dictionary _simulationInitialStates; public ImmutableDictionary ForcePropertyOverrides { get; set; } = ImmutableDictionary.Empty; + + public ImmutableDictionary ForceMenuItems { get; set; } = + ImmutableDictionary.Empty; public ReactiveObjectAnalyzer(ndmf.BuildContext context) { @@ -46,8 +49,9 @@ namespace nadena.dev.modular_avatar.core.editor { var mami = obj?.GetComponent(); if (mami == null) return null; - - return ParameterAssignerPass.AssignMenuItemParameter(mami, _simulationInitialStates)?.Parameter; + + return ParameterAssignerPass.AssignMenuItemParameter(mami, _simulationInitialStates, ForceMenuItems) + ?.Parameter; } public struct AnalysisResult @@ -68,6 +72,8 @@ namespace nadena.dev.modular_avatar.core.editor var analysis = new ReactiveObjectAnalyzer(ctx); analysis.ForcePropertyOverrides = ctx.Observe(ROSimulator.PropertyOverrides, a=>a, (a,b) => false) ?? ImmutableDictionary.Empty; + analysis.ForceMenuItems = ctx.Observe(ROSimulator.MenuItemOverrides, a => a, (a, b) => false) + ?? ImmutableDictionary.Empty; return analysis.Analyze(root); }); } @@ -101,7 +107,7 @@ namespace nadena.dev.modular_avatar.core.editor FindMaterialSetters(shapes, root); ApplyInitialStateOverrides(shapes); - AnalyzeConstants(shapes); + AnalyzeConstants(shapes); ResolveToggleInitialStates(shapes); PreprocessShapes(shapes, out result.InitialStates, out result.DeletedShapes); result.Shapes = shapes; diff --git a/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectPass.cs b/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectPass.cs index ee4d37db..bf89e453 100644 --- a/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectPass.cs +++ b/Editor/ReactiveObjects/AnimationGeneration/ReactiveObjectPass.cs @@ -75,7 +75,7 @@ namespace nadena.dev.modular_avatar.core.editor { if (condition.IsConstant) continue; - if (!initialValues.ContainsKey(condition.Parameter)) + if (!initialValues.ContainsKey(condition.Parameter) && condition.InitialValue > -999f) initialValues[condition.Parameter] = condition.InitialValue; } } diff --git a/Editor/ReactiveObjects/ParameterAssignerPass.cs b/Editor/ReactiveObjects/ParameterAssignerPass.cs index 59cfdd42..bc4f442c 100644 --- a/Editor/ReactiveObjects/ParameterAssignerPass.cs +++ b/Editor/ReactiveObjects/ParameterAssignerPass.cs @@ -47,11 +47,14 @@ namespace nadena.dev.modular_avatar.core.editor var paramIndex = 0; - var declaredParams = context.AvatarDescriptor.expressionParameters.parameters.Select(p => p.name) - .ToHashSet(); + var declaredParams = context.AvatarDescriptor.expressionParameters.parameters + .GroupBy(p => p.name).Select(l => l.First()) + .ToDictionary(p => p.name); Dictionary newParameters = new(); + Dictionary nextParamValue = new(); + Dictionary> _mamiByParam = new(); foreach (var mami in context.AvatarRootTransform.GetComponentsInChildren(true)) { if (string.IsNullOrWhiteSpace(mami.Control?.parameter?.name)) @@ -64,51 +67,97 @@ namespace nadena.dev.modular_avatar.core.editor name = $"__MA/AutoParam/{mami.gameObject.name}${paramIndex++}" }; } - + var paramName = mami.Control.parameter.name; - if (!declaredParams.Contains(paramName)) + if (!_mamiByParam.TryGetValue(paramName, out var mamiList)) { - newParameters.TryGetValue(paramName, out var existingNewParam); - var wantedType = existingNewParam?.valueType ?? VRCExpressionParameters.ValueType.Bool; + mamiList = new List(); + _mamiByParam[paramName] = mamiList; + } - if (wantedType != VRCExpressionParameters.ValueType.Float && - (mami.Control.value > 1.01 || mami.Control.value < -0.01)) - wantedType = VRCExpressionParameters.ValueType.Int; + mamiList.Add(mami); + } - if (Mathf.Abs(Mathf.Round(mami.Control.value) - mami.Control.value) > 0.01f) - wantedType = VRCExpressionParameters.ValueType.Float; + foreach (var (paramName, list) in _mamiByParam) + { + // Assign automatic values first + float defaultValue; + if (declaredParams.TryGetValue(paramName, out var p)) + { + defaultValue = p.defaultValue; + } + else + { + defaultValue = list.FirstOrDefault(m => m.isDefault && !m.automaticValue)?.Control?.value ?? 0; - if (existingNewParam == null) + if (list.Count == 1) + // If we have only a single entry, it's probably an on-off toggle, so we'll implicitly let 0 + // be the 'unselected' default value + defaultValue = 1; + } + + HashSet usedValues = new(); + usedValues.Add((int)defaultValue); + + foreach (var item in list) + if (!item.automaticValue && Mathf.Abs(item.Control.value - Mathf.Round(item.Control.value)) < 0.01f) + usedValues.Add(Mathf.RoundToInt(item.Control.value)); + + var nextValue = 1; + + var canBeBool = true; + var canBeInt = true; + var isSaved = true; + var isSynced = true; + + foreach (var mami in list) + { + if (mami.automaticValue) { - existingNewParam = new VRCExpressionParameters.Parameter + if (mami.isDefault) { - name = paramName, - valueType = wantedType, - saved = mami.isSaved, - defaultValue = -1, - networkSynced = mami.isSynced - }; - newParameters[paramName] = existingNewParam; - } - else - { - existingNewParam.valueType = wantedType; + mami.Control.value = defaultValue; + } + else + { + while (usedValues.Contains(nextValue)) nextValue++; + + mami.Control.value = nextValue; + usedValues.Add(nextValue); + } } - // TODO: warn on inconsistent configuration - existingNewParam.saved = existingNewParam.saved || mami.isSaved; - existingNewParam.networkSynced = existingNewParam.networkSynced || mami.isSynced; - existingNewParam.defaultValue = mami.isDefault ? mami.Control.value : existingNewParam.defaultValue; + if (Mathf.Abs(mami.Control.value - Mathf.Round(mami.Control.value)) > 0.01f) + canBeInt = false; + else + canBeBool &= mami.Control.value is >= 0 and <= 1; + + isSaved &= mami.isSaved; + isSynced &= mami.isSynced; + } + + if (!declaredParams.ContainsKey(paramName)) + { + VRCExpressionParameters.ValueType newType; + if (canBeBool) newType = VRCExpressionParameters.ValueType.Bool; + else if (canBeInt) newType = VRCExpressionParameters.ValueType.Int; + else newType = VRCExpressionParameters.ValueType.Float; + + var newParam = new VRCExpressionParameters.Parameter + { + name = paramName, + valueType = newType, + saved = isSaved, + defaultValue = defaultValue, + networkSynced = isSynced + }; + newParameters[paramName] = newParam; } } if (newParameters.Count > 0) { - foreach (var p in newParameters) - if (p.Value.defaultValue < 0) - p.Value.defaultValue = 0; - var expParams = context.AvatarDescriptor.expressionParameters; if (!context.IsTemporaryAsset(expParams)) { @@ -120,15 +169,22 @@ namespace nadena.dev.modular_avatar.core.editor } } - internal static ControlCondition AssignMenuItemParameter(ModularAvatarMenuItem mami, Dictionary simulationInitialStates = null) + internal static ControlCondition AssignMenuItemParameter( + ModularAvatarMenuItem mami, + Dictionary simulationInitialStates = null, + IDictionary isDefaultOverrides = null) { var paramName = mami?.Control?.parameter?.name; if (mami?.Control != null && simulationInitialStates != null && ShouldAssignParametersToMami(mami)) { - paramName = "___AutoProp/" + mami.Control?.parameter?.name; - if (paramName == "___AutoProp/") paramName += mami.GetInstanceID(); + paramName = mami.Control?.parameter?.name; + if (string.IsNullOrEmpty(paramName)) paramName = "___AutoProp/" + mami.GetInstanceID(); - if (mami.isDefault) + var isDefault = mami.isDefault; + if (isDefaultOverrides?.TryGetValue(paramName, out var target) == true) + isDefault = ReferenceEquals(mami, target); + + if (isDefault) { simulationInitialStates[paramName] = mami.Control.value; } else if (!simulationInitialStates.ContainsKey(paramName)) @@ -144,7 +200,10 @@ namespace nadena.dev.modular_avatar.core.editor Parameter = paramName, DebugName = mami.gameObject.name, IsConstant = false, - InitialValue = mami.isDefault ? mami.Control.value : -999, // TODO + // Note: This slightly odd-looking value is key to making the Auto checkbox work for editor previews; + // we basically force-disable any conditions for nonselected menu items and force-enable any for default + // menu items. + InitialValue = mami.isDefault ? mami.Control.value : -999, ParameterValueLo = mami.Control.value - 0.5f, ParameterValueHi = mami.Control.value + 0.5f, DebugReference = mami, diff --git a/Editor/ReactiveObjects/Simulator/ROSimulator.cs b/Editor/ReactiveObjects/Simulator/ROSimulator.cs index 2feccc63..df5fcb90 100644 --- a/Editor/ReactiveObjects/Simulator/ROSimulator.cs +++ b/Editor/ReactiveObjects/Simulator/ROSimulator.cs @@ -15,6 +15,7 @@ namespace nadena.dev.modular_avatar.core.editor.Simulator internal class ROSimulator : EditorWindow, IHasCustomMenu { public static PublishedValue> PropertyOverrides = new(null); + public static PublishedValue> MenuItemOverrides = new(null); internal static string ROOT_PATH = "Packages/nadena.dev.modular-avatar/Editor/ReactiveObjects/Simulator/"; private static string USS = ROOT_PATH + "ROSimulator.uss"; @@ -64,6 +65,7 @@ namespace nadena.dev.modular_avatar.core.editor.Simulator private void OnEnable() { PropertyOverrides.Value = ImmutableDictionary.Empty; + MenuItemOverrides.Value = ImmutableDictionary.Empty; EditorApplication.delayCall += LoadUI; Selection.selectionChanged += SelectionChanged; is_enabled = true; @@ -79,6 +81,7 @@ namespace nadena.dev.modular_avatar.core.editor.Simulator EditorApplication.delayCall += () => { PropertyOverrides.Value = null; + MenuItemOverrides.Value = null; }; } @@ -106,6 +109,25 @@ namespace nadena.dev.modular_avatar.core.editor.Simulator EditorApplication.delayCall += RefreshUI; } + + private void UpdateMenuItemOverride(string prop, ModularAvatarMenuItem item, bool? value) + { + if (value == null) + { + MenuItemOverrides.Value = MenuItemOverrides.Value.Remove(prop); + } + else if (value.Value) + { + MenuItemOverrides.Value = MenuItemOverrides.Value.SetItem(prop, item); + } + else + { + if (MenuItemOverrides.Value.TryGetValue(prop, out var existing) && ReferenceEquals(existing, item)) + MenuItemOverrides.Value = MenuItemOverrides.Value.SetItem(prop, null); + } + + EditorApplication.delayCall += RefreshUI; + } private void UpdatePropertyOverride(string prop, bool? value) { @@ -209,6 +231,7 @@ namespace nadena.dev.modular_avatar.core.editor.Simulator var analysis = new ReactiveObjectAnalyzer(_lastComputeContext); analysis.ForcePropertyOverrides = PropertyOverrides.Value; + analysis.ForceMenuItems = MenuItemOverrides.Value; var result = analysis.Analyze(avatar.gameObject); SetThisObjectOverrides(analysis); @@ -227,11 +250,38 @@ namespace nadena.dev.modular_avatar.core.editor.Simulator private void SetThisObjectOverrides(ReactiveObjectAnalyzer analysis) { BindOverrideToParameter("this-obj-override", analysis.GetGameObjectStateProperty(currentSelection), 1); - BindOverrideToParameter("this-menu-override", analysis.GetMenuItemProperty(currentSelection), - currentSelection.GetComponent()?.Control?.value ?? 1 - ); + currentSelection.TryGetComponent(out var mami); + BindOverrideToMenuItem("this-menu-override", mami); } + private void BindOverrideToMenuItem(string overrideElemName, ModularAvatarMenuItem mami) + { + var elem = e_debugInfo.Q(overrideElemName); + var soc = elem.Q(); + + if (mami == null) + { + elem.style.display = DisplayStyle.None; + return; + } + + var prop = ParameterAssignerPass.AssignMenuItemParameter(mami)?.Parameter; + if (prop == null) + { + elem.style.display = DisplayStyle.None; + return; + } + + elem.style.display = DisplayStyle.Flex; + + if (MenuItemOverrides.Value.TryGetValue(prop, out var overrideValue)) + soc.SetWithoutNotify(ReferenceEquals(mami, overrideValue)); + else + soc.SetWithoutNotify(null); + + soc.OnStateOverrideChanged += value => { UpdateMenuItemOverride(prop, mami, value); }; + } + private void BindOverrideToParameter(string overrideElemName, string property, float targetValue) { var elem = e_debugInfo.Q(overrideElemName); @@ -460,9 +510,24 @@ namespace nadena.dev.modular_avatar.core.editor.Simulator { targetValue = Mathf.Round((condition.ParameterValueLo + condition.ParameterValueHi) / 2); } - - soc.OnStateOverrideChanged += value => UpdatePropertyOverride(prop, value, targetValue); - + + if (condition.DebugReference is ModularAvatarMenuItem mami) + { + bool? menuOverride = null; + + if (MenuItemOverrides.Value.TryGetValue(prop, out var target)) + { + menuOverride = ReferenceEquals(mami, target); + soc.SetWithoutNotify(menuOverride); + } + + soc.OnStateOverrideChanged += value => { UpdateMenuItemOverride(prop, mami, value); }; + } + else + { + soc.OnStateOverrideChanged += value => UpdatePropertyOverride(prop, value, targetValue); + } + var active = condition.InitiallyActive; var active_label = active ? "active" : "inactive"; active_label = "ro_sim.state." + active_label; diff --git a/Runtime/ModularAvatarMenuItem.cs b/Runtime/ModularAvatarMenuItem.cs index cf8d8f6a..13b88e08 100644 --- a/Runtime/ModularAvatarMenuItem.cs +++ b/Runtime/ModularAvatarMenuItem.cs @@ -1,6 +1,5 @@ #if MA_VRCSDK3_AVATARS -using System; using System.Linq; using nadena.dev.modular_avatar.core.menu; using UnityEngine; @@ -43,6 +42,13 @@ namespace nadena.dev.modular_avatar.core /// public bool isDefault; + /// + /// If true, the value for this toggle or button menu item will be automatically selected. + /// Typically, this will be zero for the default menu item, then subsequent menu items will be allocated + /// sequentially in hierarchy order. + /// + public bool automaticValue; + private void Reset() { Control = new VRCExpressionsMenu.Control(); @@ -51,6 +57,7 @@ namespace nadena.dev.modular_avatar.core isSaved = true; isSynced = true; isDefault = false; + automaticValue = true; MenuSource = SubmenuSource.Children; }