Cocos2d-xやSpriteKitといったSceneを提供するゲームフレームワークにMVCの仕組みを取り込むための設計

開発中の音ゲーがだいぶプレイアブルになってきたのでプレイ画面を構成するモジュールがどのようになっているのかを整理するのも兼ねて, Scene を提供するありがちなゲームフレームワークをリーダブルでメンテナブルにするための設計についてすこしまとめてみます。

Sceneにできること

Cocos2d-xやSpriteKitが提供するSceneと呼ばれるクラスは,通常60FPSで呼ばれゲーム内容をコマ単位で表現するために提供される update(elapsed) 関数や onClick(event)onTouch(event) 関数のような画面とのインタラクション結果を通知するベースの機能を備えていることが大半です。 これらの仕組みを組み合わせると簡単にインタラクティブなゲームが作れるので,Sceneは言わばゲームの入出力処理のハブとなるようなクラスといえます。何も考えずにゲームを実装していくとSceneにゲームを表現する大半のプログラムが書かれることになり,ゲームへの入力,描画処理,ゲーム内容のロジックといった処理が集約された大規模なクラスへと成長しがちです。 規模の小さいゲームではあまり問題にはなりませんが,それなりの規模であったり,似たようなシーンを多用するようなゲームだと処理の使い回しもしにくくソースコードの管理が煩雑になり,開発が大変になります。

それを避けるためにMVCやMVPといったアーキテクチャが考案され,ひとつの開発手法として定着しています。個人的にはゲームにMVCの考え方を導入するのはアリだと思っていて,今回の開発でもMVCをシーンに適用しやすくするための簡単な仕組みを実装しました。

Scene上におけるMVCの主な役割

Model

ゲームのロジック部分は全てここへ置く。たとえばスコア計算だったり,ゲーム設定の反映,通信処理,主人公の状態や敵の状態などが該当します。シーンの真上に乗るMVCの一部としてのModelは,シーンに深く依存しても良いと考えます。他のシーンで使いまわすことは考えなくて良いです。その代わり,シーンに依存しない細かい単位で実装されたModelをいくつか格納し,それらをうまく組み合わせてシーン独自のより具体的なModelを表現することを考えると良いです。ただし,この場合においてもViewやControllerに対して依存はしないように注意したいです。

どうしてもControllerから操作される前提でControllerにとって都合の良いインタフェースを決めがちですが,それは自然なことなので大丈夫です。

View

Cocos2d-xやSpriteKitにおける汎用 Node クラスを継承して,そのノードにシーンを構成するスプライトを配置していく形で利用します。 本来Sceneに直に置いていたものを,Viewというレイヤーを一枚かませて配置します。Sceneが直接Childとして保持するビューはただひとつ,MVCにおけるViewのみです。あまり意味のない行為にみえるかもしれませんが,onClickonTouch などの入力系のインタフェースが消え去ったクラスを用意することでビューに関する処理だけに関心を向けることができ,見通しがよくなります。

また一つの大きなUIとして使い回せるので,デバッグシーンにおいてこのViewに対するテストも簡単にできるようになります。

ViewはModelを保持することができ,都度Modelからデータを引っ張ってきたり,Modelからデータの更新通知をもらって,ビューに反映させたりすることができます。ViewにModelのインスタンスを渡すのはSceneかControllerの役目で良いと思います。

スプライトと紐づくタッチイベントなどはViewの中でハンドリングしてModelを叩いたり,他のビューに影響を及ぼしても良いと考えます。

最後に,Sceneにおける update 関数と同じインタフェースを用意し,Sceneで行なっていたupdate処理をViewに移譲させます。

Controller

Sceneにおける onClickonTouch といった入力系処理の移譲先という考え方ができます。

Sceneではこれらの処理をスルーし,Controllerに投げてハンドリングしてもらうようにします。Viewとupdate関数の関係と同じです。入力に対してViewやModelを操作します。基本的にやることはそれだけに留めます。 よってViewとModelのインタフェースを叩くだけなので,必然的にControllerのやることはシンプルで,行数は短くなりがちです。ロジックもControllerでやってしまいがちですが,Modelに押し込むことを徹底するべきです。

