安卓小游戏:飞机大战

前言

前面写了十二篇自定义view的博客,说实话写的还是有点无聊了,最近调整了一下,觉得还是要对开发有热情,就写了点小游戏,现在抽时间把博客也写一写,希望读者喜欢。

需求

这里就是飞机大战啊,很多人小时候都玩过,我这也比较简单还原了一下。核心思想如下:

  • 1,载入界面配置,设置游戏信息
  • 2,载入精灵配置,获取飞机、子弹、敌人掩图
  • 3,启动手势控制逻辑
  • 4,启动游戏controller,定时刷新处理逻辑

效果图

效果图还阔以吧,就是掩图马虎了,直接再Android Studio里面找的vector的image,玩起来还是有点小时候的感觉。

Android联网版飞机大战功能团 安卓飞机大战_飞机大战

代码

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.pow
import kotlin.math.sqrt


/**
 * 飞机大战 GameView
 *
 * 1,载入界面配置,设置游戏信息
 * 2,载入精灵配置,获取飞机、子弹、敌人掩图
 * 3,启动手势控制逻辑
 * 4,启动游戏controller,定时刷新处理逻辑
 *
 * @author silence
 * @date 2023-01-17
 */
class AirplaneFightGameView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
): View(context, attributeSet, defStyleAttr) {

    companion object{

        // 游戏更新间隔,一秒20次
        const val GAME_FLUSH_TIME = 50L
        // 敌人添加隔时间
        const val ADD_ENEMY_TIME = 1000L
        // 敌人更新隔时间
        const val UPDATE_ENEMY_TIME = 200L
        // 敌人移动距离
        const val ENEMY_MOVE_DISTANCE = 50
        // 子弹添加隔时间
        const val ADD_BULLET_TIME = 150L
        // 子弹更新隔时间
        const val UPDATE_BULLET_TIME = 100L
        // 子弹移动距离
        const val BULLET_MOVE_DISTANCE = 50

        // 碰撞间隔
        const val COLLISION_DISTANCE = 100

        // 距离计算公式
        fun getDistance(x1: Int, y1: Int, x2: Int, y2: Int): Float {
            return sqrt(((x1 - x2).toDouble().pow(2.0)
                    + (y1 - y2).toDouble().pow(2.0)).toFloat())
        }
    }

    // 默认生命值大小
    private val mDefaultLiveSize: Int

    // 得分
    private var mScore: Int = 0

    // 飞机
    private val mAirPlane: Sprite = Sprite(0, 0)
    private val mAirPlaneMask: Bitmap?

    // 子弹序列
    private val mBulletList = ArrayList<Sprite>()
    private val mBulletMask: Bitmap?

    // 敌人序列
    private val mEnemyList = ArrayList<Sprite>()
    private val mEnemyMask: Bitmap?

    // 游戏控制器
    private val mGameController = GameController(this)

    // 画笔
    private val mPaint = Paint().apply {
        color = Color.WHITE
        strokeWidth = 3f
        style = Paint.Style.STROKE
        flags = Paint.ANTI_ALIAS_FLAG
        textAlign = Paint.Align.CENTER
        textSize = 30f
    }

    // 上一个触摸点X的坐标
    private var mLastX = 0f

    init {
        // 读取配置
        val typedArray =
            context.obtainStyledAttributes(attributeSet, R.styleable.AirplaneFightGameView)

        mDefaultLiveSize =
            typedArray.getInteger(R.styleable.AirplaneFightGameView_liveSize, 3)

        // 得到的Bitmap为空
//        var resourceId =
//        typedArray.getResourceId(R.styleable.AirplaneFightGameView_airplane, 0)
//        mAirPlaneMask = if (resourceId != 0) {
//            BitmapFactory.decodeResource(resources, resourceId)
//        }else null

        // 注意Drawable也有类型
//        var drawable = typedArray.getDrawable(R.styleable.AirplaneFightGameView_airplane)
//        mAirPlaneMask = (drawable as BitmapDrawable).bitmap     //vector的xml不是BitmapDrawable
//        mAirPlaneMask = (drawable as VectorDrawable).toBitmap() //需要版本支持

        // 飞机掩图
        var drawable = typedArray.getDrawable(R.styleable.AirplaneFightGameView_airplane)
        mAirPlaneMask = if (drawable != null) drawableToBitmap(drawable, 2) else null

        // 子弹掩图
        drawable = typedArray.getDrawable(R.styleable.AirplaneFightGameView_bullet)
        mBulletMask = if (drawable != null) drawableToBitmap(drawable) else null

        // 敌人掩图
        drawable = typedArray.getDrawable(R.styleable.AirplaneFightGameView_enemy)
        mEnemyMask = if (drawable != null) drawableToBitmap(drawable, 2) else null

        typedArray.recycle()
    }

    private fun drawableToBitmap(drawable: Drawable, size: Int = 1): Bitmap? {
        val w = drawable.intrinsicWidth * size
        val h = drawable.intrinsicHeight * size
        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)
        // 开始游戏
        load(w, h)
    }

    // 加载
    private fun load(w: Int, h: Int) {
        mGameController.removeMessages(0)
        // 设置好飞机位置,坐标未中心坐标,方便计算碰撞
        mAirPlane.bitmap = mAirPlaneMask
        mAirPlane.live = mDefaultLiveSize
        mAirPlane.posX = w / 2 - mAirPlaneMask!!.width / 2
        mAirPlane.posY = h - mAirPlaneMask.height / 2 - 50
        mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
    }

    // 重新加载
    private fun reload(w: Int, h: Int) {
        mGameController.removeMessages(0)
        // 清空界面
        mBulletList.clear()
        mEnemyList.clear()
        // 重置好飞机位置、生命值、得分
        mScore = 0
        mAirPlane.live = mDefaultLiveSize
        mAirPlane.posX = w / 2 - mAirPlaneMask!!.width / 2
        mAirPlane.posY = h - mAirPlaneMask.height / 2 - 50
        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)
        // 绘制顶部信息
        canvas.drawText("生命值:${mAirPlane.live}, 当前得分:$mScore",
            (width / 2).toFloat(), 50f, mPaint)

        // 绘制敌人
        for (enemy in mEnemyList) {
            canvas.drawBitmap(mEnemyMask!!,
                enemy.posX.toFloat() - mEnemyMask.width / 2,
                enemy.posY.toFloat() - mEnemyMask.height / 2, mPaint)
        }

        // 绘制子弹
        for (bullet in mBulletList) {
            canvas.drawBitmap(mBulletMask!!,
                bullet.posX.toFloat()- mBulletMask.width / 2,
                bullet.posY.toFloat()- mBulletMask.height / 2, mPaint)
        }

        // 绘制飞机
        canvas.drawBitmap(mAirPlaneMask!!,
            mAirPlane.posX.toFloat() - mAirPlaneMask.width / 2,
            mAirPlane.posY.toFloat() - mAirPlaneMask.height / 2, mPaint)
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when(event.action) {
            MotionEvent.ACTION_DOWN -> {
                mLastX = event.x
            }
            MotionEvent.ACTION_MOVE -> {
                val len = event.x - mLastX
                val preX = mAirPlane.posX + len
                if (preX > mAirPlaneMask!!.width / 2 &&
                        preX < (width - mAirPlaneMask.width / 2)) {
                    mAirPlane.posX += len.toInt()
                    invalidate()
                }
                mLastX = event.x
            }
            MotionEvent.ACTION_UP -> {}
        }
        return true
    }

    private fun gameOver() {
        AlertDialog.Builder(context)
            .setTitle("继续游戏")
            .setMessage("请点击确认继续游戏")
            .setPositiveButton("确认") { _, _ -> reload(width, height) }
            .setNegativeButton("取消", null)
            .create()
            .show()
    }

    // kotlin自动编译为Java静态类,控件引用使用弱引用
    class GameController(view: AirplaneFightGameView): Handler(Looper.getMainLooper()){
        // 控件引用
        private val mRef: WeakReference<AirplaneFightGameView> = WeakReference(view)
        // 子弹更新频率限制
        private var bulletCounter = 0
        // 子弹出现频率控制
        private var bulletUpdateCounter = 0
        // 敌人更新频率控制
        private var enemyCounter = 0
        // 敌人出现频率控制
        private var enemyUpdateCounter = 0
        // 游戏结束标志
        internal var isGameOver = false

        override fun handleMessage(msg: Message) {
            mRef.get()?.let { gameView ->
                // 移动已有子弹
                bulletCounter++
                if (bulletCounter == (UPDATE_BULLET_TIME / GAME_FLUSH_TIME).toInt()) {
                    var mark = -1
                    for (i in 0 until gameView.mBulletList.size) {
                        val bullet = gameView.mBulletList[i]
                        bullet.posY -= BULLET_MOVE_DISTANCE
                        // 子弹出界
                        if (bullet.posY < 0) mark = i
                    }

                    // 移除出界子弹
                    if (mark >= 0) gameView.mBulletList.remove(gameView.mBulletList[mark])

                    bulletCounter = 0
                }

                // 移动敌人,并验证碰撞
                enemyUpdateCounter++
                if (enemyUpdateCounter == (UPDATE_ENEMY_TIME / GAME_FLUSH_TIME).toInt()) {
                    // 可能同时有子弹和敌人碰撞
                    val removeEnemyList = ArrayList<Sprite>()
                    val removeBulletList = ArrayList<Sprite>()
                    // 敌人和飞机碰撞标志
                    var mark = -1
                    for (i in 0 until gameView.mEnemyList.size) {
                        val enemy = gameView.mEnemyList[i]
                        enemy.posY += ENEMY_MOVE_DISTANCE

                        // 验证和子弹碰撞
                        for (j in 0 until gameView.mBulletList.size) {
                            val bullet = gameView.mBulletList[j]
                            if (getDistance(bullet.posX, bullet.posY, enemy.posX, enemy.posY)
                                <= COLLISION_DISTANCE) {
                                // 发生碰撞
                                removeEnemyList.add(enemy)
                                removeBulletList.add(bullet)
                                gameView.mScore++
                            }
                        }

                        //验证和飞机碰撞
                        if (getDistance(gameView.mAirPlane.posX, gameView.mAirPlane.posY,
                                enemy.posX, enemy.posY)
                            <= COLLISION_DISTANCE) {
                            // 发生碰撞
                            mark = i
                            if (--gameView.mAirPlane.live <= 0) {
                                isGameOver = true
                            }
                        }
                    }

                    // 统一移除
                    for (enemy in removeEnemyList) gameView.mEnemyList.remove(enemy)
                    for (bullet in removeBulletList) gameView.mBulletList.remove(bullet)
                    if (mark >= 0) gameView.mEnemyList.remove(gameView.mEnemyList[mark])

                    enemyUpdateCounter = 0
                }

                enemyCounter++
                if (enemyCounter == (ADD_ENEMY_TIME / GAME_FLUSH_TIME).toInt()) {
                    // 随机生成一个敌人
                    val x = (gameView.mEnemyMask!!.width / 2 +
                            Math.random() * (gameView.width - gameView.mEnemyMask.width)).toInt()
                    gameView.mEnemyList.add(
                        Sprite(x, gameView.mEnemyMask.height / 2, 1, gameView.mEnemyMask))

                    enemyCounter = 0
                }

                bulletUpdateCounter++
                if (bulletUpdateCounter == (ADD_BULLET_TIME / GAME_FLUSH_TIME).toInt()) {
                    // 添加新子弹,横向在飞机上居中
                    gameView.mBulletList.add(Sprite(gameView.mAirPlane.posX,
                        gameView.mAirPlane.posY, 1, gameView.mBulletMask))

                    bulletUpdateCounter = 0
                }

                // 循环发送消息,刷新页面
                gameView.invalidate()
                if (!isGameOver) {
                    gameView.mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
                }else {
                    gameView.gameOver()
                }
            }
        }
    }

    // 坐标使用中心坐标
    data class Sprite(var posX: Int, var posY: Int, var live: Int = 1, var bitmap: Bitmap? = null)

    /**
     * 供外部回收资源
     */
    fun recycle()  {
        mAirPlaneMask?.recycle()
        mBulletMask?.recycle()
        mEnemyMask?.recycle()
        mGameController.removeMessages(0)
    }
}

