【マインスイーパー開発 #4】長押し(ロングタップ)で旗を立てる|Androidアプリ開発

Android & Kotlin

Android & Kotlinの環境でマインスイーパーを開発する方法を説明します。

今回は、開いていない通常のタイルを長押し(ロングタップ)すると旗を立てて、旗のタイルを長押しすると通常のタイルに戻す処理を実装します。

そそたた
そそたた

マインスイーパーでは、地雷が置かれていると予想される位置に旗を立てていきます。

動作イメージ

「旗を立てる」→「通常のタイルに戻す」の動作イメージです。

ソースコード

開発環境は次の通りです。

PCMacBook Pro(2016年モデル)
IDEAndroid Studio 4.0.1
Android SDKminSdkVersion 16
targetSdkVersion 30
言語Kotlin 1.3.72
package com.sosotata.minesweeper.ui.widgets

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import com.sosotata.minesweeper.model.TileController

/**
 * 四角タイルゲーム画面
 */
class SquareTileGameView : SosotataImageView {

    /** タイル描画用ビットマップ */
    private lateinit var mSourceBitmap: Bitmap

    /** タイル描画用キャンバス */
    private lateinit var mRenderCanvas: Canvas

    /** タイル制御 */
    private lateinit var mTile: TileController

    /**
     * コンストラクタ
     */
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {}

    /**
     * タイルを描画する
     */
    fun initialize(tile: TileController) {
        mTile = tile
        mSourceBitmap = Bitmap.createBitmap(
            mTile.tileWidth * mTile.numX, mTile.tileHeight * mTile.numY, Bitmap.Config.ARGB_8888
        )
        mRenderCanvas = Canvas(this.mSourceBitmap)
        for (y in 0 until mTile.numY) {
            for (x in 0 until mTile.numX) {
                mRenderCanvas.drawBitmap(
                    mTile.getTileStateImage(x, y),
                    mTile.tileWidth.toFloat() * x,
                    mTile.tileHeight.toFloat() * y,
                    null
                )
            }
        }
        setImage(mSourceBitmap)

        // タイル更新
        mTile.updateTileState = { i, j, image ->
            // タッチしたタイルを描画する
            mRenderCanvas.drawBitmap(
                image, (image.width * i).toFloat(), (image.height * j).toFloat(), null)
        }
    }

    /**
     * [android.view.GestureDetector.OnGestureListener.onSingleTapUp]
     */
    override fun onSingleTapUp(e: MotionEvent?): Boolean {
        if (e!= null) {
            val p = coordinatesToTilePos(e)
            mTile.open(p.x, p.y, false)
        }
        return true
    }

    /**
     * [android.view.GestureDetector.OnGestureListener.onLongPress]
     */
    override fun onLongPress(e: MotionEvent?) {
        if (e != null) {
            val p = coordinatesToTilePos(e)
            mTile.open(p.x, p.y, true)
        }
    }

    /**
     * タッチ座標からタイル位置に変換
     */
    private fun coordinatesToTilePos(e: MotionEvent): Point {
        val values = FloatArray(9)
        this.mRenderMatrix.getValues(values)
        return Point(
            ((e.x - values[Matrix.MTRANS_X]) / values[Matrix.MSCALE_X] / mTile.tileWidth).toInt(),
            ((e.y - values[Matrix.MTRANS_Y]) / values[Matrix.MSCALE_Y] / mTile.tileHeight).toInt()
        )
    }
}
package com.sosotata.minesweeper.model

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.sosotata.minesweeper.LOG
import com.sosotata.minesweeper.R

/**
 * 四角タイル制御
 */
class Square() : TileController {

    /** タイル状態定義 */
    enum class TileState(var value: Int) {
        NUM0(0),
        NUM1(1),
        NUM2(2),
        NUM3(3),
        NUM4(4),
        NUM5(5),
        NUM6(6),
        NUM7(7),
        NUM8(8),
        TILE(9),
        TILE_FLAG(10),
        BOMB(11),
        BOMB_HIT(12),
        BOMB_NG(13),
        BOMB_OK(14);

        companion object {
            fun valueFrom(value: Int): TileState {
                return values().firstOrNull { it.value == value } ?: TILE
            }
        }
    }

    /** タイル要素  */
    data class TileElement(var state: TileState, var isBomb: Boolean)

    /** タイル画像リスト */
    private val mTileImages: MutableList<Bitmap> = mutableListOf()

    /** タイル状態管理配列 */
    private lateinit var mTiles: Array<Array<TileElement>>

    /** 爆弾数 */
    private var mNumBomb: Int = 0

    override var numX: Int = 0
    override var numY: Int = 0
    override var tileWidth: Int = 0
    override var tileHeight: Int = 0
    override var updateTileState: ((i: Int, j: Int, image: Bitmap) -> Unit)? = null

