安卓连连看游戏设计

发布时间:2021-08-08  修改时间:2021-08-08  作者:归零幻想

字节取消大小周了,于是周末时间多起来了。虽然想着到处去玩,但现在疫情形势又不好了,于是安心呆在家里当一个肥宅。除了补一补番剧,就是把之前就想过的连连看游戏做出来了。

连连看游戏规则简单,点击两个相同的元素,如果他们能在两次拐弯以内连接起来,那么就可以消除。消除后就会出现空位,可以连接的就更多了。在规定时间内连续操作,直到消除所有元素。

虽然规则比较简单,但真正动手实现一遍还是很费工夫的。游戏既然做好了,那么我水一篇博客不过分吧:>

连连看游戏界面 1

项目的代码我放到了github。写的贼丑,轻喷。 https://github.com/zerofancy/match

资源准备

连连看需要一些素材图,可以考虑用水果、动物甚至我同学的头像等,但我想把难度设计高一点,手机屏幕就这么大,图片很小也能有比较高的辨识度,思来想去还是麻将比较合适。

幸运的是,我找到了这么一套免费的图片素材,Mahjong Icons,只要提供一个指向这个页面的超链接就可以免费用。

另外还需要一个应用图标,这个我是直接在Icon Park上找了一个。

接下来将素材导入到项目,为了随处使用方便,我还定义到了一个类中:

package top.ntutn.match

/**
 * 麻将牌
 */
object Mahjong {
    val bamboos = listOf(
        R.drawable.bamboo1,
        R.drawable.bamboo2,
        R.drawable.bamboo3,
        R.drawable.bamboo4,
        R.drawable.bamboo5,
        R.drawable.bamboo6,
        R.drawable.bamboo7,
        R.drawable.bamboo8,
        R.drawable.bamboo9
    )
    val dragons = listOf(
        R.drawable.dragon_chun,
        R.drawable.dragon_green,
        R.drawable.dragon_haku
    )
    val faceDown = R.drawable.face_down
    val mans = listOf(
        R.drawable.man1,
        R.drawable.man2,
        R.drawable.man3,
        R.drawable.man4,
        R.drawable.man5,
        R.drawable.man6,
        R.drawable.man7,
        R.drawable.man8,
        R.drawable.man9
    )
    val pins = listOf(
        R.drawable.pin1,
        R.drawable.pin2,
        R.drawable.pin3,
        R.drawable.pin4,
        R.drawable.pin5,
        R.drawable.pin6,
        R.drawable.pin7,
        R.drawable.pin8,
        R.drawable.pin9
    )
    val redDoras = listOf(
        R.drawable.red_dora_bamboo5,
        R.drawable.red_dora_man5,
        R.drawable.red_dora_pin5
    )
    val winds = listOf(
        R.drawable.wind_east,
        R.drawable.wind_north,
        R.drawable.wind_south,
        R.drawable.wind_west
    )
    val front = bamboos + dragons + mans + pins + redDoras + winds
    val all = front + faceDown
}

界面设计

参考上面截图,游戏区域其实是相当简单的,但麻将格子太多,如何显示到界面中呢?我采取的是使用多个ImageView,然后用代码动态添加的方式。这样我可以将ImageView也存成一个二维数组,正好跟游戏数据的二维数组对应起来,写起来更方便。

这样我的界面就比较简单了,只要准备上方计时器的TextView和主要区域的ImageView就可以了。在onCreate()生命周期我创建并添加这些ImageView:

这里我计算了中间区域的尺寸,让游戏区域为位于中间区域的一个近似正方形:

        // post一下,不然取不到
        imageContainer.post {
            // 中间正方形区域宽高
            areaWidth = min(imageContainer.width, imageContainer.height - horizontalGap * (N - 1))
            imageViewArray = Array(N) { i ->
                Array(N) { j ->
                    ImageView(this).apply {
                        imageContainer.addView(this)
                        layoutParams = FrameLayout.LayoutParams(areaWidth / N, areaWidth / N)
                        y =
                            (i * (areaWidth / N + horizontalGap)).toFloat() - areaWidth / 2 - horizontalGap * N / 2 + imageContainer.height / 2
                        x = (j * areaWidth / N).toFloat() - areaWidth / 2 + imageContainer.width / 2
                        scaleType = ImageView.ScaleType.CENTER_INSIDE
                        setOnClickListener {
                            viewModel.itemClick(i + 1, j + 1)
                        }
                    }
                }
            }
            // 配置改变不重建
            savedInstanceState ?: kotlin.run {
                viewModel.init(N, N, mahjongSize, maxGameTime, stepGameTime)
                viewModel.start()
            }
        }

