« 工人任务分配系统 | 返回首页 | 基地建设(工厂)类游戏的玩家体验 »

gameplay 框架设计总结

游戏行业从业 20 多年,一直在做底层开发,即使是帮助其他团队写上层游戏逻辑,也都是实现某些特定的功能模块。直到最近,我想单独开发游戏,才好好想想架子该怎么搭。

从最初的原始 demo 开始,由于缺乏经验,写着写着经常有失控的感觉。好在一个人做,重构的心理负担不大。想改的时候,停下来花上两三天全部重写也不太所谓。最近总算顺畅了一点,感觉需要整理一下思路,所以写一篇 blog 记录一下。

任何复杂的软件问题,都可以通过拆分为若干子问题减少复杂度。

我认为,游戏的上层逻辑,即 gameplay 部分,主要分为三块:数据模型、外在表现和人机交互。

“数据模型”是 gameplay 的核心部分,即把游戏的画面等外在表现以及图形界面、操作控制(鼠标键盘控制器等)等剥离后的东西。如何判断一个模块是否属于数据模型,是否有不属于它的部分没有拆分出去,最简单的方法是看它是否有直接调用游戏引擎的代码。拆分干净后,这块不应该包含任何与图形、界面、时钟、控制输入有关的接口。除了一些必要的文件 IO 接口(主要是用来读取 gameplay 相关的策划数据,写 log ,做数据持久化等),也不应该涉及任何 OS 的 API 。

这样,我们就可以方便的对它进行整体或局部的测试。必要时还可以更换游戏引擎,甚至从文本 roguelike 换到 3D 表现都不会受影响。

“外在表现”当然是指游戏的画面表现、声音声效等等,通常这由游戏引擎实现,但还会有大量的代码存在于 gameplay 的实现中。这一块代码也会维护大量的状态,但不会涉及数据持久化。简单的判断方法是:如果游戏保存进度时,所有这里的所有状态都可以舍弃,下次读取进度后,这些状态又能被重建,而玩家不会感觉丢失了任何数据。

“人机交互”是游戏软件必要的部分,如果没有“人”的存在,游戏也就没有意义了。这块分为两个部分:图形界面用于展示游戏的数据给人看,同时接收一些来至界面的间接输入;控制器(包括并不限于手柄鼠标键盘触摸屏等)对游戏的直接控制。

对于联网游戏,还应包括第四大块:“网络输入”。这篇 blog 仅讨论非联网游戏。


对于“数据模型”这块,我在编码时,把这个起名为 gameplay 。可以进一步的分为两大类:被动对象 Object 和 自治体 Actor 。

Object 我认为应按类别分类,每类对象聚合在一起管理。它们可以是场景上的物件、游戏角色等这种会在表现层上找到对应物的东西,也可以是任务清单这种不在表现层呈现的东西(可能会在界面上呈现)。在实现 Object 时,应该理清它的数据和操作这些数据的方法。

对于数据部分,应该在早期就考虑如何持久化,即游戏的 Load Save 该如何实现:通常就是对每类对象的每个个体单独做持久化。为了实现持久化,每个 Object 都应用 id 管理,id 和 typename 是它们的共有属性。

其它数据应该尽量保持相互独立,避免相互引用。如非必要,不提供额外的数据控制的方法。因为一旦要提供特定的方法操作数据,往往是因为多项数据相互关联,必须用单个方法去控制它们来保持一致性。基于这个原则,Object 不应该提供像 update 这样的方法。所以,Object 是静态数据集合,它是被动的。

那么,游戏是怎么运转起来的呢?我们可以再实现一系列的自治体 Actor 。每个 Actor 对应了游戏世界中的一个实体,它可以关联一个或多个 Object ,通过读写 Object 的数据控制它们。大多数游戏在 gameplay 层面不会遇到太大的性能问题,所以这里不考虑并行处理。虽然 Actor 逻辑上是各自独立的,但串行处理可以避免考虑并发读写 Object 的问题。

