ネイティブゲームクライアントの幸せな設計図
こんにちは。Wright Flyer Studios部のにしだ(@hosi_mo)です。
部内で、主にcocos2d-xやUnityでゲームクライアントの開発をしています。
GREE Advent Calendar 2014の18日目は、
ネイティブゲームクライアントの幸せな設計図という題でお話しします。
Wright Flyer Studiosで開発するにあたり、
早く、安全に、見通しの良い実装で企画者の実現したいことを具現化するために、気をつけていることをお話しできればと思います。
1 : 後悔しない技術選定
cocos2d-xはC++で…、UnityはC#! と対比させられがちですが、技術選定時点でのエンジニア自身の言語習得能力によって選択肢を狭めることほど悲しいことはないので、純粋に企画との相性とパフォーマンス(チューニングで苦労したくない)で決めます。
また、エンジニアリングを主眼に置いた技術選定は非常に楽しいですが、いざリリースに向けての人員増強のタイミングにおいて、量産体制に耐えうる技術選定をすることを重視しています。
また、LWFやSpine、スプライトアニメーションのどれが良いかな? という見た目の技術選定においては、キャラクターモーションを付ける人、エフェクトを出す人、画面を作る人など、潤沢に人員を当てはめられるかを想像した上で技術選定を心がけています。
キャラモーションもエフェクトもViewのアニメーションも特定の人に頼った結果、業務が集中してボトルネックとなってしまう例があったからです。
2 : 見通しの良い設計
ぼくが、あるプロジェクトに途中から入ったとして、いざコードリーディングとなった際に真っ先に確認しに行くところが "ViewとLogicが切り離されているか"です。
Viewにロジックが書かれていると…
(i)Viewに大量の分岐コードが生まれ、コードが追えなくなってしまう
1 2 3 4 5 6 7 8 9 10 |
//Viewに悲しい分岐が増えて。表示バグが多発するケース bool HogeView::init() { if (isTutorial) { // ガイドの指表示 } else if (isDungeon) { // ヘッダー表示 } else { // ボタン非表示 } |
(ii)ある画面とほぼ同じ画面を作る場合、重複したコードを量産してしまう
ファイルをコピーしてロジック部分だけ書き直すことが多いです。
Viewに大量の分岐コードを書くよりは バグを生みにくい設計ではありますが、見た目の仕様変更があった場合に爆死してしまいます。
(iii)Viewにコールバックが大量につけられてしまう
Viewのファイル自体が間延びして、一目で何を実装しているのか分かりづらくなります。
プロジェクト終盤にエンバグや表示バグが多発しないためにも、ViewとLogicを出来る限り切り離す努力をします。
もし設計的に無理な状況になった場合は、安く速く安全にリファクタリングできる方法があれば、なるべくはやく基盤を敷き直しましょう。
ただし、やったらやりっぱなしはダメで、最後まで面倒を見ることがホスピタリティだと考えています。
設計例 : Viewの設計
Viewは表示のみを扱うコンポーネントとし、ロジックやステートを持たせないようしにします。
cocos2d-xではLayerをViewとし、Unityでは1画面となるGameObject(ボタンコンポーネントなどがchildにひも付けされているもの)に該当するscriptをViewとし、これらのViewには、画面にアタッチされたボタンなどのコンポーネントの制御のみ記載させます。
その上で、ロジック側からはViewのインスタンスに対して値をsetしてゆくのみにします。
これにより、似たような画面を作る際はコンポーネントとしてのViewのインスタンスを生成するだけになり、重複コードを防げます。
ボタンイベントは別途Viewのインスタンスから取得することになります。
Viewのdepth(order)管理用に、Managerクラスを作るとなお良いです。
1 2 3 4 |
//Viewの作成 auto view = ShopView::Create(); view->addItems(items); view->showBuyButtons(true); |
1 2 3 4 5 6 7 |
//Viewからボタンイベントの取得 auto ev = view->popEvent(); if (ev.Name == ButtonEvent::CLOSE) { } else if (ev.Name == ButtonEvent::PRESS_BUY_BUTTON) { } |
設計例 : ロジックの設計
弊社のネイティブゲームクライアントの多くは有限オートマトンの考え方を用いて、LogicはStateMachine(状態遷移)で管理することが多いです。
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 |
//メインループ void Game::update(float delta) { auto ev = view->popEvent(); switch (ev.Name) { case ButtonEvent::PRESS_FRIEND: // click Friend? stateMachine = Friend::create(); break; case ButtonEvent::PRESS_PARTY: // click Party? stateMachine = new Party::create(); break; case ButtonEvent::PRESS_QUEST: // click Quest? stateMachine = new Quest::create(); break; case ButtonEvent::PRESS_MENU: // click Menu? stateMachine = new Menu::create(); break; case ButtonEvent::PRESS_SHOP: // click Shop? stateMachine = new Shop::create(); break; default: break; } state->update(); } |
割愛しますが、Viewの遷移を含むロジックはStateMachineに記載、データ管理はシングルトンなマネージャクラスに持たせています。
あくまで設計例ですので、プロジェクトに応じて適切な設計を考えてゆきたいですね〜
3 : マージンを持たせた実装
「仕様変更させてください」
無理して(おうちゃくして)実装した機能は、だいたい仕様変更に耐えずに実装しなおしになった苦い記憶があります。
企画者にこの仕様で大丈夫か?と、念押しして実装した機能も、1週間後に「すみませんこの仕様なんですが… ごにょごにょ。。」な場面を想像して実装するのが望ましいと痛感させられます。
企画者「この仕yo」
エンジニア「すぐできます(ニヤリ)」
くらいの心持ちで小手先で直せる余裕を持った実装をすることで、きっと幸せになれます。
例 : 配列の横持ちをやめる(pros/consあるので少なくともクライアント側では)
初歩的な例で恐縮ですが、アクセサリーの持つ枠は3つです。という仕様に対して、
accessory_1 | accessory_2 | accessory_3 | ... | . |
と安易に横持ちでテーブルを作ると、最終的に枠を拡張できるようにする際に横持ちを配列持ちに実装変更(運用中ならもっとつらい)し直すはめになったケースをよく見ます。
少なくとも、仕様変更には弱いデータの持ち方ですので、indexがずれてアクセサリーがダブったりして阿鼻叫喚にならないためにも、横持ちは避けたいです。
4 : 演出の口を用意する
演出の口ってなんだ? と思われるかもしれません。
平たく言うと、テクニカルアーティストさんがアニメーションを付けるインターフェースを用意する、ということです。
Flashに慣れ親しんだテクニカルアーティストさんが、(あわよくば)知らないうちにcocos2d-xやUnityのプロジェクトを開いていい感じにヒュンヒュン動くアニメーションを付けてくれる未来のための手段を用意しておくことで、劇的にプロダクトの完成度が上がることが多々ありました。
AS3.0ライクなアニメーション手段の提供例
上記のアニメーションgifは以下のコードを適切なタイミングで呼び出してズームとボタンの拡大縮小を実装した例です。
cocos2d-xなプロジェクト向けにASライクにアニメーションをラップしたコード
1 2 3 4 |
//画面のズーム auto offsetY = target->getPositionY(); auto anim = Between::to(screen, convertZoomPosition(2, 0, offsetY, 0, 0), anim_time, Easing::QuadOut); this->runAction(anim); |
1 2 3 |
//xボタンの表示(拡大) auto anim = Between::tween(node, "scale:1", "scale:0", 0.2, Easing::QuadIn); this->runAction(anim); |
1 2 3 |
//xボタンの非表示(縮小) auto anim = Between::to(node, "scale:0, alpha:0", 0.1, Easing::QuadOut); this->runAction(anim); |
1 2 3 4 5 6 7 |
//ズームの相対位置の計算 std::string HogeLayer::convertZoomPosition(float zoom, int delta_x, int delta_y, int offset_x, int offset_y) { int x = -zoom * delta_x + 320 - offset_x; int y = -zoom * delta_y + 480 - offset_y; return StringUtils::format("scale:%f, x:%d, y:%d", zoom, x, y); } |
UnityではGoKit(prime31さん謹製)がAS3.0ライクで便利です。
AbstractTweenPropertyを継承すれば、好きなTweenPropertyを作れるのも魅力的です。
(ShaderにTweenして値を渡したいときに拡張しました)
こういった口をつくったりすると、ある日出社したら勝手にアニメーションが入っていたりするかもしれません。
5 : データのつなぎ込み
マスターデータと呼ばれるゲームのパラメータ群があります。大抵の場合Excelにゲームデザイナさんがパラメータを入力して、csvやjsonにコンバートしてゲームに反映させます。エンジニアなしでゲームに反映できるよう口を用意することが多いです。
反映する際のマスターデータの流れは以下のケースが一般的かと思います。
マスターデータの参照の流れ
(Server側はDBに入れずにPHP Arrayとして吐き出すケースも多いです)
運用時は、クライアント側でマスターデータのバージョンはhash値で判断し、クライアント側でマスターデータのhash値が古かったら最新データをダウンロードしに行くなどやっています。アプリの更新タイミングがiOS, Androidでズレて2バージョン運用の場合、アプリケーションのバージョンごとに適切なバージョンのマスターデータを取りに行ったりもします。
さて、
Excelがいいの? Google Spread Sheetも便利でしょ?
との声が聞こえてきそうなところで、運良く両方のケースで運用した経験がありますので、ざっくり比較しました。
(i)Excelを利用するケース
Excelをjsonやtsvなど、プロダクトに合わせたデータ形式に変換するスクリプトを書きます。
Excelと出力されたjsonやtsvのみを管理するgithubなレポジトリを用意して、submodule化してクライアント、サーバと紐付けします。
利点
Excelで一元管理することで、ゲームデザイナさんの使い慣れた環境を提供する。
マクロが優秀
欠点
差分が確認しづらい(gitでdiffみても分からない)
同時に編集が出来ない
履歴が追いづらい
(ii)Google Spread Sheetを利用するケース
apps script経由でプロダクト独自の形式(json,tsv,protocol bufferなど)に書き出しします。
jsonやtsvのみを管理するgithubなレポジトリを用意して、submodule化してクライアント、サーバと紐付けします。
利点
同時編集が可能
リビジョンが追える
欠点
ゲームデザイナさんに不評(ブラウザで巨大なテーブルいじるのには限界が…)
大枠はこの2つに分けられますが、プロダクトによってデータの構造や反映方法などは好きに変化していっています。
最近の傾向ではExcelに立ち返っている印象がありますが、同時編集によるバッティングを減らすため、1ファイルにシートをたくさん増やすのではなく、複数のファイルに分けてシートの数を減らす事で1回のcommitの影響範囲を少なくしたりしました。
ゲームデザイナさんと仲良くしたい。
開発中よく起こるのが、マスターデータに不正な値が入っていてゲームがクラッシュしたり、参照するデータが異なりゲームが進行不能となるケースです。
エンジニアが冷や汗をかいて不具合の原因を探した結果、データ入稿ミスでゲームがクラッシュしてた。なんて事態でチーム内の雰囲気が険悪になるのは良くありませんしね。
消滅都市のリードエンジニアの名言で「人に怒られるより、機械に怒られたい」というのがあり、どのゲームデザイナさんに聞いても、データの入稿チェックは不可欠と言っています。
うちはやってませんけどね。。。というエンジニアはちょっとドキっとしたりしませんか。
データの入稿やバッティング、Excelのカラム変更の影響範囲の認識(DBのカラムそんなにすぐ変えれないし! )をすりあわせることで、悲しい事故による巻き戻りがきっと減らせます。
6 : 常にうごかす
簡単な事ではありませんが、プレイできる状態を開発当初からずっと維持しつづけるプロダクトは、仕上げてくるビルドの質も高いと感じます。仕様書ベースではなく、実機ベースで会話するを実践するためにも、有効な手段だと思います。そもそも、一度動かなくなったmasterブランチを動かすとき、誰か後始末をする(自分自身かもしれません)人が必ず出てきちゃいますしね。
ずっと後始末をし続けるエンジニアがいることを忘れて、任せっきりは悲しいです。
そうならないために
Jenkinsで常に最新ビルドをまわし続けています。ビルドがこけたら気付いた人がさくっと治しちゃいましょう。
さらに
ぼくは常に実機でデバッグすることを心がけています。シミュレータや、Scene Viewは使いやすくてすごく便利です。
しかし、動作が重くなったり、落ちやすくなったり、実機でデバッグすると分かることがたくさんあると思います。
あれ、これ面白いっけ? とかもすぐわかりますし、おすすめです。
7 : チームの雰囲気
良い雰囲気のチームで仕事がしたいものです。
職種ごとに、この人のモチベーションはどこから生まれてくるのか? を意識しています。アートさんから頂いた素材は、出来る限りすぐに反映させて画面を見せてあげたり、エンジニアはイヤホン付けて集中する事が多いですが、チームの人間同士が椅子を横に向けて議論している場には耳を傾けて、拾えそうな仕様は出来る限り実装に反映することで、企画者のモチベーションも上がるのかもしれません。
間違ったことを言われれば、ちゃんと刺し合って寝たら忘れる。そんなチームでありたいですね!
まとめ
僕がゲームクライアントの開発をする上で気をつけていることは、以上です。
世の中のゲームエンジニアが少しでも共感したり、ドキっとしてくれたら、良いなと思います。
ほかにもこんなこと気をつけてるよ!などありましたら是非教えてください!
そ し て、
私事ではありますが、今日がぼくの誕生日です!(プレゼントお待ちしてます)
弊社はエンジニアブログに記事を書くと、しーてぃーおーのFさんがお寿司を食べさせてくれるらしいのですが、ぼくはプレゼントのほうがうれしいです!
ごめんなさい。
明日の記事は、インフラストラクチャ本部の足立(@foostan)さんです。お楽しみに!
あと一週間、はっぴーはっきんぐ!