This document written: 2014-05-24 .. 2022-07-09

『Android ゲームプログラミング A to Z』フレームワークの実装作業(前編)

さて、前回まででようやく設計と材料集めが終ったので、本書(BAG)の第 5 章で実際のゲーム開発用のフレームワークの作成になる。

BAG では、インターフェースの設計(第 3 章)を行ってから、インターフェースの実装に使える Android API の拾い集め(第 4 章)という段取りを踏んでいる。Java プログラミングの話の説明になるが、インターフェースというのは、複数種類の実装を行いたい場合のためにある手法である。つまり、Android 用の実装だとか、Windows 用の実装だとか、Linux 用の実装だといった風にだ。BAG では Android を対象にしているので、一例として Android API で実装するわけだが、Mario 先生が作った実際の libGDX においては、Android 以外にも、Windows / Linux / MacOS-X / iOS といったように、Java が使える環境の様々なプラットフォームに対応している。これはインターフェースを使っているからできるのである。実際に内部で使われている API はプラットフォーム毎に違うので、実装されている Java コードは異なっている。しかし、フレームワークを利用してゲームプログラムを作成するプログラマーの側は、インターフェースで共通化された libGDX の API(クラスとメソッド)を利用するので、実装とプラットフォームの違いは気にすることなく、libGDX 用にプログラミングしたものは、まったく同じプログラムで各プラットフォームで動かすことができるようになる。

そのようなメリットがあるので、一つのこと(たとえばグラフィックス)をやるのに、わざわざインターフェースと実装の二重構造に分けてフレームワークを作成しているのである(※勘違いのないように、この二重の作業はフレームワークの開発者にとって必要な作業であって、フレームワークを利用してゲーム作品をプログラミングする側にとって必要な作業ではない)。

しかし、ここでは、僕は、Android 専用のゲーム開発フレームワークを作って、Android におけるゲーム開発の雛形となるようなものを作ることを目的にする。インターフェース&実装という手法によって理想的に開発された Java マルチプラットフォームのゲーム開発フレームワークは Mario 先生の libGDX という決定版が存在するので、全く別のものを作成する必要なんてない。また、全く別の観点から言えば、そこまで BAG の内容をなぞるのであれば、本を買って読んでもらえば済む話であり、著作権的な問題にもなる。このサイトにおいては、あくまでも本にあったフレームワーク作成の流れを踏まえつつも、具体的な内容はオリジナルでつづる必要がある。

つまり、ここでは、インターフェースは使わず、Android API を直接使った抽象クラスを作成し、それをフレームワークとして利用して、実際のゲーム作品の制作においては、その抽象クラスを継承して(細かい部分をカスタマイズするようなイメージで)使うという形式にしたい。

ただし、将来的に実際に制作するゲームによっては libGDX を利用して作りたい場合もあるだろうことも考えて、わざわざ libGDX と API の名前を違うものにしたりするようなことはしない。同じ機能のものについては、素直に libGDX と同じ名前を使い、libGDX との親和性を損わないものにする方向性で行きたいと思う。なので、結果的にマリオ先生の BAG のフレームワークとそのまま重複してしまう部分は多々生じてしまうが、決してコピー&ペーストしたり書写したわけではなく、同じ動作内容を一つ一つ順を追って新たにコーディングした結果のものであることをお断りしておきたい。

タッチ(入力)

本家はインターフェースが Input、実装が AndroidInputとなっている。

さらに、AndroidInput の補助クラスとして、キーボード(KeyboardHandler)・加速度センサー(AccelerometerHandler)・方位センサー(CompassHandler)・タッチ(TouchHandler)が用意されているが、僕の自家版ではタッチ以外は除外する。

さらにさらに、TouchHandler も古いデバイス対応のために、シングルタッチ専用(SingleTouchHandler)とマルチタッチ専用(MultiTouchHandler)に分け、Android のバージョンによってどちらか一方を選択するようになっているが、現状ではマルチタッチ専用にして問題ないので、ここも単純化できる。

よって、以上の Input インターフェースと、その実装クラスの AndroidInput、その補助クラスの TouchHandler とさらにそのまた補助クラスの MultiTouchHandler(さらに後述する Pool クラス)を単一の実装クラスにまとめたのが次の Hata 版 TouchHandler クラスである:


