一直想写个2048的游戏,并且最近正在学习kotlin中,所以就决定用kotlin语言来实现,游戏拥有多种模式,还添加了障碍物来增加游戏难度,趣味性十足,现已经发布到Google Play,欢迎大家多多支持。
下载连接
Google Play:https://play.google.com/store/apps/details?id=com.cheng.app2048
其他下载地址:https://www.pgyer.com/XroD
应用预览




在这里我们仅实现游戏的核心代码,核心代码实现了游戏的界面绘制、滑动、动画、数字结合、撤销等操作
1.实现原理
2048游戏是由N*M个数字方块组成,通过手指滑动来移动方块,如果有相同的数字发生碰撞则合成一个更大的数字,以此类推尽可能得到更大值,直到没有了移动空间,整个界面都被数字充满则游戏结束。从原理中我们很容易得到它的对应模型,一个N*M大小的二维数组,整个游戏就是通过改变二维数组中的数字来控制游戏展示的。我们以4*4数量的游戏模式为例

如果我们这时候左滑一下,那么数组就会变成下面这样
如果再往下滑动,两个相同的数碰在一起会变相加

现在我们知道,数组中0代表不显示数字,不为0显示对应颜色的数字,我们再改进一下,给它加上障碍物,当数字移动时碰到障碍物会停止移动,障碍物的值为1

2.游戏界面

