一款比较经典的休闲小游戏,之前在 B 站看 java 视频的时候看到了用 java GUI 实现贪吃蛇,就想着用 Android 写一个出来,语言用的 kotlin 写的比较菜,程序还有几个小问题,文章尾部会贴出源码与参考连接,有什么问题欢迎大家指正。


目录

  • 一、绘制页面
  • 界面绘制
  • 静态蛇绘制
  • 二、让蛇动起来
  • 三、按键控制蛇的方向与游戏状态
  • 四、蛇吃食物长大并更新得分
  • 五、游戏失败
  • 六、音乐控制
  • 七、排行榜
  • 小问题(已解决)
  • 源码
  • 参考链接


先上个最后完成品的效果图

android程序设计简单案例 android程序设计小游戏_android

一、绘制页面

最终视图

android程序设计简单案例 android程序设计小游戏_游戏_02

界面绘制

  1. activity_main.xml 中的控件一共分为两个,我将按键单独提取出复合成一个控件,背景、蛇、食物自定义成一个 view
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.yunyan.snake.widget.BackgroundView
        android:id="@+id/backgroundView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/black"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.yunyan.snake.widget.KeyView
        android:id="@+id/controlView"
        android:layout_width="match_parent"
        android:layout_height="210dp"
        android:layout_margin="20dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
  1. 先来看一下 按键 的复合控件 view_key.xml 视图

用 Button 按钮来当 上下左右 与 开始 / 暂停 按键

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_gravity="center_vertical"
    android:layout_height="210dp">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="210dp"
        android:layout_height="210dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <Button
            android:id="@+id/keyView_btn_up"
            android:layout_width="70dp"
            android:layout_height="70dp"
            android:background="@drawable/select_up"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/keyView_btn_left"
            android:layout_width="70dp"
            android:layout_height="70dp"
            android:background="@drawable/select_left"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/keyView_btn_up" />

        <Button
            android:id="@+id/keyView_btn_right"
            android:layout_width="70dp"
            android:layout_height="70dp"
            android:background="@drawable/select_right"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/keyView_btn_up" />

        <Button
            android:id="@+id/keyView_btn_down"
            android:layout_width="70dp"
            android:layout_height="70dp"
            android:background="@drawable/select_down"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/keyView_btn_left" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    <Button
        android:id="@+id/keyView_btn_switch"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@drawable/select_pause"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
  1. 为了让按键视觉效果更好 定义 selector 设置 每个按钮图片的 state_pressed 以实现按压效果

这里用按键 上 的文件来举例

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:state_pressed="true" android:drawable="@drawable/ic_up_pressed_white"/>

    <item android:state_pressed="false" android:drawable="@drawable/ic_up_white"/>

</selector>
  1. 定义 KeyView 继承 FrameLayout 并实现 View.OnClickListener 接口

初始化相关的代码这里就不贴了,详细的可以点击文章尾部的 Github 源码

class KeyView(context: Context, attributeSet: AttributeSet) :

	private lateinit var mBtnUp: Button
	......
	private lateinit var mBtnSwitch: Button
	
	init {
        init()
    }
     private fun init() {
        val inflate = inflate(context, R.layout.view_key, this)
        mBtnUp = inflate.findViewById(R.id.keyView_btn_up)
        ......
        mBtnUp.setOnClickListener(this)
        ......
    }
    FrameLayout(context, attributeSet), View.OnClickListener {
       override fun onClick(v: View?) {
       }
    }

静态蛇绘制

  1. 按键视图完成现在来进行 蛇、食物的绘制

新建 BackgroundView 继承 View

class BackgroundView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
}
  1. 既然要绘制肯定需要画笔

定义三个全局变量画笔,进行延迟初始化

private lateinit var mPaintHead: Paint
private lateinit var mPaintBody: Paint
private lateinit var mPaintFood: Paint
  1. 蛇的身体可以看做是一个数组

所以我们定义两个数组存储 蛇 的x,y坐标,在定义一个变量蛇的默认长度
随机食物使用 float 类型变量存储坐标

private lateinit var mSnakeX: FloatArray
private lateinit var mSnakeY: FloatArray

private var mFoodX = 0f
private var mFoodY = 0f

/**
  * 默认蛇身长度
  */
private val DEFAULT_LENGTH = 2

private var mSnakeLength = 2
  1. 变量设置完成还需要进行初始化
init {
        init()
	}

    private fun init() {
        mPaintHead = Paint() 
        mPaintHead.isAntiAlias = true
        mPaintHead.color = resources.getColor(R.color.head, null)
        ......
        mSnakeX = FloatArray(800)
        mSnakeY = FloatArray(800)
        mSnakeX[0] = 750f
        mSnakeY[0] = 500f
        mFoodX = 25 * Random().nextInt(15).toFloat()
        mFoodY = 25 * Random().nextInt(15).toFloat()
    }
  1. 开始绘制蛇身与食物,蛇的每一截长宽为 25,食物也为25
