赞
踩
一款比较经典的休闲小游戏,之前在 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() }
// 绘制蛇身
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
定时器每个 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 源码
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。