这是一篇写于几年前的文章,重新发布在机核网,除了分享目的外,也希望能借此宣传一下这款国内玩家还并不熟悉的独特音乐游戏。
本文原载于游戏开发论坛TIGsource,此处为授权译文,记载和分享了7th Beat Games开发节奏类游戏的经验与心得。本文是系列的第一篇。文中作为范例的游戏叫做《冰与火之舞》(A Dance of Fire and Ice),是一款难度很高的单键节奏游戏,巧妙地将几何变换与音乐节奏两种元素融为一炉。
这里可以找到一个比较早期的试玩版本: 这里 。
steam上则可以找到未来还会继续更新的正式版本: 这里 。 开发者 Hafiz Azman 来自 7th Beat Games 工作室。他们的另外一款游戏参与过前两次北京核聚变的小伙伴可能会有一些印象:仍在开发当中的《节奏医生》(Rhythm Doctor)。7thbeat Games 工作室一直专注于节奏游戏的开发,团队成员的实力雄厚:美术 Kyle 曾绘制过网络漫画 Soul Symphony,而《冰与火之舞》这款游戏的音乐制作人 Jade 目前则就读于伯克利音乐学院(Hafiz 也参与了一部分作曲工作)。有兴趣的读者可以前往他们的 itch.io 页面 了解更多信息。 我已经尝试制作过好几款节奏游戏。实话,这也是我唯一真正投入其中并一直想做的游戏类型。最初尝试制作这类游戏时,我发现很少有文档涉及节奏类游戏的一般架构。因此,我将会以自己的一款节奏游戏《冰与火之舞》(A Dance of Fire and Ice)为例,向读者介绍一些简单粗暴但非常有效的技术,来展现我是如何架构此类游戏的。
大家可以看一下冰与火之舞的宣传片,它能演示这款游戏的基本玩法和机制:
在我自己的游戏中,一般会给这个类其名为 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 类中的成员变量。其中一部分是专门用于我这款游戏的,但很多都是节奏游戏中通常会用到的:
songposition = (float)(AudioSettings.dspTime – dsptimesong) * song.pitch – offset;
附注:Unity 有一个内建的变量 song.pitch 用于指定正在播放的乐曲的速度。将其作为计算乐曲位置变量的因数,我就能够在改变乐曲播放速度的同时仍然保持节奏同步。利用这个特性我把游戏里的所有乐曲的速度都下调了 20%,因为编完曲后我才发现难度设置得有些略高了。
总而言之,这样一来,Conductor 类就初步设置完毕了,接下来我们来研究如何让对象来与它同步。
所有需要同步的对象仅使用乐曲位置进行同步,不使用其他任何方法。
这里的意思是指,不要用定时器,不要用补间方法。这些方法都无法持续工作。
随着帧更新的自增定时器(例如将它放在 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;
}
但有时候你需要的并非只执行一次的动作,而是周期执行的动作。尝试引入这种系统的时候经常会造成节拍同步误差。而我从这些惨痛教训里学到的最宝贵但非常简单的经验是:
仅仅给出一条抽象的概括还是略显微妙,我们依然还是结合实例来说明。我们希望每个拍子都伴随一次闪光,而不是只触发一次效果。下面这种实现看起来比较简单……然而,它是错误的,你能看出来原因吗?
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° 的位置!
一开始,一切都很同步,效果很时髦,但随着乐曲继续播放,同步率开始越来越糟。如果你已经读过前面的说明,应该知道为什么游戏会慢慢变得不同步。
是的,这是因为这种写法违背了我之前列出的一条原则:所有需要同步的对象仅使用乐曲位置进行同步,不使用其他任何方法。在上面的例子中,我在更新星球角度时对比了 deltaTime 和乐曲位置。这是错误的做法。
但是,当我试图直接使用每帧的乐曲位置变化值 timeDifference 来替换 deltaTime,却发现,问题依然没有解决!情况变得有些复杂起来。
原因实在微妙:在增加每帧角度时,我隐式地使用乐曲位置作为前一帧的“参考时间点”。这个参考时间点每次的增量取决于两帧的间隔。经过这些计算的过程中,逐渐积累的微小误差会让结果慢慢偏离正确的数值,游戏也随之不再同步。
(是的,节奏游戏的开发就是这样棘手。在制作节奏游戏的过程中,确保你的游戏引擎能够保持毫秒级的精准是尤为关键的。臻于完美的过程需要花费了大量时间来仔细调整,但这种付出是完全值得的。)
最终我想办法修复了这个问题,通过遵守下面这条金科玉律:使用一个不依赖帧率的方法来计算参考时间点的增量。
angle = snappedlastangle + ((conductor.songpostion - conductor.lasthit) / conductor.crotchet) * Mathf.PI * controller.speed;
(这里就不再具体涉及如何计算按键应当按下的时间段,这基本上只是一个数学问题,实践起来也不想转角或者几何学那样有趣,如果你真的很感兴趣,也欢迎前往 TIGsource 论坛原帖咨询我。)
第一篇教程就到这里结束了。希望这篇入门教程,能向大家揭示节奏游戏开发中这个非常关键的秘密:细小的时间差别就会造成巨大的效果反差。后续的几篇教程我们也会持续整理发表过来。如果大家有朝一日也开始制作自己的节奏游戏,也可以和我们一起来交流心得噢。也欢迎大家尝试一下《冰与火之舞》的发售版本,看看我们最终做出了怎样的效果。
评论区
共 4 条评论热门最新