feat: add object-based menu system (#218)

This adds a GameObject/Component based menu editing system, heavily inspired by Hai's Expressions Menu Hierarchy editor (https://github.com/hai-vr/av3-expressions-menu-hierarchy-editor)
This commit is contained in:
bd_ 2023-02-25 16:45:24 +09:00
parent ce76bb2190
commit d39e17a8f6
58 changed files with 3678 additions and 923 deletions

View File

@ -15,13 +15,13 @@ namespace modular_avatar_tests
private const string MinimalAvatarGuid = "60d3416d1f6af4a47bf9056aefc38333";
[SetUp]
public void Setup()
public virtual void Setup()
{
objects = new List<GameObject>();
}
[TearDown]
public void Teardown()
public virtual void Teardown()
{
foreach (var obj in objects)
{

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 10cb5a7057c1452d8e1caf299c31b05b
timeCreated: 1676981463

View File

@ -0,0 +1,609 @@
using System.Collections.Generic;
using nadena.dev.modular_avatar.core;
using nadena.dev.modular_avatar.core.editor.menu;
using nadena.dev.modular_avatar.core.menu;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.VersionControl;
using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace modular_avatar_tests.VirtualMenuTests
{
public class VirtualMenuTests : TestBase
{
private Texture2D testTex;
private List<UnityEngine.Object> toDestroy;
private int controlIndex;
public override void Setup()
{
base.Setup();
testTex = new Texture2D(1, 1);
toDestroy = new List<UnityEngine.Object>();
controlIndex = 0;
}
public override void Teardown()
{
base.Teardown();
Object.DestroyImmediate(testTex);
foreach (var obj in toDestroy)
{
Object.DestroyImmediate(obj);
}
}
private T Create<T>(string name = null) where T : ScriptableObject
{
if (name == null) name = GUID.Generate().ToString();
T val = ScriptableObject.CreateInstance<T>();
val.name = name;
toDestroy.Add(val);
return val;
}
[Test]
public void TestEmptyMenu()
{
var virtualMenu = new VirtualMenu(null);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var root = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(0, root.Controls.Count);
Assert.AreSame(RootMenu.Instance, root.NodeKey);
}
[Test]
public void TestBasicMenu()
{
var rootMenu = Create<VRCExpressionsMenu>();
rootMenu.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl(),
GenerateTestControl()
};
var virtualMenu = new VirtualMenu(rootMenu);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var root = virtualMenu.ResolvedMenu[rootMenu];
Assert.AreEqual(2, root.Controls.Count);
Assert.AreSame(rootMenu, root.NodeKey);
AssertControlEquals(rootMenu.controls[0], root.Controls[0]);
AssertControlEquals(rootMenu.controls[1], root.Controls[1]);
}
[Test]
public void TestNativeMenuWithCycles()
{
var rootMenu = Create<VRCExpressionsMenu>("root");
var sub1 = Create<VRCExpressionsMenu>("sub1");
var sub2 = Create<VRCExpressionsMenu>("sub2");
rootMenu.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(sub1)
};
sub1.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(sub2)
};
sub2.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(rootMenu)
};
var virtualMenu = new VirtualMenu(rootMenu);
virtualMenu.FreezeMenu();
Assert.AreEqual(3, virtualMenu.ResolvedMenu.Count);
var rootNode = virtualMenu.ResolvedMenu[rootMenu];
var sub1Node = virtualMenu.ResolvedMenu[sub1];
var sub2Node = virtualMenu.ResolvedMenu[sub2];
Assert.AreEqual(1, rootNode.Controls.Count);
Assert.AreSame(rootMenu, rootNode.NodeKey);
Assert.AreSame(sub1Node, rootNode.Controls[0].SubmenuNode);
Assert.IsNull(rootNode.Controls[0].subMenu);
Assert.AreEqual(1, sub1Node.Controls.Count);
Assert.AreSame(sub1, sub1Node.NodeKey);
Assert.AreSame(sub2Node, sub1Node.Controls[0].SubmenuNode);
Assert.IsNull(sub1Node.Controls[0].subMenu);
Assert.AreEqual(1, sub2Node.Controls.Count);
Assert.AreSame(sub2, sub2Node.NodeKey);
Assert.AreSame(rootNode, sub2Node.Controls[0].SubmenuNode);
Assert.IsNull(sub2Node.Controls[0].subMenu);
}
[Test]
public void TestBasicMenuInstaller()
{
VRCExpressionsMenu testMenu = Create<VRCExpressionsMenu>();
testMenu.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl(),
GenerateTestControl()
};
var installer = CreateInstaller("test");
installer.menuToAppend = testMenu;
var virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var root = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(2, root.Controls.Count);
Assert.AreSame(RootMenu.Instance, root.NodeKey);
AssertControlEquals(testMenu.controls[0], root.Controls[0]);
AssertControlEquals(testMenu.controls[1], root.Controls[1]);
}
[Test]
public void TestMenuItemInstaller()
{
var installer = CreateInstaller("test");
installer.menuToAppend = Create<VRCExpressionsMenu>();
var item = installer.gameObject.AddComponent<ModularAvatarMenuItem>();
item.Control = GenerateTestControl();
var virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer);
virtualMenu.FreezeMenu();
item.Control.name = "test";
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var root = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(1, root.Controls.Count);
Assert.AreSame(RootMenu.Instance, root.NodeKey);
AssertControlEquals(item.Control, root.Controls[0]);
}
[Test]
public void TestInstallOntoInstaller()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var installer_c = CreateInstaller("c");
var menu_a = Create<VRCExpressionsMenu>();
var menu_b = Create<VRCExpressionsMenu>();
var menu_c = Create<VRCExpressionsMenu>();
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
menu_c.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_a.menuToAppend = menu_a;
installer_b.menuToAppend = menu_b;
installer_c.menuToAppend = menu_c;
installer_c.installTargetMenu = installer_a.menuToAppend;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.RegisterMenuInstaller(installer_c);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(3, rootMenu.Controls.Count);
Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey);
AssertControlEquals(menu_a.controls[0], rootMenu.Controls[0]);
AssertControlEquals(menu_c.controls[0], rootMenu.Controls[1]);
AssertControlEquals(menu_b.controls[0], rootMenu.Controls[2]);
}
[Test]
public void TestInstallSubmenu()
{
var installer_a = CreateInstaller("a");
var menu_a = Create<VRCExpressionsMenu>("a");
var menu_b = Create<VRCExpressionsMenu>("b");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(menu_b)
};
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_a.menuToAppend = menu_a;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.FreezeMenu();
Assert.AreEqual(2, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance];
var subMenu = virtualMenu.ResolvedMenu[menu_b];
Assert.AreSame(subMenu, rootMenu.Controls[0].SubmenuNode);
Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey);
Assert.AreSame(menu_b, subMenu.NodeKey);
Assert.AreEqual(1, subMenu.Controls.Count);
AssertControlEquals(menu_b.controls[0], subMenu.Controls[0]);
}
[Test]
public void TestYankInstaller()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var menu_a = Create<VRCExpressionsMenu>("a");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_a.menuToAppend = menu_a;
var item = installer_b.gameObject.AddComponent<ModularAvatarMenuItem>();
item.Control = GenerateTestControl();
item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
item.MenuSource = SubmenuSource.Children;
var child = CreateChild(item.gameObject, "child");
var childItem = child.AddComponent<ModularAvatarMenuInstallTarget>();
childItem.installer = installer_a;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.RegisterMenuInstallTarget(childItem);
virtualMenu.FreezeMenu();
item.Control.name = "b";
Assert.AreEqual(2, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance];
var item_node = virtualMenu.ResolvedMenu[new MenuNodesUnder(item.gameObject)];
Assert.AreEqual(1, rootMenu.Controls.Count);
Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey);
AssertControlEquals(item.Control, rootMenu.Controls[0]);
Assert.AreSame(item_node, rootMenu.Controls[0].SubmenuNode);
Assert.AreEqual(1, item_node.Controls.Count);
Assert.AreEqual(new MenuNodesUnder(item.gameObject), item_node.NodeKey);
AssertControlEquals(menu_a.controls[0], item_node.Controls[0]);
}
[Test]
public void WhenMenuInstallersLoop_LoopIsTerminated()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var menu_a = Create<VRCExpressionsMenu>("a");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_a.menuToAppend = menu_a;
var menu_b = Create<VRCExpressionsMenu>("b");
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_b.menuToAppend = menu_b;
installer_a.installTargetMenu = menu_b;
installer_b.installTargetMenu = menu_a;
VirtualMenu virtualMenu = new VirtualMenu(menu_a);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.FreezeMenu();
Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[menu_a];
var menu_a_node = virtualMenu.ResolvedMenu[menu_a];
Assert.AreEqual(3, rootMenu.Controls.Count);
}
[Test]
public void TestExternalSubmenuSource_WithMenuInstaller()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var menu_a = Create<VRCExpressionsMenu>("a");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
var menu_b = Create<VRCExpressionsMenu>("b");
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
var item_a = installer_a.gameObject.AddComponent<ModularAvatarMenuItem>();
item_a.Control = GenerateTestControl();
item_a.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
item_a.MenuSource = SubmenuSource.MenuAsset;
item_a.Control.subMenu = menu_a;
installer_b.menuToAppend = menu_b;
installer_b.installTargetMenu = menu_a;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.FreezeMenu();
var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type);
var menu_a_node = rootNode.Controls[0].SubmenuNode;
AssertControlEquals(menu_a.controls[0], menu_a_node.Controls[0]);
AssertControlEquals(menu_b.controls[0], menu_a_node.Controls[1]);
}
[Test]
public void MenuItem_NestedSubmenuNodes()
{
var installer_a = CreateInstaller("root");
var root_item = installer_a.gameObject.AddComponent<ModularAvatarMenuItem>();
root_item.Control = GenerateTestControl();
root_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
root_item.MenuSource = SubmenuSource.Children;
var mid_obj = CreateChild(root_item.gameObject, "mid");
var mid_item = mid_obj.AddComponent<ModularAvatarMenuItem>();
mid_item.Control = GenerateTestControl();
mid_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
mid_item.MenuSource = SubmenuSource.Children;
var leaf_obj = CreateChild(mid_obj, "leaf");
var leaf_item = leaf_obj.AddComponent<ModularAvatarMenuItem>();
leaf_item.Control = GenerateTestControl();
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.FreezeMenu();
var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type);
var mid_node = rootNode.Controls[0].SubmenuNode;
var leaf_node = mid_node.Controls[0].SubmenuNode;
Assert.AreEqual(1, rootNode.Controls.Count);
Assert.AreEqual(1, mid_node.Controls.Count);
Assert.AreEqual(1, leaf_node.Controls.Count);
root_item.Control.name = "root";
mid_item.Control.name = "mid";
leaf_item.Control.name = "leaf";
AssertControlEquals(root_item.Control, rootNode.Controls[0]);
AssertControlEquals(mid_item.Control, mid_node.Controls[0]);
AssertControlEquals(leaf_item.Control, leaf_node.Controls[0]);
}
[Test]
public void MenuItem_RemoteReference()
{
var installer_a = CreateInstaller("root");
var root_item = installer_a.gameObject.AddComponent<ModularAvatarMenuItem>();
var extern_root = CreateRoot("test");
var extern_obj = CreateChild(extern_root, "control");
var extern_item = extern_obj.AddComponent<ModularAvatarMenuItem>();
extern_item.Control = GenerateTestControl();
root_item.Control = GenerateTestControl();
root_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
root_item.MenuSource = SubmenuSource.Children;
root_item.menuSource_otherObjectChildren = extern_root;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.FreezeMenu();
root_item.Control.name = "root";
extern_item.Control.name = "control";
var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type);
var extern_node = rootNode.Controls[0].SubmenuNode;
Assert.AreEqual(1, rootNode.Controls.Count);
Assert.AreEqual(1, extern_node.Controls.Count);
AssertControlEquals(root_item.Control, rootNode.Controls[0]);
AssertControlEquals(extern_item.Control, extern_node.Controls[0]);
}
[Test]
public void InstallerInstallsInstallTarget()
{
var installer_a = CreateInstaller("a");
var installer_b = CreateInstaller("b");
var menu_b = Create<VRCExpressionsMenu>("b");
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl()
};
installer_b.menuToAppend = menu_b;
var item_a = installer_a.gameObject.AddComponent<ModularAvatarMenuInstallTarget>();
item_a.installer = installer_b;
VirtualMenu virtualMenu = new VirtualMenu(null);
virtualMenu.RegisterMenuInstaller(installer_a);
virtualMenu.RegisterMenuInstaller(installer_b);
virtualMenu.RegisterMenuInstallTarget(item_a);
virtualMenu.FreezeMenu();
var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance];
Assert.AreEqual(1, rootNode.Controls.Count);
AssertControlEquals(menu_b.controls[0], rootNode.Controls[0]);
}
[Test]
public void TestSerializeMenu()
{
var menu_a = Create<VRCExpressionsMenu>("test");
var menu_b = Create<VRCExpressionsMenu>("test2");
var menu_c = Create<VRCExpressionsMenu>("test3");
menu_a.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl(),
GenerateTestSubmenu(menu_b),
};
menu_b.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestControl(),
GenerateTestSubmenu(menu_c),
};
menu_c.controls = new List<VRCExpressionsMenu.Control>()
{
GenerateTestSubmenu(menu_a),
};
var virtualMenu = new VirtualMenu(menu_a);
virtualMenu.FreezeMenu();
var assetSet = new HashSet<UnityEngine.Object>();
var serialized = virtualMenu.SerializeMenu(obj => assetSet.Add(obj));
Assert.AreEqual(3, assetSet.Count);
Assert.AreEqual(2, serialized.controls.Count);
AssertControlEquals(menu_a.controls[0], serialized.controls[0]);
AssertControlEquals(menu_a.controls[1], serialized.controls[1]);
var serialized_b = serialized.controls[1].subMenu;
Assert.AreEqual(2, serialized_b.controls.Count);
AssertControlEquals(menu_b.controls[0], serialized_b.controls[0]);
AssertControlEquals(menu_b.controls[1], serialized_b.controls[1]);
var serialized_c = serialized_b.controls[1].subMenu;
Assert.AreEqual(1, serialized_c.controls.Count);
AssertControlEquals(menu_c.controls[0], serialized_c.controls[0]);
Assert.True(assetSet.Contains(serialized));
Assert.True(assetSet.Contains(serialized_b));
Assert.True(assetSet.Contains(serialized_c));
}
ModularAvatarMenuInstaller CreateInstaller(string name)
{
GameObject obj = new GameObject();
obj.name = name;
var installer = obj.AddComponent<ModularAvatarMenuInstaller>();
installer.name = name;
toDestroy.Add(obj);
return installer;
}
VRCExpressionsMenu.Control GenerateTestSubmenu(VRCExpressionsMenu menu)
{
var control = GenerateTestControl();
control.type = VRCExpressionsMenu.Control.ControlType.SubMenu;
control.subMenu = menu;
return control;
}
VRCExpressionsMenu.Control GenerateTestControl()
{
var control = new VRCExpressionsMenu.Control();
VRCExpressionsMenu.Control.ControlType[] types = new[]
{
VRCExpressionsMenu.Control.ControlType.Button,
// VRCExpressionsMenu.Control.ControlType.SubMenu,
VRCExpressionsMenu.Control.ControlType.Toggle,
VRCExpressionsMenu.Control.ControlType.RadialPuppet,
VRCExpressionsMenu.Control.ControlType.FourAxisPuppet,
VRCExpressionsMenu.Control.ControlType.TwoAxisPuppet,
};
control.type = types[Random.Range(0, types.Length)];
control.name = "Test Control " + controlIndex++;
control.parameter = new VRCExpressionsMenu.Control.Parameter();
control.parameter.name = "Test Parameter " + GUID.Generate();
control.icon = new Texture2D(1, 1);
control.labels = new[]
{
new VRCExpressionsMenu.Control.Label()
{
name = "label",
icon = testTex
}
};
control.subParameters = new[]
{
new VRCExpressionsMenu.Control.Parameter()
{
name = "Test Sub Parameter " + GUID.Generate()
}
};
control.value = 0.42f;
control.style = VRCExpressionsMenu.Control.Style.Style3;
return control;
}
void AssertControlEquals(VRCExpressionsMenu.Control expected, VRCExpressionsMenu.Control actual)
{
Assert.AreEqual(expected.type, actual.type);
Assert.AreEqual(expected.name, actual.name);
Assert.AreEqual(expected.parameter.name, actual.parameter.name);
Assert.AreNotSame(expected.parameter, actual.parameter);
Assert.AreEqual(expected.icon, actual.icon);
Assert.AreEqual(expected.labels.Length, actual.labels.Length);
Assert.AreEqual(expected.labels[0].name, actual.labels[0].name);
Assert.AreEqual(expected.labels[0].icon, actual.labels[0].icon);
Assert.AreEqual(expected.subParameters.Length, actual.subParameters.Length);
Assert.AreEqual(expected.subParameters[0].name, actual.subParameters[0].name);
Assert.AreNotSame(expected.subParameters[0], actual.subParameters[0]);
Assert.AreEqual(expected.value, actual.value);
Assert.AreEqual(expected.style, actual.style);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c96564b3caa04c63aa9c7c4ce05429eb
timeCreated: 1676981475

View File

@ -11,6 +11,7 @@
"com.unity.ugui": "1.0.0",
"com.unity.xr.oculus.standalone": "2.38.4",
"com.unity.xr.openvr.standalone": "2.0.5",
"de.thryrallo.vrc.avatar-performance-tools": "https://github.com/Thryrallo/VRC-Avatar-Performance-Tools.git",
"nadena.dev.modular-avatar": "0.0.1",
"com.unity.modules.ai": "1.0.0",
"com.unity.modules.androidjni": "1.0.0",

View File

@ -214,6 +214,8 @@ namespace nadena.dev.modular_avatar.core.editor
ErrorReportUI.MaybeOpenErrorReportUI();
AssetDatabase.SaveAssets();
Resources.UnloadUnusedAssets();
}
}
}

