安卓小游戏:小板弹球
前语
这个是通过自定义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]的时分写的,实践也无需修正,由于仅仅坐标轴对称了,逻辑并没对称。