对应style配置

res -> values -> airplane_fight_game_view_style.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name ="AirplaneFightGameView">
        <attr name="liveSize" format="integer"/>
        <attr name="airplane" format="reference"/>
        <attr name="bullet" format="reference"/>
        <attr name="enemy" format="reference"/>
    </declare-styleable>
</resources>

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

res -> drawable -> ic_airplane.xml

<vector android:height="24dp" android:tint="#01A5FD"
    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="M22,16v-2l-8.5,-5V3.5C13.5,2.67 12.83,2 12,2s-1.5,0.67 -1.5,1.5V9L2,14v2l8.5,-2.5V19L8,20.5L8,22l4,-1l4,1l0,-1.5L13.5,19v-5.5L22,16z"/>
</vector>

res -> drawable -> ic_bullet.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,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z"/>
</vector>

res -> drawable -> ic_enemy.xml

<vector android:height="24dp" android:tint="#DB172F"
    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="M5,16c0,3.87 3.13,7 7,7s7,-3.13 7,-7v-4L5,12v4zM16.12,4.37l2.1,-2.1 -0.82,-0.83 -2.3,2.31C14.16,3.28 13.12,3 12,3s-2.16,0.28 -3.09,0.75L6.6,1.44l-0.82,0.83 2.1,2.1C6.14,5.64 5,7.68 5,10v1h14v-1c0,-2.32 -1.14,-4.36 -2.88,-5.63zM9,9c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM15,9c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1z"/>