View File

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using VRC.SDK3.Avatars.ScriptableObjects;
using Object = UnityEngine.Object;
namespace nadena.dev.modular_avatar.core.editor
@ -13,6 +15,10 @@ namespace nadena.dev.modular_avatar.core.editor
internal readonly AnimationDatabase AnimationDatabase = new AnimationDatabase();
internal readonly AnimatorController AssetContainer;
internal readonly Dictionary<VRCExpressionsMenu, VRCExpressionsMenu> ClonedMenus
= new Dictionary<VRCExpressionsMenu, VRCExpressionsMenu>();
public BuildContext(VRCAvatarDescriptor avatarDescriptor)
{
AvatarDescriptor = avatarDescriptor;
@ -72,5 +78,38 @@ namespace nadena.dev.modular_avatar.core.editor
merger.AddOverrideController("", overrideController, null);
return merger.Finish();
}
public VRCExpressionsMenu CloneMenu(VRCExpressionsMenu menu)
{
if (menu == null) return null;
if (ClonedMenus.TryGetValue(menu, out var newMenu)) return newMenu;
newMenu = Object.Instantiate(menu);
this.SaveAsset(newMenu);
ClonedMenus[menu] = newMenu;
foreach (var control in newMenu.controls)
{
if (Util.ValidateExpressionMenuIcon(control.icon) != Util.ValidateExpressionMenuIconResult.Success)
control.icon = null;
for (int i = 0; i < control.labels.Length; i++)
{
var label = control.labels[i];
var labelResult = Util.ValidateExpressionMenuIcon(label.icon);
if (labelResult != Util.ValidateExpressionMenuIconResult.Success)
{
label.icon = null;
control.labels[i] = label;
}
}
if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
control.subMenu = CloneMenu(control.subMenu);
}
}
return newMenu;
}
}
}

View File