必要に応じて入力をViewやModel用の都合の良いデータ型に変換したり,Modelを叩いた結果をそのままView渡すといったこともやります。

ではSceneがやることは

MVCの保持

上記MVCに求められる基本的なインタフェースを備えたベースとなるModel,View,Controllerクラスを自前で作り,必ずこれを継承したMVCの組みを保持するような初期化方法を要求するSceneを,Cocos2d-xやSpriteKitが用意するSceneクラスを継承して作ります。 以下のような感じでどうでしょうか。

Model gist.github.com

View gist.github.com

Controller gist.github.com

ゲームにおいては必ず自分が用意したMVC付きSceneから継承し,Sceneを構成するようにルール化すると良いと思います。 Sceneは自動でViewをAddChildするなり,UpdateをView移譲するなりやりやすい形にカスタマイズします。自分は以下のようなベースシーンをつくりました。

BasicScene gist.github.com

具体的な利用例

上記MVCとSceneを継承した,Harpで実際に遊ぶことのできるPlayScene,PlayModel,PlayView,PlayControllerを紹介します。 やっていることの割にはだいぶ短くまとまっていると思います。Modelを細かく分割して,それぞれのModelに処理を隠蔽すると,それらを組み合わせても処理の流れだけを参照でき,見通しの良いModelを表現できます。 具体的にどんなことをMVCにやらせればいいか,参考程度に眺めてみてください。

github.com

まとめの図

MVCとSceneについての関係

f:id:azaraseal_7:20170530012034p:plain

まとめ

実はこれは実際の業務で得た知見を参考にしている部分も多く,自分自身まだまだ試行錯誤の段階で未完成な部分も多くあるのですが,だいぶMVCの仕組みを導入しやすい環境が整ったのではないかと思います。

作るゲームやプロジェクトによってはこの設計が正解とは真逆をいってしまうこともあるかと思いますが,一つの選択肢として参考程度にみてもらえればと思いまとめてみました。

ちなみに業務ではCocos2d-xにMVPアーキテクチャを適用させていましたが,View<-Presenter->ModelとViewからのイベントを必ずPresenterを通しハンドリング方法がとてもしんどく,Modelから直接データをPullするやり方の方がシンプルで良いなと感じたのであえて採用していません。Harpのようなゲームパッドやキーボードからの一方的な入力オンリーのゲームでは,ViewがModelのデータを参照するだけのMVCの方が絶対に良いです。ここはお好みでというのと,Viewからのイベントが頻繁に発生するような場合にはMVPの方が良いかもなぁという印象です。

Swiftのジェネリクスで型引数を別の型引数の制約に利用するとCommand failed due to signal: Segmentation fault: 11 が解決していた

別に大したこだわりはないんですけど,以下のようなことができなくてあれ?と思っていたところ blog.a-azarashi.jp

Xcode 8.3 , Swift 3.1 にアップデートしたお陰か,ビルドが通るようになっていました。

class MVC<M: Model, V: View, C: Controller<M, V>

これができるということです。ええやん。

BMSプレイヤー開発 for macOS: サウンドシステム編

前回の記事でBMSパーサー実装時の動作テストとして,パース結果をもとに譜面(の音声)をオートプレイできるところまでいけたと書きましたが,どうにも音声が遅延する問題が気がかりでした。 今回はその問題が解決できたことを報告いたします。

AVAudioPlayerはゲーム向きでない

結論です。聞いていてわかるほどの遅延が発生してもはやゲームになりません。音ゲーじゃなかったらギリギリ許せるレベルですが,できるならゲームへの採用は避けた方が良いということがわかりました。SpriteKitにもSKAction を使った音声の再生手段を提供しているようですが,自由度がないためキー音を大量に扱う音ゲーに於いてはAVAudioPlayer同様不安要素があります。

CoreAudioの概要という公式の資料を読んでみた