class TouchHandler(renderView: View, private val scaleX: Float, private val scaleY: Float) :
    OnTouchListener {

    companion object {
        const val MAX_TOUCH_POINTS = 10
    }

    private val isTouched = BooleanArray(MAX_TOUCH_POINTS)
    private val touchX = IntArray(MAX_TOUCH_POINTS)
    private val touchY = IntArray(MAX_TOUCH_POINTS)
    private val touchPointerId = IntArray(MAX_TOUCH_POINTS)
    
    private val touchEventPool: Pool<TouchEvent>

    private val touchEvents: MutableList<TouchEvent> = ArrayList()
        get() {
            synchronized(this) {
                // touchEvents に保持していた既存の分の TouchEvent オブジェクトをプールに返却してから
                val length = field.size
                for (i in 0 until length) {
                    touchEventPool.free(field[i])
                }

                // touchEventBuffer の分を一挙に touchEvent に移し替える
                field.clear()
                field.addAll(touchEventsBuffer)
                touchEventsBuffer.clear()

                return field
            }
        }

    private val touchEventsBuffer: MutableList<TouchEvent> = ArrayList()

    init {
        val factory: PoolObjectFactory<TouchEvent> = object : PoolObjectFactory<TouchEvent> {
            override fun createObject(): TouchEvent {
                return TouchEvent()
            }
        }
        touchEventPool = Pool(factory, 100)

        renderView.setOnTouchListener(this)
    }

    /*
    ここの定義は、特に何かをするのが目的というわけではなく、モデル空間用の座標に motionEvent を touchEvent に
    移し替えるために定義しているという感じである。
     */
    override fun onTouch(view: View, motionEvent: MotionEvent): Boolean {
        synchronized(this) {
            /*
            本家では、この右辺のように、ポインターインデックスのデータだけをビットマスクして残し、
            必要なだけシフトしてインデックスの値だけを単独の int 値として取り出している。
            int pointerIndex = (motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
                    >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
            これは motionEvent.actionIndex と全く同じである(Android のソースコードで確認済)。
            https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/MotionEvent.java;l=2134
             */
            val pointerIndex = motionEvent.actionIndex
            // この MotionEvent オブジェクトが保持しているポインターの総数
            val pointerCount = motionEvent.pointerCount
            var touchEvent: TouchEvent
            // 0 ~ (pointerCount - 1) の(システム側が MotionEvent で現在追跡中の)ポインターに対する処理
            for (i in 0 until pointerCount) {
                if (motionEvent.action != MotionEvent.ACTION_MOVE && i != pointerIndex) {
                    /*
                    BAG 3rd p179
                    if it's an up/down/cancel/out event (!= MotionEvent.ACTION_MOVE),
                    mask the id to see if we should process it for this touch point

                    MOVE 以外のイベントの場合は、
                    その pointerIndex のポインターの該当イベントの発生としてしか関係がない。
                     */
                    continue
                }
                /*
                一方、MOVE イベントの場合は、pointerIndex は意味はなく、いずれかのポインターの
                移動によってトリガーされるが、pointerIndex によってそれが指し示されているわけでもないので、
                イベント発生の都度、全ポインターの位置をそれぞれ getX/Y によって得て scale を反映して
                置き換えるべき状況となる。
                 */
                val pointerId = motionEvent.getPointerId(i)
                when (motionEvent.action and MotionEvent.ACTION_MASK) {
                    MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
                        touchEvent = touchEventPool.newObject()
                        touchEvent.type = TouchEvent.TOUCH_DOWN
                        touchEvent.pointerId = pointerId
                        touchX[i] = (motionEvent.getX(i) * scaleX).toInt()
                        touchEvent.x = touchX[i]
                        touchY[i] = (motionEvent.getY(i) * scaleY).toInt()
                        touchEvent.y = touchY[i]
                        isTouched[i] = true
                        touchPointerId[i] = pointerId
                        touchEventsBuffer.add(touchEvent)
                    }
                    MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
                        touchEvent = touchEventPool.newObject()
                        touchEvent.type = TouchEvent.TOUCH_UP
                        touchEvent.pointerId = pointerId
                        touchX[i] = (motionEvent.getX(i) * scaleX).toInt()
                        touchEvent.x = touchX[i]
                        touchY[i] = (motionEvent.getY(i) * scaleY).toInt()
                        touchEvent.y = touchY[i]
                        isTouched[i] = false
                        touchPointerId[i] = -1
                        touchEventsBuffer.add(touchEvent)
                    }
                    MotionEvent.ACTION_MOVE -> {
                        touchEvent = touchEventPool.newObject()
                        touchEvent.type = TouchEvent.TOUCH_DRAGGED
                        touchEvent.pointerId = pointerId
                        touchX[i] = (motionEvent.getX(i) * scaleX).toInt()
                        touchEvent.x = touchX[i]
                        touchY[i] = (motionEvent.getY(i) * scaleY).toInt()
                        touchEvent.y = touchY[i]
                        isTouched[i] = true
                        touchPointerId[i] = pointerId
                        touchEventsBuffer.add(touchEvent)
                    }
                }
            }
            // pointerCount ~ (MAX_TOUCH_POINTS - 1) の(システム側が MotionEvent で現在追跡していない)
            // ポインターに対する処理
            for (i in pointerCount until MAX_TOUCH_POINTS) {
                isTouched[i] = false
                touchPointerId[i] = -1
            }

            return true
        }
    }

    /**
     * pointerId から pointerIndex を探し出す。いわば MotionEvent#findPointerIndex の TouchEvent 版。
     *
     * @param pointerId ターゲットとする pointerId
     * @return 指定された pointerId の座標を含んだ pointerIndex。見つからなければ -1 が返る。
     */
    private fun findPointerIndex(pointerId: Int): Int {
        for (i in 0 until MAX_TOUCH_POINTS) {
            if (touchPointerId[i] == pointerId) {
                return i
            }
        }
        return -1 // not found
    }

    /**
     * 今そのポインターがタッチ中かどうかを調べる。
     *
     * @param pointerId ターゲットとする pointerId
     * @return タッチ中かどうかの真偽値
     */
    fun isTouchDown(pointerId: Int): Boolean {
        synchronized(this) {
            val index = findPointerIndex(pointerId)
            return if (index < 0 || index >= MAX_TOUCH_POINTS) {
                false
            } else {
                isTouched[index]
            }
        }
    }

    /**
     * 言ってみれば MotionEvent#getX(pointerIndex) のような役割のものだが、
     * pointerId を引数として使えるようにしてある点が異なっている。
     *
     * @param pointerId ターゲットとする pointerId
     * @return 指定された pointerId の x 座標。無効な pointerId の場合は 0 が返る。
     */
    fun getTouchX(pointerId: Int): Int {
        synchronized(this) {
            val index = findPointerIndex(pointerId)
            return if (index < 0 || index >= MAX_TOUCH_POINTS) {
                0
            } else {
                touchX[index]
            }
        }
    }

    /**
     * 言ってみれば MotionEvent#getY(pointerIndex) のような役割のものだが、
     * pointerId を引数として使えるようにしてある点が異なっている。
     *
     * @param pointerId ターゲットとする pointerId
     * @return 指定された pointerId の y 座標。無効な pointerId の場合は 0 が返る。
     */
    fun getTouchY(pointerId: Int): Int {
        synchronized(this) {
            val index = findPointerIndex(pointerId)
            return if (index < 0 || index >= MAX_TOUCH_POINTS) {
                0
            } else {
                touchY[index]
            }
        }
    }

    // Keyboard は使わず、Touch しか入力デバイスとして使わないので、この内部クラス化した TouchEvent クラス定義

    /**
     * TouchEvent オブジェクトを定義するクラス
     *
     * BAG 本家の toString メソッドは割愛している
     */
    class TouchEvent {

        companion object {
            const val TOUCH_DOWN = 0
            const val TOUCH_UP = 1
            const val TOUCH_DRAGGED = 2
        }

        var type = 0
        var x = 0
        var y = 0
        var pointerId = 0
    }

    // Touch イベント用にしか使わないので、この内部クラス化したインスタンスプール ----------------------------

    /**
     * 汎用のインスタンスプールクラス。
     *
     * @param <T> プールを利用するオブジェクトのクラス(型)を指定する
     * @param factory PoorObjectFactory インターフェイスを実装したインスタンスへの参照
     * @param maxSize プールするインスタンス数の最大値(最大値を超えるインスタンスの生成を妨げるものではない。
     * オブジェクトの返却時に、再利用するためにストックしておく数を意味する)
     */
    private class Pool<T>(private val factory: PoolObjectFactory<T>, private val maxSize: Int) {

        /**
         * プール側では、オブジェクトの種類によらず、ただプールする機能しか持たないので、個々のオブジェクトのインスタンス化にあたっては、
         * プールを利用する側のクラスで、インスタンス化用のメソッドを実装する必要がある。
         * このインターフェイスはそのインスタンス化を実装すべきことを定義付けるものである。
         *
         * @param <T> プールを利用するオブジェクトのクラス(型)を指定する
         */
        interface PoolObjectFactory<T> {
            fun createObject(): T
        }

        private val freeObjects: MutableList<T> = ArrayList(maxSize)

        /**
         * プールからインスタンスを新たに一つ引き出す。
         *
         * @return 引き当てられるインスタンス
         */
        fun newObject(): T {
            return if (freeObjects.isEmpty()) {
                // 余っているオブジェクトがない場合は仕方がないので、maxSize を超えて新しいオブジェクトを追加生成
                factory.createObject()
            } else {
                // 余っているオブジェクトがあるうちは、それを譲り渡す
                freeObjects.removeAt(freeObjects.size - 1)
            }
        }

        /**
         * 不要になったインスタンスをプールに返却する。
         * (当然、不要になったら都度必ず返却するようにしないと、インスタンスプールの意味を為さない。)
         *
         * @param unused 返却するインスタンスを指定する。
         */
        fun free(unused: T) {
            if (freeObjects.size < maxSize) {
                // 解放されたオブジェクトは余っているオブジェクトとしてこちら(freeObjects)に所属を戻す
                freeObjects.add(unused)
            }
        }
    }
}

