Android & Kotlinの環境でマインスイーパーを開発する方法を説明します。
今回は、タイルをタップした時に「タイルを開く」または「旗を立てる」という動作を切り替えるモード切替ボタンを追加します。
そそたた
元々、旗を立てる動作には、ロングタップ(長押し)を採用していましたが、実際操作してみると使い勝手が悪かったので、ロングタップを廃止しました。
動作イメージ
画面左下のモード切替ボタンをタップすると「タイルを開く」、「旗を立てる」動作を切り替えます。
ソースコード
開発環境は次の通りです。
PC | MacBook Pro(2016年モデル) |
IDE | Android Studio 4.0.1 |
Android SDK | minSdkVersion 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])
}
}
}
コメント