//  绘制蛇身
 for (i in mSnakeLength downTo 1) {
     mSnakeX[i] = mSnakeX[i - 1]
     mSnakeY[i] = mSnakeY[i - 1]
     canvas?.drawOval(mSnakeX[i],mSnakeY[i],mSnakeX[i] + 25,mSnakeY[i] + 25,mPaintBody)
}
//  绘制蛇头
canvas?.drawRect(mSnakeX[0], mSnakeY[0], mSnakeX[0] + 25, mSnakeY[0] + 25, mPaintHead)
//  绘制食物
canvas?.drawOval(mFoodX, mFoodY, mFoodX + 25, mFoodY + 25, mPaintFood)

二、让蛇动起来

  1. 定义枚举

DirectionStateEnum 用来记录方向
GameStateEnum 用来记录游戏状态

enum class DirectionStateEnum {
    UP,
    DOWN,
    LEFT,
    RIGHT
}
enum class GameStateEnum {
    START,
    PAUSE,
    STOP
}
  1. BackgroundView 中设置方向变量默认向右 ,游戏状态为停止
private var mDirectionEnum = DirectionStateEnum.RIGHT
private var mGameState = GameStateEnum.STOP
  1. 定义一个 定时器 与 handler

定时器每个 0.1s 向 handler 发送消息并调用 invalidate() 重新绘制

/**
     * 移动蛇
     */
    private fun moveSnake() {
        @SuppressLint("HandlerLeak") val mHandler: Handler = object : Handler() {
            override fun handleMessage(msg: Message) {
                if (msg.what == 99 && mGameState === GameStateEnum.START) {
                    judgmentDirection()
                    invalidate()
                }
            }
        }
        // 定时器,每隔 0.1s向 handler 发送消息
        Timer().schedule(object : TimerTask() {
            override fun run() {
                val message = Message()
                message.what = 99
                mHandler.sendMessage(message)
            }
        }, 0, 100)
    }
/**
     * 判断蛇头方向
     */
    private fun judgmentDirection() {
        when (mDirectionEnum) {
            DirectionStateEnum.UP -> {
                // 超过屏幕上侧,从下恻出
                mSnakeY[0] = mSnakeY[0] - 25
                if (mSnakeY[1] <= 0) mSnakeY[0] = measuredHeight.toFloat()
            }
            DirectionStateEnum.DOWN -> {
                // 超过屏幕下侧,从上恻出
                mSnakeY[0] = mSnakeY[0] + 25
                if (mSnakeY[0] > measuredHeight) mSnakeY[0] = 0f
            }
            DirectionStateEnum.LEFT -> {
                // 超过屏幕左侧,从右恻出
                mSnakeX[0] = mSnakeX[0] - 25
                if (mSnakeX[1] <= 0) mSnakeX[0] = measuredWidth.toFloat()
            }
            DirectionStateEnum.RIGHT -> {
                // 超过屏幕右侧,从左恻出
                mSnakeX[0] = mSnakeX[0] + 25
                if (mSnakeX[0] > measuredWidth) mSnakeX[0] = 0f
            }
        }
    }

三、按键控制蛇的方向与游戏状态

  1. 新建 IKeyData 接口用作数据传递,并在 MainActivity 中实现接口并重写方法
interface IKeyData {

    /**
     * 获取游戏状态
     */
    fun gameState(gameState: GameStateEnum)

    /**
     * 获取蛇头方向
     */
    fun direction(directionState: DirectionStateEnum)

}
override fun gameState(gameState: GameStateEnum) {
        if (gameState == GameStateEnum.STOP) {
            mKeyView.gameOver()
        } else {
            mBackgroundView.setGameState(gameState)
        }
    }

    override fun direction(directionState: DirectionStateEnum) {
        mBackgroundView.setDirection(directionState)
    }
  1. BackgroundView 中新建方法用作获取游戏状态与蛇头方向并判断蛇头方向是否与上一次方向相反
fun setGameState(gameStateEnum: GameStateEnum) {
        this.mGameState = gameStateEnum
    }

	/**
     * 设置蛇头方向
     */
    fun setDirection(directionState: DirectionStateEnum) {
        if (isDirectionContrary(directionState)) {
            gameOver()
            Toast.makeText(context, "方向相反,游戏失败!", Toast.LENGTH_SHORT).show()
        }
        if (mGameState == GameStateEnum.START) {
            this.mDirectionEnum = directionState
        }
    }