数据结构定义

对于每一个麻将牌,我们需要关心他们显示的内容和当前的状态(是否被选中,是否已经消除),因而定义这样的数据结构:

/**
 * 麻将数据类型
 * @param id 本次游戏中的编号(不是资源id)
 * @param isSelected 麻将是否被选中
 * @param isDeleted 麻将是否已经被删除
 */
data class MahjongType(
    val id: Int,
    var isSelected: Boolean = false,
    var isDeleted: Boolean = false
)

在ViewModel中,我用一个二维数组来存储游戏数据

    var mahjongArea: Array<Array<MahjongType>> = arrayOf()
        private set

对于选中点,需要记录它的行数和列数

    private var selectedIndex: Pair<Int, Int>? = null

游戏有三种事件:游戏状态改变(开始、结束、暂停等)、游戏区域刷新、倒计时改变。这些我定义为LiveData。这里的refreshArea是一个Unit类型的LiveData,因为不需要传递什么数据,只要通知这个事件到来就可以了。

    // 游戏区域刷新事件
    private val _refreshArea = MutableLiveData<Unit>()
    val refreshArea: LiveData<Unit>
        get() = _refreshArea

    // 游戏状态改变
    private val _gameState = MutableLiveData<GameState>()
    val gameState: LiveData<GameState>
        get() = _gameState

    // 游戏倒计时
    private val _gameTime = MutableLiveData<Int>()
    val gameTime: LiveData<Int>
        get() = _gameTime

    enum class GameState {
        PENDING,
        RUNNING,
        PAUSE,
        SUCCEEDED,
        FAILED
    }

游戏数据初始化

游戏数据初始化时,我们要将所有变量置为初始状态。

  1. 游戏区域 为了计算方便,我们实际存储的游戏区域比显示出来的游戏区域实际上是多一圈的,多一圈空格子,这样边上的元素计算能否连通的时候就不需要特殊处理
  2. 游戏中,麻将元素应该是成对的,所以我首先选择一些元素,然后随机添加到一个list中,每个添加两份,最后再打乱顺序
  3. 游戏中,所有需要后面判断和比较差异的复制都应该是深拷贝,否则他们是同一个对象,怎么比都是一样
    /**
     * 初始化游戏
     * @param rows 游戏区域行数
     * @param cols 游戏区域列数
     * @param itemCount 使用的麻将牌的数量
     */
    fun init(rows: Int, cols: Int, itemCount: Int, maxGameTime: Int, stepGameTime: Int) {
        require(rows * cols % 2 == 0) { "区域应该有偶数个元素" }
        require(itemCount <= Mahjong.front.size) { "麻将牌资源不足" }
        require(maxGameTime > 5) { "游戏时间过短" }
        require(stepGameTime >= 0) { "stepGameTime参数错误" }

        this.rows = rows
        this.cols = cols
        this.maxGameTime = maxGameTime
        this.stepGameTime = stepGameTime

        // +2是为了给周围放上一圈空格子,计算的时候方便
        mahjongArea = Array(rows + 2) {
            Array(cols + 2) {
                MahjongType(id = Mahjong.faceDown, isDeleted = true)
            }
        }

        val totalItemCollection = Mahjong.front.shuffled().subList(0, itemCount)
        val res = mutableListOf<MahjongType>()
        while (res.size < rows * cols) {
            val item = MahjongType(id = totalItemCollection.random())
            res.add(item)
            // 深拷贝
            res.add(item.copy())
        }
        res.shuffle()
        for (i in res.indices) {
            mahjongArea[i / cols + 1][i % cols + 1] = res[i]
        }
        selectedIndex = null
        _refreshArea.value = Unit
        _gameState.value = GameState.PENDING
    }

游戏界面显示

我将每个ImageView对应的数据存储到它的tag中,这样在收到区域刷新事件时就可以直接比较判断是否要刷新了。

        viewModel.refreshArea.observe(this) {
            for (i in 0 until N) {
                for (j in 0 until N) {
                    val dataItem = viewModel.mahjongArea[i + 1][j + 1]
                    val viewItem = imageViewArray[i][j]
                    if (viewItem.tag != dataItem) {
                        if (dataItem.isDeleted) {
                            viewItem.setImageDrawable(null)
                        } else {
                            viewItem.setImageResource(dataItem.id)
                        }
                        viewItem.scaleType = ImageView.ScaleType.CENTER_INSIDE
                        viewItem.tag = dataItem.copy() // 深拷贝,否则一直一样
                        if (dataItem.isSelected) {
                            viewItem.colorFilter = grayColorMatrixColorFilter
                        } else {
                            viewItem.clearColorFilter()
                        }
                    }
                }
            }
        }

