Godot自定义菜单构建

在Godot中,编辑器扩展和在Unity中相比底层的支持更少,从引擎的量级对比上就可以看出。默认的可添加UI控件的位置并不包含左上角和场景、项目等并列的位置,这就导致使用起来不方便的问题。

另一个问题是,当使用外部编辑器之后,对于EditorScript的运行便无法通过常规的Script->文件->执行调用,这样导致如果要频繁地调用简单的编辑器专用脚本,切换时非常麻烦。因此需要采用EditorPlugin的方式来创建,但这样就回到了第一个问题。

考虑到Godot使用的是构建游戏的所有对象来构建自己的引擎,二者是同源的,Godot的UI也是在启动的时候构建成了一个树状结构,那么我们可以筛查这个UI结构,找到要插入控件的父对象,然后把我们要添加的自定义菜单PopupMenu对象作为孩子插入即可。

定位语法树

既然语法树是一个树状结构,我们可以通过递归调用的方式获取并输出每一个节点。获取根节点的接口是EditorInterface.Singleton.GetBaseControl(),通过依次递归调用,得到了从根节点到目标位置的语法树(Godot版本4.4.1 NET.,不保证稳定):

  • @Panel@14 (Panel)
    • @VBoxContainer@15 (VBoxContainer)
      • @EditorTitleBar@16 (HBoxContainer)
        • @MenuBar@103 (MenuBar)
          • 场景 (PopupMenu)
            • @PopupMenu@4924 (PopupMenu)
            • @PopupMenu@4930 (PopupMenu)
          • 项目 (PopupMenu)
            • @PopupMenu@4941 (PopupMenu)
              • CSharpTools (PopupMenu)
            • @PopupMenu@14113 (PopupMenu)
          • 调试 (PopupMenu)
          • 编辑器 (PopupMenu)
            • @PopupMenu@4954 (PopupMenu)
            • @PopupMenu@4960 (PopupMenu)
          • 帮助 (PopupMenu)

根据Godot的API:

使用@EditorTitleBar@*这样的方式可以找到最终的位置,此时调用AddChild方法添加一个PopedMenu对象即可。

添加可执行项

现在离成功执行脚本只差一步,只有向PopupMenu中添加元素才能有效果。这一点很简单,调用AddItem接口,传递选项显示内容和唯一ID即可做到。

关于处理菜单选择,PopupMenu通过IdPressed信号来接收操作,该信号传递一个类型为long的id,表示对应的选项被按下,这个时候需要自己处理该怎么操作,比如维护一个回调Dictionary。信号处理函数如下:

private Dictionary<long, Action> _menuCallbacks = new();
private void OnMenuItemPressed(long id)
{
    if (_menuCallbacks.TryGetValue(id, out Action action))
        action?.Invoke();
}

热重载

使用C#比较麻烦的一点是,任何改动如果要生效都需要重新Build,这就涉及到一个热重载的问题。我做了一个简易的实验,在构造函数和 _ExitTree中提供字段的输出,在 _EnterTree中进行字段的设置,得到了以下的结果:

其中True和False输出的是是否为空。

根据实验可以得到以下结论:

  • 当EditorPlugin在启用的情况下Build,那么这个对象将会被销毁并重新构建,同时会复制之前所有的字段值,如果是引用对象也会复制原来的引用。
  • Build的过程中不会触发 _EnterTree函数和 _ExitTree函数,但是会调用构造函数。
  • Build的过程中不应该预设UI字段仍然有效,所以在_ExitTree函数中应该重新FindChild。

妥协

因此,鉴于引用是直接复制的,那么要求就是向管理目录的对象中添加的回调一定是static的,即不包含实际调用的信息,不包含对象的成员。以下面的代码为例,ScanDeployAsset是一个static函数,具体内容省略。

public partial class DeployScanner : EditorPlugin
{
	private static readonly string itemName = "扫描可部署资产";

	public override void _EnterTree()
	{
		GetTree().ProcessFrame += Register;
	}

	public override void _ExitTree()
	{
		ProjectTopMenu.Instance?.RemoveItem(itemName);
  }

	public void Register()
	{
    GetTree().ProcessFrame -= Register;
    ProjectTopMenu.Instance?.AddItem(itemName, ScanDeployAsset);
  }
}