« May 2023 | Main | July 2023 »

June 21, 2023

手机游戏交互的一点想法

这一篇是延续去年的想法 。最近正在按这些想法改进我们正在制作的一款类异星工厂的手机游戏。

我们的游戏引擎是在 PC 上开发的,但希望专门用于手机。虽然我希望日常游戏开发都在真机上进行,引擎为之也做了很多的努力。但实际并不算顺利。主要还是引擎以及游戏的核心 C 部分还在频繁改动,在真机上调试毕竟不如 PC 上方便。好在最近已经慢慢稳定下来,开发中使用真机的机会越来越多,也就更多的考虑在手机上的交互问题。

我意识到的第一个问题就是:在 PC 上模拟的版本是无法体验手机上的交互感受的,想把手机上的交互做好,就必须时刻在手机上体验。

我们引擎原本的交互界面都是基于鼠标消息,触摸屏消息只是对鼠标消息的模拟。这样是不对的。意识到问题后,我们首先把鼠标消息从引擎中去掉,把触摸屏手势消息改为 first class ,而鼠标消息则反过来用手势消息模拟。

基本手势其实有四个:点击、长按、拖动、缩放。其中在不同场合下,拖动可以被清扫取代,但两者不可共存。

玩建设类游戏,触摸屏交互和鼠标交互看似差不多,实际差别很大。

触摸屏的点击面其实是一个圆形区域,而鼠标则是一个点。鼠标更容易精确定位点击,而触摸屏难以做到精确。

鼠标有移动和悬停消息,在类似游戏中非常有用,但触摸屏是没有悬停事件的。

手指点击物件的时候,手指会遮挡住点击处,如果按鼠标的模式进一步做拖动,更是会一直挡住被拖动物件,这会导致非常糟糕的交互体验。

所以,我思考了以上问题后,采取了如下的方案:

首先,我们需要定义出一个和游戏场景内物件聚焦的操作。这个操作类似鼠标的悬浮,表示交互焦点在某个物件上。由于触摸屏没有悬浮事件,用点击(tap) 作为等价行为是合适的。

当玩家点击屏幕某个位置时,我们认为他其实是想把交互焦点移到手指下的物件。手指点击难以精确,所以我们可以轮询点击处附近的物件。当物件挤在一起时,只要多次点击就能选中。对于选中物,应该有明确的视觉表现,比如对物件描边。

由于点击仅仅是聚焦(类似鼠标悬浮),而不是对该物件交互,所以误点永远不是问题。这好比用鼠标快速扫过若干物件,最后精确停在想选中的物件上。前面扫过无关的物件是无所谓的。

那么,每次点击都可以立即响应本该由鼠标悬浮触发的事件:比如显示物件的 tips 窗口。但不应该触发原本鼠标点击物件发生的交互事件。

然后,我们把和已聚焦物件的交互放在右下角大拇指最舒适的虚拟按钮上。也就是按下主按钮(右下角大按钮),才被视为鼠标点击了那个物件,与之交互。如果是鼠标操作,这个交互行为通常有两个:左键点击和右键点击。这恰好可以映射为轻点主按钮和长按住按钮。

在我们的游戏中,和物件交互通常是呼出交互菜单。如果是鼠标操作,交互菜单一般会放在交互物件的旁边。因为这样鼠标移动距离最短,操作最舒适,而且可以提示操作对象的位置。

但在手指操作时,交互是通过按下右下角按钮实现的。交互菜单自然可以环绕主按钮做一个扇面。这样也很舒适。特别是,它不会让手指遮挡住操作的物件。

如果是修建菜单,可以视为和场景地板交互,也就是在无聚焦物件时点下主按钮。这时在弹出的二级菜单中选择要修建的建筑,就可以把它在屏幕中心显示出来。通过移动场景的拖动手势,便可以定位。即,手指移动的是场景而不是建筑,建筑永远在屏幕中央。这比按住待修建筑在屏幕上拖动(鼠标逻辑)要好得多。因为玩家不用在拖动物件时兼顾移动场景,也不用担心手指遮住了准备施工的区域。


在手机屏幕上,所有的信息都应该简洁,避免过小的字体和密集的版面。这点说起来容易,做起来很容易忽视。主要原因还是开发时往往在 PC 显示器上进行,使用的是熟悉的桌面界面逻辑。

在手机上,我们通常不会将注意力同时在多个窗口间切换。但这在桌面环境却非常常见:窗口只在屏幕的一小部分,注意力也容易聚焦在屏幕的一个区域。手机上,我们需要当前展现的信息尽量在屏幕中央的阅读舒适区域,内容可以一目了然(而不需要视线来回扫动)。

