DreamGUI
核心概念

输入

输入系统:事件系统与预设 Actor、射线检测器与输入模块、每个玩家各自的指针 / 焦点 / 文本目标、动作路由、导航(Tab、导航作用域、焦点外观、平台的确认与返回、肩键切换标签),弹出层、模态、提示和拖放,文本输入与键盘布局,以及 Slate 输入源。

输入在 DreamGUIInput 模块里,位于核心之上。最重要的一点:输入是按玩家的。每个本地玩家有自己的指针、焦点和文本目标, 分屏的第二个玩家自己悬停、自己聚焦、自己打字。

组成部分

部分类作用
输入子系统UDreamUIInputSubsystem每个世界一个。拥有每个本地玩家的输入(UDreamUIInputUser),在 TG_PostPhysics 里、按玩家顺序跑每个玩家的一帧(游戏暂停时也跑)—— 在所有玩家控制器跑完 TG_PrePhysics 的输入之后、UI 管理器布局和绘制之前
事件系统UDreamEventSystem关卡里放置、Blueprint 能拿到的"一个玩家的输入":它的细节面板就是那个玩家的设置,函数读写那个玩家的输入,事件转发那个玩家的事件
预设 ActorADreamEventSystemActor、ADreamStandaloneInputEventSystemActor、ADreamEnhancedInputEventSystemActor带着事件系统和输入绑定;没有放置时按 UDreamGUISettings::EventSystemActorClass 生成
输入模块UDreamPointerInputModule、UDreamStandaloneInputModule把按键、指针、导航变成对控件的事件
射线检测器UDreamScreenSpaceRaycaster、UDreamWorldSpaceRaycaster屏幕空间与世界空间的命中测试,每个玩家一个(世界空间的见世界空间 UI)
动作路由UDreamUIActionRouter哪个界面听哪个键
导航栈UDreamUINavigationStack导航作用域的栈
弹出层、模态、提示、拖放、虚拟光标UDreamUIPopupLayer、UDreamUIModalSubsystem、UDreamUITooltipSubsystem、UDreamUIDragDropSubsystem、UDreamUIVirtualCursorSubsystem见下文

这些都按需创建:屏幕根、射线检测器和事件系统在第一次用到时就有了,不需要在关卡里放任何东西。

指针、焦点与事件

  • 指针 id 分段:鼠标(以及替代它的虚拟光标)是 0,手指是 100 加手指序号,脚本自己编的指针从 1000 开始。以前第一根手指和鼠标是同一个指针, 会抢悬停和选中;按手指序号取触摸指针要用 DreamUIPointerIds::ForTouch。
  • 点击要求在按下的那个控件上抬起;抬起事件发给接住按下的那个控件。
  • 冒泡:事件从命中的控件沿父链向上,直到某个处理者返回 false。UDreamUserWidget 的 bAllowEventBubbleUp 默认开。
  • 焦点:SetFocus、SetKeyboardFocus、SetUserFocus、ClearKeyboardFocus、HasKeyboardFocus、HasUserFocus、 HasFocusedDescendants,都按控件所属玩家来问(GetOwningPlayer、GetOwningPlayerIndex)—— 树上方最近的用户控件拥有它, 没有用户控件的树属于第一个本地玩家。bIsFocusable 关掉时会放弃它持有的焦点。
  • 暂停:Enhanced Input 预设按原样绑定你给它的动作。游戏暂停且 UDreamUISettings::bScreenSpaceUIAffectByGamePause 打开时, 预设丢弃到达的输入;要让暂停时的点击能到达,那个动作必须在暂停时也触发 —— 插件自带的 IA_* 动作是这样的,你自己的动作要设 bTriggerWhenPaused。
  • 光标:悬停的控件可以驱动硬件光标(Cursor,Default 表示"没意见",让下面的控件决定);ResetCursor 回到没意见。

动作路由

以前"哪个界面听哪个键"是输入 Actor 的 cpp 里一个静态的 FKey 数组:项目没法加动作、改绑定、为它画提示,除非改插件。 现在绑定随注册它的界面生灭,只有最上面的界面拿到按键 —— 这样对话框打开时,"Delete"指的是对话框的删除而不是列表的。

  • UDreamUIActionRouter::RegisterAction(Scope, Action, Callback, UserIndex, bDisplayInActionBar) 注册,动作是一个数据表行; UnregisterAction 注销;GetDisplayBindings 给提示栏用,已经按当前设备解析好;GetHoldProgress 读按住的进度。
  • 路由器自己 tick,游戏暂停时也 tick:按住确认("按住退出""按住重开")就在暂停菜单上,按住要在时间到时触发,而不是松手时。
  • UDreamUIActionTrigger 行为放在按钮旁边,表示"这个按钮也就是 Confirm 做的事"(CommonUI 的 TriggeringInputAction): 界面的确认键和确认按钮成了一回事,不会各走各的。它是行为而不是按钮上的字段,所以对任何控件都有效。
  • 控件库里的 UDreamUIActionBar 是屏幕底部那一排"A:确认 B:返回"提示。它读路由器而不是被告知显示什么,所以不会和按键实际做的事脱节; 只在绑定增减、玩家换设备或换了手柄型号时重建。
  • 绑定先于一切拿到每个按键。

