安卓小游戏:小板弹球

前语

这个是通过自定义View实现小游戏的第三篇,是小时分玩的那种五块钱的游戏机上的,和俄罗斯方块很像,小时分觉得很有意思,就模仿了一下。

需求

这儿的逻辑便是板能把球弹起来,球在磕碰的时分能把顶部的方针打掉,当板没有挡住球,掉到了屏幕下面,游戏就完毕了。中心思维如下:

  • 1,载入装备,读取游戏信息、装备及掩图
  • 2,启动游戏操控逻辑,球体碰到东西有反弹效果
  • 3,手势操控板的左右移动

效果图

效果图现已把游戏的逻辑玩出来了,大致便是这么个玩法,便是我感觉这不像一个游戏,由于小球的初始方向就决议了游戏成果,也许我应该把板的速度和球的方向结合起来,发明不一样。

安卓小游戏:小板弹球

代码

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.*
/**
 * 弹球游戏view
 *
 * 1,载入装备,读取游戏信息、装备及掩图
 * 2,启动游戏操控逻辑,球体碰到东西有反弹效果
 * 3,手势操控板的左右移动
 *
 * @author silence
 * @date 2023-02-08
 */
class BombBallGameView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
): View(context, attrs, defStyleAttr) {
    companion object {
        // 游戏更新间隔,一秒20次
        const val GAME_FLUSH_TIME = 50L
        // 方针移动间隔
        const val TARGET_MOVE_DISTANCE = 20
        // 间隔核算公式
        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())
        }
        // 两点连线视点核算, (x1, y1) 为起点
        fun getDegree(x1: Float, y1: Float, x2: Float, y2: Float): Double {
            // 弧度
            val radians = atan2(y1 - y2, x1 - x2).toDouble()
            // 从弧度转换成视点
            return Math.toDegrees(radians)
        }
    }
    // 板的长度
    private val mLength: Int
    // 行的数量、距离
    private val rowNumb: Int
    private var rowDelta = 0
    // 列的数量、距离
    private val colNumb: Int
    private var colDelta = 0
    // 球的掩图
    private val mBallMask: Bitmap?
    // 方针的掩图
    private val mTargetMask: Bitmap?
    // 方针的原始装备
    private val mTargetConfigList = ArrayList<Sprite>()
    // 方针的集合
    private val mTargetList = ArrayList<Sprite>()
    // 球
    private val mBall = Sprite(0, 0, 0f)
    // 板
    private val mBoard = Sprite(0, 0, 0f)
    // 游戏操控器
    private val mGameController = GameController(this)
    // 上一个触摸点X的坐标
    private var mLastX = 0f
    // 画笔
    private val mPaint = Paint().apply {
        color = Color.WHITE
        strokeWidth = 10f
        style = Paint.Style.STROKE
        flags = Paint.ANTI_ALIAS_FLAG
        textAlign = Paint.Align.CENTER
        textSize = 30f
    }
    init {
        // 读取装备
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BombBallGameView)
        mLength = typedArray.getInteger(R.styleable.BombBallGameView_length, 300)
        rowNumb = typedArray.getInteger(R.styleable.BombBallGameView_row, 30)
        colNumb = typedArray.getInteger(R.styleable.BombBallGameView_col, 20)
        // 球的掩图
        var drawable = typedArray.getDrawable(R.styleable.BombBallGameView_ballMask)
        mBallMask = if (drawable != null) drawableToBitmap(drawable) else null
        // 方针的掩图
        drawable = typedArray.getDrawable(R.styleable.BombBallGameView_targetMask)
        mTargetMask = if (drawable != null) drawableToBitmap(drawable) else null
        // 读取方针的布局装备
        val configId = typedArray.getResourceId(R.styleable.BombBallGameView_targetConfig, -1)
        if (configId != -1) {
            getTargetConfig(configId)
        }
        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
    }
    private fun getTargetConfig(configId: Int) {
        val array = resources.getStringArray(configId)
        try {
            for (str in array) {
                // 取出坐标
                val pos = str.substring(1, str.length - 1).split(",")
                val x = pos[0].trim().toInt()
                val y = pos[1].trim().toInt()
                mTargetConfigList.add(Sprite(x, y, 0f))
            }
        }catch (e : Exception) {
            e.printStackTrace()
        }
        // 填入游戏的list
        mTargetList.clear()
        mTargetList.addAll(mTargetConfigList)
    }
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 开端游戏
        load()
    }
    // 加载
    private fun load() {
        mGameController.removeMessages(0)
        // 设置网格
        rowDelta = height / rowNumb
        colDelta = width / colNumb
        // 设置球,随机朝下的方向
        mBall.posX = width / 2
        mBall.posY = height / 2
        mBall.degree = (Math.random() * 180 + 180).toFloat()
        // 设置板
        mBoard.posX = width / 2
        mBoard.posY = height - 50
        // 将方针集合中的坐标改为实践坐标
        for (target in mTargetList) {
            val exactX = target.posY * colDelta + colDelta / 2
            val exactY = target.posX * rowDelta + rowDelta / 2
            target.posX = exactX
            target.posY = exactY
        }
        mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
    }
    // 重新加载
    private fun reload() {
        mGameController.removeMessages(0)
        // 重置
        mTargetList.clear()
        mTargetList.addAll(mTargetConfigList)
        mGameController.isGameOver = false
        // 设置球,随机朝下的方向,留意:由于Y轴朝下应该是180度以内
        mBall.posX = width / 2
        mBall.posY = height / 2
        mBall.degree = (Math.random() * 180 + 180).toFloat()
        // 设置板
        mBoard.posX = width / 2
        mBoard.posY = height - 50
        // 由于mTargetConfigList内方针被load修正了,清空并不影响方针,不需求再转换了
        mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
    }
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 制作网格
        mPaint.strokeWidth = 1f
        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)
        }
        mPaint.strokeWidth = 10f
        // 制作板
        canvas.drawLine(mBoard.posX - mLength / 2f, mBoard.posY.toFloat(),
            mBoard.posX + mLength / 2f, mBoard.posY.toFloat(), mPaint)
        // 制作球
        canvas.drawBitmap(mBallMask!!, mBall.posX - mBallMask.width / 2f,
            mBall.posY - mBallMask.height / 2f, mPaint)
        // 制作方针物
        for (target in mTargetList) {
            canvas.drawBitmap(mTargetMask!!, target.posX - mTargetMask.width / 2f,
                target.posY - mTargetMask.height / 2f, 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 = mBoard.posX + len
                if (preX > mLength / 2 && preX < (width - mLength / 2)) {
                    mBoard.posX += len.toInt()
                    invalidate()
                }
                mLastX = event.x
            }
            MotionEvent.ACTION_UP -> {}
        }
        return true
    }
    private fun gameOver() {
        AlertDialog.Builder(context)
            .setTitle("持续游戏")
            .setMessage("请点击承认持续游戏")
            .setPositiveButton("承认") { _, _ -> reload() }
            .setNegativeButton("撤销", null)
            .create()
            .show()
    }
    // kotlin自动编译为Java静态类,控件引证运用弱引证
    class GameController(view: BombBallGameView): Handler(Looper.getMainLooper()){
        // 控件引证
        private val mRef: WeakReference<BombBallGameView> = WeakReference(view)
        // 游戏完毕标志
        internal var isGameOver = false
        override fun handleMessage(msg: Message) {
            mRef.get()?.let { gameView ->
                // 移动球
                val radian = Math.toRadians(gameView.mBall.degree.toDouble())
                val deltaX = (TARGET_MOVE_DISTANCE * cos(radian)).toInt()
                val deltaY = (TARGET_MOVE_DISTANCE * sin(radian)).toInt()
                gameView.mBall.posX += deltaX
                gameView.mBall.posY += deltaY
                // 查看反弹磕碰
                checkRebound(gameView)
                // 球和方针的磕碰
                val iterator = gameView.mTargetList.iterator()
                while (iterator.hasNext()) {
                    val target = iterator.next()
                    if (checkCollision(gameView.mBall, target,
                            gameView.mBallMask!!, gameView.mTargetMask!!)) {
                        // 与方针磕碰,移除该方针并修正球的方向
                        iterator.remove()
                        collide(gameView.mBall, target)
                        break
                    }
                }
                // 循环发送音讯,刷新页面
                gameView.invalidate()
                if (!isGameOver) {
                    gameView.mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
                }else {
                    gameView.gameOver()
                }
            }
        }
        // 检测磕碰
        private fun checkCollision(s1: Sprite, s2: Sprite, mask1: Bitmap, mask2: Bitmap): Boolean {
            // 选较长边的一半作为磕碰半径
            val len1 = if(mask1.width > mask1.height) mask1.width / 2f else mask1.height / 2f
            val len2 = if(mask2.width > mask2.height) mask2.width / 2f else mask2.height / 2f
            return getDistance(s1.posX, s1.posY, s2.posX, s2.posY) <= (len1 + len2)
        }
        // 击中方针时获取反弹视点,视点以两球圆心连线对称并加180度
        private fun collide(ball: Sprite, target: Sprite) {
            // 圆心连线视点,留意向量方向,球的方向向上,连线以球为起点
            val lineDegree = getDegree(ball.posX.toFloat(), ball.posY.toFloat(),
                target.posX.toFloat(), target.posY.toFloat())
            val deltaDegree = abs(lineDegree - ball.degree)
            ball.degree += if(lineDegree > ball.degree) {
                2 * deltaDegree.toFloat() + 180
            }else {
                -2 * deltaDegree.toFloat() + 180
            }
        }
        // 击中边际或者板时反弹视点,反射视点和法线对称,方向相反
        private fun checkRebound(gameView: BombBallGameView) {
            val ball = gameView.mBall
            val board = gameView.mBoard
            // 左面边际,法线取同向的180度
            if (ball.posX <= 0) {
                val deltaDegree = abs(180 - ball.degree)
                ball.degree += if (ball.degree < 180)  {
                    2 * deltaDegree - 180
                }else {
                    -2 * deltaDegree - 180
                }
            // 右边边际
            }else if (ball.posX >= gameView.width) {
                val deltaDegree: Float
                ball.degree += if (ball.degree < 180)  {
                    deltaDegree = ball.degree - 0
                    -2 * deltaDegree + 180
                }else {
                    deltaDegree = 360 - ball.degree
                    2 * deltaDegree - 180
                }
            // 上边边际
            }else if(ball.posY <= 0) {
                val deltaDegree = abs(90 - ball.degree)
                ball.degree += if (ball.degree < 90)  {
                    2 * deltaDegree + 180
                }else {
                    -2 * deltaDegree + 180
                }
            // 和板磕碰,由于移动间隔的关系y不能完全持平
            }else if (ball.posY + gameView.mBallMask!!.height / 2 >= board.posY) {
                // 板内
                if (abs(ball.posX - board.posX) <= gameView.mLength / 2){
                    val deltaDegree = abs(270 - ball.degree)
                    ball.degree += if (ball.degree < 270)  {
                        2 * deltaDegree - 180
                    }else {
                        -2 * deltaDegree - 180
                    }
                }else {
                    isGameOver = true
                }
            }
        }
    }
    // 圆心坐标,视点方向(degree,对应弧度radian)
    data class Sprite(var posX: Int, var posY: Int, var degree: Float)
    /**
     * 供外部收回资源
     */
    fun recycle()  {
        mBallMask?.recycle()
        mTargetMask?.recycle()
        mGameController.removeMessages(0)
    }
}

