我用Cocos2d-x模拟《Love Live!学院偶像祭》的Live场景(三)

【前言和思路整理】

  千呼万唤Shi出来啊(好像也没人呼唤),最近公司项目紧,闲暇时间少更得慢,请见谅。

 

  上一章我们分析并实现了打击物件类BeatObject,和它的父节点BeatObjectColume。这一章来完成BeatObjectManager类,并让它可以根据数据运作起来。

 

  既然要让物件根据数据联动起来,我们在开工前应该构思一下程序的框架。如下是我的设计图:

  这个设计图表示每次更新时的流程。设计思维依然是将数据和显示分开,使用LiveController类连接数据和显示。接下来我们来一一实现它们。

 


 【歌曲数据结构的实现】

  一个歌曲数据类中应当包含哪些数据呢?

    1、  歌曲文件名

    2、  物件飞行速度索引

    3、  打击判定索引

    4、  存放物件数据的列表

 

  成员很少。因为是一个简化版的游戏,所以砍掉了歌曲名艺术家名作词作曲编曲等等,只保留游戏中会用到的数据。

 

  歌曲文件名好说,二三四是嘛玩意?

 

  先说二和三。玩过《节奏大师》的话,就知道一首歌可以有多个难度。不同难度中,物件飞过来的速度和打击判定严格程度也不同。物件飞行速度表示物件每ms移动多少px,打击判定则是打击时间和物件时间相差多少ms获得什么判定。

 

  在这里我们制定一个映射表来表示这两个数值,根据索引映射出物件飞行速度是多少px/ms,以及打击的时间和物件时间相差多少ms获得什么打击判定。如此做可以便于统一数据标准。两个数据的暂定取值范围值都是[1, 7],而这个映射表暂时放在Common.h里面: 

1
const float DropVelocity[] = { -1, 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f };

  本章不涉及到打击判定的规则,故只设置了飞行速度。

 

  然后说四。从数据上讲,单个物件应该包含如下信息:

    1、  物件类型,表明它是圈还是条

    2、  起始时间,如果它是圈,则表明它飞到按钮上的时间;如果它是条,则表明它的头部飞到按钮上的时间

    3、  结束时间,仅对条类有效,表示条的尾部飞到按钮上的时间

    4、  是否同时出现,表明物件绘制时是否应该加上横条(若有横条,则表示这几个物件的起始时间相同)

    5、  是否星星,表明物件绘制时是否应该加上星星(若有星星,则打出Good及以下的判定时,会扣除体力值)

 

  我采用一个列表结构来存放每一列的打击物件数据,如下是歌曲数据的头文件:

  实现:

  ★感谢博友 肖志栋 提醒这里写错了一个地方(代码中已修复)

  ★数据类做好谱面读取,提供获取数据的接口即可。

  ★将歌曲的数据保存在代码中绝对是不科学的,我们应该将数据保存在文件中,歌曲类实例化时,从文件读取数据存储在内存中。我采用XML格式来存储的,所以读取的时候需要使用tinyXML2——cocos2d-x自带的XML解析器来读取。当然,你也可以使用JSON(cocos2d-x自带RapidJSON解析器),或者自定义的格式。反正目的是把文本字符串转化为我们需要的东西。在这个项目中,我使用的XML是这样的格式:

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<SongData SongFile="歌曲文件" Velocity="飞行速度" Judgement="判定">
    <TimeTrack Time="1000">
        <BeatObject Colume="1" Type="1" Star="false"/>
    <BeatObject Colume="2" Type="1" EndTime="2000" Star="false"/>
    </TimeTrack>
</SongData>

  是否有星星是随机生成的,和官方谱面不同。本章使用的谱面数据由一个自制的谱面编辑器生成,参考官方的《START:DASH!!》Expert难度制作。这个歌曲名是不是想说明START类继承于DASH

  

  编辑器制作不属于这一系列文章讨论的范畴,就不多做讲解了。原理不复杂的,唯一一个难点就是如何在MFC中使用Cocos2d-x,而网上教程遍地都是。

 


 【物件管理器的实现】  

  数据类有了,接下来可以编写BeatObjectManager类了。其作用是根据数据创建打击物件,并在游戏开始后对其进行更新操作。以下是BeatObjectManager的头文件:

  实现:

  ★注意ResetObjsFromData方法。这个方法的作用是根据传入的SongData数据创建显示的BeatObject实例。在方法中第二层for循环中,可以看到这里是采用倒序添加的。为什么呢?

 

  因为,如果使用顺序添加,在物件密集的时候就是这样的效果:

   

  如图所示,后面的物件会盖在前面的物件上。虽然LL也是这样的(找一些较密集的谱面的视频,放慢看会发现),但是我不认为这是一个好的设计和体验,所以我采用倒序添加,把时间靠后的物件放在时间靠前的物件的层级下。

 

  然后,我们需要对BeatObject类和BeatObjectColume类做一些修改。

  

  首先修改BeatObject类。BeatObject是根据时间显示的,所以我们不希望刚添加进去就显示在屏幕上,所以这里要修改BeatObject类的init方法:

