devlog#0 Unity UI 开发二三事

@molingyu 2019-10-07 12:42:51发表于 molingyu/blog devlogunity

前情回顾

在差不多一年以前,我曾发过一篇预告讲述了当时正在做的事情。然而在此之后,我遇到了两个当时比较棘手的问题,让我终止了开发,转而去解决这两个问题其中之一:Unity 多语言的问题。当然,虽然我为此开发了 Rosetta,但不得不承认其中太多时间被我摸鱼了 orz,这一年里有折腾 unity 的一些其他东西,包括维护了一个 GithubForUnity 的分支版本,也因为机缘巧合参与了一段时间的 MojsulPlus 的维护,做了一些与此相关的工作。近期找工作途中的一些事情让我意识到是时候捡起之前的 RM4U(RPG Maker for Unity 缩写,之后会一直使用这个简写)的开发了,并且最好写 devlog,一方面算是个记录和强制,另一方面也可以把我 Unity 2D 开发遇到的诸多问题和对应的解决办法以及体悟分享给大家。这样既能帮助别人也能得到别人的斧正,正所谓我为人人,人人为我。

关于日志内容/更新/发布的说明

下边是一些关于这个系列的问题说明,如果你只关注这篇文章的议题而不在意其他,则可直接跳到下一节开始阅读。

这个系列主要以 RPG Maker for Unity 的开发内容为主。当然,由于我一贯的喜欢事情做到一半造轮子的行径,因而除了上述内容外,开发途中钻研一些相关技术或者造些可能会用上的轮子的经历也会记录在其中。

这个系列我会尽量维持每周一篇的更新频率。通常来说我会在周末两天内来完成这件事。如果有事烦扰的话,最迟会在下周一来完成。如果超过这个时间,则意味着这篇 blog 被我鸽了。

同时这些 devlog 也会同步发布到个人博客/知乎专栏/COWLEVELINDIENOVA上。

需要注意的是,除了这个日志系列外,我可能依然会尝试写一些讲述某个方向领域的复杂问题解决方法之类的长文。由于内容的原因也难以归类到这个开发日志系列内。如果当周内更新了长文,那么 devlog 基本上不会再更新了。

正文


在去年的那篇文章里,我展示了当时正在开发的 RM4U 的 UI 部分,其 UI 基本是依照 RMVA 的来实现的。

old UI

当时在设计时考虑了鼠标为主键盘为辅的输入模式。在之后停掉 RM4U 的开发后,我曾用 Axure 做过另一版本的 UI 设计。在这一版的 UI 设计里,一改 RMVA 偏拟物的风格,而采用扁平 UI,ActorList 界面也换成了带人物立绘版的。不过这版只完成了 ActorList 和 两个 Item 界面的制作。

这两个版本的 UI 设计都是尽可能还原 RM 的原版设计。即使是扁平化的版本,其布局也是有参照官方在 RMMV 上的一个对应的 UI 插件。另一方面,UI canvas 的大小也依然采用 640 * 360(16 : 9),和游戏本身的分辨率保持一致(RM4U 的设计分辨率是 640 * 360,这样在 1080P 环境下正好三倍放大)。

让 UI 的分辨率和游戏保持一致,这本是很自然的做法。但是这样大小的 UI 有着致命的问题:太过小巧的尺寸,使得 UI 界面可以使用的空间过小,UI 元素很容易拥挤在一起。很多时候需要弹出二级三级窗口。这个致命的问题在你的 UI 比较朴素或者元素较少(比如 R剧这样)时不容易暴露,但当你希望做出比较出彩的 UI 设计时,在 RM 本身密集 UI 元素显示的硬需求下就很容易爆发。这也是当时第二版的扁平化 UI 设计时的困境,一方面,我希望不仅在元素风格,在布局上也能践行扁平化 UI 的理念,使得 UI 界面能有更多的留白,另一方面,过度拥挤的 UI 元素又让我不得不回头参考 RM 原版多级窗口的方式等来处理布局的问题。该版设计稿的 Item 界面存在的两个版本就是当时的取舍难题的延申。

当然,这些工作都是很早之前的事情了。而在近期复工之后,为了找寻新的灵感,去尝试玩了下今年十分有名的 JRPG 大作《歧路旅人》。《歧路旅人》可谓是十分的 JRPG 了,其无论是系统还是 UI 界面上,给我一股十分浓厚的 RM 气息。游戏在系统机制上我个人觉得是没太多出彩的地方,最大的亮点还是它的渲染模式。至于剧情,由于我着急观察它的 UI 布局,甚至通过修改器改了存档而尽快开启了被禁用的一些菜单项,所以说基本没体验到。希望之后能不带着这么强的功利心而重新体验一番它吧。

