解谜类游戏以精妙的谜题设计和引人入胜的故事叙述为特点,考验着玩家的智慧与观察力。《迷失岛2》与《南瓜先生2九龙城寨》正是这一领域的佳作。游戏以独特的艺术风格和玩法设计吸引了大量玩家,而它们背后隐藏着一套强大的框架。
上海胖布丁游戏的技术总监在 Unite Shanghai 2024 游戏专场上,为大家分享了《解谜类游戏的框架设计》。这个框架以“傻瓜化”与“高效率”为主要目标,涵盖鼠标点击、拾取道具、选择道具、关卡切换、NPC 对话、根据条件控制显示、非线性路程、二周目、小游戏等内容。目前该框架已经在多款游戏中得到应用,如《迷失岛》系列、《南瓜先生2九龙城寨》以及《怪物之家》等。经过多个项目的迭代和优化,已经不仅仅局限于解谜类游戏,在模拟经营、生存等其他类型上同样胜任,适用于各种复杂度的游戏需求。
解谜类游戏的框架设计
大家好!我演讲主题是《解谜类游戏的框架设计》,这个框架设计是我们公司游戏用的解谜类的框架,然后我是来自上海胖布丁游戏的技术总监。
我们公司的游戏有很多,早期做解谜类的,现在我们做了很多包括模拟经营、平台跳跃、生存类,包括动作类的现在也在开发,类型很多。
这次用这个框架的主要游戏是《迷失岛2》,是解密类的游戏。这里面有一些小游戏,通过点触的方式移动场景进行关卡切换、跟 NPC 对话,然后可以拾取一些道具,把道具用在一些地方,比如说触发门开。
还有一个是《南瓜先生2九龙城寨》,是有个人走的这样一个解谜游戏,这个游戏也是用的这个框架,它跟《迷失岛》不太一样,《迷失岛》是点触的,这个是通过手指点击场景会自动寻路,包括可以用手柄或者键盘去操作角色,然后会有一个触发区域的概念,只有走到触发区域的时候才会进行一些交互。
我们看一下这个框架大概有哪些内容,常见的是鼠标点击、拾取道具,可以通过点击场景中的道具区域拾取道具,可以选择道具,然后通过点击场景中的一些交互区域进行使用道具;还有一些关卡切换,点击区域可以进行关卡的跳转。
我们这些动画用的是 Spine 的切换,有一些特殊的会用到 Spine 骨骼,还有皮肤。NPC 是可以对话的,有一些 NPC 可以切换对话,不同的游戏进程会播放不同的对话。有一些部分会根据条件控制物体的显示隐藏,比如说本来是一条完整的鱼,如果你用刀对它使用可以切掉它,然后得到一些道具之类的。
我们的游戏不像传统的解谜游戏是线性的,必须通过这个场景之后才可以进行下一个。我们的游戏玩家可以在很多的场景自由地跳转。不同场景中的一些游戏状态会影响到别的场景,所以这也就是说我们设计框架的时候要考虑到这些情况。我们的游戏是二周目的,它的场景会有不太一样的,跟第一次玩的时候不太一样,比如说 NPC 的状态或场景的一些内容,包括可能一周目跳转这个场景,二周目就变成了另外一个场景。
小游戏我们做了十几二十几个,跟游戏剧情不太关联,是比较独立的小游戏。每个小游戏都会有三种随机的,也就是每个玩家玩的小游戏不一样,这也是我们在设计框架要考虑怎么实现这个。我们用随机的方式,可能你每次打开这个场景,或者你关闭游戏进程再打开的时候,这个场景会随机成另外一个,我们要想办法怎么做到这次玩的时候关掉进程重新进入之后随机出来的小游戏是一样的。另外就是还有地图。
这个框架的目的我提了一个关键词叫傻瓜化,不用懂程序的概念他也可以编这些关卡出来,包括策划的需求,我就算不用懂程序,我只要知道在 Unity 引擎这里面具体怎么操作的,简单拖一些预制、改一些参数就可以实现大部分的需求。
这个方式的好处就是,开发起来很快,只要简单拖一些组件上去,把美术的资源导入进来,然后我们可以进行一些组织,根据一些游戏进程我们就可以快速去控制它,并且可以快速测试它。现在的框架实现了我现在编好之后直接一运行可以快速测试我刚才编的逻辑正不正常。还有一个好处是风险隔离,关卡中有各种各样的游戏进程,我不希望我现在改了这部分,这部分改好之后发现会影响到另外一个。这也是我们框架的目的,希望最小化地去隔离风险。
在跨场景的时候,因为我们知道 Unity 跨场景会把场景的东西卸载掉,所以我们要解决跨场景的时候这部分的游戏状态会影响到别的游戏状态,所以我们要解决这个问题。
GM 工具以及道具使用效果
我们可以开发一些 GM 工具。我们可以快速测试,比如说这部分我按照这个框架的规范编辑好了,但是我现在快速测试,这个需要有一个道具,所以这里面可以通过一个工具添加一个道具出来可以直接使用,甚至我可以不添加道具直接解锁,我直接模拟他使用了这个道具之后的一个游戏状态,然后去测试这个表现编辑的对不对。
多道具区域演示
道具还有一个我们需要考虑的。道具区域不是一对一的,大部分的情况是一个道具区域只能使用一个道具,使用不了的时候可能没有任何的反应。但这个演示是有顺序的,这几个道具虽然可以使用但是要按照一定的顺序使用,这里就弄了一个多道具的概念,一个道具区域可以识别多种道具,并且可以放上去之后可以再拾取。
小游戏
这里面是我们的一些小游戏。小游戏也是一个问题,我们应该要有一个大概的抽象思维统一的解决它们,就像我们游戏中有很多的二维数组的小游戏。如果每个小游戏单独去实现,一方面效率很慢,另一方面如果出现问题我们没有办法快速地去解决它,所以对于一些同样类型的,比如说都是二维数组类型的,我们可以抽象成通用的二元数组的工具类解决这个问题。
实现方案
实现点击的方案(一切的基础)
我们现在要看一下,因为要实现点击,我们这个游戏大部分的逻辑都是点击操作的,所以首先要考虑点击是怎么实现的,一种是 UGUI 的组件,UGUI 组件就是点击 UI 的东西,但是现在场景中的点击操作比如说道具的拾取不是 UGUI 的组件,没有办法用 UGUI 的方式。
我们看大部分的时候大家怎么做的,一方面是可以直接在 MonoBehaviour 里写出 OnMouseDown 等函数,这样如果你在物体上挂了 Collider 可以执行到,这是第一种方式。第二种方式我们在 Update 里面用射线检测,我们判断鼠标点击,然后去射线检测,检测到物体,然后再对它进行操作。
但我觉得这两种不太好,刚刚说的可能还要考虑移动端,因为移动端需要使用 GetTouch 加上射线检测做,我觉得这几种不太好是因为它会跟 UGUI 的窗口会冲突,因为我们的 UI 是用 UGUI 做的,场景逻辑全部是用 3D 场景的,所以我们需要考虑说当 UI 的界面打开的时候,我们需要特殊处理,比如当 UI 点开我们不能点击场景中的东西。还有我们移植到 Xbox、PS 的时候是用手柄操作的,我们通过虚拟鼠标的方式实现时,因为这些 OnMouseDown 还有 OnMouseUP 接口只支持鼠标或者触摸屏,当我们有虚拟鼠标的方式,用手柄控制虚拟鼠标去操作物体的时候,就没有办法做到。
还有一些比如我们要获取坐标的时候也比较麻烦,管理上也不太好。
我推荐是用这种方式,直接在 Camera 上挂一个 UGUI 的组件 Physics2DRayCaster,这个挂上去之后,它会把 UGUI 的事件转发到相机照射的物体上,这样可以在非 UGUI 的场景中实现在非 UGUI 场景中点击包括拖动等事件。
这个就是我们在物体上挂一个 Collider 2D 组件,然后我们实现 UGUI 的 IPointerDown 等事件,可以监听到 UI 点击等事件,这样的好处就是我们可以不用管跟 UGUI 冲突的问题,另一方面我们做扩展的时候特别容易,比如说我刚刚说的用虚拟鼠标,包括我们后续有出现的扩展,就是因为我们全部用 UGUI 了,就统一了起来,如果有问题我们只要解决 UGUI 的问题就行了。
通过点击区域的实现,可以组成各种逻辑
通过刚才的方式,我们可以抽象出来一个点击区域,我可以在游戏中指定我要执行什么函数,通过这个点击区域可以实现很多东西的组合,比如说道具拾取,就是通过 ClickArea 加上 PropResource 这个组件,在点击的时候执行到自定义的道具数拾取的点击,这样就可以把道具拾取到背包里面。还有一些小游戏的操作,一些门的切换也是点击之后进行的操作,包括跟 NPC,包括道具使用区域都是通过点击实现的。
游戏流程控制实现
我们刚才解决了点击区域的问题,我们还有一个问题要解决,我们的游戏表现有多种多样,比如说这个地方开门,那个地方是动画切换,包括物体的显示隐藏,如果全部用代码,会非常不高效,所以我们其实可以抽象出一个游戏 ID 和分镜 ID 的概念。
我们只要有一个游戏 ID 的概念,就可以通过这个标记做任何的事情,比如说刚刚演示的时钟,时钟完成之后相当于解锁了一个游戏 ID,这时候我们有一个标记,还有道具使用,比如说这个东西放上去之后让我们给它解锁了一个游戏 ID。有了这个游戏 ID,我们可以统一地根据这个游戏 ID 判断做一些操作,比如说会监听游戏 ID 更新,改变物体的显示隐藏,包括动画的切换。
分镜 ID 是因为大部分游戏 ID 跟分镜 ID 是一一对应的,一个游戏 ID 对应一个分镜 ID,但是有一些特殊的一个游戏 ID 可能会解锁多个表现,比如说我现在通电了会导致灯亮了这是一个表现,也可能导致风扇也转了,这样就是一个游戏 ID 加上风扇的分镜 ID,然后再加上一个电灯的分镜 ID,这些可以实现解锁了一个游戏 ID 我们可以解锁多种游戏状态。
这个流程控制器是让使用者不需要关注存档的变化,他也可以进行一些高效的测试,比如说我现在测试这个游戏 ID 对应的是什么表现,那我就可以用工具解锁一下,测试一下看这个表现对不对,我们可以通过多个场景跳转然后去测试一下它的表现。
这个是 AnimationController 的示例,可以看到它是一个游戏 ID 加上分镜 ID,会有一些触发器,我后面会讲另外一个概念,就是触发。首先游戏 ID 我们有好多种,比如说门的开放、时光机的表现、蓝莓使用、火柴使用,会对应不同的分镜 ID。比如说刚刚演示的通电灯亮了,包括说它通电风扇转了,这是它的流程控制的作用。
道具使用区域
道具使用区域会跟道具进行比对,这个道具使用区域会设定支持的道具 ID,并且这个道具 ID 使用了之后会解锁对应的游戏 ID,因为我现在的框架是所有的都是围绕游戏 ID,其他的这些东西其实都是解锁了游戏 ID,所以道具使用区域也是使用完道具以后会标记这个道具使用过的游戏 ID,我们通过这个道具使用,我们可以去做一些别的操作,如一些变化什么的。
道具拾取组件
道具拾取组件也是一样的,拾取之后会有一个游戏 ID 的匹配,这个道具使用之后如果没有判断游戏 ID 的解锁,可能这个道具还可以重复拾取,现在它会再判断一个游戏 ID 的状态。
门组件
门组件也是一样,会跟 ClickArea 做一个组合。
触发器与触发区
还有触发器的概念,因为我们有很多的表现不一样,有的表现是门开的,有的表现是解锁的动画,切换动画,有的表现是一些显示状态的切换,有的是一些音效的改变或者是 NPC 对话的改变。如果每个表现都用硬代码写,这样会非常不高效,所以我引入了一个触发器的概念,也就是我把每个东西给它最小粒度地抽象出来。
比如说显示隐藏有一个显示隐藏的触发器,它是只管物体的显示隐藏,还有一些比如说播放动画有一个组件一个触发器,它在触发的时候会把动画切到别的动态,包括可以监听动画的完成,一般情况下我们播放完之后我们需要监听它的完成事件,所以我们会切换做一些别的事情。还有包括跳转场景、随机播放,包括有一些条件的判断的触发器,我们可以分流不同的触发器的执行。
触发器这个概念是比较像是行为树,但是我觉得行为树比较重,因为这个游戏大部分的东西比较简单,所以我单独抽象了一个触发器出来,可以适用于游戏中的基本上所有逻辑。
这个触发器也是可以自定义扩展的。
这是刚刚说的触发器的条件,它可以根据不同的条件分流。
触发器有一个扩展我们可以实现自定义的触发器,有一个 TurnOn 和 TurnOff 的状态,我们在自定义触发器里实现打开之后的状态是什么样的,关闭的状态是什么样的,我们就可以实现后续没有办法实现的需求,也即可以通过自定义触发器实现。
这个触发区的概念和触发器有点像,首先要监听主角的走路,比如说我们有的主角的控制,要等待主角走到这个区域可能会显示一个气泡框,或者走过来之后会触发一些流程的表现。这是触发区的概念。
实操展示
这是我们刚刚演示的效果,具体怎么做的,有一个 Animationid control,然后我们会有一个播放动画的触发器,这个时钟是一个小游戏,时钟解锁之后会解锁一个游戏 ID,这个 Animationid 会监听对应的游戏 ID 去播放门的打开状态,这样我们可以通过不同的触发器跟 Animationid control 的组合,快速实现这样的需求,几分钟可以实现这个东西。
这是不同的逻辑之间会有串联,或者并联会有互相影响的,这是它需要解锁这边的小游戏,把电通了,通了之后会影响刚刚这个场景中好多的部分,比如说风扇会转导致墙上的海报掉下来,这时候跟 NPC 对话,本来停电的时候会很热,通电之后会凉快,所以他更愿意跟你说一些别的对话,推进新的剧情,这是我们通过多个 Animation control 实现的,可以看到这个图有多个不同的 Animation control,然后这个不同的 animation control 会判断不同的游戏 ID 的状态,比如说这个游戏 ID 有没有解锁,另外的游戏 ID 有没有解锁,然后对应分流去控制更多的一个表现。
我们看一下我们是怎么操作编辑一套流程的,可以看到这是一个门,然后我们编辑一个游戏 ID 和分镜 ID,然后这里面拖一个触发器预制,这是控制动画切换的触发器,我们设计好它的动画名字这时候监听门打开之后的动态,然后切到门常开的动画,然后我们直接运行,运行之后通过解锁游戏 ID,我们就通过这个游戏 ID 解锁快速测试到了这个表现,很方便地去编辑,方便地去测试。并且这个不是程序员也可以操作,并不需要关注说它这个代码逻辑是怎么样的,怎么执行的,不用知道它的原理,所以就是我刚刚说的傻瓜化的操作。这个会让你的游戏开发会变得更高效,并且会更不容易出错。
还有对应二维数组的小游戏我们可以抽象出通用的脚本,比如说抽象出来一个类,这个类是专门用于格子类的二维数组的小游戏,我可以设定横向多少个,纵向多少个,我可以索引到左边的格子、右边的格子的状态,包括我可以获取到某一个格子它里面的状态,比如说这个格子有没有东西。在我们实现刚刚看到的小游戏的时候,我们可以很快地做出二维数组类的小游戏。
我们在做一些东西的时候我们要考虑好怎么把它抽象,因为我觉得抽象会让这个游戏会更加快速,并且高效。
因为随着游戏需求的越来越多,我们会发现到后面复杂度越来越高的游戏,用触发器去管理的话,会绕来绕去的,会很容易出问题,并且不太直观。所以后面我抽象出来一个状态机的概念,这个状态机它是针对一个物体,比如说一个 NPC,我们预先设定好有哪些状态,比如说状态一是开口说话,状态二是可能招手,状态三可能是干嘛的,我们这样通过切状态的方式控制物体,并且很好地跟它解耦。状态机不会关注游戏进程,你可以理解它是一个机器人,它是通过外部驱动它,你外部传给它的状态是对的,它就是对的,所以这个方式有一个好处,不容易出问题,并且我们可以很好地管理某一个对象。
其他的就是一些这个框架的其他系统。
比如说存档,如果说我们刚才游戏 ID、分镜 ID,因为要存的字段太多了,如果用 Unity 的 PlayerPrefers 去存的话可能不太够,并且管理也不太好,我后续抽象出来一个通过文件的方式存,并且使用的方式也很简单,但是最终存的时候会收集成一个文件,通过 Json 的方式转成文本存到磁盘里,也支持存档槽。一般游戏有不同的存档槽,所以这个存档系统也会支持对应的存档槽功能。
音频系统。因为如果说你直接通过创建 audio source 的方式去播放一些音频的时候,这时候不太好管理,因为游戏有音效、音量的调节需求,如果一开始没有考虑好,全部都要用 audio source,可能到时候音量管理是一个问题。另外的方式就是说创建出来的太多的 audio source 可能会导致 GC 的问题,所以我弄了一个音频系统可以通过对象池的方式复用 audio source,并且它可以管理好不同音量的管理,包括可以放在不同的物体上,包括它可以直接通过代码的方式,比如说我这个就是通过直接代码的方式给它播放对应一个 key 的,我提前设定好一个 key,然后这个 key 是指定的哪一个音频文件是不用管的,这样做的好处如果有单独一个音频的人员他做音频的时候不需要关注你这个游戏是什么样的,他不需要关注代码怎么写的,你只要在特定的地方打上调用的代码你就不用管,包括他也能控制这个音频是不是随机的音频,比如说有一些脚步声,他会随机地播放一些音频,包括音量多少,包括会有一些更高级的用法,这样会跟音频的开发人员区分开,各司其职。
本地化的问题。因为我们的游戏基本上都会面向全球,所以说我们会有一个本地化的问题,大部分本地化直接 Text,如果一开始没有考虑到本地化的问题,可能直接把文本写在代码里面,这样对未来本地化的时候可能是一个大灾难,所以我们的本地化系统是基于配置表的,我们把所有的本地化语言都写在一个配置表里,我们去设置一个 Key,通过比如说我们在 Text 里面挂上一个脚本对应,然后指定它对应的是哪一个 Key,然后会自动读取对应语言的文本。
我们还会有一些资源管理要考虑,是用 Resource 或者是 AssetBundle,这些可能也要考虑一下。还有就是我们会有一些内存泄露,内存泄露有比较常见的问题我们需要关注,因为有一些时候你在场景里面你写了一个静态的方法,给它设置进去切换场景之后会导致这里面一些引用计数在的话它就不会被清掉,所以这些也要考虑。