字节取消大小周了,于是周末时间多起来了。虽然想着到处去玩,但现在疫情形势又不好了,于是安心呆在家里当一个肥宅。除了补一补番剧,就是把之前就想过的连连看游戏做出来了。
连连看游戏规则简单,点击两个相同的元素,如果他们能在两次拐弯以内连接起来,那么就可以消除。消除后就会出现空位,可以连接的就更多了。在规定时间内连续操作,直到消除所有元素。
虽然规则比较简单,但真正动手实现一遍还是很费工夫的。游戏既然做好了,那么我水一篇博客不过分吧:>
项目的代码我放到了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
}
游戏数据初始化
游戏数据初始化时,我们要将所有变量置为初始状态。
- 游戏区域 为了计算方便,我们实际存储的游戏区域比显示出来的游戏区域实际上是多一圈的,多一圈空格子,这样边上的元素计算能否连通的时候就不需要特殊处理
- 游戏中,麻将元素应该是成对的,所以我首先选择一些元素,然后随机添加到一个list中,每个添加两份,最后再打乱顺序
- 游戏中,所有需要后面判断和比较差异的复制都应该是深拷贝,否则他们是同一个对象,怎么比都是一样
/**
* 初始化游戏
* @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了。
- 如果当前元素当前方向的下一个元素可以与目标元素拐n次弯相连,那么当前元素可以与目标元素拐n次弯相连
- 如果当前元素非当前方向的下一个元素可以与目标元素拐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发布了,也许有机会我会重构下这个小游戏的界面,你可以不抱期待等等看。
最后修改于 2021-04-25