rewrite the virtual menu system to be more extensible

This commit is contained in:
bd_ 2023-02-23 19:23:33 +09:00
parent 88f90b6c9a
commit 1528db1312
18 changed files with 421 additions and 440 deletions

View File

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using nadena.dev.modular_avatar.core; using nadena.dev.modular_avatar.core;
using nadena.dev.modular_avatar.core.editor.menu; using nadena.dev.modular_avatar.core.editor.menu;
using nadena.dev.modular_avatar.core.menu;
using NUnit.Framework; using NUnit.Framework;
using UnityEditor; using UnityEditor;
using UnityEditor.VersionControl; using UnityEditor.VersionControl;
@ -13,12 +14,14 @@ namespace modular_avatar_tests.VirtualMenuTests
{ {
private Texture2D testTex; private Texture2D testTex;
private List<UnityEngine.Object> toDestroy; private List<UnityEngine.Object> toDestroy;
private int controlIndex;
public override void Setup() public override void Setup()
{ {
base.Setup(); base.Setup();
testTex = new Texture2D(1, 1); testTex = new Texture2D(1, 1);
toDestroy = new List<UnityEngine.Object>(); toDestroy = new List<UnityEngine.Object>();
controlIndex = 0;
} }
public override void Teardown() public override void Teardown()
@ -280,14 +283,14 @@ namespace modular_avatar_tests.VirtualMenuTests
Assert.AreEqual(2, virtualMenu.ResolvedMenu.Count); Assert.AreEqual(2, virtualMenu.ResolvedMenu.Count);
var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance]; var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance];
var item_node = virtualMenu.ResolvedMenu[item]; var item_node = virtualMenu.ResolvedMenu[new MenuNodesUnder(item.gameObject)];
Assert.AreEqual(1, rootMenu.Controls.Count); Assert.AreEqual(1, rootMenu.Controls.Count);
Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey); Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey);
AssertControlEquals(item.Control, rootMenu.Controls[0]); AssertControlEquals(item.Control, rootMenu.Controls[0]);
Assert.AreSame(item_node, rootMenu.Controls[0].SubmenuNode); Assert.AreSame(item_node, rootMenu.Controls[0].SubmenuNode);
Assert.AreEqual(1, item_node.Controls.Count); Assert.AreEqual(1, item_node.Controls.Count);
Assert.AreSame(item, item_node.NodeKey); Assert.AreEqual(new MenuNodesUnder(item.gameObject), item_node.NodeKey);
AssertControlEquals(menu_a.controls[0], item_node.Controls[0]); AssertControlEquals(menu_a.controls[0], item_node.Controls[0]);
} }
@ -558,11 +561,10 @@ namespace modular_avatar_tests.VirtualMenuTests
VRCExpressionsMenu.Control.ControlType.RadialPuppet, VRCExpressionsMenu.Control.ControlType.RadialPuppet,
VRCExpressionsMenu.Control.ControlType.FourAxisPuppet, VRCExpressionsMenu.Control.ControlType.FourAxisPuppet,
VRCExpressionsMenu.Control.ControlType.TwoAxisPuppet, VRCExpressionsMenu.Control.ControlType.TwoAxisPuppet,
VRCExpressionsMenu.Control.ControlType.OneAxisPuppet,
}; };
control.type = types[Random.Range(0, types.Length)]; control.type = types[Random.Range(0, types.Length)];
control.name = "Test Control " + GUID.Generate(); control.name = "Test Control " + controlIndex++;
control.parameter = new VRCExpressionsMenu.Control.Parameter(); control.parameter = new VRCExpressionsMenu.Control.Parameter();
control.parameter.name = "Test Parameter " + GUID.Generate(); control.parameter.name = "Test Parameter " + GUID.Generate();
control.icon = new Texture2D(1, 1); control.icon = new Texture2D(1, 1);

View File

