【マインスイーパー開発 #12】タップ時の動作を切り替える(タイルを開く、旗を立てる)|Android & Kotlinアプリ開発

Android & Kotlin

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

今回は、タイルをタップした時に「タイルを開く」または「旗を立てる」という動作を切り替えるモード切替ボタンを追加します。

そそたた
そそたた

元々、旗を立てる動作には、ロングタップ(長押し)を採用していましたが、実際操作してみると使い勝手が悪かったので、ロングタップを廃止しました。

動作イメージ

画面左下のモード切替ボタンをタップすると「タイルを開く」、「旗を立てる」動作を切り替えます。

ソースコード

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

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

import android.content.Context
import android.graphics.Bitmap

/**
 * タイル制御インターフェース
 */
interface TileController {
    companion object {
        fun create(type: TileType): TileController = when (type) {
            TileType.Square -> Square()
            TileType.Triangle -> TODO("Not Implemented")
            TileType.Pentagon -> TODO("Not Implemented")
            TileType.Hexagon -> TODO("Not Implemented")
        }
    }

    /** 横方向のタイル要素数 */
    var numX: Int

    /** 縦方向のタイル要素数 */
    var numY: Int

    /** タイル幅 */
    var tileWidth: Int

    /** タイル高さ */
    var tileHeight: Int

    /** タイル状態更新通知 */
    var updateTileState: ((Int, Int, Bitmap) -> Unit)?

    /** 爆弾数更新通知 */
    var updateBombCount: ((Int) -> Unit)?

    /** ゲーム開始 */
    var startedGame: (() -> Unit)?

    /** ゲームオーバー通知 */
    var gameOver: (() -> Unit)?

    /** ゲームクリア */
    var clearedGame: (() -> Unit)?

    /** 初期化処理 */
    fun initialize(context: Context)

    /** 指定したタイル画像の取得 */
    fun getTileStateImage(x: Int, y: Int): Bitmap

    /** タイルを開く */
    fun open(x: Int, y: Int)

    /** モード切替 */
    fun toggleMode(): GameMode
}
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

    /** フラグ数 */
    private var mCurNumFlag = 0

    /** オープン数 */
    private var mCurNumOpen = 0

    /** ゲーム開始フラグ */
    private var mBegan = false

    /** ゲーム終了フラグ */
    private var mFinished = false

    /** ゲームモード */
    private var mGameMode = GameMode.Bomb

    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 var updateBombCount: ((Int) -> Unit)? = null
    override var startedGame: (() -> Unit)? = null
    override var gameOver: (() -> Unit)? = null
    override var clearedGame: (() -> 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))

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

        // タイル状態初期化
        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++
            }
        }

        mBegan = false

        updateBombCount?.invoke(mNumBomb - mCurNumFlag)
    }

    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) {
        if (x in 0 until numX && y in 0 until numY && !mFinished) {
            if (!mBegan) {
                mBegan = true
                startedGame?.invoke()
            }

            if (mGameMode == GameMode.Flag) {
                val tmpState = mTiles[y][x].state
                when (mTiles[y][x].state) {
                    TileState.TILE -> {
                        mTiles[y][x].state = TileState.TILE_FLAG
                        mCurNumFlag++
                        updateBombCount?.invoke(mNumBomb - mCurNumFlag)
                    }

                    TileState.TILE_FLAG -> {
                        mTiles[y][x].state = TileState.TILE
                        mCurNumFlag--
                        updateBombCount?.invoke(mNumBomb - mCurNumFlag)
                    }

                    else -> {
                    }
                }
                if (tmpState != mTiles[y][x].state) {
                    updateTileState?.invoke(x, y, mTileImages[mTiles[y][x].state.value])
                }
            } else {
                if (mTiles[y][x].isBomb) {
                    mTiles[y][x].state = TileState.BOMB
                    gameOver?.invoke()
                } else {
                    val bombNum = getAroundBombs(x, y)
                    mTiles[y][x].state = TileState.valueFrom(bombNum)
                    mCurNumOpen++
                }
                updateTileState?.invoke(x, y, mTileImages[mTiles[y][x].state.value])
            }
        }
    }

    override fun toggleMode(): GameMode {
        mGameMode = when (mGameMode) {
            GameMode.Bomb -> GameMode.Flag
            GameMode.Flag -> GameMode.Bomb
        }
        return mGameMode
    }

    /**
     * 有効タイル位置判定
     */
    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
    }
}
package com.sosotata.minesweeper.ui.main

import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.ScaleAnimation
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import com.sosotata.minesweeper.R
import com.sosotata.minesweeper.model.GameMode
import com.sosotata.minesweeper.model.TileController
import com.sosotata.minesweeper.model.TileType
import com.sosotata.minesweeper.ui.widgets.PlayTimeCounter
import kotlinx.android.synthetic.main.game_fragment.*

class GameFragment : Fragment() {
    companion object {
        fun newInstance() = GameFragment()
    }