/**
     * 判断按键方向是否与所前进方向相反
     */
    private fun isDirectionContrary(directionState: DirectionStateEnum): Boolean {
        when (directionState) {
            DirectionStateEnum.UP -> {
                if (this.mDirectionEnum == DirectionStateEnum.DOWN) return true
            }
            DirectionStateEnum.DOWN -> {
                if (this.mDirectionEnum == DirectionStateEnum.UP) return true
            }
            DirectionStateEnum.LEFT -> {
                if (this.mDirectionEnum == DirectionStateEnum.RIGHT) return true
            }
            DirectionStateEnum.RIGHT -> {
                if (this.mDirectionEnum == DirectionStateEnum.LEFT) return true
            }
        }
        return false
    }
  1. KeyView 中实现了 View.OnClickListener 接口用作点击监听,
    并将游戏状态与方向传递出去
override fun onClick(v: View?) {
        val id = v?.id
        if (mGameState == GameStateEnum.START) {
            when (id) {
                R.id.keyView_btn_up -> {
                    mDirection = DirectionStateEnum.UP
                }
                R.id.keyView_btn_down -> {
                    mDirection = DirectionStateEnum.DOWN
                }
                R.id.keyView_btn_left -> {
                    mDirection = DirectionStateEnum.LEFT
                }
                R.id.keyView_btn_right -> {
                    mDirection = DirectionStateEnum.RIGHT
                }
            }
        }
        if (id == R.id.keyView_btn_switch) {
            if (mGameState == GameStateEnum.STOP || mGameState == GameStateEnum.PAUSE) {
                mGameState = GameStateEnum.START
                mBtnSwitch.setBackgroundResource(R.drawable.select_start)
            } else {
                mBtnSwitch.setBackgroundResource(R.drawable.select_pause)
                mGameState = GameStateEnum.PAUSE
            }
            //  更新游戏状态
            mIKeyData.gameState(mGameState)
        }
        //  将方向传到背景视图中
        mIKeyData.direction(mDirection)
    }

四、蛇吃食物长大并更新得分

BackgroundView 中的 onDraw 方法中判断是否吃到食物,吃到食物蛇身+1,食物坐标随机,得分+1并更新分数。

原先使用 if (mSnakeX[0] == mFoodX && mSnakeY[0] == mFoodY) 判断,由于蛇与食物坐标均采用浮点类型,食物随机坐标会出现微小偏差,从而无法与蛇头坐标完全匹配,进而导致吃食物失败现象。
故采用蛇头是否处于食物坐标±15范围内进行判断。

//  判断是否吃到食物
if ((mSnakeX[0] in mFoodX - 15..mFoodX + 15) && (mSnakeY[0] in mFoodY - 15..mFoodY + 15)) {
	mFoodX = 25 * Random().nextInt(measuredWidth / 25).toFloat()
    mFoodY = 25 * Random().nextInt(measuredHeight / 25).toFloat()
    mSnakeLength++
    // 计分
    mScore = mSnakeLength - DEFAULT_LENGTH
    // 刷新分数
	mIScore.refreshScore(mScore)
	// 分数等于最大长度则游戏通关
    if (mScore == MAX_LENGTH - DEFAULT_LENGTH) {
    	gameClearance()
    }
}

五、游戏失败

游戏失败蛇头坐标重新赋值,游戏状态设置 停止,蛇身长度设回默认长度,将游戏状态传递出去通知按键视图更改

/**
     * 游戏失败
     */
    private fun gameOver() {
        mGameState = GameStateEnum.STOP
        mDirectionEnum = DirectionStateEnum.RIGHT
        mIKeyData.gameState(mGameState)
        mSnakeLength = DEFAULT_LENGTH
        mSnakeX[0] = 750f
        mSnakeY[0] = 500f
        initData()
        invalidate()
    }

六、音乐控制

使用 switch 控件进行控制音乐播放

<androidx.appcompat.widget.SwitchCompat
        android:id="@+id/atyMain_switch_music"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="top|end"
        android:checked="false"
        android:layout_margin="30dp"
        android:thumb="@drawable/select_music"
        app:layout_constraintEnd_toStartOf="@+id/atyGame_tv_score"
        app:layout_constraintTop_toTopOf="parent" />

音乐在代码中已删除,请自行放在 res/raw 文件夹下
音乐播放状态使用 SharedPreferences 进行存储