ちなみに、BAG の第 2 版(“Beginning Android Games 2nd Ed.”)では、翻訳版の BAG(初版ベース)から TouchEvent の処理の仕方が改良されている。この TouchHandler はもちろん、2 版のフレームワークをベースにしてある。

インスタンスプール

Pool クラスは、ゲーム開発フレームワークの API として存在するインターフェースではなく、5 章の実装段階で追加されているクラスである。上の自家版 TouchHandler では、内部クラスとして組み込んである。

タッチ用のドライバーオブジェクトにおいて、タッチイベントが発生するとその都度、タッチイベントオブジェクトが new() される。つまり、TouchEvent クラスのインスタンスがどんどん生成してゆくことになる。インスタンスが大量に発生してメモリーを圧迫すると、その都度、JavaVM のガベージコレクションが行われる。そうなると、ゲームプログラムは一定時間、停止することになる。

このことを避けるため、インスタンスプールという仕組みによって、インスタンスを無制限に new() して VM のガベージコレクターの機能に完全に任せるのではなく、一定数のインスタンスのセットを用意してそこに再入力して使い回すという方法を取るわけである。

このクラスはファクトリーメソッドの newObject() を通じて新しいインスタンスの生成を要求すれば、プールされているインスタンスがない場合はインスタンスを new() し、プールされている既存のインスタンスが存在する場合にはそれを代入するために渡してくれる。また、プールされるインスタンスの最大値を指定できるようになっている。用例は次のような形であり、TouchEvent 用にファクトリーオブジェクトを用意し、そのファクトリーオブジェクトとプールの最大数を指定してイベントプールオブジェクトを作成する。あとは作成したイベントプールオブジェクトに対して、newObject()free() によって、予約、解放を行う。


val factory: PoolObjectFactory<TouchEvent> = object : PoolObjectFactory<TouchEvent> {
    override fun createObject(): TouchEvent {
        return TouchEvent()
    }
}
touchEventPool = Pool(factory, 100)

// 中略

touchEventPool.free(touchEvent)

Mario 先生の BAG のフレームワークにおいては、TouchEvent だけではなく KeyEvent にも用いているので、汎用的な Pool クラスを用意して、それを TouchEvent なり KeyEvent 用に設定して使うという設計になっている。実際に TouchEvent だけしか使わないのであれば、TouchHandler クラスに内部クラスとして統合して、TouchEvent が最初からインスタンスプール方式を組み込んであるような形にしてもいいと思う。


読解『A to Z』