Actor 使用消息驱动。不同类的 actor 有不同的 update 函数来处理每个 tick 的行为,可以处理消息。游戏世界接收外界输入只能通过向 actor 发送消息完成。actor 通常实现为一个状态机,这样可以让游戏世界中的虚拟角色在不同状态下有不同的行为。actor 需要维护许多的数据中间状态,同时也要考虑持久化问题,但大部分内部状态不应该持久化。大多数情况下,应保证只持久化状态机当前状态的名字就够了。其余运行时状态应当可以根据它重建。

例如:游戏中一个工人,接收了一个任务订单,需要从 A 处拿取一个货物送到 B 处。

  • 订单是一个 Object ,数据内容有起点和终点的位置,货物的总类和数量等信息。
  • 工人是一个 Object ,数据内容有它的当前位置、携带物、订单号等。
  • 有一个 Actor 作为工人的控制器,它用于控制工人的行为,比如申请订单、接收订单、执行订单等。

而执行订单的过程,又可以分成若干步骤:

  1. 确定去 A 点的路径
  2. 移动到 A 点
  3. 获取物品
  4. 确定去 B 点的路径
  5. 移动到 B 点
  6. 放置物品

这些步骤,有些是可以立刻完成的,有些则需要若干 tick 。对于需要很多 tick 才能执行完的过程,必定存在一些中间状态,这些状态不必参与持久化。这些运行时的临时状态应当可以被重建。比如,在“移动”这个步骤,一旦外界环境发生变化(例如场景变化了,路程可能被封堵),actor 收到消息,就会把状态机切换到“寻路”这个步骤,之前“移动”步骤的执行过程所创建的中间状态就不需要了。

设计持久化方案是一个优先级很高的事情。因为在考虑持久化时,就会认真设计数据模型。修改数据模型,如果同时考虑不破坏持久化功能,也会更谨慎一些。

不要简单的将 持久化 等同于把 Object 和 Actor 的运行期内存数据结构简单的序列化。持久化更像是把运行时的对象还原为一系列的构造参数,下次加载时可以通过这些参数重新构造运行时结构;而运行时结构往往会考虑性能因素构造成更复杂的数据结构,数据结构中存在一些复杂的相互引用关系。

例如:订单系统的运行时结构可能是 id 到 订单的映射表,这样方便从订单 id 查询到订单。但在做持久化时,把订单保存在一个顺序列表中更好。


如何把数据模型表现出来呢?这要看引擎是用什么模式工作。

一般会有两种模式:立即模式(Immediate Mode)和保留模式(Retained Mode)。引擎也可能根据渲染不同类型的东西混合提供两种模式。

如果是立即模式,那么每帧画面由“表现层”(代码中,命名为 visual )遍历“数据层”的 Object 取出其状态,提交渲染即可。

如果是保留模式,一般我会在表现层为数据模型里的 Object 建立对应的 visual object 。对应关系可以是 1:1 ,也可以是 1:n 即一个数据 object 对应多个 visual object 。而数据层记录每个 tick 的状态变化,最后用消息队列的方式仅把变化传递到表现层。根据这个状态变化消息,修改 visual object 的状态,同步给引擎渲染。

无论是什么模式,都不会在数据模型中直接调用渲染引擎的 API ,数据模型也不会直接持有 visual object 的引用。

渲染层一般不会直接给数据模型中的 Actor 发送消息,而只会读取(不会改变) Object 的状态数据。但如果表现层有额外的反馈设计,比如有物理系统,让物理系统可以对游戏世界发生反馈,一些属于纯表现的,就在表现层自己消化。另一些会影响数据模型的,就会变成一个消息源,向 Actor 发送消息。可以把它们看成是交互层的一部分。


交互层通常分为 HUD 、GUI、Controller 。大部分用户输入来至于 Controller :手柄、鼠标、键盘等。需要对这些设备的原始输入根据场合做一些转换,避免直接处理诸如鼠标按下、手柄摇杆向左这样的消息,而应该转换为更高阶对 gameplay 有意义的消息:例如变成发起攻击、跳跃,向左行走等。还有一些输入来自于 HUD 或 GUI ,更应当避免在 GUI 的代码中直接访问数据模型,更不要直接控制表现层的 visual object ,而应该先转换成 gameplay 的消息。

