gewuyou 289f12f309 docs(batch-boot): 收口旧入口对比文案
- 更新 Core、Game、Godot 与 source-generators 多个页面的 reader-facing 契约说明

- 将旧文档和旧入口对比句式改成直接陈述当前默认入口、约束与推荐做法

- 补充 documentation full coverage active topic 的 RP-047 跟踪与验证记录
2026-04-28 07:37:20 +08:00

9.3 KiB
Raw Blame History

title, description
title description
Godot UI 系统 说明 GFramework.Godot 当前如何把 Game UI 契约接到 PackedScene、页面行为和层级 UI 接入路径上。

Godot UI 系统

GFramework.Godot.UI 当前负责的是把 GFramework.Game 的 UI 路由契约接到 Control / CanvasLayer / PackedScene 上,而不是再提供一套 Godot 专用路由器类型。

当前真正参与这条链路的核心类型是:

  • IGodotUiRegistry / GodotUiRegistry
  • GodotUiFactory
  • CanvasItemUiPageBehaviorBase<T>
  • UiPageBehaviorFactory
  • Page / Overlay / Modal / Toast / Topmost 五类 layer behavior
  • 项目侧实现的 IUiRoot
  • 项目侧继承 UiRouterBase 的路由器

当前公开入口

IGodotUiRegistry

Godot 侧 UI 资源表,底层是 IAssetRegistry<PackedScene>。它只负责:

  • uiKey -> PackedScene 映射
  • GodotUiFactory 可以按 key 实例化 UI 页面

框架当前不会自动扫描 .tscn、不会自动根据类型名补全注册表。

GodotUiFactory

GodotUiFactory.Create(string uiKey) 的当前行为比场景工厂更严格:

  1. IGodotUiRegistry 取出 PackedScene
  2. 调用 Instantiate()
  3. 节点必须实现 IUiPageBehaviorProvider
  4. 返回 provider.GetPage()

如果实例化得到的节点没有实现 IUiPageBehaviorProvider,当前实现会直接抛 InvalidCastException。这也是 UI 页面文档必须强调 GetPage() / [AutoUiPage] 的原因。

CanvasItemUiPageBehaviorBase<T>

Godot runtime 的页面行为包装基类。它把 IUiPageBehavior 的这些语义接到 CanvasItem 上:

  • Key
  • Layer
  • Handle
  • IsAlive
  • IsVisible
  • InteractionProfile
  • OnEnter / OnExit
  • OnPause / OnResume
  • OnShow / OnHide
  • TryHandleUiAction(UiInputAction action)

如果 owner 同时实现了 IUiPageIUiInteractionProfileProviderIUiActionHandler,这些契约都会被页面行为继续利用。

UiPageBehaviorFactory

当前 layer 到 behavior 的映射来自运行时代码本身:

  • UiLayer.Page -> PageLayerUiPageBehavior<T>
  • UiLayer.Overlay -> OverlayLayerUiPageBehavior<T>
  • UiLayer.Modal -> ModalLayerUiPageBehavior<T>
  • UiLayer.Toast -> ToastLayerUiPageBehavior<T>
  • UiLayer.Topmost -> TopmostLayerUiPageBehavior<T>

几个容易混淆的默认语义如下:

  • Page
    • 不可重入,阻断输入
  • Overlay
    • 可重入,非模态,不阻断输入;暂停时不会停掉节点处理
  • Modal
    • 可重入,模态,阻断输入
  • Toast
    • 可重入,非模态,不阻断输入
  • Topmost
    • 不可重入,模态,阻断输入

最小接入路径

1. 继续在项目层保留自己的路由器

仓库当前不存在 GodotUiRouter 类型。实际做法仍然是项目侧继承 GFramework.Game.UI.UiRouterBase

最小形态通常就是:

using LoggingTransitionHandler = GFramework.Game.UI.Handler.LoggingTransitionHandler;

namespace GameProject.UI;

[Log]
public partial class UiRouter : UiRouterBase
{
    protected override void RegisterHandlers()
    {
        _log.Debug("Registering default transition handlers");
        RegisterHandler(new LoggingTransitionHandler());
    }
}

Godot runtime 自身并不接管这层路由器定义。

2. 注册 IGodotUiRegistryIUiFactory

最小 wiring 需要显式注册 UI 资源表和工厂:

using GFramework.Game.Abstractions.UI;
using GFramework.Godot.UI;
using Godot;

public sealed class GameUiRegistry : GodotUiRegistry
{
    public GameUiRegistry()
    {
        Register(nameof(UiKey.MainMenu), GD.Load<PackedScene>("res://ui/main_menu.tscn"));
        Register(nameof(UiKey.PauseMenu), GD.Load<PackedScene>("res://ui/pause_menu.tscn"));
        Register(nameof(UiKey.OptionsMenu), GD.Load<PackedScene>("res://ui/options_menu.tscn"));
    }
}

architecture.RegisterUtility<IGodotUiRegistry>(new GameUiRegistry());
architecture.RegisterUtility<IUiFactory>(new GodotUiFactory());
architecture.RegisterSystem(new UiRouter());

3. 提供 IUiRoot

UiRouterBase 只负责页面栈、layer UI、输入仲裁和暂停语义真正把页面挂到 Godot 容器的是项目自己的 IUiRoot

CoreGrid 当前的 UiRoot 做法和源码契约一致:

  • 继承 CanvasLayer
  • 为每个 UiLayer 创建一个 Control 容器
  • _Ready() 时调用 _uiRouter.BindRoot(this)
  • AddUiPage / RemoveUiPage 中处理 CanvasItem 挂载与释放

