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

【前言和思路整理】

  千呼万唤Shǐ出来!最近莫名被基友忽悠着进舰坑了,加上要肝LL活动,又碰上公司项目紧张经常加班,这一章发得比以往时候来得更晚一些,抱歉啊。

 

  上一章我们实现了BeatObjectManager等几个类,让游戏可以播放预设好的谱面了。这一章我们给游戏加入用户输入和判定,并引入音频系统,最后部署到移动平台上,让游戏可以玩起来。

 

  本章的难点是物件判定的流程设计,和对物件判定逻辑的理解。

 

  关于音频系统,我采用了一个第三方的非开源库,严格意义上讲和Cocos2dx基本无关,可以选择性跳过。

 

  关于部署,因为我的手机是诺基亚的,所以只能弄WP平台。想部署到其他平台请自行百度了。

 

  本章的模块设计简图如下:

 

 


 【物件判定逻辑】

  物件判定可以说是Live场景的核心逻辑,我认为在开始敲代码前应该先把思维理清,把过程想透。

 

  执行物件判定功能的模块可以称为打击判定器。需要判定的时候,将物件输入模块,在输出端得到判定结果。通过第一章的分析可以知道,对于输入的任何的物件,模块的输出只能是如下几种情况:

         ·Perfect

         ·Great

         ·Good

         ·Bad

         ·Miss

         ·None

  和第一章不同的是,这里我加上了一个None。None表示不对这个物件进行判定。什么时候会用到None呢?情况之一是长条在按住的时候,是不需要判定结果的;情况之二是判定触发时间早于物件时间太多(也就是按得太早)的时候也不需要结果。

 

  那么什么时候才需要进行判定呢?

    1、用户触发了九个圆形按钮的触摸事件时

    2、控制器更新物件时

 

  第一个很好理解,只要用户点击了按钮,就需要针对这次点击操作进行一次判定。第二个是嘛?

 

  玩过LL的话就知道,如果开始游戏后不进行任何操作,物件飞过了按钮一定时间后,会报出Miss。而这个Miss的判定,就是在物件进行更新的时候触发的。上一章中我们设计的更新物件是放在LiveController类中,于是第二个判定操作也应当由LiveController发起。

 

  那么什么操作会触发什么样的判定结果呢?我们知道这是一个音乐游戏,那么对于物件的判定可以理解为我打得准不准。这个“准不准”是通过时间来体现的。

 

  如图所示,“准不准”遵循这个规则:

  

  轴下方的时间表示触发判定的时间和物件时间的差值。数值刻度根据需求不一定线性变化,但一定是对称的。

 

  还有一点需要注意的是,对于块物件,只要触发了非None判定,物件就会消失,而对于条物件,则稍微复杂一些:若头部判定为Miss,则物件消失;若头部点击过且为失Miss,则尾部判定(模式1)或长按判定(模式2)非None时物件消失。

        

  光用文字描述还是不容易理解,画成流程图看看,先是模式1:

  

 

  第二种模式的功能应当仅用于判定是否Miss:

  

  其中,头部和尾部判定输出除Miss外的所有情况,Miss判定则仅输出Miss或None判定。

 


 【判定器的实现】

  大致的思路有了,可以开始编码了,先从关键部分开始做。从外部来说判定器的结构非常简单,而它内部的核心逻辑应该是这样的:

   

  判定器对外有三个接口,分别用于判定块和条头、判定条尾、判定Miss。其中,判定块和条头在TouchBegan时调用(模式1),判定条尾在TouchEnded或TouchCanceled时调用(模式1),判定Miss每帧调用(模式2)。

 

  如下是判定器的代码。因为判定等级是1-7,默认0档为无效值,而每一档有4个判定时间阈,故采用一个二维数组来存放。其实想透了代码还是不复杂的:

  实现:

  ★这里的判定阈使用代码生成,实际应用中应当把这个值做成配置文件方便修改。

 


 【用户输入UI的实现】

  从视频中可以看出,Live场景中涉及到用户输入的部分很少,除开右上角的暂停按钮,就只有中间呈扇形分布的九个圆形按钮了。

 

  极端情况下,如果有人做了全押的谱,在某一时刻可能需要9个按钮同时按下(虽然到目前LL已有的谱最多同时按俩)。Cocos2dx默认最多支持5个点,再多的话需要修改一下底层,让它支持9点触控。

 

  修改很简单,只需要把这个常量的值改为9即可(CCEventTouch.h, 39行):