</vector>

主要问题

下面简单讲讲吧。

资源加载

这里用了styleable来配置游戏所需的资源,关于styleable我前面转载了一篇博文,别人写的很好,我就不再赘述。在init函数里面加载了生命值和掩图的资源id,注意这里没对id验证,如果没有设置或者设置不对可能会闪退,但是我这小游戏你不设置也没必要玩。。

掩图加载

这里我需要通过资源id拿到掩图资源,并转化为bitmap,这里踩了挺多坑。首先我是通过BitmapFactory对拿到的resourceId直接decode,结果拿到的bitmap为空,为此我还调试了一下,也不好确定原因,原因在后面也出现了,就是我这用的是vector的资源,并不能转换为BitmapDrawable,拿不到bitmap。后面还试了拿到Drawable,强制转换成BitmapDrawable拿到bitmap,也是不行,转成VectorDrawable需要改最低的api,最后还是借助canvas拿到的bitmap。

加载游戏

我这里是在onSizeChanged里面开始的,onSizeChanged会在第一次onMeasure后调用(前提是尺寸不发生变化),这里可以拿到控件宽高,正好对游戏进行一些配置,并发送空消息给handler,开启定时刷新。

游戏逻辑

onSizeChanged后开启定时刷新,游戏逻辑写在handler里就可以了,还可以根据刷新频率稍微控制下飞机、子弹、敌人的频率,我这里用了很多控制变量,当然小游戏可以这么写,复杂点的还是整理下吧。

游戏逻辑无非就是移动、碰撞、死亡几个,很简单,看代码就行,不多讲。这里稍稍注意下对列表数据的移除,我这里写的也不是很好,应该用iterator去移除的。

飞机移动逻辑

这里飞机移动逻辑和游戏逻辑分离了,直接在onTouchEvent中移动飞机,并通过invalidate刷新。飞机移动就没有必要和游戏刷新频率有关了吧,直接invalidate看起来舒服点。

画面绘制

画面绘制没得说,只要把飞机、子弹、敌人绘制出来就行了,逻辑和绘制分离。一开始我还想着又怎么移动,又怎么绘制,使用动画移动子弹什么的,太复杂了,反而不现实。

资源回收

这里用到了bitmap,最好要做下bitmap的回收。

想法的失误

一开始我是想把飞机、子弹、敌人当成控件,游戏当成viewgroup的,然后对控件进行操作,面向对象嘛,也许有道理,但是为什么不直接使用ondraw进行绘制,性能都更好一些。