我按以上逻辑设计了物件的 tips 窗口。当玩家清点屏幕聚焦物件时,tips 窗口显示在屏幕左侧,占大约 1/3 屏幕。切换聚焦物件,这个 tips 窗口内容就立即切换。

信息窗口仅供暂时物件的信息用,不设置任何进一步交互按钮。这一点和 tips 窗口的逻辑一样。这个信息窗口应该尽量少放信息,避免让玩家为分辨密集信息而疲劳。因为这个窗口上没有进一步交互按钮,我们就可以把点击信息窗口的各个区域设定为二级信息窗,用来在屏幕中央展示部分更详尽的信息。

这相当于把需要展示的信息分成两级,总览放在一级窗口,挂在屏幕左侧。一级窗口划分为三到四个区域,对应三四个二级信息面板,玩家触摸这些区域就可以在屏幕中央看到它们。


主屏幕我希望足够干净。做多放一两个常驻按钮就够了,可以放在屏幕的四角舒适的地方。玩家的主要操作是用手指拖动及缩放场景、轻点场景中的物件聚焦、以及按右下主按钮和聚焦物件交互。

如果需要更多的交互菜单,很多手机游戏会把它们放在屏幕左下,列成一行。我不喜欢这样的设计。所以把更多的交互菜单的入口设计为长按屏幕中央,弹出一个全屏的操作面板。相当于在 PC 上按 esc 键弹出的主菜单。

这样设计会让游戏主屏幕显得清爽很多。

June 14, 2023

抽王八的新玩法

之前教孩子玩扑克牌,从抽王八入手。孩子们都很喜欢。

这是一个传统游戏,在西方叫 Old Maid

规则很简单,就是把一副 54 张的扑克牌中抽掉一张王和一张 8 (或其它任意牌)。把牌平均发给参加的玩家。每个玩家将得到的牌中成对的牌都扔掉,保留在手上的全部是单牌。然后,每个人每个回合抽下家一张牌,如果能和手上已有的牌配对,便可以把成对的牌扔掉,否则必须把抽到的卡保留在手中。先打完手牌的玩家胜利,而到游戏最后,最后一个玩家手上肯定保留了一张王和一张八,而其他玩家都出完手牌。

这种玩法非常简单欢乐,小孩子一学便会。但我觉得玩起来毫无技巧可言,所以我最近把规则改进了一下,变成了一个非常依赖记忆力的游戏。

改进规则如下:

在发完手牌后,玩家把手上的若干单牌一一盖在面前的桌面上,每张牌相互不可重叠。一旦摆好后,就不可以再查看,也不能移动牌的位置。最好自己记住每张牌。

抽下家的牌时,从对方桌面自由选择一张盖住的牌,自己暗自查看(不能给其他玩家看,包括被抽牌的玩家),然后可以选择: 背过来放在自己面前,加入已有的牌组;或是正面朝上打出,同时翻开自己面前牌组中的一张牌,如果两张牌成对,就可以把两张扔到弃牌堆,如果不配对,则将两张牌都翻过来盖上。这个过程必须让所有玩家可见。

打牌过程中,除了抽牌后可以翻开特定的一张牌,不可以查看自己面前的牌,即使想起来自己还有成对的牌也不可以翻开。消牌只能在抽下家的牌后才能进行一次。


昨天晚上我们在家四个人玩了三盘。可可毕竟还是年纪小,记忆力不如哥哥,每盘都被碾压。但云豆和可可都表示新规则很有趣,比之前的好玩。

June 02, 2023

近期 ECS 的一些优化

最近在优化我们的 3d engine 。引擎的渲染对象管理层是基于 ECS 框架,且整个引擎基于 Lua 设计和构建。也就是说,渲染部分的数据都可以通过 Lua 读写。但是,对于核心渲染循环,Lua 的性能有限,当需要渲染的对象很多时,之前用 Lua 编写的循环的性能问题就显露出来。

为此,我们很早就设计了 luaecs。把数据放在 C 结构中,并给出 Lua 访问的接口。这样就方便了初期使用 Lua 快速开发,后期针对核心循环用 C 重构优化。今年年初时,我们把渲染核心系统用 C 重构了一遍,基本解决了性能问题。

最近,我们在 profile 的基础上,又做了一些优化工作。这次发现的性能热点在于游戏场景中存在大量的对象,但镜头内需要渲染的比例很少。之前,针对这个场景已经做过一次优化,针对方案是给 ecs 框架中加入分组这个特性