导航

Tab 和 Shift+Tab

不按 Ctrl、Alt、Cmd 时,Tab 和 Shift+Tab 按层级顺序走(深度优先,兄弟按 TabIndex)。控件上的三个属性塑造这条路:

属性作用
bIsTabStopTab 是否停在这里(前提是能聚焦、能导航到)。关掉就只留给方向键、手柄、指针和代码 —— 浏览器的 tabindex="-1"
TabIndex在兄弟之间的顺序,小的在前,相同的按层级顺序;只和兄弟比较
TabNavigation里面的 Tab 停靠点怎么参与:Continue(就地走完、继续往后)、Cycle(在里面转圈)、Contained(到头停住)、Once(整体是一个停靠点)、None(里面都不是停靠点)

列表、平铺和树视图是 Once:Tab 从选中的那一行进去(自己写的容器覆写 ResolveTabEntry 决定从哪进),下一次 Tab 离开。 滚出视野的停靠点会被滚进来;提示框、弹出面板和滚动条从来不是停靠点。

Tab 被关在里面的情况:模态、调暗了背景的对话框、Cycle 的弹出层;另一个玩家的弹出层永远不是停靠点。Tab 提交文本框的编辑并移到下一个, 通过 Tab 到达的文本框开始编辑(bTabStartsTextEdit);下拉框在 Tab 时提交高亮的那一行(bTabCommitsHighlightedRow),菜单整条链关闭。

项目设置(Project Settings > Plugins > Dream GUI):bTabNavigation、TabOrder(Hierarchy;LegacyGeometric 在这个版本里还能放回 2.0 的几何顺序)、bTabWrapsAtScreenEnd、bTabStartsTextEdit。玩家有 DreamGUI 焦点或 Tab 停靠点时,Slate 从裸的游戏视口发起的导航 (Tab、方向键)由 DreamGUI 接管,不会把焦点移出视口、移进 UMG。

方向键、手柄与导航区域

方向导航按 DirectionKeys 表移动焦点。每个控件可以有自己的方向规则(UDreamWidgetNavigation,用 GetOrCreateNavigation 拿到, SetRule、SetExplicitTarget、SetCustomDelegate、SetAllNavigationRules),对应 UMG 的 Navigation。 bRestrictNavigationArea 把导航限制在一个控件的孩子之内,NavigationBoundaryRule 决定走到边界时:Stop(不动)、Wrap(从另一边接上)、 Escape(交给外面那一层,像列表把移动交还给页面)。

导航作用域

UDreamUINavigationScope 是导航意义上的"一个界面、面板或对话框":可以推到前面、拿到焦点、退出时还回去。它记住焦点该从哪开始、 上次交出控制时焦点在哪 —— 打开子菜单再回来,焦点回到原处,而不是落到恰好第一个注册的那个可选控件上。栈的顺序是推入顺序,不是控件层级, 所以从页面打开的对话框无论挂在哪里都在页面前面。

作用域有输入模式 InputMode(EDreamUIScopeInputMode):

值含义
AllDreamGUI 的内置按键都有效(导航、确认、返回、翻页、切标签),游戏听到 DreamGUI 没留下的
Menu和 All 一样,但游戏应当让开 —— 项目的玩法输入问 UDreamUINavigationStack::GetEffectiveInputMode,它说 Menu 时忽略该玩家
Game对该玩家关掉内置的导航、确认和返回:HUD 上的按钮不会被十字键走到,也不会被跳跃键按下;控件注册的绑定、指针和打字仍然有效

没有作用域时用项目设置 InputModeWithoutScope(默认 All)。没有作用域也没有焦点时,导航键只看该玩家自己的屏幕空间画布。

按键作用于焦点,焦点外观只给按键

  • Enter、Space 和手柄的确认键按下的是有焦点的控件,而不是鼠标悬停的那个;没有焦点时什么也不按。
  • 焦点外观 —— Focused 状态和焦点环 —— 只在焦点是按键或手柄移过来的时候显示,像 CSS 的 :focus-visible(bFocusVisibleOnlyFromKeys)。 鼠标点击不再显示它,鼠标悬停也不再把焦点环拿走。
  • 焦点环由项目设置 NavigationSelectionClass 指定的控件类画,并贴合它标记的控件;不设就没有焦点环。外观自己就能表示焦点的控件用 UUISelectable::bUseFocusRing(运行时 SetUseFocusRing)拒绝它。
  • 确认键和返回键用平台的,在运行时读取(bUsePlatformAcceptBack;Switch 上两者对调)。按键表都是项目设置,改了下一个按键就生效: ConfirmKeys、BackKeys、DirectionKeys、PageKeys、ExtentKeys、PreviousTabKeys、NextTabKeys。
  • 扳机键翻页;肩键切换当前标签视图的标签,跳过禁用的、两端循环,提示栏显示"上一个标签""下一个标签"。自己写的控件实现 IDreamUITabSwitchTarget 就能响应肩键。
  • 在 DreamGUI 的 UI-only 输入模式(UDreamUIInputModeLibrary::SetInputModeUIOnly)下,玩家用手柄时光标隐藏,回到鼠标或键盘时出现 (bHideCursorOnGamepad);别的模式下 DreamGUI 不碰光标。bAutoVirtualCursorOnGamepad(默认关)在物理设备是手柄时显示一个虚拟光标。

