我用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个判定时间阈,故采用一个二维数组来存放。其实想透了代码还是不复杂的:
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 | #ifndef __HIT_JUDGEMENT_H__ #define __HIT_JUDGEMENT_H__ #include "SongData.h" enum HitJudgeType; class HitJudger { public : HitJudger(); ~HitJudger(){} public : HitJudgeType JudgeHead( int pJudgementLevel, long pHitTime, const BeatObjectData* pObjData); HitJudgeType JudgeTail( int pJudgementLevel, long pHitTime, const BeatObjectData* pObjData); bool JudgeMiss( int pJudgementLevel, long pCurTime, const BeatObjectData* pObjData); private : /* * 根据物件和点击的时间计算出判定 * @param pJudgementLevel 判定等级 * @param pTimeOffset 点击时间减去物件时间 */ HitJudgeType GetResult( int pJudgementLevel, long pTimeOffset); private : int m_JudgeValue[8][4]; }; enum HitJudgeType { Perfect, Great, Good, Bad, Miss, None }; #endif // __HIT_JUDGEMENT_H__ |
实现:
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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | #include "HitJudger.h" HitJudger::HitJudger() { // 列分别对应:Perfect, Great, Good, Bad // 大于Bad则为Miss // for ( int i = 0; i < 4; i++) { this ->m_JudgeValue[0][i] = -1; } for ( int i = 1; i < 8; i++) { for ( int j = 0; j < 4; j++) { this ->m_JudgeValue[i][j] = 150 - 20 * i + 30 * j; } } } HitJudgeType HitJudger::JudgeHead( int pJudgementLevel, long pHitTime, const BeatObjectData* pObjData) { return this ->GetResult(pJudgementLevel, pHitTime - pObjData->StartTime); } HitJudgeType HitJudger::JudgeTail( int pJudgementLevel, long pHitTime, const BeatObjectData* pObjData) { auto ret = this ->GetResult(pJudgementLevel, pHitTime - pObjData->EndTime); // 若松手时间比miss还早,同样判定为miss // if (ret == HitJudgeType::None) { ret = HitJudgeType::Miss; } return ret; } bool HitJudger::JudgeMiss( int pJudgementLevel, long pCurTime, const BeatObjectData* pObjData) { if (pObjData->HeadHitted) { return false ; } auto timeOffset = pCurTime - pObjData->StartTime; if ( abs (timeOffset) > this ->m_JudgeValue[pJudgementLevel][3]) { return timeOffset > 0; } return false ; } HitJudgeType HitJudger::GetResult( int pJudgementLevel, long pTimeOffset) { auto value = this ->m_JudgeValue[pJudgementLevel]; auto offsetABS = abs (pTimeOffset); if (offsetABS <= value[0]) { return HitJudgeType::Perfect; } else if (offsetABS <= value[1]) { return HitJudgeType::Great; } else if (offsetABS <= value[2]) { return HitJudgeType::Good; } else if (offsetABS <= value[3]) { return HitJudgeType::Bad; } else { return HitJudgeType::None; } } |
★这里的判定阈使用代码生成,实际应用中应当把这个值做成配置文件方便修改。
【用户输入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的误差,对音游来说比较大。虽然可以采用多线程来降低误差,但需要考虑异步啊死锁啊一大堆问题,麻烦。
于是就使用原生的事件触发机制来做这个功能,然后按钮的位置使用圆的参数方程计算得出。代码如下:
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 | #ifndef __HIT_INPUT_H__ #define __HIT_INPUT_H__ #include "cocos2d.h" #include "ui/CocosGUI.h" USING_NS_CC; using namespace cocos2d::ui; class HitInputUI : public Node { public : ~HitInputUI(){} CREATE_FUNC(HitInputUI); private : HitInputUI(){} bool init(); private : void CircleOnTouchEvent(Ref* sender, Widget::TouchEventType type); private : Button* m_BeatCircles[9]; }; #endif // __HIT_INPUT_H__ |
实现:
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 59 60 61 62 | #include "HitInputUI.h" #include "Common.h" #include "GameModule.h" bool HitInputUI::init() { if (!Node::init()) { return false ; } for ( int i = 0; i < 9; i++) { std::ostringstream oss; oss << "BeatCircle_" << (i + 1) << ".png" ; auto rad = CC_DEGREES_TO_RADIANS(22.5f * i + 180); auto circle = Button::create(oss.str(), oss.str(), oss.str()); circle->setPosition(Vec2( 400 * cos (rad), 400 * sin (rad))); this ->addChild(circle); circle->addTouchEventListener(CC_CALLBACK_2(HitInputUI::CircleOnTouchEvent, this )); this ->m_BeatCircles[i] = circle; } return true ; } void HitInputUI::CircleOnTouchEvent(Ref* sender, Widget::TouchEventType type) { if (type == Widget::TouchEventType::MOVED) { return ; } int touchedIndex = -1; for ( int i = 0; i < 9; i++) { if ( this ->m_BeatCircles[i] == sender) { touchedIndex = i; break ; } } WASSERT(touchedIndex != -1); switch (type) { case Widget::TouchEventType::BEGAN: GameModule::GetLiveController()->HitButtonsOnEvent(touchedIndex, true ); break ; case Widget::TouchEventType::ENDED: case Widget::TouchEventType::CANCELED: GameModule::GetLiveController()->HitButtonsOnEvent(touchedIndex, false ); break ; } } |
★需要将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吧:
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 | #ifndef _SOUND_SYSTEM_H_ #define _SOUND_SYSTEM_H_ #include <string> #include "HitJudger.h" #include "fmod/fmod.hpp" #include "fmod/fmod_errors.h" using namespace FMOD; class SoundSystem { public : SoundSystem(); ~SoundSystem(); public : void SetSong( const std::string& pFilename); void PlaySong(); void PauseSong(); void ResumeSong(); void StopSong(); long GetCurPosition(); void PlayHitSound( const HitJudgeType& pType, int pColume); private : void PlaySound(Sound* pSound, bool pIsSong, int pColume); void CreateSound(Sound** pOutSound, const char * pFilename, bool pIsStream); void ERRCHECK(FMOD_RESULT result); private : System *m_pSystem; Sound *m_pSong; Sound *m_pSound_Prefect, *m_pSound_Great, *m_pSound_Good, *m_pSound_Bad, *m_pSound_Miss; Channel *m_pChannel_Song; Channel *m_Channel_HitSound[9]; }; #endif // _SOUND_SYSTEM_H_ |
实现:
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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | #include "SoundSystem.h" #include "Common.h" USING_NS_CC; #define SAFE_RELEASE_FMOD_COMPONENT(__COM__) { if((__COM__)) (__COM__)->release(); (__COM__) = nullptr; } SoundSystem::SoundSystem() : m_pSong( nullptr ) , m_pChannel_Song( nullptr ) { // 初始化系统 // auto result = FMOD::System_Create(& this ->m_pSystem); ERRCHECK(result); unsigned int version; result = this ->m_pSystem->getVersion(&version); ERRCHECK(result); if (version < FMOD_VERSION) { log ( "Error!\r\nYou are using an old version of FMOD %08x.\r\nThis program requires %08x\n" , version, FMOD_VERSION); WASSERT( false ); } result = this ->m_pSystem->init(32, FMOD_INIT_NORMAL, 0); ERRCHECK(result); // 初始化音轨 // for ( int i = 0; i < 9; i++) { this ->m_Channel_HitSound[i] = nullptr ; } // 创建打击音效 // this ->CreateSound( & this ->m_pSound_Prefect, FileUtils::getInstance()->fullPathForFilename( "perfect.wav" ).c_str(), false ); this ->CreateSound( & this ->m_pSound_Great, FileUtils::getInstance()->fullPathForFilename( "great.wav" ).c_str(), false ); this ->CreateSound( & this ->m_pSound_Good, FileUtils::getInstance()->fullPathForFilename( "good.wav" ).c_str(), false ); this ->CreateSound( & this ->m_pSound_Bad, FileUtils::getInstance()->fullPathForFilename( "bad.wav" ).c_str(), false ); this ->CreateSound( & this ->m_pSound_Miss, FileUtils::getInstance()->fullPathForFilename( "miss.wav" ).c_str(), false ); } void SoundSystem::SetSong( const std::string& pFilename) { SAFE_RELEASE_FMOD_COMPONENT( this ->m_pSong); this ->CreateSound( & this ->m_pSong, FileUtils::getInstance()->fullPathForFilename(pFilename).c_str(), true ); } void SoundSystem::PlaySong() { WASSERT( this ->m_pSong); this ->PlaySound( this ->m_pSong, true , -1); } void SoundSystem::PauseSong() { WASSERT( this ->m_pSong); auto result = this ->m_pChannel_Song->setPaused( true ); ERRCHECK(result); } void SoundSystem::ResumeSong() { WASSERT( this ->m_pSong); auto result = this ->m_pChannel_Song->setPaused( false ); ERRCHECK(result); } void SoundSystem::StopSong() { WASSERT( this ->m_pSong); auto result = this ->m_pChannel_Song->stop(); ERRCHECK(result); } long SoundSystem::GetCurPosition() { WASSERT( this ->m_pSong); unsigned int ret = -1; auto result = this ->m_pChannel_Song->getPosition(&ret, FMOD_TIMEUNIT_MS); ERRCHECK(result); return ret; } void SoundSystem::PlayHitSound( const HitJudgeType& pType, int pColume) { Sound* hitSound = nullptr ; switch (pType) { case HitJudgeType::Perfect: hitSound = this ->m_pSound_Prefect; break ; case HitJudgeType::Great: hitSound = this ->m_pSound_Great; break ; case HitJudgeType::Good: hitSound = this ->m_pSound_Good; break ; case HitJudgeType::Bad: hitSound = this ->m_pSound_Bad; break ; case HitJudgeType::Miss: hitSound = this ->m_pSound_Miss; break ; } if (hitSound) { this ->PlaySound(hitSound, false , pColume); } } void SoundSystem::PlaySound(Sound* pSound, bool pIsSong, int pColume) { auto result = this ->m_pSystem->playSound( pIsSong ? FMOD_CHANNEL_REUSE : FMOD_CHANNEL_FREE, pSound, false , pIsSong ? & this ->m_pChannel_Song : & this ->m_Channel_HitSound[pColume]); ERRCHECK(result); } void SoundSystem::CreateSound(Sound** pOutSound, const char * pFilename, bool pIsStream) { FMOD_RESULT result; if (pIsStream) { result = this ->m_pSystem->createStream( pFilename, FMOD_HARDWARE | FMOD_LOOP_OFF | FMOD_2D, 0, pOutSound); } else { result = this ->m_pSystem->createSound( pFilename, FMOD_HARDWARE | FMOD_CREATESAMPLE | FMOD_LOOP_OFF | FMOD_2D, 0, pOutSound); } ERRCHECK(result); } void SoundSystem::ERRCHECK(FMOD_RESULT pResult) { if (pResult != FMOD_OK) { log (FMOD_ErrorString(pResult)); WASSERT( false ); } } SoundSystem::~SoundSystem() { SAFE_RELEASE_FMOD_COMPONENT( this ->m_pSong); SAFE_RELEASE_FMOD_COMPONENT( this ->m_pSound_Prefect); SAFE_RELEASE_FMOD_COMPONENT( this ->m_pSound_Great); SAFE_RELEASE_FMOD_COMPONENT( this ->m_pSound_Good); SAFE_RELEASE_FMOD_COMPONENT( this ->m_pSound_Bad); SAFE_RELEASE_FMOD_COMPONENT( this ->m_pSound_Miss); // 不可在释放系统前释放音频,否则报错 // auto result = this ->m_pSystem->close(); ERRCHECK(result); SAFE_RELEASE_FMOD_COMPONENT( this ->m_pSystem); } |
【整合模块】
让我们把完成的模块链接在一起,再修改一下之前的代码,为后面的部署做准备。
首先把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
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 周边上新:园子的第一款马克杯温暖上架
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
· 使用C#创建一个MCP客户端