消除

对于被消除的元素,通过viewItem.setImageDrawable(null)清除显示内容。

选中

对于被选中的元素,我通过设置colorFilter的方式调整图片饱和度来做到高亮显示的目的。这个colorFilter定义如下:

    private val grayColorMatrixColorFilter by lazy {
        val colorMatrix = ColorMatrix().apply {
            setSaturation(25f)
        }
        ColorMatrixColorFilter(colorMatrix)
    }

麻将牌点击事件

选中

首先我们只需要处理没有被消除的元素

        if (mahjongArea[row][col].isDeleted) {
            // 当前点击元素已经消除
            return
        }

如果之前没有选中元素,那么我们应该直接选中这个元素

        if (selectedIndex == null) {
            // 没有已经选中的,选中点击项
            mahjongArea[row][col].isSelected = true
            selectedIndex = row to col
            _refreshArea.value = Unit
            return
        }

消除

否则就判断一下两个元素能否配对消除,并判断是否已经全部消除

        val previousSelected = mahjongArea[selectedIndex!!.first][selectedIndex!!.second]
        val currentSelected = mahjongArea[row][col]
        // 判断是否可消除
        if (checkIsCanDelete(row to col, selectedIndex!!)) {
            previousSelected.isSelected = false
            previousSelected.isDeleted = true
            currentSelected.isDeleted = true
            selectedIndex = null
            val gameTime = (_gameTime.value ?: 1) + stepGameTime
            _gameTime.value = gameTime.takeIf { it <= maxGameTime } ?: maxGameTime
            if (mahjongArea.all { it.all { it.isDeleted } }) {
                // 所有麻将已经消除,游戏胜利
                _gameState.value = GameState.SUCCEEDED
            }
        } else {
            previousSelected.isSelected = false
            selectedIndex = null
        }
        _refreshArea.value = Unit

那么如何判断能否消除呢?

    /**
     * 判断两个元素是否能消除
     */
    private fun checkIsCanDelete(itemIndex1: Pair<Int, Int>, itemIndex2: Pair<Int, Int>): Boolean {
        if (itemIndex1 == itemIndex2) {
            return false
        }
        if (mahjongArea.getByPair(itemIndex1).id != mahjongArea.getByPair(itemIndex2).id) {
            return false
        }
        return VisitDirection.values().any {
            checkIsCanMatch(itemIndex1, itemIndex2, it)
        }
    }

如果是同一个元素,肯定不能消除;如果两个元素不同,那也不能消除。在排除了这两种情况后,要判断能否通过两次以内的拐弯就到达就需要BFS了。

  1. 如果当前元素当前方向的下一个元素可以与目标元素拐n次弯相连,那么当前元素可以与目标元素拐n次弯相连
  2. 如果当前元素非当前方向的下一个元素可以与目标元素拐n次弯相连,那么当前元素可以与目标元素拐n+1次弯相连