1
static const int MAX_TOUCHES = 9; //changed for EasyLive, default = 5;

  

  UI的功能就是在每个按钮收到消息时向LiveController类发送消息。使用过DX SDK的人可能会觉得这里需要采用轮询方式获取触摸状态。但是,游戏是每秒60帧运行的=>每秒更新60次=>每两次更新间隔16ms,也就是说每次点击有16ms的误差,对音游来说比较大。虽然可以采用多线程来降低误差,但需要考虑异步啊死锁啊一大堆问题,麻烦。

 

  于是就使用原生的事件触发机制来做这个功能,然后按钮的位置使用圆的参数方程计算得出。代码如下:

  实现:

  ★需要将libGUI项目(位于解决方案目录\cocos2d\cocos\ui\下,根据目标平台选择)引入解决方案中,设为主项目的生成依赖项,并在主项目的属性——链接器——附加依赖项中加入“libGUI.lib”

 


 【歌曲数据的修改】

  上一章中我们设计的歌曲数据是在外部仅能访问,不能修改的。而在现在的情况下得做一下修改了。

 

  删掉GetObjColumeInternal方法,统一使用GetObjColume来获取列数据的指针。同时,BeatObjectData结构中需要加入Enabled和HeadHitted两个bool型变量,用于指示物件是否可见,以及物件的头部是否已被点击(仅限于条):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class SongData
{
    //...
    //
public:
    std::vector<BeatObjectData>* GetObjColume(int pIndex);
    //
    //...
};
 
 
struct BeatObjectData
{
    //...
    //
    bool Enabled;
    bool HeadHitted;
 
    BeatObjectData()
    {
        //...
        //
        this->Enabled = true;
        this->HeadHitted = false;
    }
};
 
#endif // __SONG_DATA_H__

  cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::vector<BeatObjectData>* SongData::GetObjColume(int pIndex)
{
    switch (pIndex)
    {
    case 0:return &this->m_Colume_1;
    case 1:return &this->m_Colume_2;
    case 2:return &this->m_Colume_3;
    case 3:return &this->m_Colume_4;
    case 4:return &this->m_Colume_5;
    case 5:return &this->m_Colume_6;
    case 6:return &this->m_Colume_7;
    case 7:return &this->m_Colume_8;
    case 8:return &this->m_Colume_9;
     
    default:
        WASSERT(false);
        return nullptr;
    }
}

  ★修改后其他调用SongData的部分也需要做修改,把常量引用改为指针。修改很简单,这里不细说了。

 


 【音频系统的引入】

  对于这个项目,我们需要音频引擎提供如下功能:

    1、  音乐和音效分轨播放,即播放音乐的时候音效也可以播放出来,声音不冲突;

    2、  相同音效分轨播放,即同一音效可以叠加播放;

    3、  控制音乐播放、暂停、继续、停止

    4、  获取当前音乐的播放时间,精确到ms

 

  Cocos2dx自带一个SimpleAudioEngine,可以做到上面1和3的功能。要做到2和4则需要修改底层代码。是个Cocos2dx码农都知道这引擎是相当地不好用。当然本来这玩意的名字都说明了它是一个简单的音频引擎,图森破。

 

  改这里的底层代码会遇到一个很蛋疼的问题:SimpleAudioEngine在不同平台上的实现都不一样,基本上是做哪个平台就得改一下对应的代码。我是懒逼,懒得去折腾这个。

 

  所以这里隆重向大家安利一个灰常强大的第三方的音频库:FMOD。我最早是在解包LOL的语音的时候发现他们用了这玩意,然后查了一下卧槽通用API跨平台挺牛逼啊。据我所知目前国内不少手游使用了FMOD。

 

  FMOD是什么这里不做解释了,有兴趣的自行百度百科吧。直接放出地址:FMOD Ex地址

  请注意FMOD不是一个完全免费的库。商业项目中使用FMOD需要购买它的许可

 

  往下拉一点可以看到FMOD Ex Programmer’s API,下载对应平台的版本装上即可。装好后,目录下有个api文件夹,里面有C#接口、头文件、lib和dll。然后把FMOD的头文件拷贝到Classes下,引入到项目中。

  

  如果要在其他平台使用FMOD(比如下文说的部署到WP上),只需要换一下lib和dll就行,代码层是不需要修改的,那是相当地方便(说实话我很希望Cocos2dx的音频引擎也能有这么牛逼啊,毕竟这玩意的商业许可证不便宜)。

 

  然后在VS中打开项目属性,打开链接器项,把lib文件名加入到附加依赖项中。别忘了把lib文件和fmodex.dll文件拷贝到输出目录(Debug.win32或Release.win32)下。

  

  然后我直接放代码了,FMOD怎么用不是这一系列文章的重点,自行看安装目录中的Sample吧:

  实现:

  


 【整合模块】      

  让我们把完成的模块链接在一起,再修改一下之前的代码,为后面的部署做准备。

 

  首先把HitJudger和SoundSystem加入GameModule中。代码和其他模块一致,别忘了在析构方法中CC_SAFEDELETE一下。同时修改一下SetSongData方法(不修改的话部署后找不到文件会崩):

