说明
本文译自 TIGsource 论坛,为介绍节奏类游戏的开发日志,本文为系列的第一篇。文中作为范例的游戏,A Dance of Fire and Ice,是一款难度很高的单按键节奏游戏,巧妙地将几何变换与音乐两种元素融为一炉。
游戏原型的试玩地址见这里。
也可以在在线游戏网站 Kongregate 上找到这个 demo。
移动版本则可以花 1 美元在这里买到,后续将获得免费的更新。
开发者 Hafiz Azman 来自 7thbeat Games 工作室。如果大家对这个名字稍微觉得陌生的话,应该也有听说过前年斩获 IGF 学生奖的一款音乐游戏作品:《节奏医生》(Rhythm Doctor)。7thbeat Games 工作室目前专注于节奏游戏的开发,团队成员的实力雄厚:美术 Kyle 曾绘制过网络漫画 Soul Symphony,而音乐制作人 Jade 目前则就读于伯克利音乐学院。有兴趣的读者可以前往他们的 itch.io 页面了解更多信息。
有些读者可能也还没有体验过他们的前作《节奏医生》,可以参看下面的介绍视频(感谢谜之声授权分享,大家也可以前往 b 站观看视频):
引言
我已经完成了数款节奏游戏,实际上,这也是我唯一真正投入其中并一直想做的类型。最初尝试制作这类游戏时,我发现很少有文档涉及节奏类游戏的一般架构。因此,我将会以自己的一款节奏游戏 A Dance of Fire and Ice 为例,向读者介绍一些简单粗暴但非常有效的技术,来展现我是如何架构此类游戏的。
大家可以先看下面这个视频,会演示这款游戏的基本玩法和机制:
节奏游戏法则第 1 条
节奏游戏需要专门编写一个类来负责保持节奏
在我自己的游戏中,一般会给这个类其名为 Conductor
(中文意思即指挥家)。
这个类需要提供一个简单的成员函数/变量来标注乐曲位置,以便用到游戏中需要和节奏同步的一切事物上。以示范游戏为例,Conductor
类拥有一个名为 songposition
的成员变量,它可谓游戏中其他一切的基石。
// Conductorint crotchetsperbar = 8; public float bpm = 180; public float crotchet; public float songpostion; public float deltasongpos; public float lasthit;// = 0.0f; //上次按键的时间(已与拍子对齐) public float actuallasthit; float nextbeattime = 0.0f; float nextbartime = 0.0f; public float offset = 0.2f; //调整歌曲开头的位置 public float addoffset; //针对每首乐曲单独的调整值 public static float offsetstatic = 0.40f; public static bool hasoffsetadjusted = false; public int beatnumber = 0; public int barnumber = 0;
上面列出了 Conductor
类中的成员变量。其中一部分是专门用于我这款游戏的,但很多都是节奏游戏中通常会用到的:
- bpm -- 用于指定乐曲的 bpm (即每分钟节拍数);
- crotchet -- 指定四分音符(crotchet)时长,通过 bpm 计算得出;
- offset -- 偏移量,非常重要的一个变量,因为事实上 mp3 文件开始处总是会存在微小的间隙,文件开头会用于存放一些数据(包含如艺术家名字,曲名等等的信息);
- songpostion -- 乐曲位置,一个应当直接从
Audio
对象的对应变量获取“值”的变量。每个引擎中对应的变量不同,对 Unity 来说,我们可以使用AudioSettings.dspTime
。而我的做法是,在播放乐曲的每一帧都记录变量dspTime
的值并将其赋给songpostion
,这样在的乐曲为止变量每一帧都会像下面的代码这样设置:songposition = (float)(AudioSettings.dspTime – dsptimesong) * song.pitch – offset;
附注:Unity 有一个内建的变量
song.pitch
用于指定正在播放的乐曲的速度。将其作为计算乐曲位置变量的因数,我就能够在改变乐曲播放速度的同时仍然保持节奏同步。利用这个特性我把游戏里的所有乐曲的速度都下调了 20%,因为编完曲后我才发现难度设置得有些略高了。
总而言之,这样一来,Conductor
类就初步设置完毕了,接下来我们来研究如何让对象来与它同步。
节奏游戏法则第 2 条
所有需要同步的对象仅使用乐曲位置进行同步,不使用其他任何方法。
这里的意思是指,不要用定时器,不要用补间方法。这些方法都无法持续工作。
随着帧更新的自增定时器(例如将它放在 Update
函数中)并不靠谱,只要 FPS 不稳就会导致一切都毁掉。
统计离逝时间的函数也依然不够精准,尤其是当我们出于某些原因需要快进或者跳过歌曲的时候,也会出现严重问题。
所以说,只考虑使用乐曲位置变量,不要用定时器。
(设计心得:尽可能让游戏里的所有元素都随着节拍舞动!让整个游戏都变得动感!)
但在那之前,还有一件非常微妙的事情你需要予以关注——这也是我最开始被困扰之处。
你应该有注意到,即便我们打算把乐曲位置变量用到所有需要同步的游戏元素中,我们也还是需要一些用与检查乐曲位置的参考点:比如,所有的乐曲都会需要用乐曲位置变量去检查乐曲起始处的原点。
来举一个实际的例子吧,现在有四道光,你想要让它们在乐曲的头四个节拍处亮起来。于是你编写了一个名为 Spotlight
(聚光灯)的类的脚本。
代码如下:
int beatnumber = 1; //或者 2 或者 3 或者 4 bool islitup = false; float bpm = 140; float crotchet; //四分音符长度 void Start(){ crotchet = 60 / bpm; } void Update(){ if (Conductor.songposition > crotchet * beatnumber) islitup = true; }
但有时候你需要的并非只执行一次的动作,而是周期执行的动作。尝试引入这种系统的时候经常会造成节拍同步误差。而我从这些惨痛教训里学到的最宝贵但非常简单的经验是:
节奏游戏法则第 3 条
不要随意更新参考点。只对它进行增量。
仅仅给出一条抽象的概括还是略显微妙,我们依然还是结合实例来说明。我们希望每个拍子都伴随一次闪光,而不是只触发一次效果。下面这种实现看起来比较简单……然而,它是错误的,你能看出来原因吗?
代码如下:
float lastbeat; //这就是那个“会动的参考点” float bpm = 140; void Start(){ lastbeat = 0; crotchet = 60 / bpm; } void Update(){ if (Conductor.songposition > lastbeat + crotchet) { Flash(); lastbeat = Conductor.songposition; } }
字面上就五行代码。看起来没什么问题对吧?每当我们需要移动到下一个拍子上,我们就把参考点设置为当前时间,然后等下一个拍子经过。
但是……这样不行!这样想当然地做下去最后准得哭鼻子。会有越来越多没和拍子同步的闪光亮起来,每拍都可能会与节拍错开最多六十分之一秒。(哪里出问题了呢,我这里已经提示地非常明显了)。
问题精确地反映在了我上面列出的原则里:不要随意更新参考点。只对它进行增量。
我们将当前歌曲位置赋值给上一拍的做法正是我说的“随意更新参考点”的行为。问题在于,你的游戏总是工作在特定帧率下,比如 60 fps,因此在一秒内也最多只能检查 60 次。这样一来,当返回状态为真时,你可能刚好错开了六十分之一秒。这时,你赋给的上一拍 lastbeat
的时间点并非真正的上一拍,而是接下来的一拍!
重要图示:
因此,正确的做法是什么呢?像我反复强调过的那样——只增量不赋值:
代码如下:
float lastbeat; //这就是那个“会动的参考点” float bpm = 140; void Start(){ lastbeat = 0; crotchet = 60 / bpm; } void Update(){ if (Conductor.songposition > lastbeat + crotchet) { Flash(); lastbeat += crotchet;// 关键差别在这里 } }
这条原则虽然简单但很重要。
将上述原则运用到游戏中
说实话,这些技巧在我去年开始制作第一款节奏游戏的时候就早已经掌握了,我比较在意的是如何呈现更加复杂的场景。
你会留意到,在我的游戏中,两个星球互相环绕飞行,并且遵循乐曲播放速度:半圈恰好为一拍。当玩家按下按钮时,环绕飞行的星球和不动的星球会交换角色。因此,如果玩家每拍按一次按钮,环绕的双星会优雅地走出一条笔直的线条。
在某一帧内:若乐曲位置在 0° 的上一拍处,那么接下来一拍应该落在上一拍时间点加上 180° 的四分音符长度处,因此,转角的增量应该是 (deltaTime / crotchet) * 180 degrees
。
这样,每经过一个四分音符,我们移动 180° 就可以了。似乎并不麻烦!
难点在于,玩家按键并非精准地落在拍子上(当然那几乎不可能做到)。这款游戏基于网格——为了提供充分的乐趣,不必要求玩家一定要精准地抓住时机,略微早点或晚点都问题不大。因此,问题在于,我应当如何将星球对齐到格子中,不让一切东西都发生偏移。
(天才如)我想出一个蛮厉害的方法来解决这个问题:在按键同时,游戏可以完成许多事情:
- 记录正在移动的星球的位置转过的角度,将它对齐到网格中(如果双星沿着直线运动,那对齐角度应为 180°)。
- 将转动星球对齐到网格,并将其作为锚点(不动的星球)。
- 而之前作为锚点不动星球,略微调整它的起始角度(令其抵消掉上一次转动星球超前或延迟的角度),并将其设为转动星球。
按键前的一帧:
一帧后:
目前来看,这种方法效果非常出色 - 通过抵消上一拍的误差,接下来的一拍会总是依然保持在 180° 的位置!
这种方法具体如何实现呢?可得费一番功夫!
让一切可以动的东西同步起来
一开始,一切都很同步,效果很时髦,但随着乐曲继续播放,同步率开始越来越糟。如果你已经读过前面的说明,应该知道为什么游戏会慢慢变得不同步。
是的,这是因为这种写法违背了我之前列出的一条原则:所有需要同步的对象仅使用乐曲位置进行同步,不使用其他任何方法。在上面的例子中,我在更新星球角度时对比了 deltaTime
和乐曲位置。这是错误的做法。
但是,当我试图直接使用每帧的乐曲位置变化值 timeDifference
来替换 deltaTime
,却发现,问题依然没有解决!情况变得有些复杂起来。
原因实在微妙:在增加每帧角度时,我隐式地使用乐曲位置作为前一帧的“参考时间点”。这个参考时间点每次的增量取决于两帧的间隔。经过这些计算的过程中,逐渐积累的微小误差会让结果慢慢偏离正确的数值,游戏也随之不再同步。
(是的,节奏游戏的开发就是这样棘手。在制作节奏游戏的过程中,确保你的游戏引擎能够保持毫秒级的精准是尤为关键的。臻于完美的过程需要花费了大量时间来仔细调整,但这种付出是完全值得的。)
最终我想办法修复了这个问题,通过遵守下面这条金科玉律:使用一个不依赖帧率的方法来计算参考时间点的增量。
这个终极解决方案的注意事项如下所列:
- 不要使用两帧间隔时间来计算角度增量了。而是,使用插值法!记录上次双星交换角色时的乐曲位置(即所谓的
lasthit
)以及此时星球转过的。这样,每帧转角的计算代码可以使用下面的方法:angle = snappedlastangle + ((conductor.songpostion - conductor.lasthit) / conductor.crotchet) * Mathf.PI * controller.speed;
- 如何解决玩家无法精准按键的问题:不要将
lasthit
作为按键时机,而是将其作为按键的最后期限。换言之,lasthit
变量只随着节拍增加。这样一来,就完全排除了随意更新参考点作为计算值的问题(就像之前曾提到过那个随着节拍闪光的问题)。也就是说,玩家按键输入的确切时间并不参与我们的计算,这样许多麻烦的问题也就迎刃而解了。
(这里就不再具体涉及如何计算按键应当按下的时间段,这基本上只是一个数学问题,实践起来也不想转角或者几何学那样有趣,如果你真的很感兴趣,也欢迎前往 TIGsource 论坛原帖咨询我。)
结语
第一篇教程就到这里结束了。希望这篇入门教程,能向大家揭示节奏游戏开发中这个非常关键的秘密:细小的时间差别就会造成巨大的效果反差。
教程的结尾,特别鸣谢 microngame.com 的创始人 Tom Voros,一路一来助我良多,给我提供了不少建议和提示,令我得以使用 AS3 实现这款节奏类游戏。
一直想学习的知识!
良心的音乐游戏,音乐是为游戏机制定制的感觉。
名字跟 U2 乐队有关吧?
节奏医生有首歌很好听
超级有趣、手指好累
已经中毒了
dsptimesong...这个变量找不到啊。。。。。
如何 知道 音乐的乐点位置呢?以及 乐点位置校准,不是很清楚