@ -1,49 +0,0 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core.editor
{
internal static class ClonedMenuMappings
{
/// <summary>
/// Map to link the cloned menu from the clone source.
/// If one menu is specified for multiple installers, they are replicated separately, so there is a one-to-many relationship.
/// </summary>
private static readonly Dictionary<VRCExpressionsMenu, ImmutableList<VRCExpressionsMenu>> ClonedMappings =
new Dictionary<VRCExpressionsMenu, ImmutableList<VRCExpressionsMenu>>();
/// <summary>
/// Map to link the clone source from the cloned menu.
/// Map is the opposite of ClonedMappings.
/// </summary>
private static readonly Dictionary<VRCExpressionsMenu, VRCExpressionsMenu> OriginalMapping =
new Dictionary<VRCExpressionsMenu, VRCExpressionsMenu>();
public static void Clear()
{
ClonedMappings.Clear();
OriginalMapping.Clear();
}
public static void Add(VRCExpressionsMenu original, VRCExpressionsMenu clonedMenu)
{
if (!ClonedMappings.TryGetValue(original, out ImmutableList<VRCExpressionsMenu> clonedMenus))
{
clonedMenus = ImmutableList<VRCExpressionsMenu>.Empty;
}
ClonedMappings[original] = clonedMenus.Add(clonedMenu);
OriginalMapping[clonedMenu] = original;
}
public static bool TryGetClonedMenus(VRCExpressionsMenu original, out ImmutableList<VRCExpressionsMenu> clonedMenus)
{
return ClonedMappings.TryGetValue(original, out clonedMenus);
}
public static VRCExpressionsMenu GetOriginal(VRCExpressionsMenu cloned)
{
return OriginalMapping.TryGetValue(cloned, out VRCExpressionsMenu original) ? original : null;
}
}
}

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using nadena.dev.modular_avatar.core;
using nadena.dev.modular_avatar.core.menu;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
@ -137,7 +138,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting
private static List<ErrorLog> CheckInternal(ModularAvatarMenuInstaller mi)
{
// TODO - check that target menu is in the avatar
if (mi.menuToAppend == null)
if (mi.menuToAppend == null && mi.GetComponent<MenuSourceComponent>() == null)
{
return new List<ErrorLog>()
{

View File

@ -334,7 +334,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting
internal static T ReportingObject<T>(UnityEngine.Object obj, Func<T> action)
{
CurrentReport._references.Push(obj);
if (obj != null) CurrentReport._references.Push(obj);
try
{
return action();
@ -347,7 +347,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting
}
finally
{
CurrentReport._references.Pop();
if (obj != null) CurrentReport._references.Pop();
}
}

View File

@ -0,0 +1,28 @@
using System;
using System.Runtime.Serialization;
using JetBrains.Annotations;
namespace nadena.dev.modular_avatar.editor.ErrorReporting
{
/// <summary>
/// These exceptions will not be logged in the error report.
/// </summary>
public class NominalException : Exception
{
public NominalException()
{
}
protected NominalException([NotNull] SerializationInfo info, StreamingContext context) : base(info, context)
{
}
public NominalException(string message) : base(message)
{
}
public NominalException(string message, Exception innerException) : base(message, innerException)
{
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 152d780db95c4e408240ec3cd4dad60e
timeCreated: 1676979798

View File

@ -1,180 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.IMGUI.Controls;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using VRC.SDK3.Avatars.ScriptableObjects;
using VRC.SDKBase;
namespace nadena.dev.modular_avatar.core.editor
{
class AvMenuTreeViewWindow : EditorWindow
{
private VRCAvatarDescriptor _avatarDescriptor;
private AvMenuTreeView _treeView;
public VRCAvatarDescriptor Avatar
{
get => _treeView.Avatar;
set => _treeView.Avatar = value;
}
public ModularAvatarMenuInstaller TargetInstaller
{
get => _treeView.TargetInstaller;
set => _treeView.TargetInstaller = value;
}
public Action<VRCExpressionsMenu> OnMenuSelected = (menu) => { };
private void Awake()
{
_treeView = new AvMenuTreeView(new TreeViewState());
_treeView.OnSelect = (menu) => OnMenuSelected.Invoke(menu);
_treeView.OnDoubleclickSelect = Close;
}
private void OnLostFocus()
{
Close();
}
private void OnDisable()
{
OnMenuSelected = (menu) => { };
}
private void OnGUI()
{
if (_treeView == null || _treeView.Avatar == null)
{
Close();
return;
}
_treeView.OnGUI(new Rect(0, 0, position.width, position.height));
}
internal static void Show(VRCAvatarDescriptor Avatar, ModularAvatarMenuInstaller Installer, Action<VRCExpressionsMenu> OnSelect)
{
var window = GetWindow<AvMenuTreeViewWindow>();
window.titleContent = new GUIContent("Select menu");
window.Avatar = Avatar;
window.TargetInstaller = Installer;
window.OnMenuSelected = OnSelect;
window.Show();
}
}
class AvMenuTreeView : TreeView
{
private VRCAvatarDescriptor _avatar;
public VRCAvatarDescriptor Avatar
{
get => _avatar;
set
{
_avatar = value;
Reload();
}
}
private ModularAvatarMenuInstaller _targetInstaller;
public ModularAvatarMenuInstaller TargetInstaller
{
get => _targetInstaller;
set
{
_targetInstaller = value;
Reload();
}
}
internal Action<VRCExpressionsMenu> OnSelect = (menu) => { };
internal Action OnDoubleclickSelect = () => { };
private List<VRCExpressionsMenu> _menuItems = new List<VRCExpressionsMenu>();
private HashSet<VRCExpressionsMenu> _visitedMenus = new HashSet<VRCExpressionsMenu>();
private MenuTree _menuTree;
private Stack<VRCExpressionsMenu> _visitedMenuStack = new Stack<VRCExpressionsMenu>();
public AvMenuTreeView(TreeViewState state) : base(state)
{
}
protected override void SelectionChanged(IList<int> selectedIds)
{
OnSelect.Invoke(_menuItems[selectedIds[0]]);
}
protected override void DoubleClickedItem(int id)
{
OnSelect.Invoke(_menuItems[id]);
OnDoubleclickSelect.Invoke();
}
protected override TreeViewItem BuildRoot()
{
_menuItems.Clear();
_visitedMenuStack.Clear();
_menuTree = new MenuTree(Avatar);
_menuTree.TraverseAvatarMenu();
foreach (ModularAvatarMenuInstaller installer in Avatar.gameObject.GetComponentsInChildren<ModularAvatarMenuInstaller>(true))
{
if (installer == TargetInstaller) continue;
_menuTree.TraverseMenuInstaller(installer);
}
var root = new TreeViewItem(-1, -1, "<root>");
List<TreeViewItem> treeItems = new List<TreeViewItem>
{
new TreeViewItem
{
id = 0,
depth = 0,
displayName = $"{Avatar.gameObject.name} ({(Avatar.expressionsMenu == null ? "None" : Avatar.expressionsMenu.name)})"
}
};
_menuItems.Add(Avatar.expressionsMenu);
_visitedMenuStack.Push(Avatar.expressionsMenu);
TraverseMenu(1, treeItems, Avatar.expressionsMenu);
SetupParentsAndChildrenFromDepths(root, treeItems);
return root;
}
private void TraverseMenu(int depth, List<TreeViewItem> items, VRCExpressionsMenu menu)
{
IEnumerable<MenuTree.ChildElement> children = _menuTree.GetChildren(menu)
.Where(child => !_visitedMenuStack.Contains(child.menu));
foreach (MenuTree.ChildElement child in children)
{
if (child.menu == null) continue;
string displayName = child.installer == null ?
$"{child.menuName} ({child.menu.name})" :
$"{child.menuName} ({child.menu.name}) InstallerObject : {child.installer.name}";
items.Add(
new TreeViewItem
{
id = items.Count,
depth = depth,
displayName = displayName
}
);
_menuItems.Add(child.menu);
_visitedMenuStack.Push(child.menu);
TraverseMenu(depth + 1, items, child.menu);
_visitedMenuStack.Pop();
}
}
}
}

View File

@ -0,0 +1,4 @@
fileFormatVersion: 2
guid: c587475b37a44ad9ac97c6e7b9e1b4a2
timeCreated: 1677148000
folderAsset: yes

View File

@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using nadena.dev.modular_avatar.core.editor.menu;
using nadena.dev.modular_avatar.core.menu;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.IMGUI.Controls;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using VRC.SDK3.Avatars.ScriptableObjects;
using VRC.SDKBase;
namespace nadena.dev.modular_avatar.core.editor
{
class AvMenuTreeViewWindow : EditorWindow
{
private VRCAvatarDescriptor _avatarDescriptor;
private AvMenuTreeView _treeView;
private long _cacheIndex = -1;
public VRCAvatarDescriptor Avatar
{
get => _treeView.Avatar;
set => _treeView.Avatar = value;
}
public ModularAvatarMenuInstaller TargetInstaller
{
get => _treeView.TargetInstaller;
set => _treeView.TargetInstaller = value;
}
public Action<object> OnMenuSelected = (menu) => { };
private void Awake()
{
_treeView = new AvMenuTreeView(new TreeViewState());
_treeView.OnSelect = (menu) => OnMenuSelected.Invoke(menu);
_treeView.OnDoubleclickSelect = Close;
_cacheIndex = -1;
}
private void OnLostFocus()
{
Close();
}
private void OnDisable()
{
OnMenuSelected = (menu) => { };
}
private void OnGUI()
{
if (_treeView == null || _treeView.Avatar == null)
{
Close();
return;
}
if (_cacheIndex != VirtualMenu.CacheSequence)
{
_treeView.ReloadPreservingExpanded();
_cacheIndex = VirtualMenu.CacheSequence;
}
_treeView.OnGUI(new Rect(0, 0, position.width, position.height));
}
internal static void Show(VRCAvatarDescriptor Avatar, ModularAvatarMenuInstaller Installer,
Action<object> OnSelect)
{
var window = GetWindow<AvMenuTreeViewWindow>();
window.titleContent = new GUIContent("Select menu");
window.Avatar = Avatar;
window.TargetInstaller = Installer;
window.OnMenuSelected = OnSelect;
window.Show();
}
}
class AvMenuTreeView : TreeView
{
private VRCAvatarDescriptor _avatar;
public VRCAvatarDescriptor Avatar
{
get => _avatar;
set
{
_avatar = value;
Reload();
}
}
private ModularAvatarMenuInstaller _targetInstaller;
public ModularAvatarMenuInstaller TargetInstaller
{
get => _targetInstaller;
set
{
_targetInstaller = value;
Reload();
}
}
internal Action<object> OnSelect = (menu) => { };
internal Action OnDoubleclickSelect = () => { };
private List<object> _nodeKeys = new List<object>();
private HashSet<object> _visitedMenus = new HashSet<object>();
private VirtualMenu _menuTree;
private Stack<object> _visitedMenuStack = new Stack<object>();
public AvMenuTreeView(TreeViewState state) : base(state)
{
}
public void ReloadPreservingExpanded()
{
var expanded = GetExpanded().Select(id => _nodeKeys[id]).ToImmutableHashSet();
var selected = GetSelection().Select(id => _nodeKeys[id]).ToImmutableHashSet();
CollapseAll();
Reload();
SetExpanded(Enumerable.Range(0, _nodeKeys.Count)
.Where(i => _nodeKeys[i] != null && expanded.Contains(_nodeKeys[i]))
.ToList());
SetSelection(Enumerable.Range(0, _nodeKeys.Count)
.Where(i => _nodeKeys[i] != null && selected.Contains(_nodeKeys[i]))
.ToList());
}
protected override void SelectionChanged(IList<int> selectedIds)
{
OnSelect.Invoke(_nodeKeys[selectedIds[0]]);
}
protected override void DoubleClickedItem(int id)
{
OnSelect.Invoke(_nodeKeys[id]);
OnDoubleclickSelect.Invoke();
}
protected override TreeViewItem BuildRoot()
{
_nodeKeys.Clear();
_visitedMenuStack.Clear();
_menuTree = VirtualMenu.ForAvatar(_avatar);
var preferredRoot = FindPreferredRoot(_menuTree);
var rootName = "";
if (preferredRoot is ModularAvatarMenuGroup group)
{
if (group.targetObject != null) rootName = $"({group.targetObject.name})";
else rootName = $"({group.gameObject.name})";
}
else if (preferredRoot is VRCExpressionsMenu menu)
{
rootName = $"({menu.name})";
}
var root = new TreeViewItem(-1, -1, "<root>");
List<TreeViewItem> treeItems = new List<TreeViewItem>
{
new TreeViewItem
{
id = 0,
depth = 0,
displayName =
$"{Avatar.gameObject.name} {rootName}"
}
};
_nodeKeys.Add(preferredRoot);
_visitedMenuStack.Push(preferredRoot);
TraverseMenu(1, treeItems, _menuTree.RootMenuNode);
SetupParentsAndChildrenFromDepths(root, treeItems);
return root;
}
private object FindPreferredRoot(VirtualMenu menuTree)
{
// There's always a VRCExpressionsMenu at the root, but we'd prefer to add stuff under a MenuItem tree if
// available. See if we can find one.
foreach (var installer in _avatar.GetComponentsInChildren<ModularAvatarMenuInstaller>(true))
{
if (installer.installTargetMenu != null && installer.installTargetMenu != menuTree.RootMenuKey)
{
continue;
}
var menuSource = installer.GetComponent<MenuSource>();
if (menuSource == null || !(menuSource is ModularAvatarMenuGroup group)) continue;
return menuSource;
}
return menuTree.RootMenuKey;
}
private void TraverseMenu(int depth, List<TreeViewItem> items, VirtualMenuNode node)
{
IEnumerable<VirtualControl> children = node.Controls
.Where(control => control.type == VRCExpressionsMenu.Control.ControlType.SubMenu &&
control.SubmenuNode != null &&
!_visitedMenuStack.Contains(control.SubmenuNode));
foreach (var child in children)
{
string displayName = child.name;
items.Add(
new TreeViewItem
{
id = items.Count,
depth = depth,
displayName = displayName
}
);
_nodeKeys.Add(child.SubmenuNode.NodeKey);
_visitedMenuStack.Push(child.SubmenuNode);
TraverseMenu(depth + 1, items, child.SubmenuNode);
_visitedMenuStack.Pop();
}
}
}
}

View File

@ -0,0 +1,55 @@
using UnityEditor;
using static nadena.dev.modular_avatar.core.editor.Localization;
namespace nadena.dev.modular_avatar.core.editor
{
[CustomEditor(typeof(ModularAvatarMenuItem))]
[CanEditMultipleObjects]
internal class MAMenuItemInspector : MAEditorBase
{
private MenuItemCoreGUI _coreGUI;
void OnEnable()
{
_coreGUI = new MenuItemCoreGUI(serializedObject, Repaint);
_coreGUI.AlwaysExpandContents = true;
}
protected override void OnInnerInspectorGUI()
{
serializedObject.Update();
_coreGUI.DoGUI();
serializedObject.ApplyModifiedProperties();
ShowLanguageUI();
}
}
[CustomEditor(typeof(ModularAvatarMenuGroup))]
internal class MAMenuGroupInspector : MAEditorBase
{
private MenuPreviewGUI _previewGUI;
private SerializedProperty _prop_target;
void OnEnable()
{
_previewGUI = new MenuPreviewGUI(Repaint);
_prop_target = serializedObject.FindProperty(nameof(ModularAvatarMenuGroup.targetObject));
}
protected override void OnInnerInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.PropertyField(_prop_target, G("menuitem.prop.source_override"));
_previewGUI.DoGUI((ModularAvatarMenuGroup) target);
serializedObject.ApplyModifiedProperties();
ShowLanguageUI();
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5b674c72186c4e6884b0cd05098f11b6
timeCreated: 1676791017

View File

@ -0,0 +1,565 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using nadena.dev.modular_avatar.core.editor.menu;
using nadena.dev.modular_avatar.core.menu;
using UnityEditor;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using VRC.SDK3.Avatars.ScriptableObjects;
using static nadena.dev.modular_avatar.core.editor.Localization;
using static nadena.dev.modular_avatar.core.editor.Util;
using Object = UnityEngine.Object;
namespace nadena.dev.modular_avatar.core.editor
{
[CustomEditor(typeof(ModularAvatarMenuInstaller))]
[CanEditMultipleObjects]
internal class MenuInstallerEditor : MAEditorBase
{
private ModularAvatarMenuInstaller _installer;
private Editor _innerMenuEditor;
private VRCExpressionsMenu _menuToAppend;
private bool _menuFoldout;
private bool _devFoldout;
private MenuPreviewGUI _previewGUI;
private HashSet<VRCExpressionsMenu> _avatarMenus;
private Dictionary<VRCExpressionsMenu, List<ModularAvatarMenuInstaller>> _menuInstallersMap;
private void OnEnable()
{
_installer = (ModularAvatarMenuInstaller) target;
_previewGUI = new MenuPreviewGUI(Repaint);
FindMenus();
FindMenuInstallers();
VRCAvatarDescriptor commonAvatar = FindCommonAvatar();
}
private long _cacheSeq = -1;
private ImmutableList<object> _cachedTargets = null;
// Interpretation:
// <empty> : Inconsistent install targets
// List of [null]: Install to root
// List of [VRCExpMenu]: Install to expressions menu
// List of [InstallTarget]: Install to single install target
// List of [InstallTarget, InstallTarget ...]: Install to multiple install targets
private ImmutableList<object> InstallTargets
{
get
{
if (VirtualMenu.CacheSequence == _cacheSeq && _cachedTargets != null) return _cachedTargets;
List<ImmutableList<object>> perTarget = new List<ImmutableList<object>>();
var commonAvatar = FindCommonAvatar();
if (commonAvatar == null)
{
_cacheSeq = VirtualMenu.CacheSequence;
_cachedTargets = ImmutableList<object>.Empty;
return _cachedTargets;
}
var virtualMenu = VirtualMenu.ForAvatar(commonAvatar);
foreach (var target in targets)
{
var installer = (ModularAvatarMenuInstaller) target;
var installTargets = virtualMenu.GetInstallTargetsForInstaller(installer)
.Select(o => (object) o).ToImmutableList();
if (installTargets.Any())
{
perTarget.Add(installTargets);
}
else
{
perTarget.Add(ImmutableList<object>.Empty.Add(installer.installTargetMenu));
}
}
for (int i = 1; i < perTarget.Count; i++)
{
if (perTarget[0].Count != perTarget[i].Count ||
perTarget[0].Zip(perTarget[i], (a, b) => (Resolve(a) != Resolve(b))).Any(differs => differs))
{
perTarget.Clear();
perTarget.Add(ImmutableList<object>.Empty);
break;
}
}
_cacheSeq = VirtualMenu.CacheSequence;
_cachedTargets = perTarget[0];
return _cachedTargets;
object Resolve(object p0)
{
if (p0 is ModularAvatarMenuInstallTarget target && target != null) return target.transform.parent;
return p0;
}
}
}
private void SetupMenuEditor()
{
if (targets.Length != 1)
{
_innerMenuEditor = null;
_menuToAppend = null;
}
else if (_installer.menuToAppend != _menuToAppend)
{
if (_installer.menuToAppend == null) _innerMenuEditor = null;
else
{
_innerMenuEditor = CreateEditor(_installer.menuToAppend);
}
_menuToAppend = _installer.menuToAppend;
}
}
protected override void OnInnerInspectorGUI()
{
SetupMenuEditor();
var installTo = serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.installTargetMenu));
var isEnabled = targets.Length != 1 || ((ModularAvatarMenuInstaller) target).enabled;
VRCAvatarDescriptor commonAvatar = FindCommonAvatar();
if (InstallTargets.Count == 0)
{
// TODO - show warning for inconsistent targets?
}
else if (InstallTargets.Count > 0)
{
if (InstallTargets.Count == 1)
{
if (InstallTargets[0] == null)
{
if (isEnabled)
{
EditorGUILayout.HelpBox(S("menuinstall.help.hint_set_menu"), MessageType.Info);
}
}
else if (InstallTargets[0] is VRCExpressionsMenu menu
&& !IsMenuReachable(RuntimeUtil.FindAvatarInParents(((Component) target).transform), menu))
{
EditorGUILayout.HelpBox(S("menuinstall.help.hint_bad_menu"), MessageType.Error);
}
}
if (InstallTargets.Count == 1 && (InstallTargets[0] is VRCExpressionsMenu || InstallTargets[0] == null))
{
var displayValue = installTo.objectReferenceValue;
if (displayValue == null) displayValue = commonAvatar.expressionsMenu;
EditorGUI.BeginChangeCheck();
var newValue = EditorGUILayout.ObjectField(G("menuinstall.installto"), displayValue,
typeof(VRCExpressionsMenu), false);
if (EditorGUI.EndChangeCheck())
{
installTo.objectReferenceValue = newValue;
_cacheSeq = -1;
}
}
else
{
using (new EditorGUI.DisabledScope(true))
{
foreach (var target in InstallTargets)
{
if (target is VRCExpressionsMenu menu)
{
EditorGUILayout.ObjectField(G("menuinstall.installto"), menu,
typeof(VRCExpressionsMenu), true);
}
else if (target is ModularAvatarMenuInstallTarget t)
{
EditorGUILayout.ObjectField(G("menuinstall.installto"), t.transform.parent.gameObject,
typeof(GameObject), true);
}
}
}
}
var avatar = commonAvatar;
if (avatar != null && InstallTargets.Count == 1 && GUILayout.Button(G("menuinstall.selectmenu")))
{
AvMenuTreeViewWindow.Show(avatar, _installer, menu =>
{
if (InstallTargets.Count != 1 || menu == InstallTargets[0]) return;
if (InstallTargets[0] is ModularAvatarMenuInstallTarget oldTarget && oldTarget != null)
{
DestroyInstallTargets();
}
if (menu is ModularAvatarMenuItem item)
{
if (item.MenuSource == SubmenuSource.MenuAsset)
{
menu = item.Control.subMenu;
}
else
{
var menuParent = item.menuSource_otherObjectChildren != null
? item.menuSource_otherObjectChildren
: item.gameObject;
menu = new MenuNodesUnder(menuParent);
}
}
else if (menu is ModularAvatarMenuGroup group)
{
if (group.targetObject != null) menu = new MenuNodesUnder(group.targetObject);
else menu = new MenuNodesUnder(group.gameObject);
}
if (menu is VRCExpressionsMenu expMenu)
{
if (expMenu == avatar.expressionsMenu) installTo.objectReferenceValue = null;
else installTo.objectReferenceValue = expMenu;
}
else if (menu is RootMenu)
{
installTo.objectReferenceValue = null;
}
else if (menu is MenuNodesUnder nodesUnder)
{
installTo.objectReferenceValue = null;
foreach (var target in targets)
{
var installer = (ModularAvatarMenuInstaller) target;
var child = new GameObject();
Undo.RegisterCreatedObjectUndo(child, "Set install target");
child.transform.SetParent(nodesUnder.root.transform, false);
child.name = installer.gameObject.name;
var targetComponent = child.AddComponent<ModularAvatarMenuInstallTarget>();
targetComponent.installer = installer;
EditorGUIUtility.PingObject(child);
}
}
serializedObject.ApplyModifiedProperties();
VirtualMenu.InvalidateCaches();
Repaint();
});
}
}
if (targets.Length == 1)
{
_menuFoldout = EditorGUILayout.Foldout(_menuFoldout, G("menuinstall.showcontents"));
if (_menuFoldout)
{
_previewGUI.DoGUI((ModularAvatarMenuInstaller) target);
}
}
if (targets.Any(t =>
{
var installer = (ModularAvatarMenuInstaller) t;
return installer.GetComponent<MenuSource>() == null && installer.menuToAppend != null;
}))
{
if (GUILayout.Button("Extract menu to objects"))
{
ExtractMenu();
}
}
bool inconsistentSources = false;
MenuSource menuSource = null;
bool first = true;
foreach (var target in targets)
{
var component = (ModularAvatarMenuInstaller) target;
var componentSource = component.GetComponent<MenuSource>();
if (componentSource != null)
{
if (menuSource == null && first)
{
menuSource = componentSource;
}
else
{
inconsistentSources = true;
}
}
}
if (menuSource != null)
{
// TODO localize
EditorGUILayout.HelpBox("Menu contents provided by " + menuSource.GetType() + " component",
MessageType.Info);
}
if (!inconsistentSources)
{
_devFoldout = EditorGUILayout.Foldout(_devFoldout, G("menuinstall.devoptions"));
if (_devFoldout)
{
SerializedProperty menuToAppendProperty =
serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.menuToAppend));
if (!menuToAppendProperty.hasMultipleDifferentValues)
{
switch (ValidateExpressionMenuIcon(
(VRCExpressionsMenu) menuToAppendProperty.objectReferenceValue))
{
case ValidateExpressionMenuIconResult.Success:
break;
case ValidateExpressionMenuIconResult.TooLarge:
EditorGUILayout.HelpBox(S("menuinstall.menu_icon_too_large"), MessageType.Error);
break;
case ValidateExpressionMenuIconResult.Uncompressed:
EditorGUILayout.HelpBox(S("menuinstall.menu_icon_uncompressed"), MessageType.Error);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(
menuToAppendProperty, new GUIContent(G("menuinstall.srcmenu")));
EditorGUI.indentLevel--;
}
}
serializedObject.ApplyModifiedProperties();
Localization.ShowLanguageUI();
}
private void ExtractMenu()
{
serializedObject.ApplyModifiedProperties();
foreach (var t in targets)
{
var installer = (ModularAvatarMenuInstaller) t;
if (installer.GetComponent<MenuSourceComponent>() || installer.menuToAppend == null) continue;
var menu = installer.menuToAppend;
if (menu.controls.Count == 0)
{
continue;
}
Undo.RecordObject(installer, "Extract menu");
if (menu.controls.Count == 1)
{
// Attach control directly to the installer
var item = installer.gameObject.AddComponent<ModularAvatarMenuItem>();
Undo.RegisterCreatedObjectUndo(item, "Extract menu");
MenuExtractor.ControlToMenuItem(item, menu.controls[0]);
}
else
{
// Use a menu group and attach items on a child
var group = installer.gameObject.AddComponent<ModularAvatarMenuGroup>();
var menuRoot = new GameObject();
menuRoot.name = "Menu";
Undo.RegisterCreatedObjectUndo(menuRoot, "Extract menu");
menuRoot.transform.SetParent(group.transform, false);
foreach (var control in menu.controls)
{
var itemObject = new GameObject();
itemObject.gameObject.name = control.name;
Undo.RegisterCreatedObjectUndo(itemObject, "Extract menu");
itemObject.transform.SetParent(menuRoot.transform, false);
var item = itemObject.AddComponent<ModularAvatarMenuItem>();
MenuExtractor.ControlToMenuItem(item, control);
}
}
PrefabUtility.RecordPrefabInstancePropertyModifications(installer);
EditorUtility.SetDirty(installer);
}
}
private void DestroyInstallTargets()
{
VirtualMenu menu = VirtualMenu.ForAvatar(FindCommonAvatar());
foreach (var t in targets)
{
foreach (var oldTarget in menu.GetInstallTargetsForInstaller((ModularAvatarMenuInstaller) t))
{
if (PrefabUtility.IsPartOfPrefabInstance(oldTarget))
{
Undo.RecordObject(oldTarget, "Change menu install target");
oldTarget.installer = null;
PrefabUtility.RecordPrefabInstancePropertyModifications(oldTarget);
}
else
{
if (oldTarget.transform.childCount == 0 &&
oldTarget.GetComponents(typeof(Component)).Length == 2)
{
Undo.DestroyObjectImmediate(oldTarget.gameObject);
}
else
{
Undo.DestroyObjectImmediate(oldTarget);
}
}
}
}
}
private VRCAvatarDescriptor FindCommonAvatar()
{
VRCAvatarDescriptor commonAvatar = null;
foreach (var target in targets)
{
var component = (ModularAvatarMenuInstaller) target;
var avatar = RuntimeUtil.FindAvatarInParents(component.transform);
if (avatar == null) return null;
if (commonAvatar == null)
{
commonAvatar = avatar;
}
else if (commonAvatar != avatar)
{
return null;
}
}
return commonAvatar;
}
private void FindMenus()
{
if (targets.Length > 1)
{
_avatarMenus = null;
return;
}
_avatarMenus = new HashSet<VRCExpressionsMenu>();
var queue = new Queue<VRCExpressionsMenu>();
var avatar = RuntimeUtil.FindAvatarInParents(((Component) target).transform);
if (avatar == null || avatar.expressionsMenu == null) return;
queue.Enqueue(avatar.expressionsMenu);
while (queue.Count > 0)
{
var menu = queue.Dequeue();
if (_avatarMenus.Contains(menu)) continue;
_avatarMenus.Add(menu);
foreach (var subMenu in menu.controls)
{
if (subMenu.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
queue.Enqueue(subMenu.subMenu);
}
}
}
}
private void FindMenuInstallers()
{
if (targets.Length > 1)
{
_menuInstallersMap = null;
return;
}
_menuInstallersMap = new Dictionary<VRCExpressionsMenu, List<ModularAvatarMenuInstaller>>();
var avatar = RuntimeUtil.FindAvatarInParents(((Component) target).transform);
if (avatar == null) return;
var menuInstallers = avatar.GetComponentsInChildren<ModularAvatarMenuInstaller>(true)
.Where(menuInstaller => menuInstaller.enabled && menuInstaller.menuToAppend != null);
foreach (ModularAvatarMenuInstaller menuInstaller in menuInstallers)
{
if (menuInstaller == target) continue;
var visitedMenus = new HashSet<VRCExpressionsMenu>();
var queue = new Queue<VRCExpressionsMenu>();
queue.Enqueue(menuInstaller.menuToAppend);
while (queue.Count > 0)
{
VRCExpressionsMenu parent = queue.Dequeue();
var controls = parent.controls.Where(control =>
control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && control.subMenu != null);
foreach (VRCExpressionsMenu.Control control in controls)
{
// Do not filter in LINQ to avoid closure allocation
if (visitedMenus.Contains(control.subMenu)) continue;
if (!_menuInstallersMap.TryGetValue(control.subMenu,
out List<ModularAvatarMenuInstaller> fromInstallers))
{
fromInstallers = new List<ModularAvatarMenuInstaller>();
_menuInstallersMap[control.subMenu] = fromInstallers;
}
fromInstallers.Add(menuInstaller);
visitedMenus.Add(control.subMenu);
queue.Enqueue(control.subMenu);
}
}
}
}
private bool IsMenuReachable(VRCAvatarDescriptor avatar, VRCExpressionsMenu menu)
{
var virtualMenu = VirtualMenu.ForAvatar(avatar);
return virtualMenu.ContainsMenu(menu);
}
private static ValidateExpressionMenuIconResult ValidateExpressionMenuIcon(VRCExpressionsMenu menu,
HashSet<VRCExpressionsMenu> visitedMenus = null)
{
if (menu == null) return ValidateExpressionMenuIconResult.Success;
if (visitedMenus == null) visitedMenus = new HashSet<VRCExpressionsMenu>();
if (visitedMenus.Contains(menu)) return ValidateExpressionMenuIconResult.Success;
visitedMenus.Add(menu);
foreach (VRCExpressionsMenu.Control control in menu.controls)
{
// Control
ValidateExpressionMenuIconResult result = Util.ValidateExpressionMenuIcon(control.icon);
if (result != ValidateExpressionMenuIconResult.Success) return result;
// Labels
if (control.labels != null)
{
foreach (VRCExpressionsMenu.Control.Label label in control.labels)
{
ValidateExpressionMenuIconResult labelResult = Util.ValidateExpressionMenuIcon(label.icon);
if (labelResult != ValidateExpressionMenuIconResult.Success) return labelResult;
}
}
// SubMenu
if (control.type != VRCExpressionsMenu.Control.ControlType.SubMenu) continue;
ValidateExpressionMenuIconResult subMenuResult =
ValidateExpressionMenuIcon(control.subMenu, visitedMenus);
if (subMenuResult != ValidateExpressionMenuIconResult.Success) return subMenuResult;
}
return ValidateExpressionMenuIconResult.Success;
}
}
}

View File

@ -0,0 +1,444 @@
using System;
using System.Linq;
using System.Runtime.Serialization;
using Codice.CM.Common.Tree.Partial;
using nadena.dev.modular_avatar.core.menu;
using UnityEditor;
using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects;
using static nadena.dev.modular_avatar.core.editor.Localization;
namespace nadena.dev.modular_avatar.core.editor
{
[CustomPropertyDrawer(typeof(SubmenuSource))]
class SubmenuSourceDrawer : EnumDrawer<SubmenuSource>
{
protected override string localizationPrefix => "submenu_source";
}
internal class MenuItemCoreGUI
{
private static readonly ObjectIDGenerator IdGenerator = new ObjectIDGenerator();
private readonly GameObject _parameterReference;
private readonly Action _redraw;
private readonly SerializedObject _obj;
private readonly SerializedProperty _name;
private readonly SerializedProperty _texture;
private readonly SerializedProperty _type;
private readonly SerializedProperty _value;
private readonly SerializedProperty _submenu;
private readonly ParameterGUI _parameterGUI;
private readonly SerializedProperty _subParamsRoot;
private readonly SerializedProperty _labelsRoot;
private readonly MenuPreviewGUI _previewGUI;
private ParameterGUI[] _subParams;
private SerializedProperty[] _labels;
private int texPicker = -1;
private readonly SerializedProperty _prop_submenuSource;
private readonly SerializedProperty _prop_otherObjSource;
public bool AlwaysExpandContents = false;
public bool ExpandContents = false;
public MenuItemCoreGUI(SerializedObject obj, Action redraw)
{
_obj = obj;
GameObject parameterReference = null;
if (obj.targetObjects.Length == 1)
{
parameterReference = (obj.targetObject as Component)?.gameObject;
}
_parameterReference = parameterReference;
_redraw = redraw;
var gameObjects = new SerializedObject(
obj.targetObjects.Select(o =>
(UnityEngine.Object) ((ModularAvatarMenuItem) o).gameObject
).ToArray()
);
_name = gameObjects.FindProperty("m_Name");
var control = obj.FindProperty(nameof(ModularAvatarMenuItem.Control));
_texture = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.icon));
_type = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.type));
var parameter = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.parameter))
.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.parameter.name));
_value = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.value));
_submenu = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subMenu));
_parameterGUI = new ParameterGUI(parameterReference, parameter, redraw);
_subParamsRoot = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subParameters));
_labelsRoot = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.labels));
_prop_submenuSource = obj.FindProperty(nameof(ModularAvatarMenuItem.MenuSource));
_prop_otherObjSource = obj.FindProperty(nameof(ModularAvatarMenuItem.menuSource_otherObjectChildren));
_previewGUI = new MenuPreviewGUI(redraw);
}
public MenuItemCoreGUI(GameObject parameterReference, SerializedProperty _control, Action redraw)
{
_obj = _control.serializedObject;
_parameterReference = parameterReference;
_redraw = redraw;
_name = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.name));
_texture = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.icon));
_type = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.type));
var parameter = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.parameter))
.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.parameter.name));
_value = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.value));
_submenu = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subMenu));
_parameterGUI = new ParameterGUI(parameterReference, parameter, redraw);
_subParamsRoot = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subParameters));
_labelsRoot = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.labels));
_prop_submenuSource = null;
_prop_otherObjSource = null;
_previewGUI = new MenuPreviewGUI(redraw);
}
public void DoGUI()
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.BeginVertical();
EditorGUILayout.PropertyField(_name, G("menuitem.prop.name"));
EditorGUILayout.PropertyField(_texture, G("menuitem.prop.icon"));
EditorGUILayout.PropertyField(_type, G("menuitem.prop.type"));
EditorGUILayout.PropertyField(_value, G("menuitem.prop.value"));
_parameterGUI.DoGUI();
EditorGUILayout.EndVertical();
if (_texture != null)
{
var tex = _texture.objectReferenceValue as Texture2D;
if (tex != null && !_texture.hasMultipleDifferentValues)
{
var size = EditorGUIUtility.singleLineHeight * 5;
var margin = 4;
var withMargin = new Vector2(margin + size, margin + size);
var rect = GUILayoutUtility.GetRect(withMargin.x, withMargin.y, GUILayout.ExpandWidth(false),
GUILayout.ExpandHeight(true));
rect.x += margin;
rect.y = rect.y + rect.height / 2 - size / 2;
rect.width = size;
rect.height = size;
GUI.Box(rect, new GUIContent(), "flow node 1");
GUI.DrawTexture(rect, tex);
}
}
EditorGUILayout.EndHorizontal();
try
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.BeginVertical();
if (_type.hasMultipleDifferentValues) return;
VRCExpressionsMenu.Control.ControlType type =
(VRCExpressionsMenu.Control.ControlType) Enum
.GetValues(typeof(VRCExpressionsMenu.Control.ControlType))
.GetValue(_type.enumValueIndex);
switch (type)
{
case VRCExpressionsMenu.Control.ControlType.Button:
case VRCExpressionsMenu.Control.ControlType.Toggle:
return;
}
EditorGUILayout.LabelField("", GUI.skin.horizontalSlider);
switch (type)
{
case VRCExpressionsMenu.Control.ControlType.SubMenu:
{
object menuSource = null;
if (_prop_submenuSource != null)
{
EditorGUILayout.PropertyField(_prop_submenuSource, G("menuitem.prop.submenu_source"));
if (_prop_submenuSource.hasMultipleDifferentValues) break;
var sourceType = (SubmenuSource) Enum.GetValues(typeof(SubmenuSource))
.GetValue(_prop_submenuSource.enumValueIndex);
switch (sourceType)
{
case SubmenuSource.Children:
{
EditorGUILayout.PropertyField(_prop_otherObjSource,
G("menuitem.prop.source_override"));
if (_prop_otherObjSource.hasMultipleDifferentValues) break;
if (_prop_otherObjSource.objectReferenceValue == null)
{
if (_obj.targetObjects.Length != 1) break;
menuSource = new MenuNodesUnder((_obj.targetObject as Component)?.gameObject);
}
else
{
menuSource =
new MenuNodesUnder((GameObject) _prop_otherObjSource.objectReferenceValue);
}
break;
}
case SubmenuSource.MenuAsset:
{
EditorGUILayout.PropertyField(_submenu, G("menuitem.prop.submenu_asset"));
if (_submenu.hasMultipleDifferentValues) break;
menuSource = _submenu.objectReferenceValue;
break;
}
}
}
else
{
// Native VRCSDK control
EditorGUILayout.PropertyField(_submenu, G("menuitem.prop.submenu_asset"));
if (_submenu.hasMultipleDifferentValues) break;
menuSource = _submenu.objectReferenceValue;
}
if (menuSource != null)
{
if (AlwaysExpandContents)
{
ExpandContents = true;
}
else
{
EditorGUI.indentLevel += 1;
ExpandContents = EditorGUILayout.Foldout(ExpandContents, G("menuitem.showcontents"));
EditorGUI.indentLevel -= 1;
}
if (ExpandContents)
{
if (menuSource is VRCExpressionsMenu menu) _previewGUI.DoGUI(menu, _parameterReference);
else if (menuSource is MenuSource nodes) _previewGUI.DoGUI(nodes);
}
}
break;
}
case VRCExpressionsMenu.Control.ControlType.RadialPuppet:
{
EnsureParameterCount(1);
_subParams[0].DoGUI(G("menuitem.param.rotation"));
break;
}
case VRCExpressionsMenu.Control.ControlType.TwoAxisPuppet:
{
EnsureParameterCount(2);
EnsureLabelCount(4);
EditorGUILayout.LabelField("Parameters", EditorStyles.boldLabel);
EditorGUILayout.Space(2);
_subParams[0].DoGUI(G("menuitem.param.horizontal"));
_subParams[1].DoGUI(G("menuitem.param.vertical"));
DoFourAxisLabels(false);
break;
}
case VRCExpressionsMenu.Control.ControlType.FourAxisPuppet:
{
DoFourAxisLabels(true);
break;
}
}
}
finally
{
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
_obj.ApplyModifiedProperties();
}
}
private void EnsureLabelCount(int i)
{
if (_labels == null || _labelsRoot.arraySize < i || _labels.Length < i)
{
_labelsRoot.arraySize = i;
_labels = new SerializedProperty[i];
for (int j = 0; j < i; j++)
{
_labels[j] = _labelsRoot.GetArrayElementAtIndex(j);
}
}
}
private void CenterLabel(Rect rect, GUIContent content, GUIStyle style)
{
var size = style.CalcSize(content);
var x = rect.x + rect.width / 2 - size.x / 2;
var y = rect.y + rect.height / 2 - size.y / 2;
GUI.Label(new Rect(x, y, size.x, size.y), content, style);
}
private void DoFourAxisLabels(bool showParams)
{
float maxWidth = 128 * 3;
EnsureLabelCount(4);
if (showParams) EnsureParameterCount(4);
float extraHeight = EditorGUIUtility.singleLineHeight * 3;
if (showParams) extraHeight += EditorGUIUtility.singleLineHeight;
EditorGUILayout.LabelField(
G(showParams ? "menuitem.label.control_labels_and_params" : "menuitem.label.control_labels"),
EditorStyles.boldLabel);
var square = GUILayoutUtility.GetAspectRect(1, GUILayout.MaxWidth(maxWidth));
var extraSpace = GUILayoutUtility.GetRect(0, 0, extraHeight,
extraHeight, GUILayout.ExpandWidth(true));
var rect = square;
rect.height += extraSpace.height;
float extraWidth = Math.Max(0, extraSpace.width - rect.width);
rect.x += extraWidth / 2;
var blockHeight = rect.height / 3;
var blockWidth = rect.width / 3;
var up = rect;
up.yMax -= blockHeight * 2;
up.xMin += blockWidth;
up.xMax -= blockWidth;
var down = rect;
down.yMin += blockHeight * 2;
down.xMin += blockWidth;
down.xMax -= blockWidth;
var left = rect;
left.yMin += blockHeight;
left.yMax -= blockHeight;
left.xMax -= blockWidth * 2;
var right = rect;
right.yMin += blockHeight;
right.yMax -= blockHeight;
right.xMin += blockWidth * 2;
var center = rect;
center.yMin += blockHeight;
center.yMax -= blockHeight;
center.xMin += blockWidth;
center.xMax -= blockWidth;
SingleLabel(0, up);
SingleLabel(1, right);
SingleLabel(2, down);
SingleLabel(3, left);
var rect_param_l = center;
rect_param_l.yMin = rect_param_l.yMax - EditorGUIUtility.singleLineHeight;
var rect_name_l = rect_param_l;
if (showParams) rect_name_l.y -= rect_param_l.height;
if (showParams) CenterLabel(rect_param_l, G("menuitem.prop.parameter"), EditorStyles.label);
CenterLabel(rect_name_l, G("menuitem.prop.label"), EditorStyles.label);
void SingleLabel(int index, Rect block)
{
var prop_name = _labels[index].FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Label.name));
var prop_icon = _labels[index].FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Label.icon));
var rect_param = block;
rect_param.yMin = rect_param.yMax - EditorGUIUtility.singleLineHeight;
var rect_name = rect_param;
if (showParams) rect_name.y -= rect_param.height;
var rect_icon = block;
rect_icon.yMax = rect_name.yMin;
EditorGUI.PropertyField(rect_name, prop_name, GUIContent.none);
if (showParams)
{
_subParams[index].DoGUI(rect_param, GUIContent.none);
}
var tex = prop_icon.objectReferenceValue as Texture;
GUIContent icon_content;
if (prop_icon.hasMultipleDifferentValues)
{
icon_content = G("menuitem.misc.multiple");
}
else
{
icon_content = tex != null ? new GUIContent(tex) : G("menuitem.misc.no_icon");
}
int objectId = GUIUtility.GetControlID(
((int) IdGenerator.GetId(this, out bool _) << 2) | index,
FocusType.Passive,
block
);
if (GUI.Button(rect_icon, icon_content))
{
texPicker = index;
EditorGUIUtility.ShowObjectPicker<Texture2D>(
prop_icon.hasMultipleDifferentValues ? null : prop_icon.objectReferenceValue, false,
"t:texture2d", objectId);
}
if (texPicker == index)
{
if (Event.current.commandName == "ObjectSelectorUpdated" &&
EditorGUIUtility.GetObjectPickerControlID() == objectId)
{
prop_icon.objectReferenceValue = EditorGUIUtility.GetObjectPickerObject() as Texture;
_redraw();
}
}
}
}
private void EnsureParameterCount(int i)
{
if (_subParams == null || _subParamsRoot.arraySize < i || _subParams.Length < i)
{
_subParamsRoot.arraySize = i;
_subParams = new ParameterGUI[i];
for (int j = 0; j < i; j++)
{
var prop = _subParamsRoot.GetArrayElementAtIndex(j)
.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Parameter.name));
_subParams[j] = new ParameterGUI(_parameterReference, prop, _redraw);
}
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f70714a2a244442e9eabd3fce5a190e6
timeCreated: 1677149863