1
2
3
4
5
void GameModule::SetSongData(const std::string& pName)
{
    CC_SAFE_DELETE(m_pSongData);
    m_pSongData = new SongData(FileUtils::getInstance()->fullPathForFilename(pName));
}

  

  然后是SongTimer类。因为加入了音频引擎,时间应当从引擎中取得,而不是逐桢递加:

1
2
3
4
long SongTimer::GetTime()
{
    return GameModule::GetSongSystem()->GetCurPosition();
}

  

  Common.h中的WASSERT宏调用了DebugBreak方法用于触发断点。但是这个方法是个WinAPI,上了其他平台就没这玩意了。同时考虑到如果项目编译Release版本,断言不需要了,所以得改改:

1
2
3
4
5
6
7
8
9
#ifdef _DEBUG
    #if CC_TARGET_PLATFORM == CC_PLATFORM_WIN32
        #define WASSERT(__COND__) if (!(__COND__)) { DebugBreak(); }
    #else
        #define WASSERT(__COND__) CC_ASSERT(__COND__)
    #endif
#else
    #define WASSERT(__COND__) do {} while (0);
#endif

  ★经测试__debugbreak方法在WP上可用,但是MSDN说这方法是“Microsoft Specific”的,估计在安卓和iOS等其他平台没有对应的实现。

 

  接下来在LiveController类中加入一个变量和一个方法。变量用于保存离按钮最近的活动的物件索引,方法用于接受UI发送的消息并调用判定器:

1
2
3
4
public:
    void HitButtonsOnEvent(int pColume, bool pIsPress);<br>
private:
    int m_CurIndexes[9];

  m_CurIndexes变量在构造方法中需要全部赋值0,按钮事件方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void LiveController::HitButtonsOnEvent(int pColume, bool pIsPress)
{
    if (this->m_CurStatus != LCStatus::Running)
    {
        return;
    }
 
    auto songData = GameModule::GetSongData();
    auto objData = &(songData->GetObjColume(pColume)->at(this->m_CurIndexes[pColume]));
    auto curTime = GameModule::GetTimer()->GetTime();
    auto judger = GameModule::GetHitJudger();
 
    auto result = HitJudgeType::None;
     
    if (pIsPress)
    {
        result = judger->JudgeHead(songData->GetJudgement(), curTime, objData); 
 
        if (result != HitJudgeType::None)
        {
            if (objData->Type == BeatObjectType::Block)
            {
                objData->Enabled = false;
            }
            else
            {
                objData->HeadHitted = true;
            }
        }
    }
    else if (objData->Type == BeatObjectType::Strip && objData->HeadHitted)
    {
        result = judger->JudgeTail(songData->GetJudgement(), curTime, objData);
        objData->Enabled = false;
    }
 
    GameModule::GetSongSystem()->PlayHitSound(result, pColume);
}

 

  同时修改Update方法,加入Miss判定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void LiveController::Update()
{
    //...
    //
    // 防止Strip在飞行时消失
    //
    if (bottomIndex > 0)
    {
        auto obj = columeData->at(bottomIndex - 1);
        if (obj.Type == BeatObjectType::Strip)
        {
            if (obj.EndTime > bottomTime && obj.Enabled)
            {
                bottomIndex--;
            }
        }
    }
    this->m_CurIndexes[i] = bottomIndex;
    // Miss判定
    //
    auto curObj = &columeData->at(bottomIndex);
    if (GameModule::GetHitJudger()->JudgeMiss(songData->GetJudgement(), curTime, curObj))
    {
        curObj->Enabled = false;
        GameModule::GetSongSystem()->PlayHitSound(HitJudgeType::Miss, i);
 
        if (bottomIndex > 0)
        {
            bottomIndex--;
        }
    }
    // 更新物件
    // ...
}

  

  在ResetObjs方法中加入初始化音频文件的代码:

