cocos2d-xでゲームを作るときの構成を考えるために実際にゲームを作ってみました。
降ってくるボールを赤緑青の順で押していくと点が入るゲームです。
やりたいこと
- テストとかCIとかやりたい
- レイアウトとかコントロールとかUIはなるべくCocosBuilderで作ってエンジニア以外でも触りやすいようにしたい
テスタブルな設計
パッと調べてみた感じだと
などなど、楽に汎用性高く書けそうなものの、文字列をキーにしてたり独自のマクロがいっぱい出てきたり一長一短はありそうでした(特にcoconutは一旦枠に沿ってしまえばかなり楽に作っていけるんじゃないかっていうのがあって、広まったら使ってみたい)。
MVCとかMVVMとかいろいろありつつの、そもそも立ち返ってみると
- 見た目と振る舞いを分けて作りたい
- 解くべき問題ごとに適した表現方法は違う
- 例えばパズルで遊ぶ画面とデッキを編集する画面はそれぞれ要件と制約が違う
ので、パキっと一気に解決する枠組みを作るというよりは、見えてる範囲の問題を表現する最低限の枠組みを書いて、別の問題が明らかになる度に徐々に大きくしていくというスタンスが良いなと思いました。
やってみよう
こうなりました
- FieldModel, BallModelがゲームの状態を表現
- GameSceneが各Modelを監視
- 変更があった場合Spriteなどにそれを伝える
- テストのときはインターフェースの実装を変えたモックでlisten
実際のテストの書き方は上記記事やこの辺が参考になりました
ランダムな値のテスト
テストは毎回同じ結果を返さないといけないけどゲームはランダムに何かが起きることが多いので、これを抽象化しないといけない。こんな感じに乱数を返すインターフェースを作って、
class IRandom {
public:
virtual int next() = 0;
};
実体は普通に環境から取った乱数を返す。モックは初期化時に配列を取って、乱数を要求される度に配列の値を順に返す。
class RandomImpl : public IRandom {
public:
RandomImpl();
int next();
};
RandomImpl::RandomImpl() {
srand((unsigned int)time(NULL));
}
int RandomImpl::next() {
return rand();
}
class RandomMock : public IRandom {
std::vector<int> arr;
int idx;
public:
RandomMock();
RandomMock(std::vector<int> arr);
int next();
};
RandomMock::RandomMock():idx(0) {
arr = {0};
}
RandomMock::RandomMock(std::vector<int> arr):arr(arr),idx(0) {
}
int RandomMock::next() {
int ret = arr[idx];
idx = (idx + 1) % arr.size();
return ret;
}
こうすることでテスト中は乱数を固定して再現性のある状況を作りやすくなります。例えば赤 => 緑 => 緑の順に押されたときに、最後の緑では加点されないテストだとこんな感じで書きます。
- (void)testNotScored
{
// {pos, color, ...}
IRandom* rnd = new RandomMock({0, 0, 0, 1, 0, 1});
FieldModel* field = new FieldModel(640, 480, 1, 32, -32, 1000, rnd);
FieldListenerMock* listener = new FieldListenerMock();
field->addListener(listener);
field->update(1); // spawn red ball
field->update(1); // spawn green ball
field->update(1); // spawn green ball again
field->update(1); // push all ball in field
XCTAssertEqual(4, (int)field->getBalls().size());
field->touch(16, 400);
XCTAssertEqual(3, (int)field->getBalls().size());
XCTAssertEqual(1, listener->score);
field->touch(16, 432);
XCTAssertEqual(2, (int)field->getBalls().size());
XCTAssertEqual(2, listener->score);
field->touch(16, 464);
// cant get score for invalid color touch
XCTAssertEqual(2, (int)field->getBalls().size());
XCTAssertEqual(2, listener->score);
}
乱数を要求する側が何の用途で要求したか記録するメモみたいな機能もあればデバッグも楽になるかも。
トラビス
テストできるようになったのでTravisCIに繋ぎます。githubにプロジェクトをpushしたらtravisの方のスイッチをオン。あとはテストの設定を書いた.travis.ymlをリポジトリに含めてpushすれば勝手にテストしてくれる。
はずなんですが、どうもObjective-Cプロジェクトはシミュレータ周りでまだ問題があるようで、上手くいったりいかなかったりします。この辺の議論を追っかけて色々試した結果掴んだ勘所としては
- xctoolのバージョンを上げたり下げたりしてみる
- -freshInstallオプションを付けたり取ったりしてみる
とかが効くみたいです。最後にうまくいった.travis.ymlはこんな感じでした。
language: objective-c
script: xctool -project proj.ios_mac/rgb.xcodeproj -scheme rgb\ iOS -sdk iphonesimulator build test
ただ、そもそも論として
- テスタブルなとこはcocosに依存してないはずなのに毎回cocos本体をビルドしてる
- C++なのにObjective-C++でテスト書いてる
- テストしたいのはデータ操作だけなので、シミュレータに依存してるのは何かおかしい
とかもあるのでその辺から見なおした方がいいかも。
CocosBuilder
レイアウトとかアニメーションはなるべくコードから切り離してエンジニア以外も編集しやすくしたいのでCocosBuilderで作ります。実際の連携方法はこの辺が参考になりました。
- [Cocos2d-x]CocosBuilderを使って素早く画面を作成する
- CocosBuilderでタイトル画面をつくる
- プロローグ画面作成① CocosBuilderとの紐付け そして初のyoutube投稿
- Cocos2d-x 〜CocosBuilderでcallbackを設定する〜
シーン開始直後のカウントダウン、ゲーム中画面、ゲーム終了後画面をそれぞれタイムラインに分けて作ります。
また、それぞれのChained Timelineを
- countDown => game
- game => game
- timeup => timeup
にしてタイムラインを繋ぎます。カウントダウン終了時にはゲーム開始処理を、ゲーム終了時にはタイムライン遷移処理をそれぞれ書きます。
void GameScene::completedAnimationSequenceNamed(const char *name) {
if (strcmp(name, "countDown") == 0) {
scheduleUpdate();
}
}
void GameScene::onRestTimeUpdate(float percent) {
if (percent > 0) {
timerLabel->setString(StringUtils::format("TIME:%.1f", TIME_LIMIT * percent));
} else {
timerLabel->setString("TIME:0");
animationManager->runAnimationsForSequenceNamed("timeup");
unscheduleUpdate();
}
}
また、カウントダウン中にLabelのtextを変えるためにcallbackも設定します。
SEL_CallFuncN GameScene::onResolveCCBCCCallFuncSelector(Object * pTarget, const char* pSelectorName) {
CCB_SELECTORRESOLVER_CALLFUNC_GLUE(this, "onCountDownOne", GameScene:: onCountDownOne);
CCB_SELECTORRESOLVER_CALLFUNC_GLUE(this, "onCountDownTwo", GameScene:: onCountDownTwo);
return NULL;
}
void GameScene::onCountDownOne(Node* node) {
countDown->setString("1");
}
void GameScene::onCountDownTwo(Node* node) {
countDown->setString("2");
}