原理清楚了,接下来就是绘制游戏界面,其实就是使用Android原生控件来进行布局,我们这里使用GridLayout,它会自动帮我们初始化子View的位置。如上图所示,整个自定义GridLayout只有两层View,GridLayout为父布局,用于初始时确定BlockView的显示位置,同时也作为游戏界面的背景图;BlockView为自定义View,作为子View是可以通过手指进行滑动的,BlockView比较简单,这里就不再多说了,下面主要看下自定义GridLayout。
-
初始化视图,创建模型
class Game2048 : GridLayout {
/**
* 移动之前时的model
*/
private lateinit var beforeModel: Array<IntArray>
/**
* 记录方块的坐标点,在onLayout时确定,之后不再改变
*/
private lateinit var pointFS: Array<PointF>
/**
* 动画集合,手指抬起时遍历模型,遍历完之后再去执行动画
*/
private val animators = mutableListOf<Animator>()
/**
* 模型中的坐标集合
*/
private val modelPoints = mutableListOf<Point>()
/**
* 值为0的模型坐标集合
*/
private val zeroModelPoints = mutableListOf<Point>()
/**
* 每次结合的方块数值
*/
private val composeNums = mutableListOf<Int>()
/**
* 上一次的mode值,用于返回上次
*/
private val cacheModel = mutableListOf<Array<IntArray>>()
constructor(ctx: Context?) : this(ctx, null)
constructor(ctx: Context?, attrs: AttributeSet?) : this(ctx, attrs, 0)
constructor(ctx: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(ctx, attrs, defStyleAttr) {
mContext = ctx!!
val typedArray = mContext.obtainStyledAttributes(attrs, R.styleable.Game2048)
rowCounts = typedArray.getInteger(R.styleable.Game2048_rows, 0)
if (rowCounts < 2) {
rowCounts = 0
}
columnCounts = typedArray.getInteger(R.styleable.Game2048_columns, 0)
if (columnCounts < 2) {
columnCounts = 0
}
fixedCounts = typedArray.getInteger(R.styleable.Game2048_fixed, 0)
if (fixedCounts < 0) {
fixedCounts = 0
}
typedArray.recycle()
init()
}
private fun init() {
density = mContext.resources.displayMetrics.density
setWillNotDraw(false)
mergerSoundId = soundPool.load(mContext, R.raw.merge, 1)
moveSoundId = soundPool.load(mContext, R.raw.move, 1)
initView()
}
/**
* 初始化视图
*/
private fun initView() {
initData()
produceInitNum()
for (i in 0 until rowCounts) {
for (j in 0 until columnCounts) {
val num = models[i][j]
val blockView: BlockView = if (rowCounts <= 4 && columnCounts <= 4) {
BlockView(mContext, SIZE_LARGE)
} else {
BlockView(mContext, SIZE_SMALL)
}
blockView.setText(num)
tvs.put(i * columnCounts + j, blockView)
addView(blockView)
}
}
}
/**
* 初始化数据
*/
private fun initData() {
models = Array(rowCounts) { IntArray(columnCounts) }
beforeModel = Array(rowCounts) { IntArray(columnCounts) }
tvs = SparseArray(rowCounts * columnCounts)
pointFS = Array(rowCounts * columnCounts) { PointF(0f, 0f) }
rowCount = rowCounts
columnCount = columnCounts
space = if (rowCounts > 5 && columnCounts > 5) {
(density.times(2) + 0.5f).toInt()
} else {
(density.times(5) + 0.5f).toInt()
}
setPadding(space, space, space, space)
}
/**
* 生产初始值
*/
private fun produceInitNum() {
modelPoints.clear()
for (i in 0 until rowCounts) {
for (j in 0 until columnCounts) {
models[i][j] = 0
modelPoints.add(Point(i, j))
}
}
if (rowCounts * columnCounts < fixedCounts + 2) {
return
}
for (i in 0 until fixedCounts + 2) {
val index = random.nextInt(modelPoints.size)
val point = modelPoints[index]
val row = point.x
val col = point.y
if (i < 2) {//产生初始数字
models[row][col] = 2
} else {//产生固定数
models[row][col] = 1
}
modelPoints.removeAt(index)
}
}
}
这段代码主要是用来初始化视图和模型的创建,有几个重要的字段:
- rowCounts: 行数
- columnCounts: 列数
- fixedCounts: 障碍物数量
- models: 数据模型,映射各数字方块,所有的数值变化都是发生在模型中
- pointFS: 记录数字方块的坐标点,在onLayout时确定,之后不再改变
创建模型时使用kotlin的Array创建一个IntArray的数组,IntArray本身即一个Int类型的数组,Array<IntArray>即相当于java中的int[][]。在初始化过程中,init()->initView()->initData()->produceInitNum(),这些过程会分别设置GridLayout的属性、创建模型、填充模型数据、根据模型设置BlockView。
-
测量、布局、绘制
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(widthSpec, heightSpec)
if (rowCounts < 1 || columnCounts < 1) {
return
}
var width = 800
var height = 800
if (View.MeasureSpec.getMode(widthSpec) == View.MeasureSpec.EXACTLY) {
width = View.MeasureSpec.getSize(widthSpec)
}
if (View.MeasureSpec.getMode(heightSpec) == View.MeasureSpec.EXACTLY) {
height = View.MeasureSpec.getSize(heightSpec)
}
if (rowCounts.toFloat() / columnCounts > height.toFloat() / width) {
viewHeight = height
blockSideLength = (viewHeight - space * 2 - rowCounts * 2 * space) / rowCounts
viewWidth = columnCounts * blockSideLength + columnCounts * 2 * space + 2 * space
} else {
viewWidth = width
blockSideLength = (viewWidth - space * 2 - columnCounts * 2 * space) / columnCounts
viewHeight = rowCounts * blockSideLength + rowCounts * 2 * space + 2 * space
}
for (i in 0 until childCount) {
getChildAt(i)?.let {
val layoutParams = it.layoutParams as GridLayout.LayoutParams
layoutParams.setMargins(space, space, space, space)
it.layoutParams = layoutParams
it.measure(View.MeasureSpec.makeMeasureSpec(blockSideLength, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(blockSideLength, View.MeasureSpec.EXACTLY))
}
}
setMeasuredDimension(viewWidth, viewHeight)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (!isLayout) {
for (i in 0 until childCount) {
val pointF = PointF(getChildAt(i).x, getChildAt(i).y)
pointFS[i] = pointF
}
isLayout = true
}
}
override fun onDraw(canvas: Canvas?) {
// super.onDraw(canvas)
paint.isAntiAlias = true
paint.color = Color.parseColor("#BBADA0")
rectF.left = 0f
rectF.top = 0f
rectF.right = viewWidth.toFloat()
rectF.bottom = viewHeight.toFloat()
val rxy = RXY * (density) + 0.5f
canvas?.drawRoundRect(rectF, rxy, rxy, paint)
paint.color = Color.parseColor("#CDC1B4")
for (pointF in pointFS) {
rectF.left = pointF.x
rectF.top = pointF.y
rectF.right = pointF.x + blockSideLength
rectF.bottom = pointF.y + blockSideLength
canvas?.drawRoundRect(rectF, rxy, rxy, paint)
}
}
在
onMeasure()中,我们通过游戏的模式动态改变视图的宽高;onLayout()方法中确定每个数字方块的位置,在接下来的滑动中会用到;onDraw()中绘制游戏界面的背景颜色。这几个过程下来,整个游戏的界面就已经完成了,接下来就是实现滑动逻辑处理
-
触摸事件处理
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
return true
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
}
MotionEvent.ACTION_UP -> {
val currentX = event.x
val currentY = event.y
isMerge = false
animators.clear()
composeNums.clear()
if (Math.sqrt(Math.pow((currentX - downX).toDouble(), 2.0) + Math.pow((currentY - downY).toDouble(), 2.0)) > 20) {
if (currentX - downX > 0) {//右
if (currentY > downY) {//下
if (currentX - downX > currentY - downY) {//右
right()
} else {//下
bottom()
}
} else {
if (currentX - downX > downY - currentY) {//右
right()
} else {//上
top()
}
}
} else {//左
if (currentY > downY) {//下
if (downX - currentX > currentY - downY) {//左
left()
} else {//下
bottom()
}
} else {
if (downX - currentX > downY - currentY) {//左
left()
} else {//上
top()
}
}
}
}
return true
}
}
return true
}
/**
* 向左滑动
*/
private fun left() {
saveBeforeModel()
for (i in 0 until rowCounts) {
var j = 1
var skip = 1
while (j < columnCounts) {
if (models[i][j] > 1) {
if (models[i][j - skip] == 0) {
models[i][j - skip] = models[i][j]
models[i][j] = 0
preTranslateAnimation(i * columnCounts + j, i * columnCounts + j - skip, false, X)
skip++
} else if (models[i][j - skip] != 1) {
if (models[i][j] == models[i][j - skip]) {
models[i][j - skip] = (models[i][j])
models[i][j] = 0
composeNums.add(models[i][j - skip])
preTranslateAnimation(i * columnCounts + j, i * columnCounts + j - skip, true, X)
} else {
if (skip > 1) {
models[i][j - skip + 1] = models[i][j]
models[i][j] = 0
preTranslateAnimation(i * columnCounts + j, i * columnCounts + j - skip + 1, false, X)
}
}
}
} else if (models[i][j] == 1) {
skip = 1
} else {
if (models[i][j - 1] != 1) {
skip++
}
}
//遍历到最后一个且动画集合大于0时执行动画
if (i == rowCounts - 1 && j == columnCounts - 1 && animators.size > 0) {
translateAnimation()
}
j++
}
}
}
/**
* 位移动画预处理
*/
private fun preTranslateAnimation(from: Int, to: Int, isAdd: Boolean, direction: Int) {
val objectAnimator: ObjectAnimator = if (direction == X) {
ObjectAnimator.ofFloat(tvs.get(from), "X", pointFS[to].x)
} else {
ObjectAnimator.ofFloat(tvs.get(from), "Y", pointFS[to].y)
}
objectAnimator.addListener(AnimatorListener(from, to, isAdd))
animators.add(objectAnimator)
if (isAdd) {
isMerge = true
}
}
/**
* 位移动画
*/
private fun translateAnimation() {
isModelChange = true
with(AnimatorSet()) {
playTogether(animators)
duration = ANIMATION_TIME
addListener(AnimatorListener())
start()
}
if (isPlaySound) {
if (isMerge) {//播放合并的声音
soundPool.play(mergerSoundId, 1f, 1f, 0, 0, 1f)
} else {//播放移动的声音
soundPool.play(moveSoundId, 1f, 1f, 0, 0, 1f)
}
}
}
在
onTouchEvent()方法中处理触摸事件,判断滑动方向,这里我们以左滑为例。当向左滑动时,整个数组中数字向左移动,矩阵中每一行的数字将从下标为1的位置开始往左进行判断,如果它的左边数和它相同,则左边的数值乘2而它其本身置为0;如果为0,它们两个的值互换;如果为非0值,那么不会发生变化。这一循环判断直到该行的最后一位。
另外,在需要移动的BlockView上添加平移动画到动画数组中,在遍历完数组中所有需要判断的值后启动位移动画,当位移动画结束之后,需要在模型中为0的位置处随机产生一个2或4的数值,即产生一个新的BlockView,并且为新显示出的BlockView加入一个缩放动画,还可以播放声音。到现在,整个向左滑动的过程执行完毕,这就是一个完整的触摸流程。同理,向上、右、下的滑动逻辑也是一样的,下面是位移动画执行完毕,产生新的BlockView的代码。
-
随机产生新的数字方块
/**
* 随机添加一个数,并改变视图,注意要在之前的位移动画全部执行完成后再执行,否则此方法中的动画会在位移动画之前执行从而影响动美观
*/
private fun changeView() {
//只有当数组发生变化时才会产生新数
if (isModelChange) {
//取出值为0或1的坐标,从中随机取出一个坐标添加一个新值
zeroModelPoints.clear()
for (i in 0 until rowCounts) {
for (j in 0 until columnCounts) {
if (models[i][j] == 0) {
zeroModelPoints.add(Point(i, j))
}
}
}
val position = random.nextInt(zeroModelPoints.size)
val point = zeroModelPoints[position]
zeroModelPoints.removeAt(position)
var newValue = 2
//产生4的概率为20%
if (random.nextInt(10) >= 8) {
newValue = 4
}
val row = point.x
val col = point.y
models[row][col] = newValue
isModelChange = false
saveModel()
cacheModel()
show()
//有新添加的数字,执行缩放动画
ObjectAnimator.ofFloat(tvs.get(row * columnCounts + col), "scaleX", 0f, 1f).setDuration(ANIMATION_TIME).start()
ObjectAnimator.ofFloat(tvs.get(row * columnCounts + col), "scaleY", 0f, 1f).setDuration(ANIMATION_TIME).start()
//填充完最后一个空检查是否game over
if (zeroModelPoints.size == 0) {
if (isGameOver()) {
Toast.makeText(mContext, "game over", Toast.LENGTH_SHORT).show();
}
}
}
}
-
Game Over判断
判断游戏是否结束,只需当整个二维数组的值都不为0时判断相邻两个数值除1外不同即可,可以分别判断数组的竖直方向和水平方向
/**
* 全部填充满时检查是否game over,检查水平方向和竖直方向相邻两个数是否相等,1除外
*/
private fun isGameOver(): Boolean {
//检查横向
for (i in 0 until rowCounts) {
for (j in 1 until columnCounts) {
if (models[i][j] != 1) {
if (models[i][j] == models[i][j - 1]) {
return false
}
}
}
}
//检查竖向
for (i in 0 until columnCounts) {
for (j in 1 until rowCounts) {
if (models[j][i] != 1) {
if (models[j][i] == models[j - 1][i]) {
return false
}
}
}
}
return true
}
-
缓存模型
缓存模型可以让我们拥有返回上一步的功能,需要一个List集合cacheModel存储每一步的模型。在手指滑动的时候先保存一下当前的模型,手指滑动完之后判断最新的模型是否和之前保存的模型是否相同,如果相同说明游戏未发生变化,不需要进行缓存,如果不同则说明游戏发生了变化那么就要在cacheModel中保存先前的模型,在点击撤销的时候从cacheModel中取出模型更新游戏界面。
/**
* 是否缓存模型,只有数据不一样才缓存
*
* @return
*/
private fun isCache(): Boolean {
for (i in 0 until rowCounts) {
if (!Arrays.equals(beforeModel[i], models[i])) {
return true
}
}
return false
}
/**
* 撤销,从缓存中取出模型,取最后一个并显示
*/
fun revoke(): Boolean {
if (cacheModel.size > 0) {
val lastModel = cacheModel[cacheModel.size - 1]
for (i in 0 until rowCounts) {
models[i] = lastModel[i].clone()
}
cacheModel.removeAt(cacheModel.size - 1)
show()
return true
}
return false
}
/**
* 缓存模型
*/
private fun cacheModel() {
if (isCache()) {
if (cacheModel.size == CACHE_COUNTS) {
cacheModel.removeAt(0)
}
val cModel = Array(rowCounts) { IntArray(columnCounts) }
for (i in 0 until rowCounts) {
cModel[i] = beforeModel[i].clone()
}
cacheModel.add(cModel)
}
}
/**
* 保存移动前的model
*/
private fun saveBeforeModel() {
for (i in 0 until rowCounts) {
beforeModel[i] = models[i].clone()
}
}
3.最后
以上是2048游戏的核心代码,通过模型决定游戏界面的显示。封装好之后就可以在布局中使用了,完全可以自定义游戏模式,比如经典的4*4,还可以扩大面板为10*10或其他数量,可以自定义障碍物的数量继续增大难度。