CoreAudioの概要 知りたい情報はわりかし序盤の方に出てくるのですが,内容が興味深くて最後まで読んでしまいました。特にDTMをやっている人には AudioUnit の概念が割と突っ込まれて書かれいるので興味を引く内容になっているのではないのでしょうか!読み物としてオススメです。

公式の見解では,ゲーム開発においては OpenAL のAPIを利用が適している,のだそうです。 OpenGLライクなAPIで音声を扱えるAPI群だそうで,広く利用されているらしいです。これがCoreAudioの上位レイヤー部分に実装されており,このAPIを利用すると遅延の少ない音声再生を実現できるとのことでした。

OpenALについての資料をあさる

参考にしたページは以下

developer.apple.com

github.com

コードを読むことが一番の近道です。

公式が提供しているObjective-Cの実装は音声を再生する手順だけではなく,パンを変えたりリバーブ等のエフェクトを掛けるところまで解説しています。IIDXっぽくいくなら音声に対するエフェクトの適用は必須なのでありがたい限りです。

またgithubに公開されていたswift-openal-example はswiftの特徴を生かし,日本語のメソッドでわかりやすくAPIの利用フローが書かれており,素早い理解への一助となりました。

どちらもmacOS及びiOSにおけるOpenALの理解への近道なのでオススメです。音声を単に再生するだけが目的なので上に紹介した実装を見れば十分であり,今回は公式のドキュメントにはあまり目を通しておりません…。おかげでメモリリークなどの問題に若干悩まされたりしまいたが…–;

ALUREでoggが再生できるとのこと

OpenALを便利に利用できるライブラリとして ALURE というものがあることを知りました。OpenALをより便利に使える OpenGLにおける GLUT 的なライブラリです。

公式サイトには

Currently ALURE includes a basic .wav and .aif file reader, and can leverage external libraries such as libSndFile (for extended wave formats and several others), VorbisFile (for Ogg Vorbis), FLAC (for FLAC and Ogg FLAC), and others

と書かれており,.ogg の読み込みをサポートしてしまうとのこと。

ALURE(OpenALユーティリティ)をiOSで使う をもとに,プロジェクトに取り込んでみると,簡単に .ogg を再生することができました。拍子抜けです。

他の方法としては oggのデコーダと Audio Queue Servicesを利用することで実現する方法があるそうです。 IDZAQAudioPlayer このリポジトリをビルドするとiOSで .ogg を再生できることを確認できました。 今回はOpenALを利用しているので,こちらは不採用となりましたが,参考までに。

かくして遅延はなくなった

厳密には遅延しているのでしょうが,聞いていてい不自然になるような出力結果にはなっていません。ひとまず安心できるレベルです。新しい課題として,キー音でないバックで流れる音声は処理落ちすると簡単にずれてしまうので,譜面の進度に合わせて音声の再生位置を無理やり同期させる処理が必要そうということがわりました。それは追々やっていきます。

詳しい実装に関して

あまりなさそうな情報なので,Qiitaに書いておきました。

qiita.com

BMSプレイヤー開発 for macOS: BMSパーサー編

開発近況

harpこと BMSプレイヤーfor macOS の開発ですが,忙しさを言い訳にしてしばらく手が止まっていました。 最近ようやく以前のようにモチベーションと時間を取り戻してきたので少しづつ進めています。 今はBMSパーサーを書いているところで,BMSの仕様のおさらいと,なにを取捨するのかをキッチリ決めることから始めました。

BMSの仕様は基本的にLR2に準ずる

BMSの仕様をしらべていくと,ものすごく詳細に歴史的経緯を踏まえた仕様を解説しているページを発見しました。

BMS command memo (JP)

このサイトを信じてLR2が実装しているBMS上の仕様をひとまずは模倣していくところから始めています。 というのもLR2がだいぶメジャーであり,(想像ですが)これを基準に譜面を作成されることも多いだろうし,このLR2の挙動を再現できればほとんどの譜面がサポートできるのではないかと思っているからです。

実装上簡単に追従できそうなものや,逆にこれ要らないんじゃない?というものは一部省いたりしています。

とにかく仕様が膨大で複雑なのでDone is better than perfectな精神で進めていくつもりです。