@ -11,6 +11,7 @@
"com.unity.ugui": "1.0.0", "com.unity.ugui": "1.0.0",
"com.unity.xr.oculus.standalone": "2.38.4", "com.unity.xr.oculus.standalone": "2.38.4",
"com.unity.xr.openvr.standalone": "2.0.5", "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", "nadena.dev.modular-avatar": "0.0.1",
"com.unity.modules.ai": "1.0.0", "com.unity.modules.ai": "1.0.0",
"com.unity.modules.androidjni": "1.0.0", "com.unity.modules.androidjni": "1.0.0",

View File

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using nadena.dev.modular_avatar.core; using nadena.dev.modular_avatar.core;
using nadena.dev.modular_avatar.core.menu;
using UnityEngine; using UnityEngine;
using VRC.SDK3.Avatars.Components; using VRC.SDK3.Avatars.Components;
@ -137,7 +138,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting
private static List<ErrorLog> CheckInternal(ModularAvatarMenuInstaller mi) private static List<ErrorLog> CheckInternal(ModularAvatarMenuInstaller mi)
{ {
// TODO - check that target menu is in the avatar // TODO - check that target menu is in the avatar
if (mi.menuToAppend == null && mi.GetComponent<MenuSource>() == null) if (mi.menuToAppend == null && mi.GetComponent<MenuSourceComponent>() == null)
{ {
return new List<ErrorLog>() 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) internal static T ReportingObject<T>(UnityEngine.Object obj, Func<T> action)
{ {
CurrentReport._references.Push(obj); if (obj != null) CurrentReport._references.Push(obj);
try try
{ {
return action(); return action();
@ -347,7 +347,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting
} }
finally finally
{ {
CurrentReport._references.Pop(); if (obj != null) CurrentReport._references.Pop();
} }
} }

View File

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using nadena.dev.modular_avatar.core.editor.menu; using nadena.dev.modular_avatar.core.editor.menu;
using nadena.dev.modular_avatar.core.menu;
using NUnit.Framework; using NUnit.Framework;
using UnityEditor; using UnityEditor;
using UnityEditor.IMGUI.Controls; using UnityEditor.IMGUI.Controls;
@ -148,7 +149,7 @@ namespace nadena.dev.modular_avatar.core.editor
return root; return root;
} }
private void TraverseMenu(int depth, List<TreeViewItem> items, MenuNode node) private void TraverseMenu(int depth, List<TreeViewItem> items, VirtualMenuNode node)
{ {
IEnumerable<VirtualControl> children = node.Controls IEnumerable<VirtualControl> children = node.Controls
.Where(control => control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && .Where(control => control.type == VRCExpressionsMenu.Control.ControlType.SubMenu &&

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Linq; using System.Linq;
using nadena.dev.modular_avatar.core.editor.menu; using nadena.dev.modular_avatar.core.editor.menu;
using nadena.dev.modular_avatar.core.menu;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
using VRC.SDK3.Avatars.Components; using VRC.SDK3.Avatars.Components;

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using nadena.dev.modular_avatar.core.menu;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
using VRC.SDK3.Avatars.Components; using VRC.SDK3.Avatars.Components;
@ -23,17 +24,18 @@ namespace nadena.dev.modular_avatar.core.editor
type = VRCExpressionsMenu.Control.ControlType.SubMenu, type = VRCExpressionsMenu.Control.ControlType.SubMenu,
name = "Avatar Menu" name = "Avatar Menu"
}; };
var rootMenu = ConvertSubmenu(gameObj, fakeControl, new Dictionary<VRCExpressionsMenu, MenuSource>()); var rootMenu = ConvertSubmenu(gameObj, fakeControl,
new Dictionary<VRCExpressionsMenu, MenuSourceComponent>());
Undo.RecordObject(avatar, "Convert menu"); Undo.RecordObject(avatar, "Convert menu");
avatar.expressionsMenu = null; avatar.expressionsMenu = null;
rootMenu.gameObject.AddComponent<ModularAvatarMenuInstaller>(); rootMenu.gameObject.AddComponent<ModularAvatarMenuInstaller>();
} }
private static MenuSource ConvertSubmenu( private static MenuSourceComponent ConvertSubmenu(
GameObject parentObj, GameObject parentObj,
VRCExpressionsMenu.Control sourceControl, VRCExpressionsMenu.Control sourceControl,
Dictionary<VRCExpressionsMenu, MenuSource> convertedMenus Dictionary<VRCExpressionsMenu, MenuSourceComponent> convertedMenus
) )
{ {
var itemObj = new GameObject(); var itemObj = new GameObject();

View File

@ -1,6 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using JetBrains.Annotations;
using nadena.dev.modular_avatar.core.menu;
using nadena.dev.modular_avatar.editor.ErrorReporting; using nadena.dev.modular_avatar.editor.ErrorReporting;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
@ -12,57 +15,116 @@ namespace nadena.dev.modular_avatar.core.editor.menu
/// <summary> /// <summary>
/// Sentinel object to represent the avatar root menu (for avatars which don't have a root menu) /// Sentinel object to represent the avatar root menu (for avatars which don't have a root menu)
/// </summary> /// </summary>
internal sealed class RootMenu internal sealed class RootMenu : MenuSource
{ {
public static readonly RootMenu Instance = new RootMenu(); public static readonly RootMenu Instance = new RootMenu();
private RootMenu() private RootMenu()
{ {
} }
}
/// <summary> public void Visit(NodeContext context)
/// 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>
internal class MenuNode
{
internal 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>
internal readonly object NodeKey;
internal MenuNode(object nodeKey)
{ {
NodeKey = nodeKey; // we initialize the root node manually
throw new NotImplementedException();
} }
} }
internal class VirtualControl : VRCExpressionsMenu.Control class NodeContextImpl : NodeContext
{ {
/// <summary> [CanBeNull]
/// VirtualControls do not reference real VRCExpressionsMenu objects, but rather virtual MenuNodes. internal delegate VirtualMenuNode NodeForDelegate(object menu);
/// </summary>
internal MenuNode SubmenuNode;
internal VirtualControl(VRCExpressionsMenu.Control control) private readonly ImmutableDictionary<object, ImmutableList<ModularAvatarMenuInstaller>>
_menuToInstallerMap;
private readonly VirtualMenuNode _node;
private readonly NodeForDelegate _nodeFor;
private readonly HashSet<object> _visited = new HashSet<object>();
public NodeContextImpl(
VirtualMenuNode node,
NodeForDelegate nodeFor,
ImmutableDictionary<object, ImmutableList<ModularAvatarMenuInstaller>> menuToInstallerMap)
{ {
this.name = control.name; _node = node;
this.type = control.type; _nodeFor = nodeFor;
this.parameter = new Parameter() {name = control?.parameter?.name}; _menuToInstallerMap = menuToInstallerMap;
this.value = control.value; }
this.icon = control.icon;
this.style = control.style; public void PushNode(VRCExpressionsMenu expMenu)
this.subMenu = null; {
this.subParameters = control.subParameters?.Select(p => new VRCExpressionsMenu.Control.Parameter() if (expMenu == null) return;
if (_visited.Contains(expMenu)) return;
_visited.Add(expMenu);
foreach (var control in expMenu.controls)
{ {
name = p.name PushControl(control);
})?.ToArray(); }
this.labels = control.labels?.ToArray();
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);
} }
} }
@ -100,17 +162,13 @@ namespace nadena.dev.modular_avatar.core.editor.menu
private Dictionary<ModularAvatarMenuInstaller, List<ModularAvatarMenuInstallTarget>> _installerToTargetComponent private Dictionary<ModularAvatarMenuInstaller, List<ModularAvatarMenuInstallTarget>> _installerToTargetComponent
= new Dictionary<ModularAvatarMenuInstaller, List<ModularAvatarMenuInstallTarget>>(); = new Dictionary<ModularAvatarMenuInstaller, List<ModularAvatarMenuInstallTarget>>();
/// <summary> private Dictionary<object, VirtualMenuNode> _resolvedMenu = new Dictionary<object, VirtualMenuNode>();
/// Maps from either VRCEXpressionsMenu objects or MenuItems to menu nodes. The ROOT_MENU here is a special
/// object used to mark contributors to the avatar root menu.
/// </summary>
private Dictionary<object, MenuNode> _menuNodeMap = new Dictionary<object, MenuNode>();
private Dictionary<object, MenuNode> _resolvedMenu = new Dictionary<object, MenuNode>();
// TODO: immutable? // TODO: immutable?
public Dictionary<object, MenuNode> ResolvedMenu => _resolvedMenu; public Dictionary<object, VirtualMenuNode> ResolvedMenu => _resolvedMenu;
public MenuNode RootMenuNode => ResolvedMenu[RootMenuKey]; public VirtualMenuNode RootMenuNode => ResolvedMenu[RootMenuKey];
private Queue<Action> _pendingGeneration = new Queue<Action>();
/// <summary> /// <summary>
/// Initializes the VirtualMenu. /// Initializes the VirtualMenu.
@ -121,12 +179,10 @@ namespace nadena.dev.modular_avatar.core.editor.menu
if (rootMenu != null) if (rootMenu != null)
{ {
RootMenuKey = rootMenu; RootMenuKey = rootMenu;
ImportMenu(rootMenu);
} }
else else
{ {
RootMenuKey = RootMenu.Instance; RootMenuKey = RootMenu.Instance;
_menuNodeMap[RootMenu.Instance] = new MenuNode(RootMenu.Instance);
} }
} }
@ -162,27 +218,6 @@ namespace nadena.dev.modular_avatar.core.editor.menu
} }
} }
private MenuNode ImportMenu(VRCExpressionsMenu menu, object menuKey = null)
{
if (menuKey == null) menuKey = menu;
if (_menuNodeMap.TryGetValue(menuKey, out var subMenuNode)) return subMenuNode;
var node = new MenuNode(menuKey);
_menuNodeMap[menuKey] = node;
foreach (var control in menu.controls)
{
var virtualControl = new VirtualControl(control);
if (control.subMenu != null)
{
virtualControl.SubmenuNode = ImportMenu(control.subMenu);
}
node.Controls.Add(virtualControl);
}
return node;
}
/// <summary> /// <summary>
/// Registers a menu installer with this virtual menu. Because we need the full set of components indexed to /// 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. /// determine the effects of this menu installer, further processing is deferred until we freeze the menu.
@ -224,7 +259,68 @@ namespace nadena.dev.modular_avatar.core.editor.menu
/// </summary> /// </summary>
internal void FreezeMenu() internal void FreezeMenu()
{ {
ResolveNode(RootMenuKey); 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);
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);
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) internal VRCExpressionsMenu SerializeMenu(Action<UnityEngine.Object> SaveAsset)
@ -265,203 +361,5 @@ namespace nadena.dev.modular_avatar.core.editor.menu
return menu; return menu;
} }
} }
private HashSet<object> _sourceTrace = null;
private MenuNode ResolveNode(object nodeKey)
{
if (_resolvedMenu.TryGetValue(nodeKey, out var node)) return node;
if (nodeKey is ModularAvatarMenuItem item)
{
return ResolveSubmenuItem(item);
}
if (nodeKey is VRCExpressionsMenu menu)
{
ImportMenu(menu);
}
if (_menuNodeMap.TryGetValue(nodeKey, out node))
{
_resolvedMenu[nodeKey] = node;
}
else
{
node = new MenuNode(nodeKey);
_menuNodeMap[nodeKey] = node;
_resolvedMenu[nodeKey] = node;
}
// Find any menu installers which target this node, and recursively include them.
// Note that we're also recursing through MenuNodes, and should not consider the objects visited on
// different submenus when cutting off cycles.
var priorTrace = _sourceTrace;
_sourceTrace = new HashSet<object>();
try
{
// We use a stack here to maintain the expected order of elements. Consider if we have three menu
// installers as follows:
// A -> root
// B -> root
// C -> A
// We'll first push [B, A], then visit A. At this point we'll push C back on the stack, so we visit
// [A, C, B] in the end.
Stack<ModularAvatarMenuInstaller> installers = new Stack<ModularAvatarMenuInstaller>();
if (_targetMenuToInstaller.TryGetValue(nodeKey, out var rootInstallers))
{
foreach (var i in rootInstallers.Select(x => x).Reverse())
{
if (_installerToTargetComponent.ContainsKey(i)) continue;
installers.Push(i);
}
}
while (installers.Count > 0)
{
var next = installers.Pop();
if (_sourceTrace.Contains(next)) continue;
_sourceTrace.Add(next);
BuildReport.ReportingObject(next, () => ResolveInstaller(node, next, installers));
}
// Resolve any submenus
foreach (var virtualControl in node.Controls)
{
if (virtualControl.SubmenuNode != null)
{
virtualControl.SubmenuNode = ResolveNode(virtualControl.SubmenuNode.NodeKey);
}
}
}
finally
{
_sourceTrace = priorTrace;
}
return node;
}
private MenuNode ResolveSubmenuItem(ModularAvatarMenuItem item)
{
return BuildReport.ReportingObject(item, () =>
{
MenuNode node = new MenuNode(item);
_resolvedMenu[item] = node;
switch (item.MenuSource)
{
case SubmenuSource.MenuAsset:
{
if (item.Control.subMenu != null)
{
node.Controls = ResolveNode(item.Control.subMenu).Controls;
}
break;
}
case SubmenuSource.Children:
{
var transformRoot = item.menuSource_otherObjectChildren != null
? item.menuSource_otherObjectChildren.transform
: item.transform;
foreach (Transform child in transformRoot)
{
if (!child.gameObject.activeSelf) continue;
var source = child.GetComponent<MenuSource>();
if (source == null) continue;
if (source is ModularAvatarMenuItem subItem)
{
var control = new VirtualControl(subItem.Control);
if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
control.SubmenuNode = ResolveNode(subItem);
}
control.name = subItem.gameObject.name;
node.Controls.Add(control);
}
else if (source is ModularAvatarMenuInstallTarget target && target.installer != null)
{
ResolveInstaller(node, target.installer, new Stack<ModularAvatarMenuInstaller>());
}
else
{
// TODO validation
}
}
break;
}
default:
// TODO validation
break;
}
return node;
});
}
private void ResolveInstaller(MenuNode node, ModularAvatarMenuInstaller installer,
Stack<ModularAvatarMenuInstaller> installers)
{
if (installer == null || !installer.enabled) return;
var menuSource = installer.GetComponent<MenuSource>();
if (menuSource == null)
{
var expMenu = installer.menuToAppend;
if (expMenu == null) return;
var controls = expMenu.controls;
if (controls == null) return;
foreach (var control in controls)
{
var virtualControl = new VirtualControl(control);
if (control.subMenu != null)
{
virtualControl.SubmenuNode = ResolveNode(control.subMenu);
}
node.Controls.Add(virtualControl);
}
if (_targetMenuToInstaller.TryGetValue(expMenu, out var subInstallers))
{
foreach (var subInstaller in subInstallers.Select(x => x).Reverse())
{
if (_installerToTargetComponent.ContainsKey(subInstaller)) continue;
installers.Push(subInstaller);
}
}
}
else if (menuSource is ModularAvatarMenuInstallTarget target)
{
if (target.installer != null)
{
installers.Push(target.installer);
}
}
else if (menuSource is ModularAvatarMenuItem item)
{
var virtualControl = new VirtualControl(item.Control);
virtualControl.name = item.gameObject.name;
node.Controls.Add(virtualControl);
if (virtualControl.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
{
virtualControl.SubmenuNode = ResolveNode(item);
}
}
else
{
BuildReport.Log(ReportLevel.Error, "virtual_menu.unknown_source_type",
strings: new object[] {menuSource.GetType().ToString()});
}
}
} }
} }

