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

2017-05-29 16:33  -  2 min read

開発中の音ゲーがだいぶプレイアブルになってきたのでプレイ画面を構成するモジュールがどのようになっているのかを整理するのも兼ねて, 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

import Foundation

class Model {
}

View

import Foundation
import SpriteKit

class View: SKNode {
  // Override if want to update views per frames
  func update(_ currentTime: TimeInterval) {}
  
  // Override if want to initialize properties of views on display
  func constructView(sceneSize: CGSize) {}
}

Controller

import Foundation
import SpriteKit

class Controller<M: Model, V: View> {
  let model: M
  let view: V
  
  init(model: M, view: V) {
    self.model = model
    self.view = view
  }
  
  func initialize() {}
  
  func keyDown(with event: NSEvent) -> Bool {
    return false
  }
  
  func mouseDown(with event: NSEvent) -> Bool {
    return false
  }
  
  func rightMouseDown(with event: NSEvent) -> Bool {
    return false
  }
}

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

BasicScene

import SpriteKit

// Fundamental class for scene in harp project
// Every scene should inherit BasicScene
class BasicScene<M: Model, V: View, C:Controller<M, V>>: SKScene {
  let m: M
  let v: V
  let c: C
  
  var rootView: View? {
    willSet {
      if let rootView = self.rootView {
        rootView.removeFromParent()
      }
    }
    didSet {
      if let rootView = self.rootView {
        rootView.constructView(sceneSize: size)
        insertChild(rootView, at: 0)
      }
    }
  }

  init(model: M, view: V, controller: C) {
    m = model
    v = view
    c = controller
    
    super.init(size: Config.Common.defaultWindowSize)
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func didMove(to view: SKView) {
    super.didMove(to: view)
    
    // Size of scene always fits with view frame
    size = Config.Common.defaultWindowSize
    scaleMode = .aspectFill
    
    // Setup root view
    rootView = v
    
    c.initialize()
  }
  
  override func keyDown(with event: NSEvent) {
    guard !c.keyDown(with: event) else {
      return
    }
  }
  
  override func mouseDown(with event: NSEvent) {
    guard !c.mouseDown(with: event) else {
      return
    }

    let location = event.location(in: self)
    let clickedNodes = nodes(at: location)

    clickedNodes.forEach {
      // Propagate event if not consumed
      if let node = $0 as? Clickable,
         let isConsumedEvent = node.onClicked?(event),
         isConsumedEvent {
        return
      }
    }
  }
  
  override func rightMouseDown(with event: NSEvent) {
    guard !c.rightMouseDown(with: event) else {
      return
    }
  }
  
  override func update(_ currentTime: TimeInterval) {
    super.update(currentTime)
    
    rootView?.update(currentTime)
  }
}

具体的な利用例

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

gomachan7/harp

まとめの図

MVCとSceneについての関係

MVCとSceneについての関係図

まとめ

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

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

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