View File

@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using nadena.dev.modular_avatar.core.menu;
using UnityEditor;
using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core.editor
{
internal class MenuPreviewGUI
{
private const float INDENT_PER_LEVEL = 2;
private Action _redraw;
private float _indentLevel = 0;
private readonly Dictionary<object, Action> _guiNodes = new Dictionary<object, Action>();
public MenuPreviewGUI(Action redraw)
{
_redraw = redraw;
}
public void DoGUI(MenuSource root)
{
_indentLevel = 0;
new VisitorContext(this).PushNode(root);
}
public void DoGUI(ModularAvatarMenuInstaller root)
{
_indentLevel = 0;
new VisitorContext(this).PushMenuInstaller(root);
}
public void DoGUI(VRCExpressionsMenu menu, GameObject parameterReference = null)
{
_indentLevel = 0;
new VisitorContext(this).PushNode(menu);
}
private void PushGuiNode(object key, Func<Action> guiBuilder)
{
if (!_guiNodes.TryGetValue(key, out var gui))
{
gui = guiBuilder();
_guiNodes.Add(key, gui);
}
gui();
}
private class Header
{
private MenuPreviewGUI _gui;
private UnityEngine.Object _headerObj;
private SerializedProperty _disableProp;
public Header(MenuPreviewGUI gui, UnityEngine.Object headerObj, SerializedProperty disableProp = null)
{
_gui = gui;
_headerObj = headerObj;
_disableProp = disableProp;
}
public IDisposable Scope()
{
GUILayout.BeginHorizontal();
GUILayout.Space(_gui._indentLevel);
_gui._indentLevel += INDENT_PER_LEVEL;
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
if (_headerObj != null)
{
GUILayout.BeginHorizontal();
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.ObjectField(new GUIContent(), _headerObj, _headerObj.GetType(),
true,
GUILayout.ExpandWidth(true));
}
if (_disableProp != null)
{
_disableProp.serializedObject.Update();
GUILayout.Space(20);
GUILayout.Label("Enabled", GUILayout.Width(50));
EditorGUILayout.PropertyField(_disableProp, GUIContent.none,
GUILayout.Width(EditorGUIUtility.singleLineHeight));
_disableProp.serializedObject.ApplyModifiedProperties();
}
GUILayout.EndHorizontal();
}
return new ScopeSentinel(_gui);
}
private class ScopeSentinel : IDisposable
{
private readonly MenuPreviewGUI _gui;
public ScopeSentinel(MenuPreviewGUI gui)
{
_gui = gui;
}
public void Dispose()
{
GUILayout.EndVertical();
_gui._indentLevel -= INDENT_PER_LEVEL;
GUILayout.EndHorizontal();
}
}
}
private class VisitorContext : NodeContext
{
private readonly HashSet<object> _visited = new HashSet<object>();
private readonly MenuPreviewGUI _gui;
public VisitorContext(MenuPreviewGUI gui)
{
_gui = gui;
}
public void PushMenu(VRCExpressionsMenu expMenu, GameObject parameterReference = null)
{
_gui.PushGuiNode((expMenu, parameterReference), () =>
{
var header = new Header(_gui, expMenu);
var obj = new SerializedObject(expMenu);
var controls = obj.FindProperty(nameof(expMenu.controls));
var subGui = new List<MenuItemCoreGUI>();
for (int i = 0; i < controls.arraySize; i++)
{
subGui.Add(new MenuItemCoreGUI(parameterReference, controls.GetArrayElementAtIndex(i),
_gui._redraw));
}
return () =>
{
using (header.Scope())
{
foreach (var gui in subGui)
{
using (new Header(_gui, null).Scope())
{
gui.DoGUI();
}
}
}
};
});
}
public void PushNode(VRCExpressionsMenu expMenu)
{
PushMenu(expMenu, null);
}
public void PushNode(MenuSource source)
{
if (source is ModularAvatarMenuItem item)
{
_gui.PushGuiNode(item, () =>
{
var header = new Header(_gui, item,
new SerializedObject(item.gameObject).FindProperty("m_IsActive"));
var gui = new MenuItemCoreGUI(new SerializedObject(item), _gui._redraw);
return () =>
{
using (header.Scope())
{
gui.DoGUI();
}
};
});
}
else
{
using (new Header(_gui, source as UnityEngine.Object).Scope())
{
if (_visited.Contains(source)) return;
_visited.Add(source);
source.Visit(this);
}
}
}
public void PushNode(ModularAvatarMenuInstaller installer)
{
using (new Header(_gui, installer).Scope())
{
PushMenuInstaller(installer);
}
}
internal void PushMenuInstaller(ModularAvatarMenuInstaller installer)
{
var source = installer.GetComponent<MenuSource>();
if (source != null)
{
PushNode(source);
}
else if (installer.menuToAppend != null)
{
PushMenu(installer.menuToAppend, installer.gameObject);
}
}
public void PushControl(VRCExpressionsMenu.Control control)
{
// Construct a read-only GUI, as we can't build a serialized property reference for this control object
_gui.PushGuiNode(control, () =>
{
var container = ScriptableObject.CreateInstance<VRCExpressionsMenu>();
container.controls = new List<VRCExpressionsMenu.Control> {control};
var prop = new SerializedObject(container).FindProperty("controls").GetArrayElementAtIndex(0);
var gui = new MenuItemCoreGUI(null, prop, _gui._redraw);
return () =>
{
using (new EditorGUI.DisabledScope(true))
{
gui.DoGUI();
}
};
});
}
public void PushControl(VirtualControl control)
{
PushControl((VRCExpressionsMenu.Control) control);
}
public VirtualMenuNode NodeFor(VRCExpressionsMenu menu)
{
return new VirtualMenuNode(menu);
}
public VirtualMenuNode NodeFor(MenuSource menu)
{
return new VirtualMenuNode(menu);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f61c45884f584638a85253fa309f247f
timeCreated: 1677302074

View File

@ -0,0 +1,278 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.IMGUI.Controls;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using Object = UnityEngine.Object;
using static nadena.dev.modular_avatar.core.editor.Localization;
namespace nadena.dev.modular_avatar.core.editor
{
internal class ParameterGUI
{
private GameObject _parameterReference;
private readonly SerializedProperty _property;
private readonly Action _redraw;
private Rect fieldRect;
internal ParameterGUI(GameObject parameterReference, SerializedProperty property, Action redraw)
{
_parameterReference = parameterReference;
_property = property;
_redraw = redraw;
}
public void DoGUI(GUIContent label = null)
{
DoGUI(EditorGUILayout.GetControlRect(
true,
EditorGUIUtility.singleLineHeight
), label);
}
public void DoGUI(Rect rect, GUIContent label = null)
{
if (label == null) label = G("menuitem.prop.parameter");
if (_parameterReference != null) GUILayout.Space(-2);
GUILayout.BeginHorizontal();
rect.width -= EditorGUIUtility.singleLineHeight;
EditorGUI.PropertyField(rect, _property, label);
Rect propField = new Rect();
if (Event.current.type == EventType.Repaint)
{
propField = rect;
}
Rect buttonRect = rect;
buttonRect.xMin = rect.xMax;
buttonRect.width = EditorGUIUtility.singleLineHeight;
GUIStyle style = "IN DropDown";
buttonRect.xMin += (buttonRect.width - style.fixedWidth) / 2;
if (_parameterReference != null &&
EditorGUI.DropdownButton(buttonRect, new GUIContent(), FocusType.Keyboard, style))
{
PopupWindow.Show(fieldRect,
new ParameterWindow(_parameterReference, _property, fieldRect.width, _redraw));
}
if (Event.current.type == EventType.Repaint)
{
float labelWidth = label == GUIContent.none ? 0 : EditorGUIUtility.labelWidth;
fieldRect = propField;
fieldRect.x += labelWidth + 2;
fieldRect.width = buttonRect.xMax - propField.x - labelWidth;
fieldRect.height = 0;
}
GUILayout.EndHorizontal();
if (_parameterReference != null) GUILayout.Space(2);
}
private class ParameterWindow : PopupWindowContent
{
private readonly GameObject _target;
private readonly SerializedProperty _prop;
private readonly float _width;
private readonly Action _redraw;
private SearchField _searchField;
private ParameterTree _tree;
private string _searchString;
public ParameterWindow(GameObject target, SerializedProperty prop, float width, Action redraw)
{
_target = target;
_prop = prop;
_width = width;
_redraw = redraw;
}
public override void OnGUI(Rect rect)
{
var sfRect = rect;
sfRect.height = EditorGUIUtility.singleLineHeight;
rect.y += EditorGUIUtility.singleLineHeight;
rect.height -= EditorGUIUtility.singleLineHeight;
if (_searchField == null)
{
_searchField = new SearchField();
}
_searchString = _searchField.OnGUI(sfRect, _searchString);
if (_tree == null)
{
_tree = new ParameterTree(new TreeViewState(), _target);
_tree.OnSelect = (s) =>
{
_prop.stringValue = s;
_prop.serializedObject.ApplyModifiedProperties();
_redraw();
};
_tree.OnCommit = (s) =>
{
_prop.stringValue = s;
_prop.serializedObject.ApplyModifiedProperties();
editorWindow.Close();
_redraw();
};
_tree.Reload();
}
_tree.searchString = _searchString;
_tree.OnGUI(rect);
}
public override Vector2 GetWindowSize()
{
return new Vector2(Math.Max(256, _width), 150);
}
}
private class ParameterTree : TreeView
{
private List<string> _items;
public Action<string> OnSelect, OnCommit;
private GameObject _obj;
private class SourceItem : TreeViewItem
{
public GameObject source;
}
private class ParamItem : TreeViewItem
{
public GameObject source;
}
public ParameterTree(TreeViewState state, GameObject obj) : base(state)
{
_obj = obj;
}
protected override void SelectionChanged(IList<int> selectedIds)
{
var item = _items[selectedIds[0]];
if (item != null) OnSelect(item);
}
protected override void DoubleClickedItem(int id)
{
var item = _items[id];
if (item != null) OnCommit(item);
}
protected override void RowGUI(RowGUIArgs args)
{
if (!string.IsNullOrEmpty(searchString) && args.item is ParamItem offer)
{
var rect = args.rowRect;
var objName = offer.source.name + " / ";
var content = new GUIContent(objName);
var width = EditorStyles.label.CalcSize(content).x;
var color = GUI.color;
var grey = color;
grey.a *= 0.7f;
GUI.color = grey;
EditorGUI.LabelField(rect, content);
GUI.color = color;
rect.x += width;
rect.width -= width;
if (rect.width >= 0)
{
EditorGUI.LabelField(rect, offer.displayName);
}
}
else if (args.item is SourceItem source)
{
var rect = args.rowRect;
rect.xMin += this.GetContentIndent(args.item) + this.extraSpaceBeforeIconAndLabel;
EditorGUI.LabelField(rect, source.source.name);
}
else
{
base.RowGUI(args);
}
}
protected override TreeViewItem BuildRoot()
{
List<TreeViewItem> treeItems = new List<TreeViewItem>();
_items = new List<string>();
_items.Add("");
var root = new TreeViewItem {id = 0, depth = -1, displayName = "Root"};
GameObject priorNode = null;
foreach ((GameObject node, string param) in FindParameters())
{
if (node != priorNode)
{
_items.Add(null);
treeItems.Add(new SourceItem()
{id = _items.Count - 1, depth = 0, displayName = "", source = node});
priorNode = node;
}
_items.Add(param);
treeItems.Add(new ParamItem {id = _items.Count - 1, depth = 1, displayName = param, source = node});
}
SetupParentsAndChildrenFromDepths(root, treeItems);
return root;
}
private IEnumerable<(GameObject, string)> FindParameters()
{
HashSet<string> emitted = new HashSet<string>();
GameObject node = _obj;
while (node != null && node.GetComponent<VRCAvatarDescriptor>() == null)
{
var paramComp = node.GetComponent<ModularAvatarParameters>();
if (paramComp != null)
{
foreach (var param in paramComp.parameters)
{
if (!param.isPrefix)
{
if (emitted.Add(param.nameOrPrefix)) yield return (node, param.nameOrPrefix);
}
}
}
node = node.transform.parent?.gameObject;
}
var desc = node?.GetComponent<VRCAvatarDescriptor>();
if (desc != null)
{
foreach (var param in desc.expressionParameters.parameters)
{
if (emitted.Add(param.name)) yield return (node, param.name);
}
}
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 69bb3e09018e430581cabad176e3068e
timeCreated: 1677148788

View File

@ -1,304 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using VRC.SDK3.Avatars.ScriptableObjects;
using static nadena.dev.modular_avatar.core.editor.Localization;
using static nadena.dev.modular_avatar.core.editor.Util;
namespace nadena.dev.modular_avatar.core.editor
{
[CustomEditor(typeof(ModularAvatarMenuInstaller))]
[CanEditMultipleObjects]
internal class MenuInstallerEditor : MAEditorBase
{
private ModularAvatarMenuInstaller _installer;
private Editor _innerMenuEditor;
private VRCExpressionsMenu _menuToAppend;
private bool _menuFoldout;
private bool _devFoldout;
private HashSet<VRCExpressionsMenu> _avatarMenus;
private Dictionary<VRCExpressionsMenu, List<ModularAvatarMenuInstaller>> _menuInstallersMap;
private void OnEnable()
{
_installer = (ModularAvatarMenuInstaller) target;
FindMenus();
FindMenuInstallers();
}
private void SetupMenuEditor()
{
if (targets.Length != 1)
{
_innerMenuEditor = null;
_menuToAppend = null;
}
else if (_installer.menuToAppend != _menuToAppend)
{
if (_installer.menuToAppend == null) _innerMenuEditor = null;
else
{
_innerMenuEditor = CreateEditor(_installer.menuToAppend);
}
_menuToAppend = _installer.menuToAppend;
}
}
protected override void OnInnerInspectorGUI()
{
SetupMenuEditor();
var installTo = serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.installTargetMenu));
var isEnabled = targets.Length != 1 || ((ModularAvatarMenuInstaller) target).enabled;
VRCAvatarDescriptor commonAvatar = FindCommonAvatar();
if (!installTo.hasMultipleDifferentValues)
{
if (installTo.objectReferenceValue == null)
{
if (isEnabled)
{
EditorGUILayout.HelpBox(S("menuinstall.help.hint_set_menu"), MessageType.Info);
}
}
else if (!IsMenuReachable(RuntimeUtil.FindAvatarInParents(((Component) target).transform),
(VRCExpressionsMenu) installTo.objectReferenceValue))
{
EditorGUILayout.HelpBox(S("menuinstall.help.hint_bad_menu"), MessageType.Error);
}
}
if (installTo.hasMultipleDifferentValues || commonAvatar == null)
{
EditorGUILayout.PropertyField(installTo, G("menuinstall.installto"));
}
else
{
var displayValue = installTo.objectReferenceValue;
if (displayValue == null) displayValue = commonAvatar.expressionsMenu;
EditorGUI.BeginChangeCheck();
var newValue = EditorGUILayout.ObjectField(G("menuinstall.installto"), displayValue,
typeof(VRCExpressionsMenu), false);
if (EditorGUI.EndChangeCheck())
{
installTo.objectReferenceValue = newValue;
}
}
var avatar = RuntimeUtil.FindAvatarInParents(_installer.transform);
if (avatar != null && GUILayout.Button(G("menuinstall.selectmenu")))
{
AvMenuTreeViewWindow.Show(avatar, _installer, menu =>
{
installTo.objectReferenceValue = menu;
serializedObject.ApplyModifiedProperties();
});
}
if (targets.Length == 1)
{
_menuFoldout = EditorGUILayout.Foldout(_menuFoldout, G("menuinstall.showcontents"));
if (_menuFoldout)
{
EditorGUI.indentLevel++;
using (var disabled = new EditorGUI.DisabledScope(true))
{
if (_innerMenuEditor != null) _innerMenuEditor.OnInspectorGUI();
else EditorGUILayout.HelpBox(S("menuinstall.showcontents.notselected"), MessageType.Info);
}
EditorGUI.indentLevel--;
}
}
_devFoldout = EditorGUILayout.Foldout(_devFoldout, G("menuinstall.devoptions"));
if (_devFoldout)
{
SerializedProperty menuToAppendProperty = serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.menuToAppend));
switch (ValidateExpressionMenuIcon((VRCExpressionsMenu)menuToAppendProperty.objectReferenceValue))
{
case ValidateExpressionMenuIconResult.Success:
break;
case ValidateExpressionMenuIconResult.TooLarge:
EditorGUILayout.HelpBox(S("menuinstall.menu_icon_too_large"), MessageType.Error);
break;
case ValidateExpressionMenuIconResult.Uncompressed:
EditorGUILayout.HelpBox(S("menuinstall.menu_icon_uncompressed"), MessageType.Error);
break;
default:
throw new ArgumentOutOfRangeException();
}
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(
menuToAppendProperty, new GUIContent(G("menuinstall.srcmenu")));
EditorGUI.indentLevel--;
}
serializedObject.ApplyModifiedProperties();
Localization.ShowLanguageUI();
}
private VRCAvatarDescriptor FindCommonAvatar()
{
VRCAvatarDescriptor commonAvatar = null;
foreach (var target in targets)
{
var component = (ModularAvatarMenuInstaller) target;
var avatar = RuntimeUtil.FindAvatarInParents(component.transform);
if (avatar == null) return null;
if (commonAvatar == null)
{
commonAvatar = avatar;
}
else if (commonAvatar != avatar)
{
return null;
}
}
return commonAvatar;
}
private void FindMenus()
{
if (targets.Length > 1)
{
_avatarMenus = null;
return;
}
_avatarMenus = new HashSet<VRCExpressionsMenu>();
var queue = new Queue<VRCExpressionsMenu>();
var avatar = RuntimeUtil.FindAvatarInParents(((Component) target).transform);
if (avatar == null || avatar.expressionsMenu == null) return;
queue.Enqueue(avatar.expressionsMenu);
while (queue.Count > 0)
{
var menu = queue.Dequeue();
if (_avatarMenus.Contains(menu)) continue;
_avatarMenus.Add(menu);
foreach (var subMenu in menu.controls)
{
if (subMenu.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
queue.Enqueue(subMenu.subMenu);
}
}
}
}
private void FindMenuInstallers()
{
if (targets.Length > 1)
{
_menuInstallersMap = null;
return;
}
_menuInstallersMap = new Dictionary<VRCExpressionsMenu, List<ModularAvatarMenuInstaller>>();
var avatar = RuntimeUtil.FindAvatarInParents(((Component)target).transform);
if (avatar == null) return;
var menuInstallers = avatar.GetComponentsInChildren<ModularAvatarMenuInstaller>(true)
.Where(menuInstaller => menuInstaller.enabled && menuInstaller.menuToAppend != null);
foreach (ModularAvatarMenuInstaller menuInstaller in menuInstallers)
{
if (menuInstaller == target) continue;
var visitedMenus = new HashSet<VRCExpressionsMenu>();
var queue = new Queue<VRCExpressionsMenu>();
queue.Enqueue(menuInstaller.menuToAppend);
while (queue.Count > 0)
{
VRCExpressionsMenu parent = queue.Dequeue();
var controls = parent.controls.Where(control => control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && control.subMenu != null);
foreach (VRCExpressionsMenu.Control control in controls)
{
// Do not filter in LINQ to avoid closure allocation
if (visitedMenus.Contains(control.subMenu)) continue;
if (!_menuInstallersMap.TryGetValue(control.subMenu, out List<ModularAvatarMenuInstaller> fromInstallers))
{
fromInstallers = new List<ModularAvatarMenuInstaller>();
_menuInstallersMap[control.subMenu] = fromInstallers;
}
fromInstallers.Add(menuInstaller);
visitedMenus.Add(control.subMenu);
queue.Enqueue(control.subMenu);
}
}
}
}
private bool IsMenuReachable(VRCAvatarDescriptor avatar, VRCExpressionsMenu menu, HashSet<ModularAvatarMenuInstaller> visitedInstaller = null)
{
if (_avatarMenus == null || _avatarMenus.Contains(menu)) return true;
if (_menuInstallersMap == null) return true;
if (visitedInstaller == null) visitedInstaller = new HashSet<ModularAvatarMenuInstaller> { (ModularAvatarMenuInstaller)target };
if (!_menuInstallersMap.TryGetValue(menu, out List<ModularAvatarMenuInstaller> installers)) return false;
foreach (ModularAvatarMenuInstaller installer in installers)
{
// Root is always reachable if installTargetMenu is null
if (installer.installTargetMenu == null) return true;
// Even in a circular structure, it may be possible to reach root by another path.
if (visitedInstaller.Contains(installer)) continue;
visitedInstaller.Add(installer);
if (IsMenuReachable(avatar, installer.installTargetMenu, visitedInstaller))
{
return true;
}
}
return false;
}
private static ValidateExpressionMenuIconResult ValidateExpressionMenuIcon(VRCExpressionsMenu menu, HashSet<VRCExpressionsMenu> visitedMenus = null)
{
if (menu == null) return ValidateExpressionMenuIconResult.Success;
if (visitedMenus == null) visitedMenus = new HashSet<VRCExpressionsMenu>();
if (visitedMenus.Contains(menu)) return ValidateExpressionMenuIconResult.Success;
visitedMenus.Add(menu);
foreach (VRCExpressionsMenu.Control control in menu.controls) {
// Control
ValidateExpressionMenuIconResult result = Util.ValidateExpressionMenuIcon(control.icon);
if (result != ValidateExpressionMenuIconResult.Success) return result;
// Labels
if (control.labels != null)
{
foreach (VRCExpressionsMenu.Control.Label label in control.labels)
{
ValidateExpressionMenuIconResult labelResult = Util.ValidateExpressionMenuIcon(label.icon);
if (labelResult != ValidateExpressionMenuIconResult.Success) return labelResult;
}
}
// SubMenu
if (control.type != VRCExpressionsMenu.Control.ControlType.SubMenu) continue;
ValidateExpressionMenuIconResult subMenuResult = ValidateExpressionMenuIcon(control.subMenu, visitedMenus);
if (subMenuResult != ValidateExpressionMenuIconResult.Success) return subMenuResult;
}
return ValidateExpressionMenuIconResult.Success;
}
}
}

View File

@ -78,5 +78,33 @@
"validation.menu_installer.no_menu": "No menu to install specified",
"validation.merge_animator.no_animator": "No animator to merge specified",
"validation.merge_armature.no_target": "No merge target specified",
"validation.merge_armature.target_is_child": "Merge target cannot be a child of this object"
"validation.merge_armature.target_is_child": "Merge target cannot be a child of this object",
"submenu_source.Children": "Children",
"submenu_source.MenuAsset": "Expressions Menu Asset",
"menuitem.showcontents": "Show menu contents",
"menuitem.prop.name": "Name",
"menuitem.prop.icon": "Icon",
"menuitem.prop.icon.tooltip": "(Optional) The icon to be shown in the expressions menu",
"menuitem.prop.type": "Type",
"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.parameter": "Parameter",
"menuitem.prop.label": "Label",
"menuitem.prop.submenu_asset": "Submenu Asset",
"menuitem.prop.submenu_asset.tooltip": "The asset to use as the submenu",
"menuitem.prop.submenu_source": "Submenu Source",
"menuitem.prop.submenu_source.tooltip": "Where to find the items to put inside this submenu",
"menuitem.prop.source_override": "Source object override",
"menuitem.prop.source_override.tooltip": "If specified, this object will be used as the source for the contents of the submenu. Otherwise, children of this menu item will be used.",
"menuitem.param.rotation": "Parameter: Rotation",
"menuitem.param.rotation.tooltip": "The parameter to set based on the rotation of this menu item",
"menuitem.param.horizontal": "Parameter: Horizontal",
"menuitem.param.horizontal.tooltip": "The parameter to set based on the horizontal position of the thumbstick",
"menuitem.param.vertical": "Parameter: Vertical",
"menuitem.param.vertical.tooltip": "The parameter to set based on the vertical position of the thumbstick",
"menuitem.label.control_labels_and_params": "Control Labels and Parameters",
"menuitem.label.control_labels": "Control Labels",
"menuitem.misc.multiple": "(multiple)",
"menuitem.misc.no_icon": "(no icon)"
}

View File

@ -75,5 +75,33 @@
"validation.bone_proxy.no_target": "ターゲットオブジェクトが未設定、もしくは存在しません。",
"validation.menu_installer.no_menu": "インストールするメニューがありません。",
"validation.merge_animator.no_animator": "Animator Controllerがありません。",
"validation.merge_armature.no_target": "ターゲットオブジェクトが未設定、もしくは存在しません。"
"validation.merge_armature.no_target": "ターゲットオブジェクトが未設定、もしくは存在しません。",
"submenu_source.Children": "子オブジェクトから生成",
"submenu_source.MenuAsset": "Expressions Menu アセットを指定",
"menuitem.showcontents": "メニュー内容を表示",
"menuitem.prop.name": "表示名",
"menuitem.prop.icon": "アイコン",
"menuitem.prop.icon.tooltip": "(任意) メニューに表示するべきアイコン",
"menuitem.prop.type": "タイプ",
"menuitem.prop.type.tooltip": "この項目の種別",
"menuitem.prop.value": "パラメーター値",
"menuitem.prop.value.tooltip": "この項目が操作されたとき、パラメーターが設定される値",
"menuitem.prop.parameter": "パラメーター名",
"menuitem.prop.label": "表示名",
"menuitem.prop.submenu_asset": "サブメニューアセット",
"menuitem.prop.submenu_asset.tooltip": "サブメニューとして引用するアセット",
"menuitem.prop.submenu_source": "サブメニュー引用元",
"menuitem.prop.submenu_source.tooltip": "このサブメニューの内容をどこから引用するべきかを指定",
"menuitem.prop.source_override": "引用元オブジェクト",
"menuitem.prop.source_override.tooltip": "指定した場合は、指定したオブジェクトの子をメニューの内容として指定します。指定されてない場合はこのオブジェクト直下の子を使用します。",
"menuitem.param.rotation": "回転パラメーター名",
"menuitem.param.rotation.tooltip": "このメニューアイテムの回転に連動するべきパラメーター",
"menuitem.param.horizontal": "横パラメーター名",
"menuitem.param.horizontal.tooltip": "横操作に連動するパラメーター名",
"menuitem.param.vertical": "立てパラメーター名",
"menuitem.param.vertical.tooltip": "上下操作に連動するパラメーター名",
"menuitem.label.control_labels_and_params": "表示名・パラメーター",
"menuitem.label.control_labels": "表示名",
"menuitem.misc.multiple": "(複数設定)",
"menuitem.misc.no_icon": "(アイコン無し)"
}

View File

@ -0,0 +1,4 @@
fileFormatVersion: 2
guid: 435b9162d1fd4050b9ced045ab20af27
timeCreated: 1676977199
folderAsset: yes

View File

@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core.editor
{
internal static class ClonedMenuMappings
{
/// <summary>
/// Map to link the cloned menu from the clone source.
/// If one menu is specified for multiple installers, they are replicated separately, so there is a one-to-many relationship.
/// </summary>
private static readonly Dictionary<VRCExpressionsMenu, ImmutableList<VRCExpressionsMenu>> ClonedMappings =
new Dictionary<VRCExpressionsMenu, ImmutableList<VRCExpressionsMenu>>();
/// <summary>
/// Map to link the clone source from the cloned menu.
/// Map is the opposite of ClonedMappings.
/// </summary>
private static readonly Dictionary<VRCExpressionsMenu, VRCExpressionsMenu> OriginalMapping =
new Dictionary<VRCExpressionsMenu, VRCExpressionsMenu>();
public static void Clear()
{
ClonedMappings.Clear();
OriginalMapping.Clear();
}
public static void Add(VRCExpressionsMenu original, VRCExpressionsMenu clonedMenu)
{
if (!ClonedMappings.TryGetValue(original, out ImmutableList<VRCExpressionsMenu> clonedMenus))
{
clonedMenus = ImmutableList<VRCExpressionsMenu>.Empty;
}
ClonedMappings[original] = clonedMenus.Add(clonedMenu);
OriginalMapping[clonedMenu] = original;
}
public static bool TryGetClonedMenus(VRCExpressionsMenu original,
out ImmutableList<VRCExpressionsMenu> clonedMenus)
{
return ClonedMappings.TryGetValue(original, out clonedMenus);
}
public static VRCExpressionsMenu GetOriginal(VRCExpressionsMenu cloned)
{
return OriginalMapping.TryGetValue(cloned, out VRCExpressionsMenu original) ? original : null;
}
}
}

View File

@ -0,0 +1,133 @@
using System.Collections.Generic;
using System.Linq;
using nadena.dev.modular_avatar.core.menu;
using UnityEditor;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core.editor
{
internal class MenuExtractor
{
private const int PRIORITY = 49;
[MenuItem("GameObject/[Modular Avatar] Extract menu", false, PRIORITY)]
static void ExtractMenu(MenuCommand menuCommand)
{
if (!(menuCommand.context is GameObject gameObj)) return;
var avatar = gameObj.GetComponent<VRCAvatarDescriptor>();
if (avatar == null || avatar.expressionsMenu == null) return;
var parent = ExtractSingleLayerMenu(avatar.expressionsMenu, gameObj, "Avatar Menu");
parent.AddComponent<ModularAvatarMenuInstaller>();
parent.AddComponent<ModularAvatarMenuGroup>();
// The VRCSDK requires that an expressions menu asset be provided if any parameters are defined.
// We can't just remove the asset, so we'll replace it with a dummy asset. However, to avoid users
// accidentally overwriting files in Packages, we'll place this dummy asset next to where the original
// asset was (or in the Assets root, if the original asset was in Packages).
Undo.RecordObject(avatar, "Extract menu");
var assetPath = AssetDatabase.GetAssetPath(avatar.expressionsMenu);
var dummyAssetPathBase = assetPath.Replace(".asset", " placeholder");
if (dummyAssetPathBase.StartsWith("Packages" + System.IO.Path.DirectorySeparatorChar))
{
var filename = System.IO.Path.GetFileName(dummyAssetPathBase);
dummyAssetPathBase = System.IO.Path.Combine("Assets", filename);
}
// Check that a similarly-named file doesn't already exist
int i = 0;
do
{
var fullPath = dummyAssetPathBase + (i > 0 ? " " + i : "") + ".asset";
if (System.IO.File.Exists(fullPath))
{
var asset = AssetDatabase.LoadAssetAtPath<VRCExpressionsMenu>(fullPath);
if (asset != null && asset.controls.Count == 0)
{
avatar.expressionsMenu = asset;
break;
}
}
else if (!System.IO.File.Exists(fullPath))
{
var dummyAsset = ScriptableObject.CreateInstance<VRCExpressionsMenu>();
AssetDatabase.CreateAsset(dummyAsset, fullPath);
avatar.expressionsMenu = dummyAsset;
break;
}
i++;
} while (true);
EditorUtility.SetDirty(avatar);
PrefabUtility.RecordPrefabInstancePropertyModifications(avatar);
}
/// <summary>
/// Extracts a single expressions menu asset to Menu Item components.
/// </summary>
/// <param name="menu">The menu to extract</param>
/// <param name="parent">The parent object to use</param>
/// <param name="containerName">The name of a gameobject to place between the parent and menu item objects,
/// or null to skip</param>
/// <returns>the direct parent of the generated menu items</returns>
internal static GameObject ExtractSingleLayerMenu(
VRCExpressionsMenu menu,
GameObject parent,
string containerName = null)
{
if (containerName != null)
{
var container = new GameObject();
container.name = containerName;
container.transform.SetParent(parent.transform, false);
parent = container;
Undo.RegisterCreatedObjectUndo(container, "Convert menu");
}
foreach (var control in menu.controls)
{
var itemObj = new GameObject();
itemObj.name = string.IsNullOrEmpty(control.name) ? " " : control.name;
Undo.RegisterCreatedObjectUndo(itemObj, "Convert menu");
itemObj.transform.SetParent(parent.transform, false);
var menuItem = itemObj.AddComponent<ModularAvatarMenuItem>();
ControlToMenuItem(menuItem, control);
}
return parent;
}
internal static void ControlToMenuItem(ModularAvatarMenuItem menuItem, VRCExpressionsMenu.Control control)
{
menuItem.Control = CloneControl(control);
if (menuItem.Control.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
menuItem.MenuSource = SubmenuSource.MenuAsset;
}
}
internal static VRCExpressionsMenu.Control CloneControl(VRCExpressionsMenu.Control c)
{
return new VRCExpressionsMenu.Control()
{
type = c.type,
name = c.name,
icon = c.icon,
parameter = new VRCExpressionsMenu.Control.Parameter() {name = c.parameter?.name},
subMenu = c.subMenu,
subParameters = c.subParameters?.Select(p =>
new VRCExpressionsMenu.Control.Parameter() {name = p?.name})
.ToArray(),
labels = c.labels.ToArray(),
style = c.style,
value = c.value,
};
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: de7ea831512b4d9c9e92985ab6fd5f17
timeCreated: 1676896326

View File

@ -0,0 +1,378 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using JetBrains.Annotations;
using nadena.dev.modular_avatar.core.menu;
using nadena.dev.modular_avatar.editor.ErrorReporting;
using UnityEditor;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core.editor.menu
{
/// <summary>
/// Sentinel object to represent the avatar root menu (for avatars which don't have a root menu)
/// </summary>
internal sealed class RootMenu : MenuSource
{
public static readonly RootMenu Instance = new RootMenu();
private RootMenu()
{
}
public void Visit(NodeContext context)
{
// we initialize the root node manually
throw new NotImplementedException();
}
}
class NodeContextImpl : NodeContext
{
[CanBeNull]
internal delegate VirtualMenuNode NodeForDelegate(object menu);
private readonly ImmutableDictionary<object, ImmutableList<ModularAvatarMenuInstaller>>
_menuToInstallerMap;
private readonly VirtualMenuNode _node;
private readonly NodeForDelegate _nodeFor;
private readonly Action<VRCExpressionsMenu> _visitedMenu;
private readonly HashSet<object> _visited = new HashSet<object>();
public NodeContextImpl(
VirtualMenuNode node,
NodeForDelegate nodeFor,
ImmutableDictionary<object, ImmutableList<ModularAvatarMenuInstaller>> menuToInstallerMap,
Action<VRCExpressionsMenu> visitedMenu
)
{
_node = node;
_nodeFor = nodeFor;
_menuToInstallerMap = menuToInstallerMap;
_visitedMenu = visitedMenu;
}
public void PushNode(VRCExpressionsMenu expMenu)
{
if (expMenu == null) return;
if (_visited.Contains(expMenu)) return;
_visited.Add(expMenu);
_visitedMenu(expMenu);
foreach (var control in expMenu.controls)
{
PushControl(control);
}
if (_menuToInstallerMap.TryGetValue(expMenu, out var installers))
{
foreach (var installer in installers)
{
PushNode(installer);
}
}
}
public void PushNode(MenuSource source)
{
if (source == null) return;
if (_visited.Contains(source)) return;
_visited.Add(source);
BuildReport.ReportingObject(source as UnityEngine.Object, () => source.Visit(this));
}
public void PushNode(ModularAvatarMenuInstaller installer)
{
if (installer == null) return;
if (_visited.Contains(installer)) return;
_visited.Add(installer);
BuildReport.ReportingObject(installer, () =>
{
var menuSourceComp = installer.GetComponent<MenuSourceComponent>();
if (menuSourceComp != null)
{
PushNode(menuSourceComp);
}
else if (installer.menuToAppend != null)
{
PushNode(installer.menuToAppend);
}
});
}
public void PushControl(VRCExpressionsMenu.Control control)
{
var virtualControl = new VirtualControl(control);
virtualControl.SubmenuNode = NodeFor(control.subMenu);
PushControl(virtualControl);
}
public void PushControl(VirtualControl control)
{
_node.Controls.Add(control);
}
public VirtualMenuNode NodeFor(VRCExpressionsMenu menu)
{
if (menu == null) return null;
return _nodeFor(menu);
}
public VirtualMenuNode NodeFor(MenuSource source)
{
if (source == null) return null;
return _nodeFor(source);
}
}
/**
* The VirtualMenu class tracks a fully realized shadow menu. Notably, this is _not_ converted to unity
* ScriptableObjects, making it easier to discard it when we need to update it.
*/
internal class VirtualMenu
{
internal readonly object RootMenuKey;
private static long _cacheSeq = 0;
internal static void InvalidateCaches()
{
_cacheSeq++;
}
static VirtualMenu()
{
RuntimeUtil.OnMenuInvalidate += InvalidateCaches;
}
internal static long CacheSequence => _cacheSeq;
private readonly long _initialCacheSeq = _cacheSeq;
internal bool IsOutdated => _initialCacheSeq != _cacheSeq;
/// <summary>
/// Indexes which menu installers are contributing to which VRCExpressionMenu assets.
/// </summary>
private Dictionary<object, List<ModularAvatarMenuInstaller>> _targetMenuToInstaller
= new Dictionary<object, List<ModularAvatarMenuInstaller>>();
private Dictionary<ModularAvatarMenuInstaller, List<ModularAvatarMenuInstallTarget>> _installerToTargetComponent
= new Dictionary<ModularAvatarMenuInstaller, List<ModularAvatarMenuInstallTarget>>();
private Dictionary<object, VirtualMenuNode> _resolvedMenu = new Dictionary<object, VirtualMenuNode>();
// TODO: immutable?
public Dictionary<object, VirtualMenuNode> ResolvedMenu => _resolvedMenu;
public VirtualMenuNode RootMenuNode => ResolvedMenu[RootMenuKey];
private Queue<Action> _pendingGeneration = new Queue<Action>();
private HashSet<VRCExpressionsMenu> _visitedMenus = new HashSet<VRCExpressionsMenu>();
/// <summary>
/// Initializes the VirtualMenu.
/// </summary>
/// <param name="rootMenu">The root VRCExpressionsMenu to import</param>
internal VirtualMenu(VRCExpressionsMenu rootMenu)
{
if (rootMenu != null)
{
RootMenuKey = rootMenu;
}
else
{
RootMenuKey = RootMenu.Instance;
}
}
internal static VirtualMenu ForAvatar(VRCAvatarDescriptor avatar)
{
var menu = new VirtualMenu(avatar.expressionsMenu);
foreach (var installer in avatar.GetComponentsInChildren<ModularAvatarMenuInstaller>(true))
{
menu.RegisterMenuInstaller(installer);
}
foreach (var target in avatar.GetComponentsInChildren<ModularAvatarMenuInstallTarget>(true))
{
menu.RegisterMenuInstallTarget(target);
}
menu.FreezeMenu();
return menu;
}
internal IEnumerable<ModularAvatarMenuInstallTarget> GetInstallTargetsForInstaller(
ModularAvatarMenuInstaller installer
)
{
if (_installerToTargetComponent.TryGetValue(installer, out var targets))
{
return targets;
}
else
{
return Array.Empty<ModularAvatarMenuInstallTarget>();
}
}
/// <summary>
/// Registers a menu installer with this virtual menu. Because we need the full set of components indexed to
/// determine the effects of this menu installer, further processing is deferred until we freeze the menu.
/// </summary>
/// <param name="installer"></param>
internal void RegisterMenuInstaller(ModularAvatarMenuInstaller installer)
{
// initial validation
if (installer.menuToAppend == null && installer.GetComponent<MenuSource>() == null) return;
var target = installer.installTargetMenu ? (object) installer.installTargetMenu : RootMenuKey;
if (!_targetMenuToInstaller.TryGetValue(target, out var targets))
{
targets = new List<ModularAvatarMenuInstaller>();
_targetMenuToInstaller[target] = targets;
}
targets.Add(installer);
}
/// <summary>
/// Registers an install target with this virtual menu. As with menu installers, processing is delayed.
/// </summary>
/// <param name="target"></param>
internal void RegisterMenuInstallTarget(ModularAvatarMenuInstallTarget target)
{
if (target.installer == null) return;
if (!_installerToTargetComponent.TryGetValue(target.installer, out var targets))
{
targets = new List<ModularAvatarMenuInstallTarget>();
_installerToTargetComponent[target.installer] = targets;
}
targets.Add(target);
}
/// <summary>
/// Freezes the menu, fully resolving all members of all menus.
/// </summary>
internal void FreezeMenu()
{
ImmutableDictionary<object, ImmutableList<ModularAvatarMenuInstaller>> menuToInstallerFiltered =
_targetMenuToInstaller
.Select(kvp => new KeyValuePair<object, ImmutableList<ModularAvatarMenuInstaller>>(
kvp.Key,
kvp.Value.Where(i => !_installerToTargetComponent.ContainsKey(i)).ToImmutableList()
))
.Where(kvp => !kvp.Value.IsEmpty)
.ToImmutableDictionary();
var RootNode = new VirtualMenuNode(RootMenuKey);
_resolvedMenu[RootMenuKey] = RootNode;
var rootContext =
new NodeContextImpl(RootNode, NodeFor, menuToInstallerFiltered, m => _visitedMenus.Add(m));
if (RootMenuKey is VRCExpressionsMenu menu)
{
foreach (var control in menu.controls)
{
rootContext.PushControl(control);
}
}
if (menuToInstallerFiltered.TryGetValue(RootMenuKey, out var installers))
{
foreach (var installer in installers)
{
rootContext.PushNode(installer);
}
}
while (_pendingGeneration.Count > 0)
{
_pendingGeneration.Dequeue()();
}
VirtualMenuNode NodeFor(object key)
{
if (_resolvedMenu.TryGetValue(key, out var node)) return node;
node = new VirtualMenuNode(key);
_resolvedMenu[key] = node;
_pendingGeneration.Enqueue(() =>
{
BuildReport.ReportingObject(key as UnityEngine.Object, () =>
{
var context = new NodeContextImpl(node, NodeFor, menuToInstallerFiltered,
m => _visitedMenus.Add(m));
if (key is VRCExpressionsMenu expMenu)
{
context.PushNode(expMenu);
}
else if (key is MenuSource source)
{
context.PushNode(source);
}
else
{
// TODO warning
}
});
});
return node;
}
}
internal VRCExpressionsMenu SerializeMenu(Action<UnityEngine.Object> SaveAsset)
{
Dictionary<object, VRCExpressionsMenu> serializedMenus = new Dictionary<object, VRCExpressionsMenu>();
return Serialize(RootMenuKey);
VRCExpressionsMenu Serialize(object menuKey)
{
if (menuKey == null) return null;
if (serializedMenus.TryGetValue(menuKey, out var menu)) return menu;
if (!_resolvedMenu.TryGetValue(menuKey, out var node)) return null;
menu = ScriptableObject.CreateInstance<VRCExpressionsMenu>();
serializedMenus[menuKey] = menu;
menu.controls = node.Controls.Select(c =>
{
var control = new VRCExpressionsMenu.Control();
control.name = c.name;
control.type = c.type;
control.parameter = new VRCExpressionsMenu.Control.Parameter() {name = c.parameter.name};
control.value = c.value;
control.icon = c.icon;
control.style = c.style;
control.labels = c.labels.ToArray();
control.subParameters = c.subParameters.Select(p => new VRCExpressionsMenu.Control.Parameter()
{
name = p.name
}).ToArray();
control.subMenu = Serialize(c.SubmenuNode?.NodeKey);
return control;
}).ToList();
SaveAsset(menu);
return menu;
}
}
public bool ContainsMenu(VRCExpressionsMenu menu)
{
return _visitedMenus.Contains(menu);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 70cfde8e889a4057b14153c7021e16c8
timeCreated: 1676977210

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using nadena.dev.modular_avatar.core.editor.menu;
using nadena.dev.modular_avatar.editor.ErrorReporting;
using UnityEditor;
using UnityEngine;
@ -20,12 +21,8 @@ namespace nadena.dev.modular_avatar.core.editor
private BuildContext _context;
private Dictionary<VRCExpressionsMenu, VRCExpressionsMenu> _clonedMenus;
private VRCExpressionsMenu _rootMenu;
private MenuTree _menuTree;
private Stack<ModularAvatarMenuInstaller> _visitedInstallerStack;
public void OnPreprocessAvatar(GameObject avatarRoot, BuildContext context)
@ -38,8 +35,6 @@ namespace nadena.dev.modular_avatar.core.editor
.ToArray();
if (menuInstallers.Length == 0) return;
_clonedMenus = new Dictionary<VRCExpressionsMenu, VRCExpressionsMenu>();
_visitedInstallerStack = new Stack<ModularAvatarMenuInstaller>();
VRCAvatarDescriptor avatar = avatarRoot.GetComponent<VRCAvatarDescriptor>();
@ -49,59 +44,19 @@ namespace nadena.dev.modular_avatar.core.editor
var menu = ScriptableObject.CreateInstance<VRCExpressionsMenu>();
_context.SaveAsset(menu);
avatar.expressionsMenu = menu;
_clonedMenus[menu] = menu;
context.ClonedMenus[menu] = menu;
}
_rootMenu = avatar.expressionsMenu;
_menuTree = new MenuTree(avatar);
_menuTree.TraverseAvatarMenu();
avatar.expressionsMenu = CloneMenu(avatar.expressionsMenu);
foreach (ModularAvatarMenuInstaller installer in menuInstallers)
var virtualMenu = VirtualMenu.ForAvatar(avatar);
avatar.expressionsMenu = virtualMenu.SerializeMenu(asset =>
{
BuildReport.ReportingObject(installer, () => _menuTree.TraverseMenuInstaller(installer));
}
foreach (MenuTree.ChildElement childElement in _menuTree.GetChildInstallers(null))
{
BuildReport.ReportingObject(childElement.installer, () => InstallMenu(childElement.installer));
}
context.SaveAsset(asset);
if (asset is VRCExpressionsMenu menu) SplitMenu(menu);
});
}
private void InstallMenu(ModularAvatarMenuInstaller installer, VRCExpressionsMenu installTarget = null)
{
if (!installer.enabled) return;
if (installer.installTargetMenu == null)
{
installer.installTargetMenu = _rootMenu;
}
if (installTarget == null)
{
installTarget = installer.installTargetMenu;
}
if (installer.installTargetMenu == null || installer.menuToAppend == null) return;
if (!_clonedMenus.TryGetValue(installTarget, out var targetMenu)) return;
// Clone before appending to sanitize menu icons
targetMenu.controls.AddRange(CloneMenu(installer.menuToAppend).controls);
SplitMenu(installer, targetMenu);
if (_visitedInstallerStack.Contains(installer)) return;
_visitedInstallerStack.Push(installer);
foreach (MenuTree.ChildElement childElement in _menuTree.GetChildInstallers(installer))
{
InstallMenu(childElement.installer, childElement.parent);
}
_visitedInstallerStack.Pop();
}
private void SplitMenu(ModularAvatarMenuInstaller installer, VRCExpressionsMenu targetMenu)
private void SplitMenu(VRCExpressionsMenu targetMenu)
{
while (targetMenu.controls.Count > VRCExpressionsMenu.MAX_CONTROLS)
{
@ -128,42 +83,8 @@ namespace nadena.dev.modular_avatar.core.editor
labels = Array.Empty<VRCExpressionsMenu.Control.Label>()
});
_clonedMenus[installer.installTargetMenu] = newMenu;
targetMenu = newMenu;
}
}
private VRCExpressionsMenu CloneMenu(VRCExpressionsMenu menu)
{
if (menu == null) return null;
if (_clonedMenus.TryGetValue(menu, out var newMenu)) return newMenu;
newMenu = Object.Instantiate(menu);
_context.SaveAsset(newMenu);
_clonedMenus[menu] = newMenu;
foreach (var control in newMenu.controls)
{
if (Util.ValidateExpressionMenuIcon(control.icon) != Util.ValidateExpressionMenuIconResult.Success)
control.icon = null;
for (int i = 0; i < control.labels.Length; i++)
{
var label = control.labels[i];
var labelResult = Util.ValidateExpressionMenuIcon(label.icon);
if (labelResult != Util.ValidateExpressionMenuIconResult.Success)
{
label.icon = null;
control.labels[i] = label;
}
}
if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
control.subMenu = CloneMenu(control.subMenu);
}
}
return newMenu;
}
}
}