private fun playMusic() {
        val isPlay: Boolean = SpUtils.getInstance(this).getBoolean("isPlay", false)
        switchMusic.isChecked = isPlay
        if (isPlay) {
			MusicUtils.playSound(this, R.raw.music)
        }
        switchMusic.setOnCheckedChangeListener { buttonView: CompoundButton, isChecked: Boolean ->
            if (isChecked) {
				MusicUtils.playSound(buttonView.context, R.raw.music)
            } else {
                MusicUtils.release()
            }
            SpUtils.getInstance(buttonView.context).put("isPlay", isChecked)
        }
    }

播放音乐使用 MediaPlayer 进行控制

fun playSound(context: Context?, rawResId: Int) {
        release()
        mPlayer = MediaPlayer.create(context, rawResId)
        mPlayer!!.start()
    }

    fun pause() {
        if (mPlayer != null && mPlayer!!.isPlaying) {
            mPlayer!!.pause()
            isPause = true
        }
    }

    fun start() {
        if (mPlayer != null && isPause) {
            mPlayer!!.start()
            isPause = false
        }
    }

    fun release() {
        if (mPlayer != null) {
            mPlayer!!.release()
            mPlayer = null
        }
    }

注意在 GameActivity 的生命周期中对音乐播放状态进行控制

override fun onPause() {
        MusicUtils.pause()
        super.onPause()
    }

    override fun onStart() {
        MusicUtils.start()
        super.onStart()
    }

    override fun onDestroy() {
        MusicUtils.release()
        super.onDestroy()
    }

七、排行榜

使用 RecyclerView 进行展示,数据使用 SQL 数据库进行存储(使用 SQLite 保存数据)。
新建数据库帮助类 MyDBOpenHelper

class MyDBOpenHelper(context: Context?) :
    SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {

    companion object {
        private const val DATABASE_NAME = "snake.db"
        private const val DATABASE_VERSION = 1
    }

    override fun onCreate(db: SQLiteDatabase) {
        val sql =
            "CREATE TABLE ranking(id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,name VARCHAR(20) NOT NULL,score INTEGER NOT NULL)"
        db.execSQL(sql)
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {}

}

对应存储数据的数据类 UserDao

data class UserDao(var name: String, var score: Int)

进行数据增加与查询功能的工具类 RankingDBManager

/**
     * 插入数据
     */
    fun insert(userDao: UserDao) {
        val db = mDB!!.writableDatabase
        val sql = "INSERT INTO ranking(name,score) VALUES(?,?)"
        db.execSQL(
            sql, arrayOf<Any>(userDao.name, userDao.score)
        )
        db.close()
    }

    /**
     * 查询数据
     */
    fun query(): List<UserDao> {
        val db = mDB!!.readableDatabase
        // 进行数据查询并按照分数降序排序
        val sql = "SELECT * FROM ranking ORDER BY score DESC"
        val cursor = db.rawQuery(sql, null)
        val list: MutableList<UserDao> = ArrayList()
        if (cursor.count > 0) {
            cursor.moveToFirst()
            for (i in 0 until cursor.count) {
                val name = cursor.getString(cursor.getColumnIndexOrThrow("name"))
                val score = cursor.getInt(cursor.getColumnIndexOrThrow("score"))
                list.add(UserDao(name, score))
                cursor.moveToNext()
            }
        }
        cursor.close()
        db.close()
        return list
    }

既然使用 RecyclerView 进行展示,肯定要有其适配器用于定义数据显示方式(使用 RecyclerView 创建动态列表

...
	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view: View = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_rv_ranking, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.tvName.text = mList[position].name
        holder.tvScore.text = mList[position].score.toString()
        val score = position + 1
        holder.tvRanking.text = score.toString()
    }
    
    override fun getItemCount(): Int {
        return mList.size
    }
    ...

展示视图中每个子项的布局 item_rv_ranking

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="@color/purple_700" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/itemRv_ranking_ranking"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="18dp"
            tools:text="1" />

        <TextView
            android:id="@+id/itemRv_ranking_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="18dp"
            tools:text="user" />

        <TextView
            android:id="@+id/itemRv_ranking_score"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="18dp"
            tools:text="score" />

    </LinearLayout>

</LinearLayout>

设置适配器,进行排行榜数据展示

val ranking = findViewById<RecyclerView>(R.id.atyRanking_rv)
	val list: List<UserDao> = RankingDBManager.getInstance(this).query()
	val rankingAdapter = RankingAdapter(list)
	ranking.layoutManager = LinearLayoutManager(this)
	ranking.adapter = rankingAdapter

小问题(已解决)

代码写的比较菜,目前还有个小问题

  • 当蛇头与食物碰撞时食物不消失但多碰撞几次就消失(食物随机坐标会出现与蛇头坐标不完全匹配现象)

源码

完整代码:Github 源码

参考链接

自定义视图组件Android invalidate()方法分析[ 狂神说Java ] GUI编程入门到游戏实战