例如:“存盘”就应该是一条消息,而不应该是直接的函数调用。保存进度和读取进度在消息处理过程中应该只做一个标记,而在每个 tick 末尾再检查这个标记做对应操作(通常是先 save 再 load )。这样才能更简单的保证数据一致性。

最终在每个 tick ,这些交互层产生的消息会分发发到数据模型中的 actor 。actor 的 update 驱动了整个 gameplay 的状态变化。

Comments

我理解这种说法,我经历过两款游戏的做法都是这样做的并且也很好的上线验证了框架。 目前我在尝试一款新的ARPG游戏,发现复杂的动作游戏动画中的统位置信息很重要,例如RootMotion驱动移动,HitBox跟随骨骼变换。如果按照将动画数据抽离的做法,并且游戏有着一套复杂的动作系,不完美复刻引擎动画管理模块逻辑的话,那么表现跟逻辑有比较大的概率会出现不一致的情况,这些不一致不一定是可接受的。 所以目前我的想法是,动画、物理模块根据游戏的类型来决定是否属于逻辑层,逻辑层的定义应当是由策划,也就是玩法决定的。区分逻辑层与表现层是有很多好处的,上面提到的框架我也认同,但是我认为逻辑与表现的划分跟是否调用引擎提供的模块无关。如果玩法非常依赖动画状态以及数据,按照严格剥离的思路应该在逻辑层自行实现一套动画管理系统,表现层仅负责根据提供的动画数据进行蒙皮渲染。
我在这篇 blog 里谈了这个问题 https://blog.codingnow.com/2024/07/avatar_animation.html 游戏引擎谈及对动画的支持,不管是动画状态机还是行为树这些,本质上都是为了解决逻辑上的简单状态到动画呈现的映射。 即,通过几个简单的参数,例如位置,速度,方向等,让动画系统可以正确的表达。这样控制逻辑只需要设置几个和动画无关的变量就可以让引擎每帧正确的驱动动画。这个方向就是为了不要让动画反过来驱动逻辑上的状态变化。 即使像在动画制作过程中,让动画关键帧的时刻、位置等信息来影响玩法,那也只是把这些数据放在动画制作流程中。而这些数据在(动画)制作工具里,也会被单独提取出来,尽量和具体表现的数据分离的。比如 hitbox 。
之前的项目都是这样设计,逻辑层对表现层依赖隔离,逻辑层可以自己脱离引擎跑。但是之后的项目越来越复杂后这个理念我觉得有些不适用的地方,例如对于ARPG类的游戏,逻辑对于角色动画有着比较强的依赖,这时候动画算逻辑层还是表现层?动画状态机的状态转变计算逻辑在逻辑层计算吗?这里还涉及到同步问题,逻辑对物理、动画有依赖时,如果采用CS模式,服务端实现一套物理、动画模块还是使用DS之类的技术也是需要考量的,不知道大佬如何考虑这个问题
大佬,如何看黑悟空!
静态object,和actor有点像ECS的E和S了,倒是更灵活一点
接下,是说模式设计, 有些模式比较出名,如如Builder, visitor, 如MV C/P/VM,ECS也是种, gameplay 中当然存在很多模式,甚至不同游戏类型 也会有适应的模式,unity/unreal里也提供了一些推荐/基础的模式,我想 这些非常值得积累和归纳的
人们解决很多问题,沉淀过很多方法,在特定的领域有特定的模式, 人们曾经面对gameplay的问题,使用过“面向对象”, 然后人们又批判,面对问题再捡起来
即使是云风这样的大佬,在开发的时候也会遇到各种各样的问题
会不会变成消息满天飞,然后需要查那些actor关心那些消息,不太好维护
我现在做的三维项目就在应用领域驱动的概念进行数据和表现分离,把unity当作表现层,数据部分一点Unity的东西都不引入
我是用 ECS 实现的 gameplay ┌(ㆆ㉨ㆆ)ʃ

Post a comment

非这个主题相关的留言请到:留言本