输入
输入系统:事件系统与预设 Actor、射线检测器与输入模块、每个玩家各自的指针 / 焦点 / 文本目标、动作路由、导航(Tab、导航作用域、焦点外观、平台的确认与返回、肩键切换标签),弹出层、模态、提示和拖放,文本输入与键盘布局,以及 Slate 输入源。
输入在 DreamGUIInput 模块里,位于核心之上。最重要的一点:输入是按玩家的。每个本地玩家有自己的指针、焦点和文本目标,
分屏的第二个玩家自己悬停、自己聚焦、自己打字。
组成部分
| 部分 | 类 | 作用 |
|---|---|---|
| 输入子系统 | UDreamUIInputSubsystem | 每个世界一个。拥有每个本地玩家的输入(UDreamUIInputUser),在 TG_PostPhysics 里、按玩家顺序跑每个玩家的一帧(游戏暂停时也跑)—— 在所有玩家控制器跑完 TG_PrePhysics 的输入之后、UI 管理器布局和绘制之前 |
| 事件系统 | UDreamEventSystem | 关卡里放置、Blueprint 能拿到的"一个玩家的输入":它的细节面板就是那个玩家的设置,函数读写那个玩家的输入,事件转发那个玩家的事件 |
| 预设 Actor | ADreamEventSystemActor、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)。控件上的三个属性塑造这条路:
| 属性 | 作用 |
|---|---|
bIsTabStop | Tab 是否停在这里(前提是能聚焦、能导航到)。关掉就只留给方向键、手柄、指针和代码 —— 浏览器的 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):
| 值 | 含义 |
|---|---|
All | DreamGUI 的内置按键都有效(导航、确认、返回、翻页、切标签),游戏听到 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 接住的按键 |
正在编辑的文本框里敲的键总是留下。这个输入源目前默认关闭,会在之后的版本里成为默认。
渲染
渲染器怎么工作:DreamGUIRenderer 视图扩展、世界画布的两种渲染器、draw call 合批、渲染层、视觉上的材质(渲染线程代理、DreamUI_IsRenderByDreamUIRenderer、sRGB 编码值上的混合与渲染器自己做的预乘)、渲染目标画布、屏幕效果,以及 r.DreamUI.Verify* 检查。
动画
三种动画方式:DreamTween 模块的补间(面板里的控件用 RenderOffsetTo、RenderScaleTo、RenderAngleTo、RenderTranslationTo,EDreamTweenEase,DreamTween.MaxStepSeconds),控件动画(UDreamWidgetAnimation 与 Sequencer),以及 .dui 里的 timeline。