    private lateinit var viewModel: GameViewModel
    private var playTimeCounter: PlayTimeCounter? = null

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View {
        return inflater.inflate(R.layout.game_fragment, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(this).get(GameViewModel::class.java)

        viewModel.tile = TileController.create(TileType.Square)
        viewModel.tile.startedGame = {
            playTimeCounter?.start()
        }

        viewModel.tile.gameOver = {
            playTimeCounter?.stop()
            animateButton(resetButton) {
                resetButton.setImageBitmap(BitmapFactory.decodeResource(context?.resources, R.drawable.reset_button_gameover))
            }
            vibrate()
        }

        viewModel.tile.clearedGame = {
            playTimeCounter?.stop()
            animateButton(resetButton) {
                resetButton.setImageBitmap(BitmapFactory.decodeResource(context?.resources, R.drawable.reset_button_victory))
            }
        }

        viewModel.tile.updateBombCount = {count ->
            bombCountText.text = "%03d".format(count)
        }

        viewModel.tile.initialize(requireContext())

        playTimeCounter = PlayTimeCounter(playTimer)
        squareView.initialize(viewModel.tile)

        resetButton.setOnClickListener {
            animateButton(resetButton) {
                viewModel.tile.initialize(requireContext())
                squareView.initialize(viewModel.tile)
                resetButton.setImageBitmap(BitmapFactory.decodeResource(context?.resources, R.drawable.reset_button_normal))
                playTimeCounter?.stop()
                playTimeCounter?.reset()
            }
        }

        modeButton.setOnClickListener {
            animateButton(modeButton) {
                when (viewModel.tile.toggleMode()) {
                    GameMode.Bomb -> modeButton.setImageBitmap((BitmapFactory.decodeResource(context?.resources, R.drawable.mode_bomb)))
                    GameMode.Flag -> modeButton.setImageBitmap((BitmapFactory.decodeResource(context?.resources, R.drawable.mode_flag)))
                }
            }
        }
    }

    override fun onResume() {
        playTimeCounter?.resume()
        super.onResume()
    }

    override fun onPause() {
        playTimeCounter?.pause()
        super.onPause()
    }

    private fun animateButton(button: View, Animated: () -> Unit)
    {
        val btnEffect = ScaleAnimation(
            1.0f, 1.3f, 1.0f, 1.3f,
            Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f)
        btnEffect.duration = 100
        btnEffect.setAnimationListener(object : Animation.AnimationListener {
            override fun onAnimationStart(animation: Animation) {}
            override fun onAnimationRepeat(animation: Animation) {}
            override fun onAnimationEnd(animation: Animation) {
                Animated()
            }
        })
        button.startAnimation(btnEffect)
    }

    private fun vibrate() {
        val vibrator = context?.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
        vibrator.let {
            if (it.hasVibrator()) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    it.vibrate(VibrationEffect.createOneShot(150, VibrationEffect.DEFAULT_AMPLITUDE))
                } else {
                    it.vibrate(150)
                }
            }
        }
    }
}

解説

TileControllerインターフェースにモード切替のtoggleModeメソッドを追加し、SquareクラスでtoggleModeメソッドをオーバーライドします。

override fun toggleMode(): GameMode {
    mGameMode = when (mGameMode) {
        GameMode.Bomb -> GameMode.Flag
        GameMode.Flag -> GameMode.Bomb
    }
    return mGameMode
}

次に、SquareTileGameViewクラスでモード切替ボタンをタップ時にtoggleModeメソッドを呼び出してボタンのイメージを変更します。

modeButton.setOnClickListener {
    animateButton(modeButton) {
        when (viewModel.tile.toggleMode()) {
            GameMode.Bomb -> modeButton.setImageBitmap((BitmapFactory.decodeResource(context?.resources, R.drawable.mode_bomb)))
            GameMode.Flag -> modeButton.setImageBitmap((BitmapFactory.decodeResource(context?.resources, R.drawable.mode_flag)))
        }
    }
}

最後に、タイルをタップした時に処理であるSquareクラスのopenメソッド内で、現在のモードにより「タイルを開く」か「旗を立てる」処理を分岐します。

override fun open(x: Int, y: Int) {
    if (x in 0 until numX && y in 0 until numY && !mFinished) {
        if (!mBegan) {
            mBegan = true
            startedGame?.invoke()
        }

        if (mGameMode == GameMode.Flag) {
            val tmpState = mTiles[y][x].state
            when (mTiles[y][x].state) {
                TileState.TILE -> {
                    mTiles[y][x].state = TileState.TILE_FLAG
                    mCurNumFlag++
                    updateBombCount?.invoke(mNumBomb - mCurNumFlag)
                }

                TileState.TILE_FLAG -> {
                    mTiles[y][x].state = TileState.TILE
                    mCurNumFlag--
                    updateBombCount?.invoke(mNumBomb - mCurNumFlag)
                }

                else -> {
                }
            }
            if (tmpState != mTiles[y][x].state) {
                updateTileState?.invoke(x, y, mTileImages[mTiles[y][x].state.value])
            }
        } else {
            if (mTiles[y][x].isBomb) {
                mTiles[y][x].state = TileState.BOMB
                gameOver?.invoke()
            } else {
                val bombNum = getAroundBombs(x, y)
                mTiles[y][x].state = TileState.valueFrom(bombNum)
                mCurNumOpen++
            }
            updateTileState?.invoke(x, y, mTileImages[mTiles[y][x].state.value])
        }
    }
}

コメント

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