安卓小游戏:贪吃蛇
前言
这个是通过自定义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按掩图的宽高来摆放,让掩图中心放在位置上,最后出来的效果就比较好看了。