1
2
3
4
5
6
7
8
bool BeatObject::init(int pType, float pLength /* = 0 */)
{
    // ...
 
    this->setVisible(false);
 
    return true;
}

  

  然后是BeatObjectColume类。极端情况下,一个列中可能有上百上千个物件,所以我们必须保证在每一帧中只更新会在屏幕上显示的物件,不然会造成FPS降低。

 

  在BeatObjectColume类中添加两个变量和一个方法:

1
2
3
4
5
6
private:
    void UpdateObjects(int pStartIndex, int pEndIndex);
 
private:
    int m_nLastStartIndex;
    int m_nLastEndIndex;

  方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void BeatObjectColume::UpdateObjects(int pStartIndex, int pEndIndex)
{
    for (int i = this->m_nLastStartIndex; i <= this->m_nLastEndIndex; i++)
    {
        this->m_BeatObjList.at(i)->setVisible(false);
    }
 
    for (int i = pStartIndex; i <= pEndIndex; i++)
    {
        this->m_BeatObjList.at(i)->setVisible(true);
    }
 
    this->m_nLastStartIndex = pStartIndex;
    this->m_nLastEndIndex = pEndIndex;
}

  ★两个变量别忘记赋初值,这里使用的0。

  ★UpdateObjects方法是就是在每一帧更新时由外部调用,隐藏上一帧显示的物件,再显示当前帧范围内的物件。

 


 【Live控制器的实现】

  LiveController类将数据和显示连接在一起,是一个重要的枢纽类。根据设计,这个类在每一帧会计算出更新范围和物件的Y坐标。怎么计算呢?

 

  首先我们来确定当前要显示的物件索引范围,这需要知道屏幕中能够显示出的物件的时间区间。

 

  如果我们去掉伪3D效果,再简化一下贴图材质,那么游戏运行的时候可以大致用如下的图片来描述:

  

  我是懒逼所以就只做了3列,意思一下就行。

 

  回忆一下小学物理(还是初中物理?记不清了)中提到的参考系概念。图中的效果是以窗口为参考系的。如果我们以物件为参考系,上图描述的过程就变成了物件不动窗口动,窗口的移动速度和以窗口为参考系时物件的移动速度大小相同,方向相反。

 

  那么,取得窗口上某一点对应的时间就非常简单了。如果窗口底端所在的时间为当前时间,有如下公式:

    顶端时间 = 当前时间 + (窗口高度 / 移动速度)

    顶端时间 = 当前时间 + (0 / 移动速度)

  我们要做的就是只显示物件时间处于底端时间和顶端时间之间的物件。

 

  根据上一章的分析得出物件从生成点到窗口底边的距离是480px,到按钮的距离为400px,按钮到窗口底边的距离为80px。所以,在每一帧中,我们只需要显示y坐标处于[-480, 0]之间的物件。虽然在经过旋转后,物件飞出窗口的距离可能大于480px,但是根据游戏设计,物件只要飞过了按钮而用户并没有点击,这个物件就会消失,并判定为miss(视频中没有展示)。而飞过按钮的距离实际上很短,所以取480px完全够了。

 

  由于按钮所在的位置才是当前时间,所以公式要稍微变化一下:

    顶端时间 =当前时间 + (400 / 物件飞行速度)

    底端时间 = 当前时间 + (-80 / 物件飞行速度)

  时间范围确定了,接下来就是根据物件的时间计算它的坐标了。上一章中,我们最后在LiveScene的Update中加入了让物件移动的代码,它的作用是:每帧将物件的Y坐标-4。实际使用中,我们是不是应该根据上文说的飞行速度索引取得飞行速度,然后每帧都将物件的坐标减去这个值呢?

 

  答案是否定的。因为这是一个音乐游戏,物件的坐标和歌曲的时间必须紧密相关。以上面的方式设计的话,如果在某一时刻,音频卡顿了一下,由于音频播放走的多线程,Update方法不会受到影响,物件继续在移动,在这一时刻后,物件时间相对于歌曲时间就会提提前一些,严重影响节奏感。简单地说,就是“动次打次动次打次”和物件飞行对不上了。

 

  所以这里需要采取根据歌曲时间来设定物件y坐标的设计。如果某一时刻卡顿了,在下一帧时物件会发生一个瞬移,虽然视觉上看起来不大好,但是物件坐标相对于歌曲时间还是没有发生变化的,节奏还是跟着歌曲在走的,节奏感就不会乱。

 

  简略地说,就是这种方式保证物件的飞行和歌曲播放时间轴一致

        

  在继续工作前,先用下面一张简图来表示一下物件飞行和时间刻度的关系:

  

  能看出,物件自身的时间在移动的过程中是不会改变的,影响物件y坐标的因素是当前时间和物件飞行速度。

 

  可以推算出物件当前y坐标和当前时间的关系:

    物件坐标y = (物件时间 – 当前时间) × 物件飞行速度 – 400

  接下来就是根据时间取值获取能显示的物件,需要知道-480点和0点对应的最接近的物件的索引。

 

  由于BeatObjectManager中的数据取自SongData类,而SongData的数据来自文件。只要我们保证文件中记录的物件是按时间顺序排列的,那么BeatObjectManager中的显示物件也是顺序排列的。顺序排列的物件使用二分查找法再合适不过。这里采用了一个二分近似查找法,使用二分查找得到起始时间和传入参数最接近的物件的索引。计算出结果后,应当对底部的物件索引再进行一次判断,防止物件头部时间超过当前时间太多时(如该物件是一个很长的条)整个物件不再显示。

 

  如下是LiveController类的头文件:

  实现:

  