对应style装备,这儿rowNunb不能用了,和上个贪吃蛇游戏冲突了,不能用一样的称号。游戏数据的数组我也写在这儿了,实践应该分隔写的,但是小游戏罢了,就这样吧!

res -> values -> bomb_ball_game_view_style.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="BombBallGameView">
        <attr name="length" format="integer"/>
        <attr name="row" format="integer"/>
        <attr name="col" format="integer"/>
        <attr name="ballMask" format="reference"/>
        <attr name="targetMask" format="reference"/>
        <attr name="targetConfig" format="reference"/>
    </declare-styleable>
    <string-array name="BombBallGameConfig">
        <item>(0,5)</item>
        <item>(0,6)</item>
        <item>(0,7)</item>
        <item>(0,8)</item>
        <item>(0,9)</item>
        <item>(0,10)</item>
        <item>(0,11)</item>
        <item>(0,12)</item>
        <item>(0,13)</item>
        <item>(0,14)</item>
        <item>(1,3)</item>
        <item>(1,5)</item>
        <item>(1,7)</item>
        <item>(1,9)</item>
        <item>(1,11)</item>
        <item>(1,13)</item>
        <item>(1,15)</item>
    </string-array>
</resources>

掩图也还是从Android Studio里边的vector image来的,我觉得还阔以。

