using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using nadena.dev.modular_avatar.ui; using nadena.dev.ndmf.localization; using nadena.dev.ndmf.preview; using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; namespace nadena.dev.modular_avatar.core.editor.Simulator { internal class ROSimulator : EditorWindow, IHasCustomMenu { public static PublishedValue<ImmutableDictionary<string, float>> PropertyOverrides = new(null); internal static string ROOT_PATH = "Packages/nadena.dev.modular-avatar/Editor/ReactiveObjects/Simulator/"; private static string USS = ROOT_PATH + "ROSimulator.uss"; private static string UXML = ROOT_PATH + "ROSimulator.uxml"; private static string EFFECT_GROUP_UXML = ROOT_PATH + "EffectGroup.uxml"; private ObjectField f_inspecting; private VisualElement e_debugInfo; private VisualTreeAsset effectGroupTemplate; private StyleSheet uss; [MenuItem(UnityMenuItems.GameObject_ShowReactionDebugger, false, UnityMenuItems.GameObject_ShowReactionDebuggerOrder)] internal static void ShowWindow(MenuCommand command) { OpenDebugger(command.context as GameObject); } private void Awake() { void SetTitle(EditorWindow w) { w.titleContent = new GUIContent(Localization.L.GetLocalizedString("ro_sim.window.title")); } LanguagePrefs.RegisterLanguageChangeCallback(this, SetTitle); SetTitle(this); } public static void OpenDebugger(GameObject target) { var window = GetWindow<ROSimulator>(); if (window.is_enabled && window.locked) return; window.locked = target != Selection.activeGameObject; // avoid racing with initial creation if (window.f_inspecting == null) { window.LoadUI(); } // ReSharper disable once PossibleNullReferenceException window.f_inspecting.SetValueWithoutNotify(target); window.RefreshUI(); } private void OnEnable() { PropertyOverrides.Value = ImmutableDictionary<string, float>.Empty; EditorApplication.delayCall += LoadUI; Selection.selectionChanged += SelectionChanged; is_enabled = true; } private void OnDisable() { Selection.selectionChanged -= SelectionChanged; is_enabled = false; PropertyOverrides.Value = null; } private ComputeContext _lastComputeContext; private GameObject currentSelection; private GUIStyle lockButtonStyle; private bool locked, is_enabled; private Dictionary<(int, string), bool> foldoutState = new(); private Button _btn_clear; private void UpdatePropertyOverride(string prop, bool? enable, float f_val) { if (enable == null) { PropertyOverrides.Value = PropertyOverrides.Value.Remove(prop); } else if (enable.Value) { PropertyOverrides.Value = PropertyOverrides.Value.SetItem(prop, f_val); } else { PropertyOverrides.Value = PropertyOverrides.Value.SetItem(prop, 0f); } EditorApplication.delayCall += RefreshUI; } private void UpdatePropertyOverride(string prop, bool? value) { if (value == null) { PropertyOverrides.Value = PropertyOverrides.Value.Remove(prop); } else { PropertyOverrides.Value = PropertyOverrides.Value.SetItem(prop, value.Value ? 1f : 0f); } RefreshUI(); } private void ShowButton(Rect rect) { if (lockButtonStyle == null) { lockButtonStyle = "IN LockButton"; } locked = GUI.Toggle(rect, locked, GUIContent.none, lockButtonStyle); } void IHasCustomMenu.AddItemsToMenu(GenericMenu menu) { menu.AddItem(new GUIContent("Lock"), locked, () => locked = !locked); } void SelectionChanged() { if (locked) return; if (currentSelection != Selection.activeGameObject) { UpdateSelection(); } } private void LoadUI() { var root = rootVisualElement; root.Clear(); root.AddToClassList("rootVisualContent"); uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(USS); root.styleSheets.Add(uss); var content = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UXML).CloneTree(); root.Add(content); Localization.L.LocalizeUIElements(content); root.Q<Button>("debug__reload").clickable.clicked += LoadUI; f_inspecting = root.Q<ObjectField>("inspecting"); f_inspecting.RegisterValueChangedCallback(evt => { locked = true; UpdateSelection(); }); _btn_clear = root.Q<Button>("clear-overrides"); _btn_clear.clickable.clicked += () => { PropertyOverrides.Value = ImmutableDictionary<string, float>.Empty; RefreshUI(); }; e_debugInfo = root.Q<VisualElement>("debug-info"); effectGroupTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(EFFECT_GROUP_UXML); UpdateSelection(); } private void UpdateSelection() { currentSelection = locked ? f_inspecting.value as GameObject : Selection.activeGameObject; f_inspecting.SetValueWithoutNotify(currentSelection); RefreshUI(); } private void RefreshUI() { var avatar = RuntimeUtil.FindAvatarInParents(currentSelection?.transform); if (avatar == null) { e_debugInfo.style.display = DisplayStyle.None; return; } _btn_clear.SetEnabled(!PropertyOverrides.Value.IsEmpty); e_debugInfo.style.display = DisplayStyle.Flex; _lastComputeContext = new ComputeContext("RO Simulator"); _lastComputeContext.InvokeOnInvalidate(this, MaybeRefreshUI); var analysis = new ReactiveObjectAnalyzer(_lastComputeContext); analysis.ForcePropertyOverrides = PropertyOverrides.Value; var result = analysis.Analyze(avatar.gameObject); SetThisObjectOverrides(analysis); SetOverallActiveHeader(currentSelection, result.InitialStates); SetAffectedBy(currentSelection, result.Shapes); } private static void MaybeRefreshUI(ROSimulator self) { if (self.is_enabled) { self.RefreshUI(); } } private void SetThisObjectOverrides(ReactiveObjectAnalyzer analysis) { BindOverrideToParameter("this-obj-override", analysis.GetGameObjectStateProperty(currentSelection), 1); BindOverrideToParameter("this-menu-override", analysis.GetMenuItemProperty(currentSelection), currentSelection.GetComponent<ModularAvatarMenuItem>()?.Control?.value ?? 1 ); } private void BindOverrideToParameter(string overrideElemName, string property, float targetValue) { var elem = e_debugInfo.Q<VisualElement>(overrideElemName); var soc = elem.Q<StateOverrideController>(); if (property == null) { elem.style.display = DisplayStyle.None; return; } elem.style.display = DisplayStyle.Flex; if (PropertyOverrides.Value.TryGetValue(property, out var overrideValue)) { soc.SetWithoutNotify(Mathf.Approximately(overrideValue, targetValue)); } else { soc.SetWithoutNotify(null); } soc.OnStateOverrideChanged += value => { UpdatePropertyOverride(property, value, targetValue); }; } private void SetAffectedBy(GameObject gameObject, Dictionary<TargetProp, AnimatedProperty> shapes) { var effect_list = e_debugInfo.Q<ScrollView>("effect-list"); effect_list.Clear(); var orderedShapes = shapes.Values .Where(s => AffectedBy(s.TargetProp, gameObject)) .OrderBy( s => { if (s.TargetProp.TargetObject is GameObject go && s.TargetProp.PropertyName == "m_IsActive") return (0, null, null); return (1, s.TargetProp.TargetObject.GetType().ToString(), s.TargetProp.PropertyName); } ); foreach (var shape in orderedShapes) { var targetProp = shape.TargetProp; var propInfo = shape; var propGroup = new Foldout(); propGroup.text = targetProp.TargetObject.GetType() + "." + targetProp.PropertyName; var foldoutStateKey = (shape.TargetProp.TargetObject?.GetInstanceID() ?? -1, shape.TargetProp.PropertyName); propGroup.RegisterValueChangedCallback(evt => { foldoutState[foldoutStateKey] = evt.newValue; if (evt.newValue) { propGroup.AddToClassList("foldout-open"); } else { propGroup.RemoveFromClassList("foldout-open"); } }); if (shape.TargetProp.TargetObject is GameObject go && shape.TargetProp.PropertyName == "m_IsActive") { propGroup.text = "Active State"; propGroup.value = true; } else { propGroup.value = false; } effect_list.Add(propGroup); if (foldoutState.TryGetValue(foldoutStateKey, out var state)) { propGroup.value = state; } if (propGroup.value) { propGroup.AddToClassList("foldout-open"); } else { propGroup.RemoveFromClassList("foldout-open"); } foreach (var reactionRule in propInfo.actionGroups) { if (reactionRule.ControllingObject == null) continue; var effectGroup = effectGroupTemplate.CloneTree(); propGroup.Add(effectGroup); effectGroup.styleSheets.Add(uss); if (reactionRule.InitiallyActive) { effectGroup.AddToClassList("st-active"); } var source = effectGroup.Q<ObjectField>("effect__source"); source.SetEnabled(false); source.SetValueWithoutNotify(reactionRule.ControllingObject); var conditions = effectGroup.Q<VisualElement>("controlling-conditions"); BuildRuleConditionBlock(conditions, reactionRule); // For our TextFields, we want to localize the label, not the text, so we'll do this manually... foreach (var field in effectGroup.Query<VisualElement>(classes:"ndmf-tr-label").ToList()) { var labelProp = field.GetType().GetProperty("label"); var tooltipProp = field.GetType().GetProperty("tooltip"); if (labelProp == null) continue; var key = labelProp.GetValue(field) as string; Action<VisualElement> relocalize = f2 => { labelProp.SetValue(f2, Localization.L.GetLocalizedString(key)); tooltipProp?.SetValue(f2, Localization.L.GetLocalizedString(key + ".tooltip")); }; relocalize(field); LanguagePrefs.RegisterLanguageChangeCallback(field, relocalize); field.RemoveFromClassList("ndmf-tr-label"); } // Localize once we've built the condition blocks as they contain dynamically created translated // strings. Localization.L.LocalizeUIElements(effectGroup); var f_target_component = effectGroup.Q<ObjectField>("effect__target"); var f_property = effectGroup.Q<TextField>("effect__prop"); var f_set_active = effectGroup.Q<VisualElement>("effect__set-active"); var f_set_inactive = effectGroup.Q<VisualElement>("effect__set-inactive"); var f_value = effectGroup.Q<FloatField>("effect__value"); var f_material = effectGroup.Q<ObjectField>("effect__material"); var f_delete = effectGroup.Q("effect__deleted"); f_target_component.style.display = DisplayStyle.None; f_target_component.SetEnabled(false); f_property.style.display = DisplayStyle.None; f_property.SetEnabled(false); f_set_active.style.display = DisplayStyle.None; f_set_inactive.style.display = DisplayStyle.None; f_value.style.display = DisplayStyle.None; f_value.SetEnabled(false); f_material.style.display = DisplayStyle.None; f_material.SetEnabled(false); f_delete.style.display = DisplayStyle.None; f_delete.SetEnabled(false); if (targetProp.TargetObject is GameObject && targetProp.PropertyName == "m_IsActive") { if (((float)reactionRule.Value) > 0.5f) { f_set_active.style.display = DisplayStyle.Flex; } else { f_set_inactive.style.display = DisplayStyle.Flex; } } else { f_target_component.SetValueWithoutNotify(targetProp.TargetObject); f_target_component.style.display = DisplayStyle.Flex; f_property.value = targetProp.PropertyName; f_property.style.display = DisplayStyle.Flex; if (reactionRule.IsDelete) { f_delete.style.display = DisplayStyle.Flex; } else if (reactionRule.Value is float f) { f_value.SetValueWithoutNotify(f); f_value.style.display = DisplayStyle.Flex; } else if (reactionRule.Value is Material m) { f_material.SetValueWithoutNotify(m); f_material.style.display = DisplayStyle.Flex; } } } } } private bool AffectedBy(TargetProp shapeKey, GameObject gameObject) { return (shapeKey.TargetObject == gameObject) || (shapeKey.TargetObject is Component c && c.gameObject == gameObject); } private void BuildRuleConditionBlock(VisualElement conditions, ReactionRule rule) { if (rule.Inverted) { conditions.AddToClassList("rule-inverted"); } foreach (var condition in rule.ControllingConditions) { var conditionElem = new VisualElement(); conditions.Add(conditionElem); conditionElem.AddToClassList("controlling-condition"); var soc = new StateOverrideController(); conditionElem.Add(soc); var prop = condition.Parameter; if (PropertyOverrides.Value.TryGetValue(prop, out var overrideValue)) { soc.SetWithoutNotify(condition, overrideValue); } float targetValue; if (!float.IsFinite(condition.ParameterValueHi)) { targetValue = Mathf.Round(condition.ParameterValueLo + 0.5f); } else if (!float.IsFinite(condition.ParameterValueLo)) { targetValue = Mathf.Round(condition.ParameterValueHi - 0.5f); } else { targetValue = Mathf.Round((condition.ParameterValueLo + condition.ParameterValueHi) / 2); } soc.OnStateOverrideChanged += value => UpdatePropertyOverride(prop, value, targetValue); var active = condition.InitiallyActive; var active_label = active ? "active" : "inactive"; active_label = "ro_sim.state." + active_label; var active_classname = active ? "source-active" : "source-inactive"; switch (condition.DebugReference) { case GameObject go: { var controller = new ObjectField(active_label); controller.SetEnabled(false); controller.SetValueWithoutNotify(go); controller.AddToClassList(active_classname); controller.AddToClassList("ndmf-tr"); conditionElem.Add(controller); break; } case Component c: { var controller = new ObjectField(active_label); controller.SetEnabled(false); controller.SetValueWithoutNotify(c); controller.AddToClassList(active_classname); controller.AddToClassList("ndmf-tr"); conditionElem.Add(controller); break; } default: { var controller = new TextField(active_label); controller.SetEnabled(false); controller.value = condition.DebugReference.ToString(); controller.AddToClassList(active_classname); controller.AddToClassList("ndmf-tr"); conditionElem.Add(controller); break; } } } } private void SetOverallActiveHeader(GameObject obj, Dictionary<TargetProp, object> initialStates) { bool activeState = obj.activeInHierarchy; if (initialStates.TryGetValue(TargetProp.ForObjectActive(obj), out var activeStateObj)) { activeState = ((float)activeStateObj) > 0; } var ve_active = e_debugInfo.Q<VisualElement>("state-enabled"); var ve_inactive = e_debugInfo.Q<VisualElement>("state-disabled"); ve_active.style.display = activeState ? DisplayStyle.Flex : DisplayStyle.None; ve_inactive.style.display = activeState ? DisplayStyle.None : DisplayStyle.Flex; } } }