View File

@ -0,0 +1,18 @@
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 override void Visit(NodeContext context)
{
context.PushNode(new MenuNodesUnder(gameObject));
}
}
}

View File

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

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using nadena.dev.modular_avatar.core.menu;
using VRC.SDK3.Avatars.ScriptableObjects; using VRC.SDK3.Avatars.ScriptableObjects;
using VRC.SDKBase; using VRC.SDKBase;
@ -14,55 +15,13 @@ namespace nadena.dev.modular_avatar.core
/// ///
/// We can also end up with a loop between install targets; in this case, we break the loop at an arbitrary point. /// We can also end up with a loop between install targets; in this case, we break the loop at an arbitrary point.
/// </summary> /// </summary>
internal class ModularAvatarMenuInstallTarget : MenuSource internal class ModularAvatarMenuInstallTarget : MenuSourceComponent
{ {
public ModularAvatarMenuInstaller installer; public ModularAvatarMenuInstaller installer;
private static HashSet<MenuSource> _recursing = new HashSet<MenuSource>(); public override void Visit(NodeContext context)
internal delegate T Returning<T>();
/**
* Temporarily clears the list of install targets we're recursing through. This is useful if we need to generate
* a submenu; these have their own recursion stack, and we shouldn't truncate the set of controls registered on
* a different submenu that happens to transclude the same point.
*/
internal static T PushRecursing<T>(Returning<T> callback)
{ {
HashSet<MenuSource> oldRecursing = _recursing; context.PushNode(installer);
_recursing = new HashSet<MenuSource>();
try
{
return callback();
}
finally
{
_recursing = oldRecursing;
}
}
internal override VRCExpressionsMenu.Control[] GenerateMenu()
{
if (installer == null) return new VRCExpressionsMenu.Control[] { };
_recursing.Add(this);
try
{
var source = installer.GetComponent<MenuSource>();
if (source != null)
{
return source.GenerateMenu();
}
else
{
// ReSharper disable once Unity.NoNullPropagation
return installer.menuToAppend?.controls?.ToArray() ?? new VRCExpressionsMenu.Control[] { };
}
}
finally
{
_recursing.Remove(this);
}
} }
} }
} }

