安卓小游戏:贪吃蛇

前语

这个是经过自定义View完成小游戏的第二篇,实际上第一篇做起来麻烦点,后边的基本便是照葫芦画瓢了,只要设计下游戏逻辑就行了,技术上不难,想法比较重要。

需求

贪吃蛇,太经典了,小时分在诺基亚上玩了不知道多少回,游戏也很简略,就两个逻辑,一个是吃东西变长,一个是吃到自己逝世。核心思想如下:

  • 1,载入装备,读取游戏信息及掩图
  • 2,启动游戏操控逻辑
  • 3,手势操控切换方向

作用图

这儿就略微演示了一下,就这速度,要演示到逝世估计得一分钟以上了,掩图用的比较low,勉强将就。

安卓小游戏:贪吃蛇

代码

import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.silencefly96.module_views.R
import java.lang.ref.WeakReference
import kotlin.math.abs
/**
 * 贪吃蛇游戏view
 *
 * @author silence
 * @date 2023-02-07
 */
class SnarkGameView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attributeSet, defStyleAttr) {
    companion object{
        // 四个方向
        const val DIR_UP = 0
        const val DIR_RIGHT = 1
        const val DIR_DOWN = 2
        const val DIR_LEFT = 3
        // 游戏更新距离,一秒5次
        const val GAME_FLUSH_TIME = 200L
        // 蛇体移动频率
        const val SNARK_MOVE_TIME = 600L
        // 食物增加距离时刻
        const val FOOD_ADD_TIME = 5000L
        // 食物存活时刻
        const val FOOD_ALIVE_TIME = 10000L
        // 食物闪耀时刻,要比存货时刻长
        const val FOOD_BLING_TIME = 3000L
        // 食物闪耀距离
        const val FOOD_BLING_FREQ = 300L
    }
    // 屏幕划分数量及等分长度
    private val rowNumb: Int
    private var rowDelta: Int = 0
    private val colNumb: Int
    private var colDelta: Int = 0
    // 节点掩图
    private val mNodeMask: Bitmap?
    // 头节点
    private val mHead = Snark(0, 0, DIR_DOWN, null)
    // 尾节点
    private var mTail = mHead
    // 食物数组
    private val mFoodList = ArrayList<Food>()
    // 游戏操控器
    private val mGameController = GameController(this)
    // 画笔
    private val mPaint = Paint().apply {
        color = Color.LTGRAY
        strokeWidth = 1f
        style = Paint.Style.STROKE
        flags = Paint.ANTI_ALIAS_FLAG
        textAlign = Paint.Align.CENTER
        textSize = 30f
    }
    // 上一个触摸点X、Y的坐标
    private var mLastX = 0f
    private var mLastY = 0f
    init {
        // 读取装备
        val typedArray =
            context.obtainStyledAttributes(attributeSet, R.styleable.SnarkGameView)
        // 横竖划分
        rowNumb = typedArray.getInteger(R.styleable.SnarkGameView_rowNumb, 30)
        colNumb = typedArray.getInteger(R.styleable.SnarkGameView_colNumb, 20)
        // 节点掩图
        val drawable = typedArray.getDrawable(R.styleable.SnarkGameView_node)
        mNodeMask = if (drawable != null) drawableToBitmap(drawable) else null
        typedArray.recycle()
    }
    private fun drawableToBitmap(drawable: Drawable): Bitmap? {
        val w = drawable.intrinsicWidth
        val h = drawable.intrinsicHeight
        val config = Bitmap.Config.ARGB_8888
        val bitmap = Bitmap.createBitmap(w, h, config)
        //注意,下面三行代码要用到,否则在View或许SurfaceView里的canvas.drawBitmap会看不到图
        val canvas = Canvas(bitmap)
        drawable.setBounds(0, 0, w, h)
        drawable.draw(canvas)
        return bitmap
    }
    // 完成丈量开端游戏
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        rowDelta = h / rowNumb
        colDelta = w / colNumb
        // 开端游戏
        load()
    }
    // 加载
    private fun load() {
        mGameController.removeMessages(0)
        // 设置贪吃蛇的方位
        mHead.posX = colNumb / 2
        mHead.posY = rowNumb / 2
        mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
    }
    // 重新加载
    private fun reload() {
        mGameController.removeMessages(0)
        // 清空界面
        mFoodList.clear()
        mHead.posX = colNumb / 2
        mHead.posY = rowNumb / 2
        // 蛇体链表收回,让GC经过可达性剖析去收回
        mHead.next = null
        mGameController.isGameOver = false
        mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
    }
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        setMeasuredDimension(getDefaultSize(0, widthMeasureSpec),
            getDefaultSize(0, heightMeasureSpec))
    }
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 制作网格
        for (i in 0..rowNumb) {
            canvas.drawLine(0f, rowDelta * i.toFloat(),
                width.toFloat(), rowDelta * i.toFloat(), mPaint)
        }
        for (i in 0..colNumb) {
            canvas.drawLine(colDelta * i.toFloat(), 0f,
                colDelta * i.toFloat(), height.toFloat(), mPaint)
        }
        // 制作食物
        for (food in mFoodList) {
            if (food.show) canvas.drawBitmap(mNodeMask!!,
                (food.posX + 0.5f) * colDelta - mNodeMask.width / 2,
                (food.posY + 0.5f) * rowDelta - mNodeMask.height / 2, mPaint)
        }
        // 制作蛇体
        var p: Snark? = mHead
        while (p != null) {
            canvas.drawBitmap(mNodeMask!!,
                (p.posX + 0.5f) * colDelta - mNodeMask.width / 2,
                (p.posY + 0.5f) * rowDelta - mNodeMask.height / 2, mPaint)
            p = p.next
        }
    }
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when(event.action) {
            MotionEvent.ACTION_DOWN -> {
                mLastX = event.x
                mLastY = event.y
            }
            MotionEvent.ACTION_MOVE -> {}
            MotionEvent.ACTION_UP -> {
                val lenX = event.x - mLastX
                val lenY = event.y - mLastY
                mHead.dir = if (abs(lenX) > abs(lenY)) {
                    if (lenX >= 0) DIR_RIGHT else DIR_LEFT
                }else {
                    if (lenY >= 0) DIR_DOWN else DIR_UP
                }
                invalidate()
            }
        }
        return true
    }
    private fun gameOver() {
        AlertDialog.Builder(context)
            .setTitle("持续游戏")
            .setMessage("请点击确认持续游戏")
            .setPositiveButton("确认") { _, _ -> reload() }
            .setNegativeButton("取消", null)
            .create()
            .show()
    }
    // kotlin主动编译为Java静态类,控件引证运用弱引证
    class GameController(view: SnarkGameView): Handler(Looper.getMainLooper()){
        // 控件引证
        private val mRef: WeakReference<SnarkGameView> = WeakReference(view)
        // 蛇体移动操控
        private var mSnarkCounter = 0
        // 食物闪耀操控
        private var mFoodCounter = 0
        // 游戏完毕标志
        internal var isGameOver = false
        override fun handleMessage(msg: Message) {
            mRef.get()?.let { gameView ->
                mSnarkCounter++
                if (mSnarkCounter == (SNARK_MOVE_TIME / GAME_FLUSH_TIME).toInt()) {
                    // 移动蛇体
                    var p: Snark? = gameView.mHead
                    var dir = gameView.mHead.dir
                    while (p != null) {
                        // 移动逻辑,会穿过屏幕边界
                        when(p.dir) {
                            DIR_UP -> {
                                p.posY--
                                if (p.posY < 0)  {
                                    p.posY = gameView.rowNumb - 1
                                }
                            }
                            DIR_RIGHT -> {
                                p.posX++
                                if (p.posX >= gameView.colNumb) {
                                    p.posX = 0
                                }
                            }
                            DIR_DOWN -> {
                                p.posY++
                                if (p.posY >= gameView.rowNumb)  {
                                    p.posY = 0
                                }
                            }
                            DIR_LEFT -> {
                                p.posX--
                                if (p.posX < 0) {
                                    p.posX = gameView.colNumb - 1
                                }
                            }
                        }
                        // 逝世逻辑,蛇头撞到身体了
                        if (p != gameView.mHead &&
                            p.posX == gameView.mHead.posX && p.posY == gameView.mHead.posY) {
                            isGameOver = true
                        }
                        // 移动修正方向为上一节的方
                        val temp = p.dir
                        p.dir = dir
                        dir = temp
                        p = p.next
                    }
                    mSnarkCounter = 0
                }
                // 食物操控
                val iterator = gameView.mFoodList.iterator()
                while (iterator.hasNext()) {
                    val food = iterator.next()
                    food.counter++
                    // 食物消失
                    if (food.counter >= (FOOD_ALIVE_TIME / GAME_FLUSH_TIME)) {
                        iterator.remove()
                        continue
                    }
                    // 食物闪耀
                    if (food.counter >= ((FOOD_ALIVE_TIME - FOOD_BLING_TIME) / GAME_FLUSH_TIME)) {
                        food.blingCounter++
                        if (food.blingCounter >= (FOOD_BLING_FREQ / GAME_FLUSH_TIME)) {
                            food.show = !food.show
                            food.blingCounter = 0
                        }
                    }
                    // 食物被吃,增加一节蛇体到尾部
                    if (food.posX == gameView.mHead.posX && food.posY == gameView.mHead.posY) {
                        var x = gameView.mTail.posX
                        var y = gameView.mTail.posY
                        // 在尾部增加
                        when(gameView.mTail.dir) {
                            DIR_UP -> y++
                            DIR_RIGHT -> x--
                            DIR_DOWN -> y--
                            DIR_LEFT -> x++
                        }
                        gameView.mTail.next = Snark(x, y, gameView.mTail.dir,null)
                        gameView.mTail = gameView.mTail.next!!
                        // 移除被吃食物
                        iterator.remove()
                    }
                }
                mFoodCounter++
                if (mFoodCounter == (FOOD_ADD_TIME / GAME_FLUSH_TIME).toInt()) {
                    // 生成食物
                    val x = (Math.random() * gameView.colNumb).toInt()
                    val y = (Math.random() * gameView.rowNumb).toInt()
                    gameView.mFoodList.add(Food(x, y, 0, 0,true))
                    mFoodCounter = 0
                }
                // 循环发送消息,改写页面
                gameView.invalidate()
                if (!isGameOver) {
                    gameView.mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
                }else {
                    gameView.gameOver()
                }
            }
        }
    }
    data class Food(var posX: Int, var posY: Int, var counter: Int, var blingCounter: Int, var show: Boolean)
    data class Snark(var posX: Int, var posY: Int, var dir: Int, var next: Snark? = null)
}

