一款比较经典的休闲小游戏,之前在 B 站看 java 视频的时候看到了用 java GUI 实现贪吃蛇,就想着用 Android 写一个出来,语言用的 kotlin 写的比较菜,程序还有几个小问题,文章尾部会贴出源码与参考连接,有什么问题欢迎大家指正。
目录
- 一、绘制页面
- 界面绘制
- 静态蛇绘制
- 二、让蛇动起来
- 三、按键控制蛇的方向与游戏状态
- 四、蛇吃食物长大并更新得分
- 五、游戏失败
- 六、音乐控制
- 七、排行榜
- 小问题(已解决)
- 源码
- 参考链接
先上个最后完成品的效果图
一、绘制页面
最终视图
界面绘制
-
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>
- 先来看一下 按键 的复合控件
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>
- 为了让按键视觉效果更好 定义
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>
- 定义
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?) {
}
}
静态蛇绘制
- 按键视图完成现在来进行 蛇、食物的绘制
新建
BackgroundView
继承 View
class BackgroundView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
}
- 既然要绘制肯定需要画笔
定义三个全局变量画笔,进行延迟初始化
private lateinit var mPaintHead: Paint
private lateinit var mPaintBody: Paint
private lateinit var mPaintFood: Paint
- 蛇的身体可以看做是一个数组
所以我们定义两个数组存储 蛇 的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
- 变量设置完成还需要进行初始化
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()
}
- 开始绘制蛇身与食物,蛇的每一截长宽为 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)
二、让蛇动起来
- 定义枚举
DirectionStateEnum
用来记录方向GameStateEnum
用来记录游戏状态
enum class DirectionStateEnum {
UP,
DOWN,
LEFT,
RIGHT
}
enum class GameStateEnum {
START,
PAUSE,
STOP
}
- 在
BackgroundView
中设置方向变量默认向右 ,游戏状态为停止
private var mDirectionEnum = DirectionStateEnum.RIGHT
private var mGameState = GameStateEnum.STOP
- 定义一个 定时器 与 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
}
}
}
三、按键控制蛇的方向与游戏状态
- 新建
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)
}
- 在
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
}
- 在
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编程入门到游戏实战