Android原生控件实现2048游戏-Kotlin版

  一直想写个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数量的游戏模式为例

\begin{bmatrix} 0 & 0 & 0 & 2 \\ 2 & 0 & 0 & 4 \\ 0 & 0 & 4 & 16 \\ 0 & 0 & 16 & 32 \end{bmatrix} \tag{此数组对应的游戏界面如下}

  如果我们这时候左滑一下,那么数组就会变成下面这样
\begin{bmatrix} 2 & 0 & 0 & 0 \\ 2 & 4 & 0 & 0 \\ 4 & 16 & 0 & 0 \\ 16 & 32 & 0 & 0 \end{bmatrix}

  如果再往下滑动,两个相同的数碰在一起会变相加

\begin{bmatrix} 0 & 0 & 0 & 0 \\ 4 & 4 & 0 & 0 \\ 4 & 16 & 0 & 0 \\ 16 & 32 & 0 & 0 \end{bmatrix} \tag{对应游戏界面如下}

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

\begin{bmatrix} 0 & 0 & 0 & 0 \\ 2 & 4 & 1 & 0 \\ 2 & 0 & 0 & 8 \\ 0 & 0 & 16 & 8 \end{bmatrix} \tag{对应游戏界面如下}

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或其他数量,可以自定义障碍物的数量继续增大难度。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

友情链接更多精彩内容