View File

@ -1,209 +0,0 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using UnityEngine;
using VRC.SDK3.Avatars.Components;
using VRC.SDK3.Avatars.ScriptableObjects;
using static VRC.SDK3.Avatars.ScriptableObjects.VRCExpressionsMenu.Control;
namespace nadena.dev.modular_avatar.core.editor
{
internal class MenuTree
{
public struct ChildElement
{
/// <summary>
/// Parent menu control name
/// </summary>
public string menuName;
public VRCExpressionsMenu menu;
public VRCExpressionsMenu parent;
/// <summary>
/// Installer to install this menu. Is null if the this menu is not installed by the installer.
/// </summary>
public ModularAvatarMenuInstaller installer;
/// <summary>
/// Whether the this submenu is added directly by the installer
/// </summary>
public bool isInstallerRoot;
}
private readonly HashSet<VRCExpressionsMenu> _included;
private readonly VRCExpressionsMenu _rootMenu;
/// <summary>
/// Map to link child menus from parent menu
/// </summary>
private readonly Dictionary<VRCExpressionsMenu, ImmutableList<ChildElement>> _menuChildrenMap;
public MenuTree(VRCAvatarDescriptor descriptor)
{
_rootMenu = descriptor.expressionsMenu;
_included = new HashSet<VRCExpressionsMenu>();
_menuChildrenMap = new Dictionary<VRCExpressionsMenu, ImmutableList<ChildElement>>();
if (_rootMenu == null)
{
// If the route menu is null, create a temporary menu indicating the route
_rootMenu = ScriptableObject.CreateInstance<VRCExpressionsMenu>();
}
_included.Add(_rootMenu);
}
public void TraverseAvatarMenu()
{
if (_rootMenu == null) return;
TraverseMenu(_rootMenu);
}
public void TraverseMenuInstaller(ModularAvatarMenuInstaller installer)
{
if (!installer.enabled) return;
if (installer.menuToAppend == null) return;
TraverseMenu(installer);
}
public ImmutableList<ChildElement> GetChildren(VRCExpressionsMenu parent)
{
if (parent == null) parent = _rootMenu;
return !_menuChildrenMap.TryGetValue(parent, out ImmutableList<ChildElement> immutableList) ? ImmutableList<ChildElement>.Empty : immutableList;
}
public IEnumerable<ChildElement> GetChildInstallers(ModularAvatarMenuInstaller parentInstaller)
{
HashSet<VRCExpressionsMenu> visitedMenus = new HashSet<VRCExpressionsMenu>();
Queue<VRCExpressionsMenu> queue = new Queue<VRCExpressionsMenu>();
if (parentInstaller != null && parentInstaller.menuToAppend == null) yield break;
if (parentInstaller == null)
{
queue.Enqueue(_rootMenu);
}
else
{
if (parentInstaller.menuToAppend == null) yield break;
foreach (KeyValuePair<string, VRCExpressionsMenu> childMenu in GetChildMenus(parentInstaller.menuToAppend))
{
queue.Enqueue(childMenu.Value);
}
}
while (queue.Count > 0)
{
VRCExpressionsMenu parentMenu = queue.Dequeue();
if (visitedMenus.Contains(parentMenu)) continue;
visitedMenus.Add(parentMenu);
HashSet<ModularAvatarMenuInstaller> returnedInstallers = new HashSet<ModularAvatarMenuInstaller>();
foreach (ChildElement childElement in GetChildren(parentMenu))
{
if (!childElement.isInstallerRoot)
{
queue.Enqueue(childElement.menu);
continue;
}
// One installer may add multiple children, so filter to return only one.
if (returnedInstallers.Contains(childElement.installer)) continue;
returnedInstallers.Add(childElement.installer);
yield return childElement;
}
}
}
private void TraverseMenu(VRCExpressionsMenu root)
{
foreach (KeyValuePair<string, VRCExpressionsMenu> childMenu in GetChildMenus(root))
{
TraverseMenu(root, new ChildElement
{
menuName = childMenu.Key,
menu = childMenu.Value
});
}
}
private void TraverseMenu(ModularAvatarMenuInstaller installer)
{
IEnumerable<KeyValuePair<string, VRCExpressionsMenu>> childMenus = GetChildMenus(installer.menuToAppend);
IEnumerable<VRCExpressionsMenu> parents = Enumerable.Empty<VRCExpressionsMenu>();
if (installer.installTargetMenu != null &&
ClonedMenuMappings.TryGetClonedMenus(installer.installTargetMenu, out ImmutableList<VRCExpressionsMenu> parentMenus))
{
parents = parentMenus;
}
VRCExpressionsMenu[] parentsMenus = parents.DefaultIfEmpty(installer.installTargetMenu).ToArray();
bool hasChildMenu = false;
/*
* Installer adds the controls in specified menu to the installation destination.
* So, since the specified menu itself does not exist as a child menu,
* and the child menus of the specified menu are the actual child menus, a single installer may add multiple child menus.
*/
foreach (KeyValuePair<string, VRCExpressionsMenu> childMenu in childMenus)
{
hasChildMenu = true;
ChildElement childElement = new ChildElement
{
menuName = childMenu.Key,
menu = childMenu.Value,
installer = installer,
isInstallerRoot = true
};
foreach (VRCExpressionsMenu parentMenu in parentsMenus)
{
TraverseMenu(parentMenu, childElement);
}
}
if (hasChildMenu) return;
/*
* If the specified menu does not have any submenus, it is not mapped as a child menu and the Installer information itself is not registered.
* Therefore, register elements that do not have child menus themselves, but only have information about the installer.
*/
foreach (VRCExpressionsMenu parentMenu in parentsMenus)
{
TraverseMenu(parentMenu, new ChildElement
{
installer = installer,
isInstallerRoot = true
});
}
}
private void TraverseMenu(VRCExpressionsMenu parent, ChildElement childElement)
{
if (parent == null) parent = _rootMenu;
childElement.parent = parent;
if (!_menuChildrenMap.TryGetValue(parent, out ImmutableList<ChildElement> children))
{
children = ImmutableList<ChildElement>.Empty;
_menuChildrenMap[parent] = children;
}
_menuChildrenMap[parent] = children.Add(childElement);
if (childElement.menu == null) return;
if (_included.Contains(childElement.menu)) return;
_included.Add(childElement.menu);
foreach (KeyValuePair<string, VRCExpressionsMenu> childMenu in GetChildMenus(childElement.menu))
{
TraverseMenu(childElement.menu, new ChildElement
{
menuName = childMenu.Key,
menu = childMenu.Value,
installer = childElement.installer
});
}
}
private static IEnumerable<KeyValuePair<string, VRCExpressionsMenu>> GetChildMenus(VRCExpressionsMenu expressionsMenu)
{
return expressionsMenu.controls
.Where(control => control.type == ControlType.SubMenu && control.subMenu != null)
.Select(control => new KeyValuePair<string, VRCExpressionsMenu>(control.name, control.subMenu));
}
}
}

