using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; using VF.Builder; using VF.Feature.Base; using VF.Inspector; using VF.Model; using VF.Model.Feature; using VF.Model.StateAction; using Object = UnityEngine.Object; using Toggle = VF.Model.Feature.Toggle; namespace VF.Feature { public class ToggleBuilder : FeatureBuilder { private List exclusiveTagTriggeringStates = new List(); private VFABool param; private AnimationClip restingClip; public ISet GetExclusiveTags() { if (model.enableExclusiveTag) { return model.exclusiveTag.Split(',') .Select(tag => tag.Trim()) .Where(tag => !string.IsNullOrWhiteSpace(tag)) .ToImmutableHashSet(); } return new HashSet(); } public VFABool GetParam() { return param; } [FeatureBuilderAction] public void Apply() { // If the toggle is setup to /actually/ toggle something (and it's not an off state for just an exclusive tag or something) // Then don't even bother adding it. The user probably removed the object, so the toggle shouldn't be present. if (model.state.IsEmpty() && model.state.actions.Count > 0) { return; } if (model.slider) { var stops = new List { new Puppet.Stop(1, 0, model.state) }; var puppet = new Puppet { name = model.name, saved = model.saved, slider = true, stops = stops, defaultX = model.slider && model.defaultOn ? model.defaultSliderValue : 0, enableIcon = model.enableIcon, icon = model.icon, }; addOtherFeature(puppet); return; } var physBoneResetter = CreatePhysBoneResetter(model.resetPhysbones, model.name); var layerName = model.name; var fx = GetFx(); var layer = fx.NewLayer(layerName); var off = layer.NewState("Off"); VFACondition onCase; var paramName = model.paramOverride ?? model.name; if (model.useInt) { var numParam = fx.NewInt(paramName, synced: true, saved: model.saved, def: model.defaultOn ? 1 : 0, usePrefix: model.usePrefixOnParam); onCase = numParam.IsNotEqualTo(0); } else { var boolParam = fx.NewBool(paramName, synced: true, saved: model.saved, def: model.defaultOn, usePrefix: model.usePrefixOnParam); param = boolParam; onCase = boolParam.IsTrue(); } if (model.separateLocal) { var isLocal = fx.IsLocal().IsTrue(); Apply(fx, layer, off, onCase.And(isLocal.Not()), "On Remote", model.state, model.transitionStateIn, model.transitionStateOut, physBoneResetter); Apply(fx, layer, off, onCase.And(isLocal), "On Local", model.localState, model.localTransitionStateIn, model.localTransitionStateOut, physBoneResetter); } else { Apply(fx, layer, off, onCase, "On", model.state, model.transitionStateIn, model.transitionStateOut, physBoneResetter); } if (model.addMenuItem) { manager.GetMenu().NewMenuToggle( model.name, param, icon: model.enableIcon ? model.icon : null ); } } private void Apply( ControllerManager fx, VFALayer layer, VFAState off, VFACondition onCase, string onName, State action, State inAction, State outAction, VFABool physBoneResetter ) { var clip = LoadState(model.name + " " + onName, action); if (restingClip == null && model.includeInRest) { restingClip = clip; var defaultsManager = allBuildersInRun .OfType() .First(); defaultsManager.forceRecordBindings.UnionWith(AnimationUtility.GetCurveBindings(clip)); defaultsManager.forceRecordBindings.UnionWith(AnimationUtility.GetObjectReferenceCurveBindings(clip)); } if (model.securityEnabled) { var securityLockUnlocked = allBuildersInRun .OfType() .Select(f => f.GetEnabled()) .FirstOrDefault(); if (securityLockUnlocked != null) { onCase = onCase.And(securityLockUnlocked); } } VFAState inState; VFAState onState; if (model.hasTransition && inAction != null && !inAction.IsEmpty()) { var transitionClipIn = LoadState(model.name + onName + " In", inAction); inState = layer.NewState(onName + " In").WithAnimation(transitionClipIn); onState = layer.NewState(onName).WithAnimation(clip); inState.TransitionsTo(onState).When().WithTransitionExitTime(1); } else { inState = onState = layer.NewState(onName).WithAnimation(clip); } exclusiveTagTriggeringStates.Add(inState); off.TransitionsTo(inState).When(onCase); if (model.simpleOutTransition) outAction = inAction; if (model.hasTransition && outAction != null && !outAction.IsEmpty()) { var transitionClipOut = LoadState(model.name + onName + " Out", outAction); var outState = layer.NewState(onName + " Out").WithAnimation(transitionClipOut).Speed(model.simpleOutTransition ? -1 : 1); onState.TransitionsTo(outState).When(onCase.Not()); outState.TransitionsToExit().When().WithTransitionExitTime(1); } else { onState.TransitionsToExit().When(onCase.Not()); } if (physBoneResetter != null) { off.Drives(physBoneResetter, true); inState.Drives(physBoneResetter, true); } if (model.enableDriveGlobalParam && !string.IsNullOrWhiteSpace(model.driveGlobalParam)) { var driveGlobal = fx.NewBool( model.driveGlobalParam, synced: false, saved: false, def: false, usePrefix: false ); off.Drives(driveGlobal, false); inState.Drives(driveGlobal, true); } } [FeatureBuilderAction(FeatureOrder.CollectToggleExclusiveTags)] public void ApplyExclusiveTags() { if (exclusiveTagTriggeringStates.Count == 0) return; var fx = GetFx(); var allOthersOffCondition = fx.Always(); var myTags = GetExclusiveTags(); foreach (var other in allBuildersInRun .OfType() .Where(b => b != this)) { var otherTags = other.GetExclusiveTags(); var conflictsWithOther = myTags.Any(myTag => otherTags.Contains(myTag)); if (conflictsWithOther) { var otherParam = other.GetParam(); if (otherParam != null) { foreach (var state in exclusiveTagTriggeringStates) { state.Drives(otherParam, false); } allOthersOffCondition = allOthersOffCondition.And(otherParam.IsFalse()); } } } if (model.exclusiveOffState && param != null) { var layer = fx.NewLayer(model.name + " - Off Trigger"); var off = layer.NewState("Idle"); var on = layer.NewState("Trigger"); off.TransitionsTo(on).When(allOthersOffCondition); on.TransitionsTo(off).When(allOthersOffCondition.Not().Or(param.IsFalse())); on.Drives(param, true); } } /** * This method is needed, because: * 1. If you clip.SampleAnimation on the avatar while it has a humanoid Avatar set on its Animator, it'll * bake into motorcycle pose. * 2. If you change the avatar or controller on the Animator, the Animator will reset all transforms of all * children objects back to the way they were at the start of the frame. * Only destroying the animator then recreating it seems to "reset" this "start of frame" state. */ public static void WithoutAnimator(GameObject obj, System.Action func) { var animator = obj.GetComponent(); if (!animator) { func(); return; } var controller = animator.runtimeAnimatorController; var avatar = animator.avatar; var applyRootMotion = animator.applyRootMotion; var updateMode = animator.updateMode; var cullingMode = animator.cullingMode; Object.DestroyImmediate(animator); animator = obj.AddComponent(); animator.applyRootMotion = applyRootMotion; animator.updateMode = updateMode; animator.cullingMode = cullingMode; func(); animator.runtimeAnimatorController = controller; animator.avatar = avatar; } [FeatureBuilderAction(FeatureOrder.ApplyToggleRestingState)] public void ApplyRestingState() { if (restingClip != null) { WithoutAnimator(avatarObject, () => { restingClip.SampleAnimation(avatarObject, 0); }); } } public override string GetEditorTitle() { return "Toggle"; } public override VisualElement CreateEditor(SerializedProperty prop) { return CreateEditor(prop, content => content.Add(VRCFuryStateEditor.render(prop.FindPropertyRelative("state")))); } private static VisualElement CreateEditor(SerializedProperty prop, Action renderBody) { var content = new VisualElement(); var savedProp = prop.FindPropertyRelative("saved"); var sliderProp = prop.FindPropertyRelative("slider"); var securityEnabledProp = prop.FindPropertyRelative("securityEnabled"); var defaultOnProp = prop.FindPropertyRelative("defaultOn"); var includeInRestProp = prop.FindPropertyRelative("includeInRest"); var exclusiveOffStateProp = prop.FindPropertyRelative("exclusiveOffState"); var enableExclusiveTagProp = prop.FindPropertyRelative("enableExclusiveTag"); var resetPhysboneProp = prop.FindPropertyRelative("resetPhysbones"); var enableIconProp = prop.FindPropertyRelative("enableIcon"); var enableDriveGlobalParamProp = prop.FindPropertyRelative("enableDriveGlobalParam"); var separateLocalProp = prop.FindPropertyRelative("separateLocal"); var hasTransitionProp = prop.FindPropertyRelative("hasTransition"); var simpleOutTransitionProp = prop.FindPropertyRelative("simpleOutTransition"); var defaultSliderProp = prop.FindPropertyRelative("defaultSliderValue"); var flex = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.FlexStart } }; content.Add(flex); var name = VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("name"), "Menu Path"); name.style.flexGrow = 1; flex.Add(name); var button = VRCFuryEditorUtils.Button("Options", () => { var advMenu = new GenericMenu(); if (savedProp != null) { advMenu.AddItem(new GUIContent("Saved Between Worlds"), savedProp.boolValue, () => { savedProp.boolValue = !savedProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (sliderProp != null) { advMenu.AddItem(new GUIContent("Use Slider Wheel"), sliderProp.boolValue, () => { sliderProp.boolValue = !sliderProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (securityEnabledProp != null) { advMenu.AddItem(new GUIContent("Protect with Security"), securityEnabledProp.boolValue, () => { securityEnabledProp.boolValue = !securityEnabledProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (defaultOnProp != null) { advMenu.AddItem(new GUIContent("Default On"), defaultOnProp.boolValue, () => { defaultOnProp.boolValue = !defaultOnProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (includeInRestProp != null) { advMenu.AddItem(new GUIContent("Show in Rest Pose"), includeInRestProp.boolValue, () => { includeInRestProp.boolValue = !includeInRestProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (resetPhysboneProp != null) { advMenu.AddItem(new GUIContent("Add PhysBone to Reset"), false, () => { VRCFuryEditorUtils.AddToList(resetPhysboneProp); }); } if (enableExclusiveTagProp != null) { advMenu.AddItem(new GUIContent("Enable Exclusive Tags"), enableExclusiveTagProp.boolValue, () => { enableExclusiveTagProp.boolValue = !enableExclusiveTagProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (exclusiveOffStateProp != null) { advMenu.AddItem(new GUIContent("This is Exclusive Off State"), exclusiveOffStateProp.boolValue, () => { exclusiveOffStateProp.boolValue = !exclusiveOffStateProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (enableIconProp != null) { advMenu.AddItem(new GUIContent("Set Custom Menu Icon"), enableIconProp.boolValue, () => { enableIconProp.boolValue = !enableIconProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (enableDriveGlobalParamProp != null) { advMenu.AddItem(new GUIContent("Drive a Global Parameter"), enableDriveGlobalParamProp.boolValue, () => { enableDriveGlobalParamProp.boolValue = !enableDriveGlobalParamProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (separateLocalProp != null) { advMenu.AddItem(new GUIContent("Separate Local State"), separateLocalProp.boolValue, () => { separateLocalProp.boolValue = !separateLocalProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } if (hasTransitionProp != null) { advMenu.AddItem(new GUIContent("Enable Transition State"), hasTransitionProp.boolValue, () => { hasTransitionProp.boolValue = !hasTransitionProp.boolValue; prop.serializedObject.ApplyModifiedProperties(); }); } advMenu.ShowAsContext(); }); button.style.flexGrow = 0; flex.Add(button); renderBody(content); if (resetPhysboneProp != null) { content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (resetPhysboneProp.arraySize > 0) { c.Add(VRCFuryEditorUtils.WrappedLabel("Reset PhysBones:")); c.Add(VRCFuryEditorUtils.List(prop.FindPropertyRelative("resetPhysbones"))); } return c; }, resetPhysboneProp)); } if (enableExclusiveTagProp != null) { content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (enableExclusiveTagProp.boolValue) { c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("exclusiveTag"), "Exclusive Tags")); } return c; }, enableExclusiveTagProp)); } content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (sliderProp.boolValue && defaultOnProp.boolValue) { c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("defaultSliderValue"), "Default Value")); } return c; }, sliderProp, defaultOnProp)); if (enableIconProp != null) { content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (enableIconProp.boolValue) { c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("icon"), "Menu Icon")); } return c; }, enableIconProp)); } if (enableDriveGlobalParamProp != null) { content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (enableDriveGlobalParamProp.boolValue) { c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("driveGlobalParam"), "Drive Global Param")); c.Add(VRCFuryEditorUtils.Warn( "Warning, Drive Global Param is an advanced feature. The driven parameter should not be placed in a menu " + "or controlled by any other driver or shared with any other toggle. It should only be used as an input to " + "manually-created state transitions in your avatar. This should NEVER be used on vrcfury props, as any merged " + "full controllers will have their parameters rewritten.")); } return c; }, enableDriveGlobalParamProp)); } if (separateLocalProp != null) { content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (separateLocalProp.boolValue) { c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("localState"), "Local State")); } return c; }, separateLocalProp)); } content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (hasTransitionProp.boolValue) { c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("transitionStateIn"), "Transition In")); if (!simpleOutTransitionProp.boolValue) c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("transitionStateOut"), "Transition Out")); } return c; }, hasTransitionProp, simpleOutTransitionProp)); content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (separateLocalProp.boolValue && hasTransitionProp.boolValue) { c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("localTransitionStateIn"), "Local Trans. In")); if (!simpleOutTransitionProp.boolValue) c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("localTransitionStateOut"), "Local Trans. Out")); } return c; }, separateLocalProp, hasTransitionProp, simpleOutTransitionProp)); content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var c = new VisualElement(); if (hasTransitionProp.boolValue) { c.Add(VRCFuryEditorUtils.Prop(prop.FindPropertyRelative("simpleOutTransition"), "Transition Out is reverse of Transition In")); } return c; }, hasTransitionProp)); // Tags content.Add(VRCFuryEditorUtils.RefreshOnChange(() => { var tags = new List(); if (savedProp != null && savedProp.boolValue) tags.Add("Saved"); if (sliderProp != null && sliderProp.boolValue) tags.Add("Slider"); if (securityEnabledProp != null && securityEnabledProp.boolValue) tags.Add("Security"); if (defaultOnProp != null && defaultOnProp.boolValue) tags.Add("Default On"); if (includeInRestProp != null && includeInRestProp.boolValue) tags.Add("Shown in Rest Pose"); if (exclusiveOffStateProp != null && exclusiveOffStateProp.boolValue) tags.Add("This is the Exclusive Off State"); var row = new VisualElement(); row.style.flexWrap = Wrap.Wrap; row.style.flexDirection = FlexDirection.Row; foreach (var tag in tags) { var flag = new Label(tag); flag.style.width = StyleKeyword.Auto; flag.style.backgroundColor = new Color(1f, 1f, 1f, 0.1f); flag.style.borderTopRightRadius = 5; flag.style.marginRight = 5; VRCFuryEditorUtils.Padding(flag, 2, 4); row.Add(flag); } return row; }, savedProp, sliderProp, securityEnabledProp, defaultOnProp, includeInRestProp, exclusiveOffStateProp )); return content; } } }