继续说回 UI 的设计问题,《歧路旅人》虽然要显示的 UI 元素和 RM 基本重叠,但是因为它的 UI 分辨率是 1080 * 720,这使得界面看起来并不怎么臃肿,反而有了留白,使得有足够的地方来装饰 UI。两者结合就让它的 UI 设计十分具有美感。因此复工的第一周,主要工作就是在 Unity 下抄《歧路旅人》的 UI。囧

newUI

InputSystem


在前两版的 UI 设计里,我都在尝试让鼠标和键盘/手柄的操作融合在一起,尤其是能自然的切换和适应。但之后发现这几乎是一个不可能的事情。无论是 RMMV 还是《歧路旅人》,鼠标的引入都是足够的糟糕。因而我决定制作一个纯键盘/手柄的操作输入系统。

不过这里就遇到一个问题,如果我希望能在各个手柄上很自然的适应,那么就需要一个足够好的中间层来解决这个问题。之前的开发经历让我对 InputManager 系统还算熟悉,不过这次我决定试一试新东西———— Input System。这是 Unity 最新的用于取代旧的 InputManager 的输入管理系统。当然,目前依然处于 Preview 状态。不过我一直在 Unity 版本上采取比较激进的态度,这点自不需要在意。

关于 Input System,除了官方文档外,B 站 up 主风农的相关视频也是很不错的学习和了解的途径:av67858981/av67860431

InputSystem 的思路是定义 Action 之后在 Action 上绑定具体的硬件操作。然后使用的时候直接和 Action 相关,除此之外便于在不同情况下使用不同的 Action 集(比如日常操作和 UI 操作)而有了 Theme。除此之外,具体到 Action 和其上的绑定,有 Interaction 和 processor 的概念。前者用来限制实际硬件的输入会不会出发 Action,比如只有按住多少秒才算触发这种。官方本身提供了一些 interaction,你有需要的话也可以自己定义新的。这套东西甚至可以帮助你编辑格斗游戏的搓招。后者作用是在 Action 触发后,把触发的指传给 Action 时做一些额外的操作。主要用途还是为了抹除各个硬件输入系统差异。比如用摇杆绑定了一个 vector2 然后手柄 a 的值区间和 手柄 b 的值区间有差异,则可以通过对这个值的 scale 操作来确保在相似的区间。甚至你可以反转摇杆的方向之类的。

在 UGUI 下,Input System 的核心是 PlayerInput 组件。一个 PlayerInput 组件对应一个玩家输入,你可以同时激活多个来实现多本地玩家输入。在这个新版的 UI 里,每个 Window 类都有一个 Control 字段来指向该窗口所拥有的 PlayerInput 组件。当然,不是每个 Window 类的该字段是有值的,只有那些会响应操作的窗口才会真正有一个实例。多个 PlayerInput 组件并存的解决方法是,Window 有个 focus 的概念,只有当前拥有 focus 的窗口其上的 PlayerInput 组件才会被启用,其他的都处于禁用状态。

遐思

使用 InputSystem 的时候,发现了两个问题,算是我觉得有待增强和可提高的地方。第一点是,InputSystem 在 Action 的绑定的时候,可以增加 Interaction 来做一些处理。目前官方提供的 Interaction 有想 hold、press 等,但是缺乏一个 filter 来限制触发频率。一个常见的场景是 UI 菜单的选项切换。当我们按一次或者短暂的按住时,选单只会切换一次,但是当我们持续按住后,当超过一个指定时间,则选单会依照一个特定的频率来持续切换。类似的例子还有人物走动一格和持续按住切换连续行走。另一个地方是,你可以通过添加并激活多个 InputPlayer 组件从而实现本地的多个输入。这样你把每个 InputPlayer 绑定给独立的 Player 对象就可以十分方便的实现本地多人。那么既然 InputSystem 的抽象层能消除掉本地不同硬件输入的差异,同意抽象成一套上层的机制,那么只要我们让他同时支持远程硬件信号的输入,那么就可以用支持本地多人的方法来支持线上多人游戏。

UniRx 和 MVVM