View File

@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: effd4557902f4578af42d3bdfb7f876d
timeCreated: 1670746991

View File

@ -178,6 +178,26 @@ namespace nadena.dev.modular_avatar.core.editor
break;
}
case ModularAvatarMenuItem menuItem:
{
if (menuItem.Control.parameter?.name != null &&
remaps.TryGetValue(menuItem.Control.parameter.name, out var newVal))
{
menuItem.Control.parameter.name = newVal;
}
foreach (var subParam in menuItem.Control.subParameters ??
Array.Empty<VRCExpressionsMenu.Control.Parameter>())
{
if (subParam?.name != null && remaps.TryGetValue(subParam.name, out var subNewVal))
{
subParam.name = subNewVal;
}
}
break;
}
}
});
}

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using nadena.dev.modular_avatar.core.menu;
using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core
{
public class ModularAvatarMenuGroup : MenuSourceComponent
{
private bool recursing = false;
public GameObject targetObject;
public override void Visit(NodeContext context)
{
context.PushNode(new MenuNodesUnder(targetObject != null ? targetObject : gameObject));
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 97e46a47dd8a425eb4ce9411defe313d
timeCreated: 1677080023

View File

@ -6,15 +6,22 @@ namespace nadena.dev.modular_avatar.core
{
[AddComponentMenu("Modular Avatar/MA Menu Installer")]
public class ModularAvatarMenuInstaller : AvatarTagComponent
{
public VRCExpressionsMenu menuToAppend;
{
public VRCExpressionsMenu menuToAppend;
public VRCExpressionsMenu installTargetMenu;
// ReSharper disable once Unity.RedundantEventFunction
void Start()
{
// Ensure that unity generates an enable checkbox
}
{
// Ensure that unity generates an enable checkbox
}
protected override void OnValidate()
{
base.OnValidate();
RuntimeUtil.InvalidateMenu();
}
}
}

View File

@ -0,0 +1,174 @@
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects;
// Internal runtime API for the Virtual Menu system.
//
// IMPORTANT: This API is currently considered unstable. Due to C# protection rules, we are required to make classes
// here public, but be aware that they may change without warning in the future.
namespace nadena.dev.modular_avatar.core.menu
{
/// <summary>
/// A MenuNode represents a single VRCExpressionsMenu, prior to overflow splitting. MenuNodes form a directed graph,
/// which may contain cycles, and may include contributions from multiple MenuInstallers, or from the base avatar
/// menu.
/// </summary>
public class VirtualMenuNode
{
public List<VirtualControl> Controls = new List<VirtualControl>();
/// <summary>
/// The primary (serialized) object that contributed to this menu; if we want to add more items to it, we look
/// here. This can currently be either a VRCExpressionsMenu, a MAMenuItem, or a RootMenu.
/// </summary>
public readonly object NodeKey;
internal VirtualMenuNode(object nodeKey)
{
NodeKey = nodeKey;
}
}
/**
* A single control on a MenuNode. The main difference between this and a true VRCExpressionsMenu.Control is that
* we use a MenuNode instead of a VRCExpressionsMenu for submenus.
*/
public class VirtualControl : VRCExpressionsMenu.Control
{
/// <summary>
/// VirtualControls do not reference real VRCExpressionsMenu objects, but rather virtual MenuNodes.
/// </summary>
public VirtualMenuNode SubmenuNode;
internal VirtualControl(VRCExpressionsMenu.Control control)
{
this.name = control.name;
this.type = control.type;
this.parameter = new Parameter() {name = control?.parameter?.name};
this.value = control.value;
this.icon = control.icon;
this.style = control.style;
this.subMenu = null;
this.subParameters = control.subParameters?.Select(p => new VRCExpressionsMenu.Control.Parameter()
{
name = p.name
})?.ToArray();
this.labels = control.labels?.ToArray();
}
}
/// <summary>
/// Helper MenuSource which includes all children of a given GameObject containing MenuSourceComponents as menu
/// items. Implements equality based on the GameObject in question.
/// </summary>
internal class MenuNodesUnder : MenuSource
{
internal readonly GameObject root;
public MenuNodesUnder(GameObject root)
{
this.root = root;
}
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != this.GetType()) return false;
return root == ((MenuNodesUnder) obj).root;
}
public override int GetHashCode()
{
return (root != null ? root.GetHashCode() : 0);
}
public void Visit(NodeContext context)
{
foreach (Transform t in root.transform)
{
var source = t.GetComponent<MenuSourceComponent>();
if (source != null) context.PushNode(source);
}
}
}
/// <summary>
/// The NodeContext provides callbacks for MenuSource visitors to append controls and/or other node types to a menu
/// node.
/// </summary>
public interface NodeContext
{
/// <summary>
/// Pushes the contents of this expressions menu asset onto the current menu node, handling loops and menu
/// installer invocations.
/// </summary>
/// <param name="expMenu"></param>
void PushNode(VRCExpressionsMenu expMenu);
/// <summary>
/// Pushes the contents of this menu source onto the current menu node.
/// </summary>
/// <param name="source"></param>
void PushNode(MenuSource source);
/// <summary>
/// Pushes this menu installer onto this node
/// </summary>
/// <param name="installer"></param>
void PushNode(ModularAvatarMenuInstaller installer);
/// <summary>
/// Pushes a single expressions menu control onto the current menu node. Converts submenus into menu nodes
/// automatically.
/// </summary>
/// <param name="control"></param>
void PushControl(VRCExpressionsMenu.Control control);
/// <summary>
/// Pushes a single expressions menu control onto the current menu node.
/// </summary>
/// <param name="control"></param>
void PushControl(VirtualControl control);
/// <summary>
/// Returns the menu node for a given VRCExpressionsMenu asset. This node may not be populated at the time this
/// node returns.
/// </summary>
/// <param name="menu"></param>
/// <returns></returns>
VirtualMenuNode NodeFor(VRCExpressionsMenu menu);
/// <summary>
/// Returns the menu node for a given menu source asset. The contents of the node may not yet be populated.
/// </summary>
/// <param name="menu"></param>
/// <returns></returns>
VirtualMenuNode NodeFor(MenuSource menu);
}
/// <summary>
/// An object which can contribute controls to a menu.
/// </summary>
public interface MenuSource
{
void Visit(NodeContext context);
}
/// <summary>
/// A component which can be used to generate menu items.
/// </summary>
[DisallowMultipleComponent]
public abstract class MenuSourceComponent : AvatarTagComponent, MenuSource
{
protected override void OnValidate()
{
base.OnValidate();
RuntimeUtil.InvalidateMenu();
}
public abstract void Visit(NodeContext context);
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1defe88300684bc38ee24944075d540e
timeCreated: 1677147255

View File

@ -0,0 +1,27 @@
using System.Collections.Generic;
using nadena.dev.modular_avatar.core.menu;
using VRC.SDK3.Avatars.ScriptableObjects;
using VRC.SDKBase;
namespace nadena.dev.modular_avatar.core
{
/// <summary>
/// The menu install target includes the controls of the target menu installer at the point of reference.
/// Notably, this can include multiple controls.
///
/// One tricky aspect of this feature is that we need to disambiguate when a menu installer also cites a target menu.
/// Generally, if an installer is targeted by any menu install target (even if - especially if - disabled), we
/// ignore its install target configuration entirely.
///
/// We can also end up with a loop between install targets; in this case, we break the loop at an arbitrary point.
/// </summary>
internal class ModularAvatarMenuInstallTarget : MenuSourceComponent
{
public ModularAvatarMenuInstaller installer;
public override void Visit(NodeContext context)
{
context.PushNode(installer);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1fad1419b52a42ae89b0df52eb861e47
timeCreated: 1676976513

View File

@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;
using nadena.dev.modular_avatar.core.menu;
using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core
{
public enum SubmenuSource
{
MenuAsset,
Children,
}
[AddComponentMenu("Modular Avatar/MA Menu Item")]
public class ModularAvatarMenuItem : MenuSourceComponent
{
public VRCExpressionsMenu.Control Control;
public SubmenuSource MenuSource;
public GameObject menuSource_otherObjectChildren;
public override void Visit(NodeContext context)
{
var cloned = new VirtualControl(Control);
cloned.subMenu = null;
cloned.name = gameObject.name;
if (cloned.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
switch (this.MenuSource)
{
case SubmenuSource.MenuAsset:
cloned.SubmenuNode = context.NodeFor(this.Control.subMenu);
break;
case SubmenuSource.Children:
{
var root = this.menuSource_otherObjectChildren != null
? this.menuSource_otherObjectChildren
: this.gameObject;
cloned.SubmenuNode = context.NodeFor(new MenuNodesUnder(root));
break;
}
}
}
context.PushControl(cloned);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3b29d45007c5493d926d2cd45a489529
timeCreated: 1676787152

View File

@ -39,6 +39,13 @@ namespace nadena.dev.modular_avatar.core
public static Action<Action> delayCall = (_) => { };
public static event Action OnHierarchyChanged;
internal static event Action OnMenuInvalidate;
internal static void InvalidateMenu()
{
OnMenuInvalidate?.Invoke();
}
public enum OnDemandSource
{
Awake,

View File

@ -11,6 +11,7 @@ GameObject:
- component: {fileID: 2929086332187968543}
- component: {fileID: 7996665789334891805}
- component: {fileID: 7184458914139497616}
- component: {fileID: 4714073180460761108}
m_Layer: 0
m_Name: Fingerpen
m_TagString: Untagged
@ -30,6 +31,7 @@ Transform:
m_LocalScale: {x: 1, y: 1, z: 1}
m_Children:
- {fileID: 6917936183219720762}
- {fileID: 728513151284643908}
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
@ -74,6 +76,32 @@ MonoBehaviour:
syncType: 3
defaultValue: 0
saved: 0
--- !u!114 &4714073180460761108
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2929086332187968542}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3b29d45007c5493d926d2cd45a489529, type: 3}
m_Name:
m_EditorClassIdentifier:
Control:
name:
icon: {fileID: 0}
type: 103
parameter:
name:
value: 1
style: 0
subMenu: {fileID: 0}
subParameters: []
labels: []
MenuSource: 1
menuSource_installer: {fileID: 0}
menuSource_otherObjectChildren: {fileID: 5354519745013217104}
--- !u!1 &2929086333654575795
GameObject:
m_ObjectHideFlags: 0
@ -250,6 +278,95 @@ TrailRenderer:
m_MinVertexDistance: 0.005
m_Autodestruct: 0
m_Emitting: 0
--- !u!1 &5354519745013217104
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 728513151284643908}
m_Layer: 0
m_Name: Menu
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &728513151284643908
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5354519745013217104}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_Children:
- {fileID: 1892928965174260857}
- {fileID: 3041863097553433968}
m_Father: {fileID: 2929086332187968543}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &6837276917073475290
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1892928965174260857}
- component: {fileID: 1436771406052350457}
m_Layer: 0
m_Name: Enable
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1892928965174260857
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6837276917073475290}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_Children: []
m_Father: {fileID: 728513151284643908}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1436771406052350457
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6837276917073475290}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3b29d45007c5493d926d2cd45a489529, type: 3}
m_Name:
m_EditorClassIdentifier:
Control:
name:
icon: {fileID: 0}
type: 102
parameter:
name: Enable
value: 1
style: 0
subMenu: {fileID: 0}
subParameters: []
labels: []
MenuSource: 0
menuSource_installer: {fileID: 0}
menuSource_otherObjectChildren: {fileID: 0}
--- !u!1 &7502135110828725241
GameObject:
m_ObjectHideFlags: 0
@ -319,6 +436,63 @@ Animator:
m_HasTransformHierarchy: 1
m_AllowConstantClipSamplingOptimization: 1
m_KeepAnimatorControllerStateOnDisable: 0
--- !u!1 &8323130273213051223
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3041863097553433968}
- component: {fileID: 1720968870752482547}
m_Layer: 0
m_Name: Draw
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3041863097553433968
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8323130273213051223}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_Children: []
m_Father: {fileID: 728513151284643908}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1720968870752482547
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8323130273213051223}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3b29d45007c5493d926d2cd45a489529, type: 3}
m_Name:
m_EditorClassIdentifier:
Control:
name:
icon: {fileID: 0}
type: 101
parameter:
name: Draw
value: 1
style: 0
subMenu: {fileID: 0}
subParameters: []
labels: []
MenuSource: 0
menuSource_installer: {fileID: 0}
menuSource_otherObjectChildren: {fileID: 0}
--- !u!1 &9091324582054793565
GameObject:
m_ObjectHideFlags: 0