对应style装备

res -> values -> snark_game_view_style.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name ="SnarkGameView">
        <attr name="rowNumb" format="integer"/>
        <attr name="colNumb" format="integer"/>
        <attr name="node" format="reference"/>
    </declare-styleable>
</resources>

蛇体掩图也给一下吧,当然你找点好看的图片代替下会更好!

res -> drawable -> ic_node.xml

<vector android:height="24dp" android:tint="#6F6A6A"
    android:viewportHeight="24" android:viewportWidth="24"
    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
    <path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM17,13h-4v4h-2v-4L7,13v-2h4L11,7h2v4h4v2z"/>
</vector>

主要问题

下面简略讲讲吧,大部分还是和上一篇的飞机大战类似,这儿就讲讲不一样的或许做点弥补吧。

资源加载

资源加载便是从styleable装备里面读取设置,这儿贪吃蛇是完全网格化的游戏,这儿读取了行数和列数,后边把屏幕等分,获取到了行高和列长,转化逻辑得注意下。蛇的掩图逻辑和上一篇博文一致,不细说了。

蛇体移动

蛇体的移动实际就要有一个方向,这儿每一截蛇都是一个节点,构成了一个链表,每个节点的方向都是移动前上一个节点的方向,这样移动起来就有作用了。当然方向的获取也简略,在onTouchEvent中监听DOWN和UP事件就行了,比较起点和终点,看看往哪边滑动的,更改蛇头方向就行,后边会向后传递。

这儿还有个穿墙的问题要更改下,从一边出去会从另一边出来,这儿改下节点的方向和方位就行了。

食物闪耀

食物的操控是经过counter对游戏改写频率计数完成的,超越计数数量就移除食物,抵达闪耀时刻food内部的blingCounter进行计数,在我的设置里是0.5秒回转一下show,这样就出来了闪耀作用。

方位摆放问题

这儿用的坐标都是中心坐标,所以和掩图的宽高有关,在生成方位的时分按中心方位去生成,在onDraw按掩图的宽高来摆放,让掩图中心放在方位上,最终出来的作用就比较好看了。