    override fun initialize(context: Context) {
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b0))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b1))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b2))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b3))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b4))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b5))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b6))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b7))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.b8))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.base_square))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.flag))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.bomb))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.bomb_hit))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.bomb_ng))
        mTileImages.add(BitmapFactory.decodeResource(context.resources, R.drawable.bomb_flag))

        // タイル数、爆弾数を設定
        numX = 9
        numY = 9
        mNumBomb = 10
        tileWidth = mTileImages[0].width
        tileHeight = mTileImages[0].height

        // タイル状態初期化
        mTiles = Array(numY) {Array(numX) {
            TileElement(TileState.TILE, false)
        } }

        // 爆弾をランダムにセット
        var count: Int = 0
        while (count < mNumBomb) {
            val x: Int = (0 until numX).random()
            val y: Int = (0 until numY).random()
            if (!mTiles[y][x].isBomb) {
                mTiles[y][x].isBomb = true
                count++
            }
        }
    }

    override fun getTileStateImage(x: Int, y: Int): Bitmap {
        return when {
            x < numX && y < numY -> mTileImages[mTiles[y][x].state.value]
            else -> {
                LOG.e("invalid param x[${x}] y[${y}]")
                mTileImages[mTiles[0][0].state.value]
            }
        }
    }

    override fun open(x: Int, y: Int, longPress: Boolean) {
        if (x in 0 until numX && y in 0 until numY) {
            if (longPress) {
                when (mTiles[y][x].state) {
                    TileState.TILE -> mTiles[y][x].state = TileState.TILE_FLAG
                    TileState.TILE_FLAG -> mTiles[y][x].state = TileState.TILE
                    else -> {}
                }
            } else {
                if (mTiles[y][x].isBomb) {
                    mTiles[y][x].state = TileState.BOMB
                } else {
                    val bombNum = getAroundBombs(x, y)
                    mTiles[y][x].state = TileState.valueFrom(bombNum)
                }
            }
            updateTileState?.invoke(x, y, mTileImages[mTiles[y][x].state.value])
        }
    }

    /**
     * 有効タイル位置判定
     */
    private fun isValidPos(x: Int, y: Int): Boolean {
        return (x in 0 until numX) && (y in 0 until numY)
    }

    /**
     * 指定したタイルの周りにある爆弾数を取得
     */
    private fun getAroundBombs(x: Int, y: Int): Int {
        var ret = 0 // 選択タイルの周りにある爆弾数
        if (isValidPos(x - 1, y - 1) && mTiles[y - 1][x - 1].isBomb) ret++  // 左上
        if (isValidPos(x, y - 1) && mTiles[y - 1][x].isBomb) ret++  // 真上
        if (isValidPos(x + 1, y - 1) && mTiles[y - 1][x + 1].isBomb) ret++  // 右上
        if (isValidPos(x - 1, y) && mTiles[y][x - 1].isBomb) ret++  // 左横
        if (isValidPos(x + 1, y) && mTiles[y][x + 1].isBomb) ret++  // 右横
        if (isValidPos(x - 1, y + 1) && mTiles[y + 1][x - 1].isBomb) ret++  // 左下
        if (isValidPos(x, y + 1) && mTiles[y + 1][x].isBomb) ret++  // 真下
        if (isValidPos(x + 1, y + 1) && mTiles[y + 1][x + 1].isBomb) ret++  // 右下
        return ret
    }
}

解説

長押し(ロングタップ)の検出

前回(その3)は、タイルをタッチしたことをView.onTouchEventで検出していました。

今回からは、長押しに対応するためAndroid SDKのGestureDetector.OnGestureListenerインターフェースのonLongPress()、onSingleTouchUp()を使用します。

実装箇所は、SuqareTileGameViewクラスのonSingleTouch()、onLongPress()になります。

そそたた
そそたた

GestureDetector.OnGestureListenerの実装は、SquareTileGameViewクラスの基底クラスであるSosotataImageViewクラスで実装しているのでそちらを参照してください。

単純にタッチダウン→タッチアップするとonSingleTouchUp()が発生し、タッチダウン→長押し→タッチアップするとonSigleTouchUp()が発生せずにonLongPress()が発生する動作になります。

他にも2点目をタッチするとonSingleTouchUp()、onLongPress()が発生しないなど一通りの挙動を確認することをおすすめします。

旗を立てる

旗を立てるには、Squareクラスのタイル状態を旗タイル(TileState.TILE_FLAG)に変更します。

実装箇所は、TileController.open()で引数にタイル位置(x番目、y番目)と長押しフラグ(longPress)を指定します。

タイル位置が範囲内かつ長押しだった場合に、通常タイル(TileState.TILE)だったら旗タイル(TileState.TILE_FLAG)に変更し、旗タイル(TileState.TILE_FLAG)だったら通常タイル(TileState.TILE)に変更します。

override fun open(x: Int, y: Int, longPress: Boolean) {
    if (x in 0 until numX && y in 0 until numY) {
        if (longPress) {
            when (mTiles[y][x].state) {
                TileState.TILE -> mTiles[y][x].state = TileState.TILE_FLAG
                TileState.TILE_FLAG -> mTiles[y][x].state = TileState.TILE
                else -> {}
            }
        } else {
            if (mTiles[y][x].isBomb) {
                mTiles[y][x].state = TileState.BOMB
            } else {
                val bombNum = getAroundBombs(x, y)
                mTiles[y][x].state = TileState.valueFrom(bombNum)
            }
        }
        updateTileState?.invoke(x, y, mTileImages[mTiles[y][x].state.value])
    }
}

旗タイルの描画は、SquareTileGameViewで設定しているupdateTileStateコールバックの実体で実施しています。

// タイル更新
mTile.updateTileState = { i, j, image ->
    // タッチしたタイルを描画する
    mRenderCanvas.drawBitmap(
        image, (image.width * i).toFloat(), (image.height * j).toFloat(), null)
}

コメント

タイトルとURLをコピーしました