res -> drawable -> ic_circle.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>

res -> drawable -> ic_target.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>

layout也说一下,前面都没写layout,这儿用到了字符串数组,说下吧

    <com.silencefly96.module_views.game.BombBallGameView
        android:id="@+id/gamaView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/black"
        app:ballMask="@drawable/ic_circle"
        app:targetMask="@drawable/ic_target"
        app:targetConfig="@array/BombBallGameConfig"
        />

首要问题

下面简略讲讲吧,首要结构和前面游戏没什么变化,便是游戏逻辑变得复杂了许多。

资源加载

和前面一样,资源加载便是从styleable装备里边读取设置,这儿需求额外阐明的便是方针的装备文件了。

这儿顶部方针是通过外部的装备文件来设置的,承受的是一个字符串数组的资源id,我这保存在下面:

res -> values -> bomb_ball_game_view_style.xml -> BombBallGameConfig

结构是一个坐标,需求留意的是要合作row和col运用(行数和列数),第一个数字表明第几行,第二个数字表明第几列。

<item>(0,5)</item>

读取的时分是把行标和列标读到了Sprite的posX和posY里边,这儿是错误的,其时在init读取的时分无法取得控件的宽高,所以暂时先存放下,在onMeasuer -> onSizeChanged得到宽高之后,在load中对数据进行处理,mTargetList(游戏操作的列表)和mTargetConfigList(原始数据列表)都保存的是读取到的装备方针,即使mTargetList清空了,装备方针不变,仍然保存在mTargetConfigList,这儿要辨明,否则reload的时分再处理就大错特错了。