View File

@ -1,25 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: -340790334, guid: 67cc4cb7839cd3741b63733d5adf0442, type: 3}
m_Name: Fingerpen
m_EditorClassIdentifier:
controls:
- name: Pen
icon: {fileID: 0}
type: 103
parameter:
name:
value: 1
style: 0
subMenu: {fileID: 11400000, guid: 1d1f3d0e712c1394d9623677363da960, type: 2}
subParameters: []
labels: []

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 8c9e45343a49f484ba069e09ccced597
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,35 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: -340790334, guid: 67cc4cb7839cd3741b63733d5adf0442, type: 3}
m_Name: FingerpenSub
m_EditorClassIdentifier:
controls:
- name: Enable
icon: {fileID: 0}
type: 102
parameter:
name: Enable
value: 1
style: 0
subMenu: {fileID: 0}
subParameters: []
labels: []
- name: Draw
icon: {fileID: 0}
type: 101
parameter:
name: Draw
value: 1
style: 0
subMenu: {fileID: 0}
subParameters: []
labels: []

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 1d1f3d0e712c1394d9623677363da960
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -158,6 +158,13 @@
"com.unity.nuget.newtonsoft-json": "2.0.2"
}
},
"de.thryrallo.vrc.avatar-performance-tools": {
"version": "https://github.com/Thryrallo/VRC-Avatar-Performance-Tools.git",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "ba9c16e482e7a376db18e9a85e24fabc04649d37"
},
"nadena.dev.modular-avatar": {
"version": "file:nadena.dev.modular-avatar",
"depth": 0,

View File

@ -9,13 +9,14 @@
},
"locked": {
"com.vrchat.avatars": {
"version": "3.1.10",
"version": "3.1.11",
"dependencies": {
"com.vrchat.base": "3.1.x"
"com.vrchat.base": "3.1.11"
}
},
"com.vrchat.base": {
"version": "3.1.10"
"version": "3.1.11",
"dependencies": {}
},
"com.vrchat.core.vpm-resolver": {
"version": "0.1.17"