节奏游戏开发指南 #0:如何拆除原子弹

作者:indienova
2016-07-23
6 20 7

说明

本文译自 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 页面了解更多信息。

7thbeat Games 工作室

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 的时间点并非真正的上一拍,而是接下来的一拍!

重要图示:

beat

因此,正确的做法是什么呢?像我反复强调过的那样——只增量不赋值:

代码如下:

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°)。
  • 将转动星球对齐到网格,并将其作为锚点(不动的星球)。
  • 而之前作为锚点不动星球,略微调整它的起始角度(令其抵消掉上一次转动星球超前或延迟的角度),并将其设为转动星球。

按键前的一帧:
1
一帧后:
2

目前来看,这种方法效果非常出色 - 通过抵消上一拍的误差,接下来的一拍会总是依然保持在 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 实现这款节奏类游戏。

近期点赞的会员

 分享这篇文章

indienova 

indienova - 独立精神 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. ayame9joe 2016-07-23

    一直想学习的知识!

  2. 林可 2016-07-24

    良心的音乐游戏,音乐是为游戏机制定制的感觉。

  3. eastecho 2016-07-24

    名字跟 U2 乐队有关吧?

  4. Alexa 2016-07-24

    节奏医生有首歌很好听

  5. erufu 2016-09-21

    超级有趣、手指好累

  6. erufu 2016-09-21

    已经中毒了

  7. Calculate0 2018-06-20

    dsptimesong...这个变量找不到啊。。。。。

您需要登录或者注册后才能发表评论

登录/注册