我在做第一版的 UI 途中就想到过要不要引入 ReactiveX 了。自动绑定对于 UI 开发确实充满了诱惑。当时因为时间仓促就按比较传统的方式来实现了。后来了解到 UniRx 这个 Unity 下 ReactiveX 的实现让这一切变得更充满诱惑了。所以最近尝试了一下 UniRx。体验结果就是很多地方并不像设想的那样美好。

在说明这个问题之前,我先稍微讲一下 RM4U 的架构和原本是如何处理 UI 数据绑定的问题的。在 RM4U 里,运行时的数据和主要逻辑存在于 GameXXX 系列的类里,大部分 GameXXX 类都有对应的 DataXXX 类版本。而我们 UI 需要显示的数据基本上都来自于 GameXXX 类。比如 Actor 界面的数据来自 GameActor 类的实例。传统的做法是在每个 Window 显示的时候 调用一个 Refresh 函数来引发整个 Window 的重绘。当然,每个 Window 的内容并不是每次都需要完全重绘的。大体上会把一些联动变动的内容单独写成 RefreshXXX 的函数,然后在操作的地方去调用对应的刷新函数(比如对于 ActorWindow 来说,使用药剂时,就会改变角色的属性值和等级相关。但是角色的贴图则不会变)。

之前编写 UI 的时候,很多时候你要考虑清楚当前窗口下会涉及那些操作,这些操作又会变动那些数据,而这些数据的变动最后又会影响那些 UI 的重绘。换到 Rx 下这些问题看起来是不需要思考了,绑定后修改 GameXXX 类,变动会自动反映到 UI 上。

然而,对于 UniRx 如果你想订阅一个 object 上属性发生的变动,那么你必须把这个属性变成 ReactiveProperty。

// Reactive Notification Model
public class Enemy
{
    public ReactiveProperty<long> CurrentHp { get; private set; }

    public ReactiveProperty<bool> IsDead { get; private set; }

    public Enemy(int initialHp)
    {
        // Declarative Property
        CurrentHp = new ReactiveProperty<long>(initialHp);
        IsDead = CurrentHp.Select(x => x <= 0).ToReactiveProperty();
    }
}

// ---
// onclick, HP decrement
MyButton.OnClickAsObservable().Subscribe(_ => enemy.CurrentHp.Value -= 99);
// subscribe from notification model.
enemy.CurrentHp.SubscribeToText(MyText);
enemy.IsDead.Where(isDead => isDead == true)
    .Subscribe(_ =>
    {
        MyButton.interactable = false;
    });

这是一段 UniRx 的示例代码。一个反应式的数据 Model。如果你的 Model 的字段也是一个对象,那么对应的这个对象也得把写成这样的形式。最后就是你的所有运行时的 Model 部分的大部分字段都得变成 ReactiveProperty 形式。

一个不饿能忽略的事情就是,这样的写法显然降低了字段访问的效率。而对于游戏来说,有时候一些角色属性等字段会有着较高的访问频率。尤其是实时战斗类型的游戏,一通技能会让访问数爆炸。而显然在这种情况下,我们对这些属性的订阅没什么意义(因为这些订阅涉及的 UI 现在都不会显示)。

MVVM 诞生是为了应对企业级的 UI 开发的需求。这些场景下 需求多变,其次 UI 对应的数据变动频率并不高,而且这些涉及的变动其源头同样来自 UI 上的操作。但是这些需求背景在游戏开发上就不见的十分适用。而且在 RM4U 下,UI 开发也不会涉及多变的需求(一方面我自己就是设计人员,另一方面 RM4U 的 UI 是在有多个设计参考的情况下去做类“复原”的工作)。

更重要的一点是,在我尝试在 RM4U 下复刻《歧路旅人》的 UI 设计里的底部操作提示栏后,发现实际上每个窗口和相应的状态下,其所对应的操作都是十分有限的而且很容易就能理清的,那么对应的 UI 数据变动这件事也很容易理清。所以传统方式的开发并没有太多困难。而在做 RM4U 第一版 UI 设计的时候主要还是自己思路上没有理清(当然,缺乏一个好的理清思路的工具也是很重要的一点。而《歧路旅人》的操作提示栏就是一个很好的帮助理清思路的工具)。

所以兜兜转转,最后有走回去了。对于 UI 上使用 MVVM 的计划也放弃掉了。

结语

以上就是上一周的(其实算是上上周的,国庆期间摸鱼摸的太厉害了)的开发内容。之后的目标重心还是在抄《歧路旅人》的 UI,不过我打算做一些提高做 UI 效率的 Editor 工具和一些中间件什么的。就敬请期待下一次的开发日志吧!