View File

@ -1,30 +1,11 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using nadena.dev.modular_avatar.core.menu;
using UnityEngine; using UnityEngine;
using VRC.SDK3.Avatars.ScriptableObjects; using VRC.SDK3.Avatars.ScriptableObjects;
namespace nadena.dev.modular_avatar.core namespace nadena.dev.modular_avatar.core
{ {
[DisallowMultipleComponent]
public abstract class MenuSource : AvatarTagComponent
{
/**
* Generates the menu items for this menu source object. Submenus are not required to be persisted as assets;
* this will be handled by the caller if necessary.
*
* Note that this method might be called outside of a build context (e.g. from custom inspectors).
*/
internal abstract VRCExpressionsMenu.Control[] GenerateMenu();
protected override void OnValidate()
{
base.OnValidate();
RuntimeUtil.InvalidateMenu();
}
}
public enum SubmenuSource public enum SubmenuSource
{ {
MenuAsset, MenuAsset,
@ -32,7 +13,7 @@ namespace nadena.dev.modular_avatar.core
} }
[AddComponentMenu("Modular Avatar/MA Menu Item")] [AddComponentMenu("Modular Avatar/MA Menu Item")]
public class ModularAvatarMenuItem : MenuSource public class ModularAvatarMenuItem : MenuSourceComponent
{ {
public VRCExpressionsMenu.Control Control; public VRCExpressionsMenu.Control Control;
public SubmenuSource MenuSource; public SubmenuSource MenuSource;
@ -40,98 +21,32 @@ namespace nadena.dev.modular_avatar.core
public ModularAvatarMenuInstaller menuSource_installer; public ModularAvatarMenuInstaller menuSource_installer;
public GameObject menuSource_otherObjectChildren; public GameObject menuSource_otherObjectChildren;
internal override VRCExpressionsMenu.Control[] GenerateMenu() public override void Visit(NodeContext context)
{ {
switch (Control.type) var cloned = new VirtualControl(Control);
{ cloned.subMenu = null;
case VRCExpressionsMenu.Control.ControlType.SubMenu: cloned.name = gameObject.name;
return GenerateSubmenu();
default:
return new[]
{Control};
}
}
private bool _recursing = false; if (cloned.type == VRCExpressionsMenu.Control.ControlType.SubMenu)
private VRCExpressionsMenu _cachedMenu;
private VRCExpressionsMenu.Control[] GenerateSubmenu()
{
List<VRCExpressionsMenu.Control> controls = null;
switch (MenuSource)
{ {
case SubmenuSource.MenuAsset: switch (this.MenuSource)
controls = Control.subMenu?.controls?.ToList();
break;
case SubmenuSource.Children:
{ {
var menuRoot = menuSource_otherObjectChildren == null case SubmenuSource.MenuAsset:
? gameObject cloned.SubmenuNode = context.NodeFor(this.Control.subMenu);
: menuSource_otherObjectChildren; break;
controls = new List<VRCExpressionsMenu.Control>(); case SubmenuSource.Children:
foreach (Transform child in menuRoot.transform)
{ {
var menuSource = child.GetComponent<MenuSource>(); var root = this.menuSource_otherObjectChildren != null
if (menuSource != null && child.gameObject.activeSelf && menuSource.enabled) ? this.menuSource_otherObjectChildren
{ : this.gameObject;
controls.AddRange(menuSource.GenerateMenu());
}
}
break; cloned.SubmenuNode = context.NodeFor(new MenuNodesUnder(root));
break;
}
} }
/*
case SubmenuSource.MenuInstaller:
controls = menuSource_installer.installTargetMenu?.controls?.ToList();
break;
case SubmenuSource.OtherMenuItem:
if (_recursing || menuSource_otherSource == null)
{
return new VRCExpressionsMenu.Control[] { };
}
else
{
_recursing = true;
try
{
return menuSource_otherSource.GenerateMenu();
}
finally
{
_recursing = false;
}
}
*/
} }
if (controls == null) context.PushControl(cloned);
{
return new VRCExpressionsMenu.Control[] { };
}
if (_cachedMenu == null) _cachedMenu = ScriptableObject.CreateInstance<VRCExpressionsMenu>();
_cachedMenu.controls = controls;
var control = CloneControl(Control);
control.name = gameObject.name;
control.subMenu = _cachedMenu;
return new[] {control};
}
private static VRCExpressionsMenu.Control CloneControl(VRCExpressionsMenu.Control control)
{
return new VRCExpressionsMenu.Control()
{
type = control.type,
parameter = control.parameter,
labels = control.labels.ToArray(),
subParameters = control.subParameters.ToArray(),
icon = control.icon,
name = control.name,
value = control.value,
subMenu = control.subMenu
};
} }
} }
} }