板的移动

这儿叫板,实践是通过paint画出来的线,仅仅设置的strokeWidth比较粗罢了。移动的时分在onTouchEvent的ACTION_MOVE事件中更新板的坐标,在onDraw会以它的坐标和长度制作成“板”。

球对四周的反弹

球的数据保存在Sprite方针里边,里边保存了三个变量,坐标以及方向。球在四个边的反弹(板实践便是下边),相似光的反射,找到反射面以及反射的法线,再以法线对称就得到反射路线了。实践操作上,先获取入射方向与法线夹角的绝对值,对称到法线另一边,再旋转180度掉头,就能得到出射方向了。

当然核算的时分要根据实践情况核算,尤其是0度和360度作为法线时。

球和方针的磕碰时的反射

球和方针的磕碰就不说了,很简略,核算下两个中心的间隔就行了。这儿说下磕碰后的反射问题,和上面在四周的反射相似,这儿也是要通过反射面和法线来决议,实践上法线便是两个圆心的连线,而且小球和方针磕碰时,方向只会向上,所以取小球中心为起点,方针中心为中点,得到法线向量,再去核算视点就很简略了。

球的初始随机方向问题

球的初始随机方向我是想让它向上的,那应该生成哪个规模的视点呢?我们上学的时分X轴向右,Y轴向上,上半部分视点时[0, 180],那这时分U轴向下了,视点规模呢?答案很简略了,便是[180, 360],上面磕碰的代码实践是我以默认上半区为[0, 180]的时分写的,实践也无需修正,由于仅仅坐标轴对称了,逻辑并没对称。