最小形态可以写成:

public sealed class UiRoot : CanvasLayer, IUiRoot
{
    [GetSystem] private IUiRouter _uiRouter = null!;

    public override void _Ready()
    {
        __InjectContextBindings_Generated();
        _uiRouter.BindRoot(this);
    }

    public void AddUiPage(IUiPageBehavior child)
    {
        AddUiPage(child, UiLayer.Page);
    }

    public void AddUiPage(IUiPageBehavior child, UiLayer layer, int orderInLayer = 0)
    {
        if (child.View is not CanvasItem item)
            throw new InvalidOperationException("UIPage View must be a Godot Node");

        AddChild(item);
        item.ZIndex = (int)layer * 100 + orderInLayer;
    }

    public void RemoveUiPage(IUiPageBehavior child)
    {
        if (child.View is Node node && node.GetParent() == this)
            RemoveChild(node);
    }
}

4. 让页面节点提供 GetPage()

因为 GodotUiFactory 不会自动回退到默认 behavior页面节点必须显式提供 GetPage()

方式 A手写 IUiPageBehaviorProvider

public partial class PauseMenu : Control, IUiPage, IUiPageBehaviorProvider
{
    private IUiPageBehavior? _page;

    public IUiPageBehavior GetPage()
    {
        return _page ??= UiPageBehaviorFactory.Create(this, nameof(UiKey.PauseMenu), UiLayer.Modal);
    }

    public void OnEnter(IUiPageEnterParam? param)
    {
    }

    public void OnExit()
    {
    }

    public void OnPause()
    {
    }

    public void OnResume()
    {
    }

    public void OnShow()
    {
    }

    public void OnHide()
    {
    }
}

方式 B[AutoUiPage] 让生成器补样板

当前更贴近真实消费者 wiring 的方式,是让生成器产出 UiKeyStrGetPage()

using GFramework.Game.Abstractions.UI;
using GFramework.Godot.SourceGenerators.Abstractions.UI;
using Godot;

[AutoUiPage(nameof(UiKey.MainMenu), nameof(UiLayer.Page))]
public partial class MainMenu : Control, IUiPageBehaviorProvider, IUiPage
{
    public void OnEnter(IUiPageEnterParam? param)
    {
    }

    public void OnExit()
    {
    }

    public void OnPause()
    {
    }

    public void OnResume()
    {
    }

    public void OnShow()
    {
    }

    public void OnHide()
    {
    }
}

当前生成器补出的核心样板与源码一致:

public IUiPageBehavior GetPage()
{
    return __autoUiPageBehavior_Generated ??=
        UiPageBehaviorFactory.Create(this, UiKeyStr, UiLayer.Page);
}

要注意两点:

  • [AutoUiPage] 不会替你自动补 : IUiPageBehaviorProvider
  • UI 层级是生成器输入的一部分;Page / Modal / Overlay 语义不是后面再猜出来的

5. 按 layer 选择正确入口

Godot runtime 只是落地 UiRouterBase 的语义,因此入口仍然和 GFramework.Game 一致:

页面栈:

await uiRouter.ReplaceAsync(nameof(UiKey.MainMenu));
await uiRouter.PushAsync(nameof(UiKey.Settings));
await uiRouter.PopAsync();

层级 UI

var handle = uiRouter.Show(nameof(UiKey.PauseMenu), UiLayer.Modal);
uiRouter.Hide(handle, UiLayer.Modal);

当前实现里,Show(..., UiLayer.Page) 会直接抛异常;Page 层必须走 PushAsync / ReplaceAsync

输入与暂停语义

如果页面只实现 IUiPage,它只有基础生命周期。

如果还需要更强的输入仲裁或暂停语义,可以像 CoreGrid 的 PauseMenu 一样继续实现:

  • IUiInteractionProfileProvider
  • IUiActionHandler

当前这条链路是成立的:

  1. 页面行为从 owner 读取 UiInteractionProfile
  2. router 根据 profile 判断动作捕获、世界输入阻断和暂停策略
  3. 如果页面实现了 IUiActionHandlerTryHandleUiAction(...) 会继续下沉到页面

这也是为什么 PauseMenu 一类 modal 页面可以声明:

  • 捕获 Cancel
  • 阻断 World pointer / action input
  • 在可见时持有暂停
  • 即使在暂停状态也继续处理节点逻辑

当前边界

没有 GodotUiRouter

仓库当前没有这个类型;真实入口仍然是项目侧的 UiRouterBase 派生类。

UI 工厂不会自动补 behavior

GodotSceneFactory 不同,GodotUiFactory 当前不会按节点类型自动创建 behavior。节点不实现 IUiPageBehaviorProvider 时会直接失败。

Page 层不是 Show(...) 的适用对象

UiLayer.Page 代表页面栈语义,而不是普通 layer UI。当前实现明确要求

  • PagePushAsync / ReplaceAsync
  • Overlay / Modal / Toast / TopmostShow / Hide

root 仍然由项目控制

IUiRoot 决定:

  • 每个 layer 是否拆独立容器
  • 层内排序怎么算
  • 页面移除时如何释放节点

Godot runtime 不会替项目自动生成统一 UI 根节点。

继续阅读

  1. Godot 运行时集成
  2. Game UI 系统
  3. AutoUiPage 生成器
  4. Godot 架构集成