View File

@ -0,0 +1,4 @@
fileFormatVersion: 2
guid: 1a87fa2d043c4c519d21645034348559
timeCreated: 1677147240
folderAsset: yes

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
{
private 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

@ -158,6 +158,13 @@
"com.unity.nuget.newtonsoft-json": "2.0.2" "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": { "nadena.dev.modular-avatar": {
"version": "file:nadena.dev.modular-avatar", "version": "file:nadena.dev.modular-avatar",
"depth": 0, "depth": 0,

View File

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

View File

@ -28,16 +28,7 @@ GraphicsSettings:
m_LensFlare: m_LensFlare:
m_Mode: 1 m_Mode: 1
m_Shader: {fileID: 102, guid: 0000000000000000f000000000000000, type: 0} m_Shader: {fileID: 102, guid: 0000000000000000f000000000000000, type: 0}
m_AlwaysIncludedShaders: m_AlwaysIncludedShaders: []
- {fileID: 7, guid: 0000000000000000f000000000000000, type: 0}
- {fileID: 15104, guid: 0000000000000000f000000000000000, type: 0}
- {fileID: 15105, guid: 0000000000000000f000000000000000, type: 0}
- {fileID: 15106, guid: 0000000000000000f000000000000000, type: 0}
- {fileID: 10753, guid: 0000000000000000f000000000000000, type: 0}
- {fileID: 10770, guid: 0000000000000000f000000000000000, type: 0}
- {fileID: 16000, guid: 0000000000000000f000000000000000, type: 0}
- {fileID: 16001, guid: 0000000000000000f000000000000000, type: 0}
- {fileID: 17000, guid: 0000000000000000f000000000000000, type: 0}
m_PreloadedShaders: [] m_PreloadedShaders: []
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000,
type: 0} type: 0}