1
2
3
4
5
6
7
8
void LiveController::Reset()
{
    auto data = GameModule::GetSongData();
    WASSERT(data);
 
    this->m_pBeatObjectManager->ResetObjsFromData(data);
    GameModule::GetSongSystem()->SetSong(data->GetSongFilename());
}

 

  再修改一下StartLive方法,加入播放歌曲的代码:

1
2
3
4
5
void LiveController::StartLive()
{
    this->m_CurStatus = LCStatus::Running;
    GameModule::GetSongSystem()->PlaySong();
}

   

  最后是最上面的GetNearlyIndex方法,插入一小段代码以跳过不显示的物件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
inline int GetNearlyIndex(int pTime, const std::vector<BeatObjectData>* pColume)
{
    //
    //...
    while ((index_Start + 1) < index_End)
    {
        if (!pColume->at(index_Start).Enabled)
        {
            index_Start++;
            continue;
        }
    //...
    //
}

 

  然后是LiveScene类,在init方法中加入HitInputUI,然后把之前用代码写死的坐标改成相对坐标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
bool LiveScene::init()
{
    if (!Layer::init())
    {
        return false;
    
    Size visibleSize = Director::getInstance()->getVisibleSize();
    Vec2 origin = Director::getInstance()->getVisibleOrigin();
    // 加入背景图
    //
    auto bg = Sprite::create("bg.jpg");
    bg->setPosition(Vec2(
        visibleSize.width / 2 + origin.x,
        visibleSize.height / 2 + origin.y));
    this->addChild(bg);
    // 加上黑色半透明蒙层
    //
    auto colorLayer = LayerColor::create(Color4B(0, 0, 0, 192));
    this->addChild(colorLayer);
    // 初始化BeatInputUI
    //
    auto hiu = HitInputUI::create();
    hiu->setPosition(Vec2(
        visibleSize.width / 2 + origin.x,
        480 + origin.y));
    this->addChild(hiu);
    // 初始化BeatObjectManager
    //
    auto bom = BeatObjectManager::create();
    bom->setPosition(Vec2(
        visibleSize.width / 2 + origin.x,
        480 + origin.y));
    this->addChild(bom);
    // 初始化歌曲数据
    //
    GameModule::SetSongData("start_dash.xml");
    // 初始化控制器
    //
    GameModule::GetLiveController()->SetBeatObjectManager(bom);
    GameModule::GetLiveController()->ResetObjs();
 
    this->runAction(Sequence::createWithTwoActions(
        DelayTime::create(2),
        CallFunc::create([]()
        {
            GameModule::GetLiveController()->StartLive();
        })));
 
    this->scheduleUpdate();
 
    return true;
}

  ★我发现之前很逗逼地把BeatObjectManager做成LiveScene类的成员变量了,现在看来完全没有必要,删掉吧。

 

  BeatObjectManager的init方法也要修改一下,去掉设置自身坐标的代码,也就是对setPosition()的调用。删一行而已,代码就不发了。

  

  看了视频可以知道LL里面的条飞到按钮上之后,头部就不会移动了。为了做到这个效果来修改一下BeatObject类的setPositionY方法:  

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
void BeatObject::setPositionY(float y)
{
    // 如果该物件是一个Block
    //   
    if (this->IsBlock())
    {
        Node::setPositionY(y);
        auto headScale = GetMoveScale(y);
        this->m_pHead->setScale(headScale);
        this->m_pHead->setVisible(headScale > 0.05f);
    }
    // 如果该物件是一个Strip,则需要处理其身体和尾部
    //
    else
    {
        if (y < -400)
        {
            Node::setPositionY(-400);
        }
        else
        {
            Node::setPositionY(y);
        }  
        auto posY = this->getPositionY();
        auto headScale = GetMoveScale(posY);
        this->m_pHead->setScale(headScale);
        this->m_pHead->setVisible(headScale > 0.05f);
        // 模拟无限远处飞来的效果,保证尾部的y坐标小于0
        //
        if (y + this->m_fLength > 0)
        {
            this->m_fCurLength = posY > -400 ? -posY : 400;
        }
        else
        {
            this->m_fCurLength = posY > -400 ? this->m_fLength : 400 + y + this->m_fLength;
        }
 
        if (this->m_fCurLength < 0)
        {
            this->m_fCurLength = 0;
        }
 
        auto tailScale = GetMoveScale(posY + this->m_fCurLength);
        this->m_pTail->setPositionY(this->m_fCurLength);
        this->m_pTail->setScale(tailScale);
        this->m_pTail->setVisible(tailScale > 0.05f);
 
        auto harfHeadWidth = headScale * 124 / 2.0f;
        auto harfTailWidth = tailScale * 124 / 2.0f;
 
        this->m_pBody->SetVertex(
            Vec2(-harfTailWidth, this->m_fCurLength),
            Vec2(-harfHeadWidth, 0),
            Vec2(harfTailWidth, this->m_fCurLength),
            Vec2(harfHeadWidth, 0));
    }
}

  ★和LL不同的是,LL中条的尾部可以飞过按钮,我不认为这是一个好的设计,所以代码中限制条的长度最小为0,即条的尾部最远飞到按钮上。

 

  最后改一下AppDelegate类的applicationDidFinishLaunching方法,把设置设计分辨率的调用放在if外(修改前WP上测试发现分辨率总是设置了无效,后来才发现WP上就没进if,听说安卓也是这样的):

1
2
3
4
5
6
7
8
9
10
11
12
13
bool AppDelegate::applicationDidFinishLaunching() {
    //
    //...
    if(!glview) {
        glview = GLView::create("My Game");
        director->setOpenGLView(glview);
    }
 
    glview->setDesignResolutionSize(960, 640, ResolutionPolicy::SHOW_ALL);
     
    //...
    //
}

  感觉改了好多东西,都是以前自己给自己挖的坑orz修改完成后,可以编译运行了。如果不出错的话,你会看到这样的界面,还能听到歌曲和音效。

 

  用LL的图片怕起纠纷,所以我自己做了一套按钮,顺便想起武媚娘剪胸事件,干脆把背景也换了:

  

  当然用鼠标的话只能一次点一个,基本上没法玩,接下来部署到移动设备上试试。

 


【部署到WP设备】

  因为设备原因,以及家里电脑没装eclipse还有懒得去下ADK、NDK,只有部署到WP了。

 

  啥,你问我WP是啥?既然你诚心诚意地问了,那么我大发慈悲地建议你略过这一小节,或者去百度一下。

 

  在公司部署过安卓项目,感觉对比一下WP真的是比安卓的部署调试爽太多了,那是相当地爽,简直和iOS有一拼。而且在VS里面可以直接对真姬,呸,真机进行断点调试native层的代码。

 

  部署只需要四个步骤。首先我们调整一下VS的WP项目文件。打开proj.wp8-xaml目录下的sln文件,将xxxxxxComponent(xxxxxx是你创建Cocos2dx工程时输入的名字)中的Classes筛选器下面所有代码清空,把我们的Classes目录下的所有文件拖进去,别忘了FMOD的头文件。

 

  然后,我们需要下载FMOD的WP8版本。安装后,在xxxxxxComponent项目中的链接器选项中加入fmodex_80_arm.lib(如果在WP模拟器上调试,则需要fmodex_80_x86.lib)。

 

  再然后,把fmodex_80_arm.dll(如果在WP模拟器上调试,则需要fmodex_80_x86.dll)拖到xxxxxx项目中,调整它的属性:复制到输出目录 - 始终复制,生成操作 - 内容。

  

  如果在其他平台上使用FMOD,也需要引入对应的库文件。

 

  最后,可以编译项目了。要让编译器把应用部署在设备上运行,请这样设置:

  

  插入已经使用开发者账号解锁的WP设备,保持屏幕打开,编译完成后VS会将项目部署到手机上并运行。

 

  如果想调试C++层的代码,需要在xxxxxx项目的属性——调试页卡中,将“UI任务”设为“仅限本机”即可。设置后,在cpp中的断点啊log啊啥的都生效了。

 

  如果要修改应用的图标啊名称啊啥的,双击打开xxxxxx项目下的Properties——WMAppManifest.xml,可以直接进行修改,那是相当地方便。

  

  然后试试我们的成果吧,可以试着打一下~

  

 


 【本章结束语】

  最头疼的一章终于弄出来了。做用户输入的时候试了好几种方案最后才定下来。所以建议各位在遇上复杂的,一时想不透的逻辑的时候,拿出笔记本或者打开Visio这类的软件,把思路画下来,整理好,弄清楚了再敲代码,省得返工。

 

  本章用到的资源:点击下载(解压后放在Resources目录下,完全覆盖已有文件。不包含FMOD组件,请自行上官网下载)

  ★打击音效资源取自网络

 

  下一章我们给游戏加入显示分数、血条等等的UI,以及打击的特效。

 

  最后感叹一下如果要做下一系列我一定全部做好了再写博文……免得遇上加班等情况延期发布……毕竟加班乃码农之常情orz

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