【歌曲计时器临时实现】

  这一章本应不涉及计时器的。由于我们要测试一下功能,就得先写一个简易的计时器。我们知道,这个游戏玩起来的时候,除非中途暂停游戏重新开始,音乐是一直播放到结束的。所以可以认为,“当前时间”是一个线性增加的量。

 

  又因为,游戏是以60fps运行的,每帧耗时约16ms,于是可以这么写:

实现:

 


【功能管理】

  随着加入的功能越来越多,而这些功能块在程序的很多地方都有用到。如果把这些功能类做成单例模式,就不可避免在各种地方调用GetInstance()方法,而程序退出时还得一个个去释放内存,略麻烦。

 

  所以我们来做一个GameModule类,将这些功能模块统一放进这里,而程序退出时也由这里统一释放内存。所有需要调功能块的地方,都从这个类里面调。这个类比较简单,直接上代码:

实现:

  ★需要注意的是这些功能块都没有做ARC,所以需要加入Dispose()方法,在程序退出时手动delete。

  ★类中加入Update方法供外部在每帧调用,用以支持某些功能块(如LiveController)每帧更新。

  ★每次在类中添加功能块后,别忘了在Dispose()方法里加上释放内存的代码。

 


 【测试一下】

  在LiveScene中,删掉上一次加入的Colume类,加上BeatObjectManager类,并在init()方法中初始化数据,在update()方法中调用GameModule类的Update()方法:

  ★别忘了在析构方法中调用GameModule类的Dispose()方法。

 

  然后让程序跑起来,可以看到物件按谱面设定的顺序飞出了。虽然暂时没有音乐但是还是可以脑补唱一下 "I say… Hey! Hey! Hey! START:DASH!"

 

  截取录制一小段,大致这个效果:

 

 


 

 【本章结束语】

  这一章就结束了。本章的难点主要在于对于物件移动时,物件的坐标和时间的相互转换的理解。我表达能力不算太好,希望大家能看懂上面的分析过程。

  下一章我们加入音乐播放功能,并让这个游戏可以接收用户输入,并执行判定逻辑,让它变得可以玩一玩。

  本章所用到的资源:百度网盘 和上一章的资源相比就多了一个start_dash.xml

 

posted @   GuyaWeiren  阅读(1165)  评论(12编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· 周边上新:园子的第一款马克杯温暖上架
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
· 使用C#创建一个MCP客户端
点击右上角即可分享
微信分享提示