这里我用递归实现了这个算法,但实现的……有点丑


    /**
     * 判断两个在不同位置的相同元素是否能消除
     * BFS
     */
    private fun checkIsCanMatch(
        currentPoint: Pair<Int, Int>,
        targetPoint: Pair<Int, Int>,
        visitDirection: VisitDirection,
        maxRounds: Int = 2
    ): Boolean {
        val nextPoints = currentPoint.getNextPoints(visitDirection, targetPoint).filter {
            if (maxRounds > 0) true else it.third == 0
        }
        if (targetPoint in nextPoints.map { it.first }) {
            return true
        }
        return nextPoints.isNotEmpty() && nextPoints.any {
            checkIsCanMatch(it.first, targetPoint, it.second, maxRounds - it.third)
        }
    }

    /**
     * 当前结点是否在游戏区域内
     */
    private fun Pair<Int, Int>.isPointValid() =
        this.first in 0..(rows + 1) && this.second in 0..(cols + 1)

    /**
     * 当前访问方向
     */
    private enum class VisitDirection {
        TOP,
        BOTTOM,
        LEFT,
        RIGHT
    }

    /**
     * 获取当前点的下一个点
     * @param currentDirection 当前朝向的方向
     * @return 一个集合,集合中有0~3个三元组,每个三元组有{点,朝向,需要拐弯次数}
     */
    private fun Pair<Int, Int>.getNextPoints(
        currentDirection: VisitDirection,
        targetPoint: Pair<Int, Int>
    ): Set<Triple<Pair<Int, Int>, VisitDirection, Int>> {
        val res = mutableSetOf<Triple<Pair<Int, Int>, VisitDirection, Int>>()
        val nextPoint = when (currentDirection) {
            VisitDirection.TOP -> first - 1 to second
            VisitDirection.BOTTOM -> first + 1 to second
            VisitDirection.LEFT -> first to second - 1
            VisitDirection.RIGHT -> first to second + 1
        }
        val (leftPoint, leftDirection) = when (currentDirection) {
            VisitDirection.TOP -> first to second - 1 to VisitDirection.LEFT
            VisitDirection.BOTTOM -> first to second + 1 to VisitDirection.RIGHT
            VisitDirection.LEFT -> first + 1 to second to VisitDirection.BOTTOM
            VisitDirection.RIGHT -> first - 1 to second to VisitDirection.TOP
        }
        val (rightPoint, rightDirection) = when (currentDirection) {
            VisitDirection.TOP -> first to second + 1 to VisitDirection.RIGHT
            VisitDirection.BOTTOM -> first to second - 1 to VisitDirection.LEFT
            VisitDirection.LEFT -> first - 1 to second to VisitDirection.TOP
            VisitDirection.RIGHT -> first + 1 to second to VisitDirection.BOTTOM
        }
        if (nextPoint.isPointCanUse(targetPoint)) {
            res.add(Triple(nextPoint, currentDirection, 0))
        }
        if (leftPoint.isPointCanUse(targetPoint)) {
            res.add(Triple(leftPoint, leftDirection, 1))
        }
        if (rightPoint.isPointCanUse(targetPoint)) {
            res.add(Triple(rightPoint, rightDirection, 1))
        }
        return res
    }

    /**
     * 判断某个点在游戏区域内,而且是空白格子或目标格子
     */
    private fun Pair<Int, Int>.isPointCanUse(targetPoint: Pair<Int, Int>) =
        isPointValid() && (mahjongArea.getByPair(this).isDeleted || this == targetPoint)

计时系统的设计

在VM中我有一个方法,当游戏在进行状态,每调用一次时间就减一。而在每成功消除一次,时间就加3,这样难度就不会太高。

    /**
     * 每秒钟被调用,计算游戏倒计时
     */
    fun timeTick() {
        if (_gameState.value != GameState.RUNNING) {
            return
        }
        val gameTime = (_gameTime.value ?: 1) - 1
        _gameTime.value = gameTime
        if (gameTime <= 0) {
            _gameState.value = GameState.FAILED
        }
    }

我利用了handler的机制来实现每秒调用一次

        object : Runnable {
            override fun run() {
                viewModel.timeTick()
                handler.postDelayed(this, 1000L)
            }
        }.run()

因为判断了游戏状态,所以暂停和继续也很好做了:

    /**
     * 游戏暂停
     */
    fun pause() {
        if (_gameState.value != GameState.RUNNING) {
            return
        }
        _gameState.value = GameState.PAUSE
    }

    /**
     * 继续游戏
     */
    fun resume() {
        if (_gameState.value != GameState.PAUSE) {
            return
        }
        _gameState.value = GameState.RUNNING
    }

还有啥

拓展函数确实是一个很好用的东西,很多通用的转换操作可以定义成拓展函数,用起来贼舒服。

/**
 * 将dp值转换为px
 */
val Number.toPxFloat: Float
    get() {
        val r: Resources = Resources.getSystem()
        val px =
            TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), r.displayMetrics)
        return px
    }

/**
 * 将dp值转换为px
 */
val Number.toPx: Int
    get() = toPxFloat.roundToInt()

/**
 * 将px值转换为dp
 */
val Number.toDpFloat: Float
    get() {
        val scale: Float = Resources.getSystem().displayMetrics.density
        return (this.toFloat() * scale + 0.5f)
    }

/**
 * 将px值转换为dp
 */
val Number.toDp: Int
    get() = toDpFloat.roundToInt()

/**
 * 使用一个Int对来取二维数组中的元素
 */
fun <T> Array<Array<T>>.getByPair(pair: Pair<Int, Int>) = this[pair.first][pair.second]

JetPack Compose发布了,也许有机会我会重构下这个小游戏的界面,你可以不抱期待等等看。


  1. 本图片由笑果图床 提供支持。