サポートする仕様はgitのwikiにまとめました。

サポートするBMS仕様 · gomachan7/harp Wiki · GitHub

実装メモ

仕様を眺めるとコマンドの意味的な分類と記述形式が Control flow, Header, Channel message の三種類に別れているので,それぞれこの優先度で単独で1行ごとにパースを試みる処理にかけて,正規表現で引っかかればパース成功として読み込む行を進めるという実装になりそう。

BPMの変更仕様がチャンネルメッセージに直接値を書く チャンネル3 と, ヘッダセクションで定義した BPM値を読みにいく チャンネル8 2つあるのがややこしい。ややこしいだけじゃなくて,内部的にこいつらをマージするのが結構面倒で泥臭かったりします。

あと気をつける点は チャンネル1 の音声再生命令は,同じ小節に命令が複数行に別れて複数定義されていても,マージせずに別物として読んでおく必要があります。普通のノートやその他チャンネルとはちょっとだけ扱い方が違うので注意しないと,なんで音が再生されないだ?と当てもなくデバッグし続けるはめになります(経験済み)

進捗共有

Done 譜面の読み込み,自動再生まで完了。

懸念 oggがデフォルトで読み込めないため,自前でデコードする処理を書いてあげるか事前にない場合はwavにでも変換してあげるプロセスを挟んであげる必要があります。 また,音声再生関連で一番簡単なAPI,AVFoundationのAVAudioPlayerだとだいぶ遅延が生じてしまう問題があるのでCoreAudioの資料を読み進めてどうすればいいのか考え中です。

リモートワークの話

要するにリモートワークがしたいです,ということです。

僕が勤めているのはそこそこの規模のイケイケ系IT会社で,見込み残業が給料に含まれていて残業代は一切でない,よくある裁量労働制を採用している会社なのですが,制度としてリモートワークやフレックスタイム制といったものがありません。所謂出勤時間に厳しく( 最近は弊チームではすごく多めに見てもらっていて個人的に助かっておりますが ),退勤時間が無限解放な,そんな感じの労働環境と言っていいでしょう。裁量労働ならば,こういうリモートやフレックスといった働き方とは相性がいいはずなので,是非ともやってもらいたいと思っています。ですが,現実的には難しいようです。

部署によっては週1でリモートワークを許可しているところもあるらしく,本日人事の方と話していてリモートって難しいんですかねぇ?と投げかけたところ,え?週1でもないの? という反応をされてしまいました。 僕も個人的にリモートしたいだの,せめてフレックスは可能なのかということをちょいちょい訴えてはいるのですが,やはり会社としての制度を変えるのは大変なようでなかなか勤務体制を変える動きまでは至っていません。

うちの会社は人材の動きが激しく,2,3年もすると新卒で入ったエンジニアの同期が結構な人数出て言ってしまうのが恒例となっていますが,こういった働き方をちょっと許容するだけでエンジニアの幸福度が段違いに上るし,人の動き,流れも変わると思うのですが,如何なものなのでしょうかね?個人的な価値観になってしまいますが,留まるに値する十分な理由って,何もお金や組織感だけじゃないと思うんですよ。これから何十年も続けていくのですから,ライフスタイルにまで影響を与えるような働き方が許容されるのは素敵なことだと思っています。

やっぱりMTGは重要だしフルリモートしたいだなんていう贅沢は言いません。対面での会話の重要性は十分わかっているつもりですが,さぁ開発フェーズだ といったときにフロー状態に入れるような環境づくりの選択肢としてリモートはやっぱりありなんじゃないでしょうか?

ですが…,今日エンジニア向けの集中開発専用ルーム的な部屋(弊社では精神とテクの部屋と呼んでいます)で作業していて,壁際の人に気づかれないような席を陣取っている人があからさまに寝ていて,リモートワークを採用したくない側の不安な気持ちもちょっとわかりました。あと僕の出勤時間がよくずれるのも原因か。。説得力無いもんな。。。でも裁量労働だから,やることやっていれば悪いことではないと思っています。

定時退社の話