弹出层、模态、提示和拖放

弹出层(UDreamUIPopupLayer)每个玩家一层。弹出的东西(下拉列表、菜单)还是它原来那个控件,只是被抬到玩家的屏幕根下、保持世界位置不变 —— 这样它不会被祖先的边界裁掉,也不算进祖先的布局,就像 UMG 的下拉列表住在 Slate 的菜单栈里。弹出层负责外部点击、焦点、菜单嵌套, 以及打开期间跟随移动的锚点。Back 只关最上面那个,关闭后焦点还给打开它的人。弹出层说明 Tab 在它里面做什么(TabBehavior): Cycle 在里面转圈;CloseAndContinue 关掉并从打开者那里继续走(下拉列表和菜单)。在一个打开的弹出层前面画着的层上按一下, 会关掉弹出层,而且按下仍然到达那一层。

模态(UDreamUIModalSubsystem):ShowModal(DialogClass, OnResult, UserIndex) 显示一个对话框并等待结果,CloseTopModal(Result)、 CloseAllModals、IsModalActive、GetModalDepth。遮罩吃掉所有指针事件(颜色 ModalScrimColor),作用域把手柄焦点关在里面、事后恢复, Back 意味着"以 Back 结果关闭"。模态可以嵌套:一个模态还开着时再 ShowModal,新对话框带着自己的遮罩和作用域盖在上面,关掉它就露出下面那个, 仍在等自己的结果。显示模态会先关掉该玩家打开着的弹出层。

提示:任何控件都有 ToolTipText 和 ToolTipWidgetClass(后者优先,是一个类:气泡出现时实例化、消失时销毁)。 UDreamUITooltipSubsystem 找指针下离得最近、提供了提示的祖先;行为可以实现 IDreamUITooltipSourceInterface 来提供一个控件。 项目设置 TooltipDelaySeconds(0.5)、TooltipOffset、TooltipMaxWidth、TooltipFontSize。

拖放:UDreamUIDragSource 在拖拽开始时建一个 UDreamDragDropOperation 写到指针的事件数据上;UDreamUIDropTarget 读取落下的操作, 按 RequiredTag 和载荷类过滤,接受时标记已处理并广播;被拒绝的放下会继续冒泡,所以嵌套的目标和别的嵌套行为一致。 没有拖拽源,拖拽就只是几何上的 —— 滚动视图要的正是这个,背包里的物品则不是。

文本输入与键盘布局

DreamGUI 的文本框不是 Slate 控件,所以从来不在键盘焦点路径上;而引擎在游戏里交出平台字符的唯一地方是虚函数 UGameViewportClient::InputChar,没有可以订阅的委托。没有人接管这个函数时,文本框退回自己的 FKey 到字符的表,而那张表只在 美式 QWERTY 上正确:AZERTY、QWERTZ、Dvorak、西里尔、死键和 AltGr 都会打错字(IME 不受影响,组字文本走 TSF)。文本框第一次被编辑时, 如果两种办法都没用上,会记一条警告。

最简单的办法是用插件的视口客户端:

[/Script/Engine.Engine]
GameViewportClientClassName=/Script/DreamGUIInput.DreamGameViewportClient

保留自己的视口客户端的话,让它派生自 UDreamGameViewportClient,或者在它的 InputChar 里、控制台之后、基类之前调用 DreamUITextInputRouter::RouteViewportCharacter。详细写法见安装。

Slate 输入源

默认情况下 DreamGUI 通过预设事件系统 Actor 在玩家控制器上的绑定听输入,所以在引擎自己的 UI-only 输入模式下什么也听不到: SetInputMode(FInputModeUIOnly()) 让游戏视口忽略输入,控制器根本收不到。

打开 Project Settings > Plugins > Dream GUI > Input > Use Slate Input Source(bUseSlateInputSource),DreamGUI 就直接从 Slate 听鼠标、触摸、按键和摇杆 —— 一个排在游戏视口之前的输入预处理器 —— 在任何输入模式下都有效。打开期间预设 Actor 退下,不会收到两次。 SlateInputConsumePolicy 决定 UI 从游戏那里留下什么:

值UI 留下的
Never(默认)什么都不留,游戏照常听到一切
WhenOverUI在 DreamGUI UI 上的按下、抬起和滚轮,以及 UI 接住的按键
WhenHandled只有落在处理按下的控件上的按下,以及 UI 接住的按键

正在编辑的文本框里敲的键总是留下。这个输入源目前默认关闭,会在之后的版本里成为默认。

本页目录