合理的分组,可以快速对镜头裁剪后的待渲染对象打上 tag ,针对 tag 可以快速对 ECS 管理的对象快速筛选。筛选是在 C 中进行的,所以极大的提高了 Lua 操作 ecs 的性能。但当我们把核心循环移到 C 中时,我们发现这块还有优化的空间。

原因是:当 ECS 管理的对象远大于渲染循环最终需要筛选出来的对象个数时,这些对象在储存空间中是离散的,检索单个组件的时间复杂度为 O (Log n) 。这会导致最终的核心循环的时间复杂度为 O (n Log n) 。luaecs 在对此做了非常有限的优化:它会记录一个组件最后查询的位置,为下次查询参考使用。所以,循序遍历这些离散的 tag 会有一定的加速,但提高有限。

这次优化的目标是:在渲染核心循环遍历所有镜头内的 Entity 的相关 Components 时,整个过程在大多数情况下能做到 O (Log N) 。为了达到这一点,自然是用空间换时间了。

设想一下在非 ECS 框架下如何做到这一点?通常需要额外建一个线性容器,保存所有可见对象的引用。ECS 框架下,对象是由若干组件动态构成的,组件和组件之间的关系会更加复杂。如果我们以组件为最小单位的视角来看,即需要有一个高效的容器放下需要处理的所有组件,其数量非常大(估算在十万数量级)。每次访问都是性能敏感的。这个容器显然是一个 Cache ,镜头的变化、对象的生死都会影响它。

缓存失效是最难应对的问题。这里依照传统方法使用类智能指针的方案一定是及其影响性能的。ECS 框架中使用 id 会是个更好的方案。先前觉得有序 id 用对分查找不会是大问题,但实际 profile 下来,这里还有优化空间。我额外实现了一个索引 cache ,在遍历过程中,记录下每个组件的具体位置,用于下一次遍历过程的参考。这个位置缓存没必要和实际情况保持一致,反正每次查找都会再核对一次,错了就更新。实测下来果然提高了不少性能。在 iPhone 8 上的对象数量相当多的大规模场景测试中,每帧渲染核心循环占据的 cpu 时间可以控制在 10ms 以下,已经能满足我们预定的性能需求。


接来来的一个小问题是:这个 cache 对象应该放在哪里?用一个全局变量显然是不合适的,虽然我们现在只有一个 ecs world ,但不排除日后变成多个。且全局变量本身就是个坏味道。

看起来,cache 对象最好是和 world 绑定的。但按传统方法直接加到 world 对象中也是个坏味道。毕竟它只和渲染系统相关,并非 ECS 框架的基础设施。

那么,作为一个独立组件类型放在 world 里怎样?也就是作为一个 singleton 的组件和其它组件共存。这里的问题有两个:

其一,目前 ecs 框架中,独立类型的个数是有限的,如果很多类似系统都为自己独有的全局对象增加新的类型,会用掉大量的类型资源。

其二,组件类型是排它的。如果很多系统都申请自己的类型,必须在某个统一地方声明,相互区隔。这样,原本是一个渲染系统内部实现的东西,就必须把自己暴露出来,维护代码的时候除了在实现文件内编写代码外,还需要在一个对外的接口中加上一笔,也不是什么好味道。

在思考解决方案时,我的想法从 C++ 的全局变量考虑起。C++ 的全局对象是个很复杂的东西,它比 C 的全局变量复杂之处在于其构造和析构过程。这需要 linker 多做很多事情。也就是 linker 协调了各个独立模块中的全局对象。

如果我们需要的不是一个全局对象,而是把对象绑定在 world 上,每个 world 中保持唯一该怎么办?我们可以给 world 开辟一个全局对象区(以一个组件单例的形式存在),把所有这类对象都在运行时绑定在这个区。该区就是一个对象指针数组,每个全局对象拥有一个独立的索引号即可。那么索引号该怎样分配才能做到系统之间相互不冲突呢?我认为可以借助一个全局对象的自增 id 分配器完成。即:这个自增 id 的分配器只为每个模块用到的全局对象分配唯一 id 而不是对象本身;而借助 id ,我们就可以在每个不同的 world 的全局对象区申请到一个唯一的槽位,保存对象的指针。最终,我们就可以做到每个 world 都有自己的独立全局对象组,而访问它们还是 O(1) 的时间复杂度。


以上,就是最近做的一点 ECS 优化。还有另外一个关于材质系统的性能优化,下次再谈。