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

android studio 下载 | 2019-01-21 20:51

一直想写个2048的游戏,并且最近正在学习kotlin中,所以就决定用kotlin语言来实现,游戏拥有多种模式,还添加了障碍物来增加游戏难度,趣味性十足,现已经发布到Google Play,欢迎大家多多支持。

下载连接

Google Play:.cheng.app2048其他下载地址:https://www.icandemy.cn/game2048-release.apk

应用预览

在这里我们仅实现游戏的核心代码,核心代码实现了游戏的界面绘制、滑动、动画、数字结合、撤销等操作

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

如果我们这时候左滑一下,那么数组就会变成下面这样

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

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

原理清楚了,接下来就是绘制游戏界面,其实就是使用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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; j == columnCounts - 1 &amp;&amp; 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游戏的核心代码,通过模型决定游戏界面的显示。封装好之后就可以在布局中使用了,完全可以自定义游戏模式,比如经典的44,还可以扩大面板为1010或其他数量,可以自定义障碍物的数量继续增大难度。

大家都在看

欢迎前往安卓巴士博客区投稿,技术成长于分享

期待巴友留言,共同探讨学习