撰写了文章 发布于 2019-06-05 01:48:23
浅谈AVG动画控制
最近正好在仿制一款类似超级机器人大战的战棋游戏。超级机器人大战在战棋以外的部分有点类似于传统AVG游戏的形式,用对话来推进剧情,只是没有角色立绘而已。没有可爱的小姐姐的立绘怎么能说服玩家掏钱呢?所以我们的仿制作品里也包含了这么一块功能。按照目前的低耦合高内聚设计思路,这东西已经基本可以拿出来做成一个基于Unity 3D二次开发的AVG游戏引擎了(逃)。
不过使用游戏引擎提供的轮子终究是一件很无趣的事情,收获的成就感远远小于自己造轮子。恰好我的本科毕设写的是一个AVG游戏引擎,这次也照搬了部分设计思路过来。目前的完成度已经远高于那时候的毕设了。
虽然我许诺会把这部分代码开源,但是考虑到解释的脚本是二进制流,这东西对用户是完全不友好的。如果将来有时间的话我会写转换器,暂定是从csv转成二进制流。
这篇文章是在讨论一种AVG动画的控制与实现思路,并不会过多涉及实现,所以不需要什么编程基础。
0. 游戏中计算机都在干什么
相信所有的玩家都知道,游戏有一个“帧”的概念。每秒显示了多少帧就是游戏画面的刷新频率。因为帧的存在,原本应该逻辑连贯的游戏过程被生生割裂,变成了一个个离散的瞬间。这并不是缺陷。一方面,连续地计算游戏在任何时刻的状态本来就是一件不切实际的事,就如同你不能穷举圆周率小数点后面的每一位;另一方面,人眼的视觉暂留特性决定了帧数只要达到一个阈值,在这以上的帧数提升就不会对画质提升起到太大的贡献,就如同圆周率使用小数点后面十位或五十位在常用范围内不会有什么区别。
基于上述论述,很自然地就能得出一个结论:游戏只要在画面需要刷新的时候执行逻辑更新画面就可以了,其他时候并不需要占用计算资源。事实上,目前的游戏确实是这样设计的。对于一个设计为60FPS的游戏,每秒钟需要执行60次游戏逻辑,每次大约有16到17毫秒的时间。如果游戏逻辑提前完成,那么游戏应当释放CPU(当然也有不释放的);如果不能及时完成就会出现掉帧。
当然,掉帧不一定是由于CPU,大部分情况下是由于GPU来不及完成绘图,但是AVG显然不存在这种情况。
总结一下,在游戏中,计算机做的是:等待唤醒->执行游戏逻辑->提交渲染->刷新画面->再次等待唤醒这么一个循环,而唤醒的时机就是刷新帧的时候。
1. AVG都有些什么动画
在设计系统之前,显然要先进行需求分析。这里的需求就是开发中的游戏都需要哪些动画效果。一般常见的动画包括移动、旋转、缩放这三大基础需求,有些游戏还会要求3D翻转从而让演出表现得更加丰富。立绘的遮罩效果也是一个常见项目,包括变色、变透明,甚至还有渐变。此外,转场效果也是一大项,比如滑动、翻转、淡入淡出等等。一些烘托气氛的特效,比如画面抖动、变暗等,也属于常见要求。
总而言之,AVG演出对于动画的需求是经常在变的,但又逃离不出移动、旋转、缩放、遮罩这四大类。
2. 动画控制器的设计思路
前面提到,游戏的逻辑是在每一帧都执行的,而且相邻的两帧之间还会被打断执行。如果我们想设计一个立绘在一秒内渐变到透明的动画,如果只是把逻辑封装成一个函数来调用,那么函数会因为不知道上一帧执行到了哪里而无法继续执行。虽然yield可以做到继续,但是这东西对很多人来讲也是个黑箱,而且并不是所有语言都支持这种特性。
于是这里会有一种思路:只要逐帧把透明度减掉六十分之一,就可以在一秒的时候完成操作。这种思路确实是有道理的,但是会存在一个问题:如何让游戏知道在这一秒内的每一帧都要调用一次这个函数?最后一次调用以后又如何让游戏在下一帧不调用?有人会说用计数器,记满60以后就不再调用这个函数了。确实,计数器能解决这个问题。
根据上面的思考,我们有了一个简单的模型:它具有一个函数和一个计数器,游戏应该每一帧都调用这个函数,并且将计数器减一,直到最终计数器变成零,模型就完成了它的使命。这个简单的模型确实能够解决问题,而且在线性变化的场合也工作得很好。但是现在有人提出了一个很现实的问题:在3D翻转中,立绘的宽度变化应该是遵循三角函数的。显然,三角函数的变化并不是线性的,上面的模型失效了。
现在我们可以对这个模型进行一些改进:联想到补间动画中的补间曲线,我们可以在我们的模型中同样引入补间曲线,这样它就有能力进行任何方式的变化了。于是,改进后的模型变成了这样的设计:它具有一个始状态和一个末状态,同时还有一个长度上限和一个计数器,并且带有一个能根据输入的比例t返回自己偏向始末状态的程度y的函数。游戏每一帧会根据计数器和长度上限计算出比例t,然后调用函数计算始末状态各自的权重y,最后根据(始状态+(末状态-始状态)*y)加权始末状态得到当前帧状态。
终于,我们的模型可以应对AVG演出的动画需求了。而这种设计,我们一般称之为状态机模型。
2.1 状态机与补间曲线
状态机在游戏中的应用并不仅仅局限于动画控制,在诸如游戏AI等方面也一样有着深远的影响。状态机最基本的思路就是把对象的行动模式抽象成几个不同的模式并保存下来,同时使用一定的数据结构保存自己所处的状态,每次执行时都根据自己所处的状态选择相应的模式。所以,虽然听上去很奇怪,但是状态机是无状态(RESTful)的。
这里的无状态是指状态机自己是高内聚的,游戏或者系统并不需要为了状态机对象额外记录一些信息,仅仅使用状态机自己记录的信息就可以完成逻辑。
使用状态机时要注意状态之间的切换条件。除去开始和结束以外,状态机至少有一个中间状态,而不同的中间状态在一定条件下是可以相互转换的。虽然在这个AVG演出的例子中我们并不需要添加更多的中间状态,但是在更多的应用中,比如八坂神奈子的「マウンテン·オブ·フェイス」(麻将山)的六段弹幕变形,状态机会有很多很多的中间状态。
补间曲线这边倒是没有什么太生僻的概念,任何t∈[0, 1]到y∈[0, 1]的连续映射都有能力成为补间曲线。事实上,只要保证了t=0时y=0和t=1时y=1,中间部分完全是放飞自我的,就看演出效果需要不需要了。关于补间曲线的拟合也是一个很有意思的话题,但是在这里展开就跑题了,所以我并不打算去说。总之,这是一个统计学问题。
2.2 初始化与销毁
初始化是很有必要的,比如演出设计要求一张立绘浮现在屏幕上,显然事先把它放在那里并且不透明度调成0来等待这一演出是一件很蠢的事,所以就需要在执行到这一条的时候及时把它实例化到屏幕上。问题是,这一工作如果由状态机来做,按照当前的设计,我们不得不在每次执行的时候都判断是不是第一帧。
在状态机执行结束的时候同样会有这样一个问题。而且,根据很多游戏的实际需求,玩家在点击屏幕的时候状态机应该立即结束并且执行到最后一帧的状态,这甚至可能要求我们直接修改计数器。
我们只得再次修改设计:对于状态机加入预处理和回调这两个函数,一个在开始前调用,另一个在结束后调用。此外,还需要添加一个函数,让状态机立刻达到末状态。现在它可以高效地处理这两种状况了。
3. 动画并发与动画管理器
刚刚我们完成了状态机的设计,现在游戏已经可以有效地管理一个动画了。然而,在策划设计演出时经常会出现需要多个动画并发的情况。为此,我们需要一个行之有效的手段来管理多个状态机。
这个实现其实很容易想,也并不优雅:只需要一个队列,每一帧依次检查队列里的状态机,如果没有到达末状态就更新,否则就销毁。当用户点击鼠标时立即让状态机们到达末状态并销毁队列里全部的状态机。队列为空时重新装填脚本。这样就能保证游戏的顺利推进与动画并发了。
不过这个架构并不是完美无缺的。由于装填脚本并不进行额外判断的缘故,我们已经默认了每一条指令都是“动画”,甚至包括一行文字显示以后的下箭头。虽然那个箭头的确是有动态效果的,但是我们设计的状态机中每一个动画都是有长度的。换句话来说,即使是这个理论上来讲应该长度无限的“动画”,在实现上也必须给它一个长度。这就是隐性的BUG。
可惜想要触发这个BUG需要原地等待大约1.13年,而且效果也只是“玩家没有点击鼠标就自动播放了下一条对话”。
当然,在设计二进制指令的时候要考虑到给指令留下控制并发的参数。目前我还在衡量怎么设计指令更好,就先不讨论了。
4. 总结
总的来说,AVG动画控制的核心就是记录动画状态的状态机与控制动画并发的队列。这其中,状态机设计为补间动画的模式,外挂初始化和销毁函数,以及一个末状态函数;并发队列设计为动态队列,负责状态机的装填与销毁。
实现更多的动画状态机,演出所能使用的动画就会变得更丰富,游戏的表现力就会倾向于变得更强。然而,动画包括立绘所起到的作用不应该是占主要地位的甚至决定性的,而应该是作为加分项的。AVG的真正精髓是它的剧本,是它的故事性和叙述性,是制作人通过作品想要表达的观点、情感、态度以及思考。虽然演出也很重要,但是在整个画面上花了太多额外的功夫,导致游戏剧本太单薄或者经不起推敲,那就得不偿失了。

RetroDaddy 1年前
不吃鱼的喵酱 [作者] 1年前
发布