定時退社するのは自分のためだし,長い目でみると会社のためにもなるので僕は定時退社を続けます。職業柄,どうしても残らなければいけないことがあることは理解した上で,でもあまり無理をしないで長く続けて行きたいです。

ViewControllerの戻るボタンのタップをハンドリングする。(戻る動作をキャンセル可能)

iOSにおいて前の画面に戻る際, 「本当に前の画面に戻りますか?」 というアラートダイアログを出して,選択次第で戻る動作をキャンセルしたい需要が生まれました。あまり大げさな実装はしたくないので,iOSが用意するUINavigationControllerによる画面遷移の仕組みに乗りたいとします。 どう対処すれば良いでしょうか。

qiita.com

こちらで紹介されている方法では,戻る動作を検知する以上のことはできませんが,これから提案する方法は戻る動作自体を取りやめることが可能になります。

なお,デメリットとして UINavigationController を拡張し,それを利用すると言うお約束が必要です。今回紹介する実装はpopする場合にとどまっていますが,modalとして呼び出された際のdismissも同じ理屈でハンドリング可能です。

UINavigationControllerの独自実装

まずは,前提となる独自実装です。UINavigationControllerへViewControllerをpushする際に,pop時のコールバックを明示的に指定するところがキモで,ここで本当にpopしていいかを一度delegateに問い合わせるということをしています。delegateが実装されていない場合は通常どおりpopされるだけです。

@objc protocol NavigationViewControllerDelegate: class {
    @objc optional func navigationController(_ navigationController: MyNavigationController,
                                             willPopViewController: UIViewController) -> Bool
}

final class MyNavigationController: UINavigationController {

    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        let action = #selector(pop)
        viewController.navigationItem.leftBarButtonItem?.action = action

        super.pushViewController(viewController, animated: animated)
    }

    @objc private func pop() {
        guard let willBePoppedVC = viewControllers.last else {
            return
        }

        if let delegate = willBePoppedVC as? NavigationViewControllerDelegate {
            let willBePoppedVC = delegate.navigationController?(self, willPopViewController: willBePoppedVC) ?? true
            if !shouldPop {
                return
            }
        }

        popViewController(animated: true)
    }
}

ハンドリングしたいViewControllerの実装

navigationController(_:willDismissViewController) の返り値のtrue/falseでpopしていいかを MyNavigationController に伝えます。 たとえば shouldShowAttention というbool値を用意しておき,これがfalseのときはダイアログを出さずにそのままpop,trueのときは,一旦popを取りやめて,ダイアログの選択次第であとから明示的にpopするということが可能になります。

final class SampleViewController: UIViewController, NavigationViewControllerDelegate {
    private var shouldShowAttention = false

    // NavigationViewControllerDelegate
    func navigationController(_ navigationController: MyNavigationController,
                              willPopViewController: UIViewController) -> Bool {
        if shouldShowAttention {
            showAttentionDialogBeforeLeaving(okCallback: {[weak self] _ in
                _ = self?.navigationController?.popViewController(animated: true)
            })
        }
        
        return !shouldShowAttention
    }

    private func showAttentionDialogBeforeLeaving(okCallback: @escaping () -> Void) {
        let alertDialog = UIAlertController(title: "警告", message: "本当に戻る?", preferredStyle: .alert)
        let okAction = UIAlertAction(title: "はい", style: .default, handler: { _ in okCallback() })
        let cancelAction = UIAlertAction(title: "いいえ", style: .cancel, handler: nil)
        
        alertDialog.addAction(cancelAction)
        alertDialog.addAction(okAction)
        
        present(alertDialog, animated: true, completion: nil)
    }
}

使い方

普通のUINavigationControllerと一緒です

let navigationVC = MyNavigationController(rootViewController: SampleViewController())
navigationVC.pushViewController(SampleViewController(), animated: false)
show(navigationVC, sender: nil)

以上,ViewControllerの戻る動作を取りやめたい場合があったので,とりあえず思いついた実装を紹介しました。意外と需要があるはずなので,何かのお役に立てれば幸いです。

追記:
バックジェスチャ?っう頭が…