安卓小游戏:飞蛋游戏

安卓小游戏:飞蛋游戏

前言

这是过年前摸鱼写的一个小游戏,模仿小时候玩过的一个飞蛋游戏,大致就是鸡蛋要从一个平台跳到另一个平台上去,没跳上去就摔死失败了。

我这就是模仿了下游戏逻辑,素材和音效都没加,玩起来还是挺有趣的,就是难度有点高了。

当然写小游戏并不仅仅是摸鱼,在这个小游戏中,鸡蛋、平台、页面的移动,都是对Android坐标体系的实践,还是能学到东西的。

需求

玩法很简单,点击屏幕起跳,只要跳到上面一层的平台就算成功,可以下一次起跳,没挨到上面平台就失败了。核心思想如下:

  • 1,载入配置,读取游戏信息及掩图
  • 2,启动游戏,各个平台按一定逻辑运动
  • 3,点击屏幕,鸡蛋起飞,校验和上面一层平台的碰撞
  • 4,如果到屏幕最顶层,则暂停游戏,移动页面到合适位置
  • 5,加入游戏结束、游戏通过的提示
  • 6,增加辅助线,降低游戏难度

效果图

代码

游戏基类封装

有了前面四个游戏的经验,我在做这个小游戏的时候,先对游戏代码做了个抽象封装,让它能复用大部分逻辑,在新游戏里面只要设计新的想法就行,下面是我封装的BaseGameView:

kotlin 复制代码
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 androidx.core.content.res.ResourcesCompat
import java.lang.ref.WeakReference
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.pow
import kotlin.math.sqrt

abstract class BaseGameView @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 DIR_UP = 0
        const val DIR_RIGHT = 1
        const val DIR_DOWN = 2
        const val DIR_LEFT = 3

        // 距离计算公式
        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 mGameController = GameController(this)

    // 上一个触摸点X、Y的坐标
    private var mLastX = 0f
    private var mLastY = 0f
    private var mStartX = 0f
    private var mStartY = 0f

    // 行的数量、间距
    private val rowNumb: Int = getRowNumb()
    private var rowDelta = 0
    protected open fun getRowNumb(): Int{ return 30 }

    // 列的数量、间距
    private val colNumb: Int = getColNumb()
    private var colDelta = 0
    protected open fun getColNumb(): Int{ return 20 }

    // 是否绘制网格
    protected open fun isDrawGrid(): Boolean{ return false }

    // 画笔
    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
    }

    protected fun drawable2Bitmap(id: Int): Bitmap {
        val drawable = ResourcesCompat.getDrawable(resources, id, null)
        return drawable2Bitmap(drawable!!)
    }

    protected fun drawable2Bitmap(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
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when(event.action) {
            MotionEvent.ACTION_DOWN -> {
                mLastX = event.x
                mLastY = event.y
                mStartX = event.x
                mStartY = event.y
            }
            MotionEvent.ACTION_MOVE -> {
                // 适用于手指移动时,同步处理逻辑
                val lenX = event.x - mLastX
                val lenY = event.y - mLastY

                // 根据返回来判断是否刷新界面,可用来限制移动
                if (onMove(lenX, lenY)) {
                    invalidate()
                }

                mLastX = event.x
                mLastY = event.y
            }
            MotionEvent.ACTION_UP -> {
                // 适用于一个动作抬起时,做出逻辑修改
                val lenX = event.x - mStartX
                val lenY = event.y - mStartY

                // 转方向
                val dir = if (abs(lenX) > abs(lenY)) {
                    if (lenX >= 0) DIR_RIGHT else DIR_LEFT
                }else {
                    if (lenY >= 0) DIR_DOWN else DIR_UP
                }

                // 根据返回来判断是否刷新界面,可用来限制移动
                if (onMoveUp(dir)) {
                    invalidate()
                }
            }
        }
        return true
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 设置网格
        rowDelta = h / rowNumb
        colDelta = w / colNumb
        // 开始游戏
        load(w, h)
        start()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 绘制网格
        if (isDrawGrid()) {
            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
        }

        // 游戏绘制逻辑
        drawGame(canvas, mPaint)
    }

    // 精灵位置
    data class Sprite(
        var posX: Int = 0,           // 坐标
        var posY: Int = 0,
        var live: Int = 1,           // 生命值
        var degree: Float = 0f,      // 方向角度,[0, 360]
        var speed: Float = 1f,       // 速度,[0, 1]刷新速度百分比
        var speedCount: Int = 0,     // 用于计数,调整速度
        var mask: Bitmap? = null,    // 掩图
        var type: Int = 0,           // 类型
        var moveCount: Int = 0       // 单次运动的计时
    )

    // 用于在网格方块中心绘制精灵掩图
    protected open fun drawSprite(sprite: Sprite, canvas: Canvas, paint: Paint) {
        sprite.mask?.let { mask ->
            canvas.drawBitmap(mask, sprite.posX - mask.width / 2f,
                sprite.posY - mask.height / 2f, paint)
        }
    }

    class GameController(view: BaseGameView): Handler(Looper.getMainLooper()){
        // 控件引用
        private val mRef: WeakReference<BaseGameView> = WeakReference(view)
        // 游戏结束标志
        private var isGameOver = false
        // 暂停标志
        private var isPause = false

        override fun handleMessage(msg: Message) {
            mRef.get()?.let { gameView ->
                // 处理游戏逻辑
                isGameOver = gameView.handleGame(gameView)

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

        fun pause(flag: Boolean) {
            isPause = flag
        }
    }

    /**
     * 加载
     *
     * @param w 宽度
     * @param h 高度
     */
    abstract fun load(w: Int, h: Int)

    /**
     * 重新加载
     *
     * @param w 宽度
     * @param h 高度
     */
    abstract fun reload(w: Int, h: Int)

    /**
     * 绘制游戏界面
     *
     * @param canvas 画板
     * @param paint 画笔
     */
    abstract fun drawGame(canvas: Canvas, paint: Paint)

    /**
     * 移动一小段,返回true刷新界面
     *
     * @param dx x轴移动值
     * @param dy y轴移动值
     * @return 是否刷新界面
     */
    protected open fun onMove(dx: Float, dy: Float): Boolean { return false }

    /**
     * 滑动抬起,返回true刷新界面
     *
     * @param dir 一次滑动后的方向
     * @return 是否刷新界面
     */
    @Suppress("MemberVisibilityCanBePrivate")
    protected open fun onMoveUp(dir: Int): Boolean{ return false }

    /**
     * 处理游戏逻辑,返回是否结束游戏
     *
     * @param gameView 游戏界面
     * @return 是否结束游戏
     */
    abstract fun handleGame(gameView: BaseGameView): Boolean

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

    /**
     * 暂停游戏刷新
     */
    protected fun pause() {
        mGameController.pause(true)
        mGameController.removeMessages(0)
    }

    /**
     * 继续游戏
     */
    protected fun start() {
        mGameController.pause(false)
        mGameController.sendEmptyMessageDelayed(0, GAME_FLUSH_TIME)
    }

    /**
     * 回收资源
     */
    protected open fun recycle() {
        mGameController.removeMessages(0)
    }
}

这里先就不详诉了,下一节再说。

飞蛋游戏编写

有了上面游戏基类,飞蛋游戏内就能降低很多工作量,下面看代码:

kotlin 复制代码
import android.animation.ValueAnimator
import android.app.AlertDialog
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import androidx.core.animation.addListener
import com.silencefly96.module_views.R
import com.silencefly96.module_views.game.base.BaseGameView
import kotlin.math.PI
import kotlin.math.pow
import kotlin.math.sin

class FlyEggGameView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
): BaseGameView(context, attrs, defStyleAttr) {

    companion object {
        // 运动类型
        const val TYPE_HERO =   1   // 小球
        const val TYPE_UNMOVE = 2   // 不移动
        const val TYPE_MOVE =   3   // 扫描移动
        const val TYPE_CIRCLE = 4   // 来回移动
        const val TYPE_BEVAL  = 5   // 斜着来回移动
        const val TYPE_SINY   = 6   // 做正弦移动

        // 页面状态
        const val STATE_NORMAL = 1
        const val STATE_FLYING = 2
        const val STATE_SCROLL = 3

        // 认定碰撞的距离
        const val COLLISION_DISTANCE = 30

        // 上下左右padding值
        const val BUTTOM_PADDING = 50
        const val TOP_PADDING = 200
        const val HORIZONTAL_PADDING = 50

        // 飞起来超过下一个船的高度
        const val HERO_OVER_HEIGHT = 100

        // 起飞运动计时,即3s内运动完
        const val HERO_FLY_COUNT = 3000 / GAME_FLUSH_TIME.toInt()
        // 小船一次移动的计时
        const val BOAT_MOVE_COUNT = 6000 / GAME_FLUSH_TIME.toInt()
    }

    // 需要绘制的sprite
    private val mDisplaySprites: MutableList<Sprite> = ArrayList()

    // 主角
    private val mHeroSprite: Sprite = Sprite().apply {
        mDisplaySprites.add(this)
    }

    // 主角坑位
    private var mHeroBoat: Sprite? = null

    // 主角状态
    private var mState = STATE_NORMAL

    // 主角飞起最大高度
    private var mHeroFlyHeight: Int = 0

    // 移动画面的动画
    private lateinit var mAnimator: ValueAnimator
    private var mScrollVauleLast = 0f
    private var mScorllValue = 0f

    // XML 传入配置:
    // 两种掩图
    private val mEggMask: Bitmap
    private val mBoatMask: Bitmap

    // 游戏配置
    private lateinit var mGameConfig: IntArray
    private val mDefaultConfig = intArrayOf(
        TYPE_UNMOVE, TYPE_SINY,
        TYPE_UNMOVE, TYPE_CIRCLE,
        TYPE_UNMOVE, TYPE_BEVAL,
        TYPE_UNMOVE, TYPE_SINY,
        TYPE_UNMOVE
    )

    // 使用游戏level(-1, 0, 1, 2)
    private var mGameLevel = -1

    // 是否显示辅助线
    var isShowTip = false

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

        // 蛋的掩图
        var drawable = typedArray.getDrawable(R.styleable.FlyEggGameView_eggMask)
        mEggMask = if (drawable != null) {
            drawable2Bitmap(drawable)
        }else {
            drawable2Bitmap( R.mipmap.ic_launcher_round)
        }

        // 船的掩图
        drawable = typedArray.getDrawable(R.styleable.FlyEggGameView_boatMask)
        mBoatMask = if (drawable != null) {
            drawable2Bitmap(drawable)
        }else {
            drawable2Bitmap( R.mipmap.ic_launcher)
        }

        // 像读取gameLevel
        mGameLevel = typedArray.getInteger(R.styleable.FlyEggGameView_gameLevel, -1)


        // 读取目标的布局配置
        val configId = typedArray.getResourceId(R.styleable.FlyEggGameView_gameConfig, -1)
        setGameLevel(mGameLevel, configId)

        // 是否显示辅助线
        isShowTip = typedArray.getBoolean(R.styleable.FlyEggGameView_showTip, false)

        typedArray.recycle()
    }

    fun setGameLevel(level: Int, configId: Int = -1) {
        mGameConfig = if (configId != -1) {
            resources.getIntArray(configId)
        }else if (level != -1) {
            // 没有设置自定义,再看设置游戏等级没
            mGameLevel = level
            when(level) {
                0 -> R.array.easy
                1 -> R.array.middle
                else -> R.array.hard
            }.let {
                resources.getIntArray(it)
            }
        }else {
            mDefaultConfig
        }
    }

    override fun load(w: Int, h: Int) {
        // 设置主角位置
        mHeroSprite.apply {
            posX = w / 2
            posY = h - BUTTOM_PADDING
            mask = mEggMask
            type = TYPE_HERO    // 英雄类型
        }

        // 主角初始化坐的船
        mHeroBoat = getBoat(TYPE_UNMOVE, 0, w, h).apply {
            mDisplaySprites.add(this)
        }

        // 主角飞起最大高度,比下一个跳板高一点
        mHeroFlyHeight = (h - BUTTOM_PADDING - TOP_PADDING) / 2 + HERO_OVER_HEIGHT

        // 构建地图
        for (i in mGameConfig.indices) {
            mDisplaySprites.add(getBoat(mGameConfig[i], i + 1, w, h))
        }

        // 页面动画
        mAnimator = ValueAnimator.ofFloat(0f, h - BUTTOM_PADDING - TOP_PADDING.toFloat()).apply {
            duration = 3000
            addUpdateListener {
                mScorllValue = mScrollVauleLast + it.animatedValue as Float
            }
            addListener(onEnd = {
                mState = STATE_NORMAL
                mScrollVauleLast += h - BUTTOM_PADDING - TOP_PADDING.toFloat()
            })
        }
    }

    private fun getBoat(boatType: Int, index: Int, w: Int, h: Int): Sprite {
        return Sprite().apply {
            // 水平居中
            posX = w / 2
            // 高度从底部往上排列,屏幕放两个boat,上下空出VALUE_PADDING
            posY = (h - BUTTOM_PADDING) - index * (h - BUTTOM_PADDING - TOP_PADDING) / 2
            type = boatType
            mask = mBoatMask
            // 对应水平居中
            moveCount = BOAT_MOVE_COUNT / 2
        }
    }

    override fun reload(w: Int, h: Int) {
        mDisplaySprites.clear()
        mState = STATE_NORMAL
        mDisplaySprites.add(mHeroSprite)
        mScorllValue = 0f
        mScrollVauleLast = 0f
        load(w, h)
    }

    override fun drawGame(canvas: Canvas, paint: Paint) {
        mDisplaySprites.forEach {
            drawSprite(it, canvas, paint)
        }
        // 简单画个辅助线
        if (isShowTip) {
            drawTip(canvas, paint)
        }
    }

    // 在原来的基础上增加页面滚动值
    override fun drawSprite(sprite: Sprite, canvas: Canvas, paint: Paint) {
        sprite.mask?.let { mask ->
            canvas.drawBitmap(mask, sprite.posX - mask.width / 2f,
                sprite.posY - mask.height / 2f + mScorllValue, paint)
        }
    }

    private fun drawTip(canvas: Canvas, paint: Paint) {
        // 其实很简单,按segment为间隔画HERO_FLY_COUNT个点就是预测的抛物线
        val x = mHeroSprite.posX.toFloat()
        val y = mHeroSprite.posY.toFloat() + mScorllValue
        val totalWidth = width - HORIZONTAL_PADDING * 2
        val segment = totalWidth / BOAT_MOVE_COUNT

        val oldStrokeWidth = paint.strokeWidth
        paint.strokeWidth = 1f
        for (i in 0..HERO_FLY_COUNT) {
            val cx1 = x + segment * i
            val cx2 = x - segment * i
            val cy = y - getFlyHeight(i)

            // 三条辅助线
            canvas.drawCircle(x, cy, 1f, paint)
            canvas.drawCircle(cx1, cy, 1f, paint)
            canvas.drawCircle(cx2, cy, 1f, paint)
        }
        paint.strokeWidth = oldStrokeWidth
    }

    override fun onMoveUp(dir: Int): Boolean {
        // 起飞,运动倒计时
        if (mState == STATE_NORMAL) {
            mState = STATE_FLYING
            mHeroSprite.moveCount = HERO_FLY_COUNT
        }
        return false
    }

    override fun handleGame(gameView: BaseGameView): Boolean {
        // 如果页面在滚动,不处理逻辑
        if (mState == STATE_SCROLL) {
            return false
        }

        // 检查游戏成功
        if (checkSuccess()) {
            return false
        }

        // 移动所有精灵
        for (sprite in mDisplaySprites) {
            moveBoat(sprite)

            // 检查是否碰撞 => 坐上船
            checkSite(sprite)
        }

        // 判断是否要移动页面
        mHeroBoat?.let {
            checkAndMovePage(it)
        }


        return mState == STATE_FLYING && mHeroSprite.moveCount == 0
    }

    private fun moveBoat(sprite: Sprite) {
        when(sprite.type) {
            TYPE_HERO -> moveHero()
            TYPE_UNMOVE -> {}
            TYPE_MOVE -> {
                // 根据moveCount线性移动
                sprite.moveCount = (sprite.moveCount + 1) % BOAT_MOVE_COUNT
                sprite.posX = HORIZONTAL_PADDING + (width - HORIZONTAL_PADDING * 2) / BOAT_MOVE_COUNT * sprite.moveCount
            }
            TYPE_CIRCLE -> {
                // 根据moveCount循环线性运动(分段函数)
                val totalWidth = width - HORIZONTAL_PADDING * 2
                val segment = totalWidth / BOAT_MOVE_COUNT
                // 两趟构成一个循环
                sprite.moveCount = (sprite.moveCount + 1) % (BOAT_MOVE_COUNT * 2)
                // 分两端函数
                if(sprite.moveCount < BOAT_MOVE_COUNT) {
                    sprite.posX = HORIZONTAL_PADDING + segment * sprite.moveCount
                }else {
                    // 坐标转换下就能回来
                    val returnX = 2 * BOAT_MOVE_COUNT - sprite.moveCount
                    sprite.posX = HORIZONTAL_PADDING + segment * returnX
                }
            }
            TYPE_BEVAL -> {
                // 移动范围
                val totalWidth = width - HORIZONTAL_PADDING * 2
                val totalHeight = (height - BUTTOM_PADDING - TOP_PADDING) / 4
                // 获取静止位置,即刚生成的时候的Y
                val index = mDisplaySprites.indexOf(sprite) - 1
                val staticY = (height - BUTTOM_PADDING) - index * (height - BUTTOM_PADDING - TOP_PADDING) / 2
                // 方便函数计算,从最低的Y坐标开始算(注意,向下是加)
                val startY = staticY + totalHeight / 2
                // 在上面基础上增加Y轴变换
                val segmentX = totalWidth / BOAT_MOVE_COUNT
                val segmentY = totalHeight / BOAT_MOVE_COUNT

                sprite.moveCount = (sprite.moveCount + 1) % (BOAT_MOVE_COUNT * 2)
                if(sprite.moveCount < BOAT_MOVE_COUNT) {
                    sprite.posX = HORIZONTAL_PADDING + segmentX * sprite.moveCount
                    sprite.posY = startY - segmentY * sprite.moveCount
                }else {
                    // 坐标转换下就能回来
                    val returnX = 2 * BOAT_MOVE_COUNT - sprite.moveCount
                    sprite.posX = HORIZONTAL_PADDING + segmentX * returnX
                    sprite.posY = startY - segmentY * returnX
                }
            }
            TYPE_SINY -> {
                // 移动范围
                val totalWidth = width - HORIZONTAL_PADDING * 2
                val totalHeight = (height - BUTTOM_PADDING - TOP_PADDING) / 4
                val halfHeight = totalHeight / 2
                // 获取静止位置,即刚生成的时候的Y
                val index = mDisplaySprites.indexOf(sprite) - 1
                val staticY = (height - BUTTOM_PADDING) - index * (height - BUTTOM_PADDING - TOP_PADDING) / 2
                // 方便函数计算,从最低的Y坐标开始算(注意,向下是加)
                val startY = staticY + halfHeight
                // 在上面基础上增加Y轴变换
                val segmentX = totalWidth / BOAT_MOVE_COUNT

                sprite.moveCount = (sprite.moveCount + 1) % (BOAT_MOVE_COUNT * 2)
                // 前一段是正弦,后一段负的正弦回去,类似 ∞ 符号
                // 写个正弦函数: x = x, y = height * sin w * x
                val w = 2 * PI / BOAT_MOVE_COUNT.toFloat()
                if(sprite.moveCount < BOAT_MOVE_COUNT) {
                    sprite.posX = HORIZONTAL_PADDING + segmentX * sprite.moveCount
                    sprite.posY = startY - (halfHeight * sin(w * sprite.moveCount)).toInt()
                }else {
                    // 坐标转换,Y函数也切换下
                    val returnX = 2 * BOAT_MOVE_COUNT - sprite.moveCount
                    sprite.posX = HORIZONTAL_PADDING + segmentX * returnX
                    sprite.posY = startY + (halfHeight * sin(w * returnX)).toInt()
                }
            }
        }
    }

    private fun moveHero() {
        if (mHeroSprite.moveCount > 0) {
            // 飞行时移动
            val moveCount = mHeroSprite.moveCount
            // 每次叠加差值
            mHeroSprite.posY -= ((getFlyHeight(moveCount) - getFlyHeight(moveCount + 1)))
            mHeroSprite.moveCount--
        }else {
            // 不飞行时,跟随坐的船移动
            mHeroSprite.posX = mHeroBoat!!.posX
            mHeroSprite.posY = mHeroBoat!!.posY
        }
    }

    private fun getFlyHeight(moveCount: Int): Int {
        if (moveCount in 0..HERO_FLY_COUNT) {
            // 在y轴上执行抛物线
            val half = HERO_FLY_COUNT / 2
            val dx = moveCount.toDouble()
            val dy = - (dx - half).pow(2.0) * mHeroFlyHeight / half.toDouble().pow(2.0) + mHeroFlyHeight
            return dy.toInt()
        }
        return 0
    }

    private fun checkSite(sprite: Sprite) {
        if (sprite != mHeroSprite && sprite != mHeroBoat) {
            // 角色和船一定距离内,认为坐上了船
            if (getDistance(sprite.posX, sprite.posY,
                    mHeroSprite.posX, mHeroSprite.posY) <= COLLISION_DISTANCE) {
                // 坐上了船
                mHeroBoat = sprite
                mHeroSprite.moveCount = 0
                mState = STATE_NORMAL
            }
        }
    }

    private fun checkAndMovePage(sprite: Sprite) {
        // 飞蛋坐的船到上面一定距离时,移动页面
        if (sprite.posY + mScorllValue <= TOP_PADDING * 1.25f) {
            mState = STATE_SCROLL
            mAnimator.start()
        }
    }

    private fun checkSuccess(): Boolean {
        val result = mDisplaySprites.indexOf(mHeroBoat) == (mDisplaySprites.size - 1)
        if (result) {
            pause()
            AlertDialog.Builder(context)
                .setTitle("恭喜通关!!!")
                .setMessage("请点击确认继续游戏")
                .setPositiveButton("确认") { _, _ ->
                    reload(width, height)
                    start()
                }
                .setNegativeButton("取消", null)
                .create()
                .show()
        }
        return result
    }

    public override fun recycle() {
        super.recycle()
        mEggMask.recycle()
        mBoatMask.recycle()
    }
}

对应style配置,难度和关卡也放在这了:

res -> values -> fly_egg_game_view_style.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="FlyEggGameView">
        <attr name="eggMask" format="reference"/>
        <attr name="boatMask" format="reference"/>
        <attr name="gameConfig" format="reference"/>
        <attr name="showTip" format="boolean"/>
        <attr name="gameLevel">
            <enum name ="easy" value="0" />
            <enum name ="middle" value="1" />
            <enum name ="hard" value="2" />
        </attr>
    </declare-styleable>
    <string-array name="gameLevel">
        <item>custom</item>
        <item>easy</item>
        <item>middle</item>
        <item>hard</item>
    </string-array>
    <integer-array name="easy">
        <item>2</item>
        <item>3</item>
        <item>2</item>
        <item>4</item>
        <item>2</item>
    </integer-array>
    <integer-array name="middle">
        <item>2</item>
        <item>3</item>
        <item>2</item>
        <item>4</item>
        <item>2</item>

        <item>5</item>
        <item>2</item>
        <item>6</item>
        <item>2</item>
    </integer-array>
    <integer-array name="hard">
        <item>2</item>
        <item>3</item>
        <item>4</item>
        <item>2</item>

        <item>5</item>
        <item>3</item>
        <item>5</item>
        <item>2</item>

        <item>4</item>
        <item>6</item>
        <item>3</item>
        <item>2</item>
    </integer-array>
</resources>

鸡蛋掩图,不推荐用我的,但是还是给一下

res -> drawable -> ic_node.xml

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_boat.xml

xml 复制代码
<vector android:height="24dp" android:tint="#71C93E"
    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="M19.5,9.5c-1.03,0 -1.9,0.62 -2.29,1.5h-2.92C13.9,10.12 13.03,9.5 12,9.5s-1.9,0.62 -2.29,1.5H6.79C6.4,10.12 5.53,9.5 4.5,9.5C3.12,9.5 2,10.62 2,12s1.12,2.5 2.5,2.5c1.03,0 1.9,-0.62 2.29,-1.5h2.92c0.39,0.88 1.26,1.5 2.29,1.5s1.9,-0.62 2.29,-1.5h2.92c0.39,0.88 1.26,1.5 2.29,1.5c1.38,0 2.5,-1.12 2.5,-2.5S20.88,9.5 19.5,9.5z"/>
</vector>

layout布局,只贴一下FlyEggGameView的写法,demo的布局可以看我源码:

xml 复制代码
    <com.silencefly96.module_views.game.FlyEggGameView
        android:id="@+id/game_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/black"
        app:eggMask="@drawable/ic_node"
        app:boatMask="@drawable/ic_boat"
        app:gameLevel="middle"
        app:gameConfig="@array/easy"
        app:showTip="true"
    />

主要问题

上面只是把所有代码展示了下,还是有必要简单说明下的。

基类逻辑

我这根据前面四个小游戏的逻辑,整理了一个基类,主要就是整合通用代码、优化编写流程。

对于通用代码,具体整合了下面内容:

  • 统一节点: Sprite,提供多种控制属性
  • 通用方法: 距离计算公式、两点连线角度计算、drawable2Bitmap、drawSprite
  • 网格绘制: 支持自动绘制网格
  • 手势控制: 滑动(获得方向)、点击
  • 游戏更新: 固定频率更新游戏,支持暂停、恢复

对于优化编写流程,提供了下面可模板方法:

  • load(w: Int, h: Int)
  • reload(w: Int, h: Int)
  • drawGame(canvas: Canvas, paint: Paint)
  • onMove(dx: Float, dy: Float): Boolean {...}
  • onMoveUp(dir: Int): Boolean {...}
  • handleGame(gameView: BaseGameView): Boolean
  • gameOver() {...}

只需要实现上面这些方法,基本就能完成一个游戏了,方法的参数、返回值说明,我在注释也写的很清楚了。

加载资源

加载资源前几个小游戏,都有用到,这里用了基类,还方便了一些,要提一下是因为加载游戏配置的时候复杂了点,这里支持三种配置:

  • 自定义配置: 从gameConfig读取的数组
  • 内置游戏难度: 从gameLevel读取的难度,并转到对应游戏配置数组
  • 默认配置: 即代码中的mDefaultConfig

这三个配置优先级为从上到下。

页面设计

这里要着重说下页面的设计,因为里面鸡蛋的起飞、平台的移动、页面的移动都和这个有关。

这里页面是从屏幕最下方,往上排列的,Y轴坐标从view的height递减,这里有几个padding,是我用来控制范围的:

  • BUTTOM_PADDING: 下面padding
  • TOP_PADDING: 上面padding
  • HORIZONTAL_PADDING: 左右padding

在横向没什么好说的,纵向去掉上下padding,就是游戏页面了,分成两部分,分别放了三个平台,每次页面移动的距离就是这个高度:

  • h - BUTTOM_PADDING - TOP_PADDING

而鸡蛋飞起的高度,就是用游戏页面的一半加上高出来的一点高度:

  • (h - BUTTOM_PADDING - TOP_PADDING) / 2 + HERO_OVER_HEIGHT

和移动有关的地方要记得,向上移动的时候是对Y轴坐标的减法,其他的就没什么说的了。

Sprite移动

这里有两种sprite,即鸡蛋和平台,但是两种sprite的移动都是一样的,就不分别讲了,下面讲下原理。

每个sprite都有一个moveCount属性,代表它运动的帧数,就比如每次鸡蛋的moveCount是:

  • HERO_FLY_COUNT = 3000 / GAME_FLUSH_TIME.toInt()

也就是3秒中运动的帧数,有了moveCount,我们就能在X、Y轴移动,把效果作用到sprite的坐标上,我们只要注意移动的效果,绘制的事交给其他方法处理。

下面就举个例子,看下简单的移动:

鸡蛋的移动

鸡蛋的移动逻辑都放在moveHero方法内,

kotlin 复制代码
private fun moveHero() {
    if (mHeroSprite.moveCount > 0) {
        // 飞行时移动
        val moveCount = mHeroSprite.moveCount
        // 每次叠加差值
        mHeroSprite.posY -= ((getFlyHeight(moveCount) - getFlyHeight(moveCount + 1)))
        mHeroSprite.moveCount--
    }else {
        // 不飞行时,跟随坐的船移动
        mHeroSprite.posX = mHeroBoat!!.posX
        mHeroSprite.posY = mHeroBoat!!.posY
    }
}

private fun getFlyHeight(moveCount: Int): Int {
    if (moveCount in 0..HERO_FLY_COUNT) {
        // 在y轴上执行抛物线
        val half = HERO_FLY_COUNT / 2
        val dx = moveCount.toDouble()
        val dy = - (dx - half).pow(2.0) * mHeroFlyHeight / half.toDouble().pow(2.0) + mHeroFlyHeight
        return dy.toInt()
    }
    return 0
}

在getFlyHeight中实际就是用一个抛物线,根据x坐标拿到y坐标,x轴坐标是moveCount,y坐标是里y=0的距离,因为移动时要的是差值,所以还得计算下。

平台移动

平台的类型比较多,这里就看下斜着运动的平台:

kotlin 复制代码
TYPE_BEVAL -> {
    // 移动范围
    val totalWidth = width - HORIZONTAL_PADDING * 2
    val totalHeight = (height - BUTTOM_PADDING - TOP_PADDING) / 4
    // 获取静止位置,即刚生成的时候的Y
    val index = mDisplaySprites.indexOf(sprite) - 1
    val staticY = (height - BUTTOM_PADDING) - index * (height - BUTTOM_PADDING - TOP_PADDING) / 2
    // 方便函数计算,从最低的Y坐标开始算(注意,向下是加)
    val startY = staticY + totalHeight / 2
    // 在上面基础上增加Y轴变换
    val segmentX = totalWidth / BOAT_MOVE_COUNT
    val segmentY = totalHeight / BOAT_MOVE_COUNT

    sprite.moveCount = (sprite.moveCount + 1) % (BOAT_MOVE_COUNT * 2)
    if(sprite.moveCount < BOAT_MOVE_COUNT) {
        sprite.posX = HORIZONTAL_PADDING + segmentX * sprite.moveCount
        sprite.posY = startY - segmentY * sprite.moveCount
    }else {
        // 坐标转换下就能回来
        val returnX = 2 * BOAT_MOVE_COUNT - sprite.moveCount
        sprite.posX = HORIZONTAL_PADDING + segmentX * returnX
        sprite.posY = startY - segmentY * returnX
    }
}

也是通过moveCount来控制x和y坐标的位置,把moveCount当作横坐标,绘制一个周期x和y的变化。

对于x轴上的运动,实际就是从x=0,移动到x=width,再返回,也就是说是移动两个横向屏幕距离,移动一个屏幕距离的moveCount是:

  • BOAT_MOVE_COUNT

也就是说一个周期移动的moveCount,是它的两倍,要让它做周期变化,我们只要用取余操作就能实现:

kotlin 复制代码
sprite.moveCount = (sprite.moveCount + 1) % (BOAT_MOVE_COUNT * 2)

平台在x轴上做匀速直线运动,所以按moveCount的递增,给他增加一小段的距离segmentX就行了,当然这是从左到右的变化,回来就不能这么操作了。

不过,从左到右的函数都写好了,从右到左的变化也就简单了,只需要对函数的x做变化,将moveCount在[BOAT_MOVE_COUNT, BOAT_MOVE_COUNT * 2]的变化,转成[BOAT_MOVE_COUNT, 0]的变化就行了。而且这样的转换,在Y轴上一样适用。

平台在y轴的运动类似,注意下起始的y坐标startY,和移动的最大长度totalHeight,根据BOAT_MOVE_COUNT(半个周期)分割成一小段segmentY,通过y坐标的减法操作,向上移动到最大高度,再通过x轴的的转换回来,完成一整个周期。

页面移动

因为游戏的页面最多只能放三个平台,所以当鸡蛋到了最上面的平台时,要先对游戏暂停,再对游戏页面整体平移,还是挺有意思的,下面看下。

其实这里就是用了个ValueAnimator,达到平移条件时,启动ValueAnimator,在一定时间内更改mScorllValue的值,有个不好的地方就是ValueAnimator的update里面只有每次的value值,没有差值,需要额外处理下。

kotlin 复制代码
private fun checkAndMovePage(sprite: Sprite) {
    // 飞蛋坐的船到上面一定距离时,移动页面
    if (sprite.posY + mScorllValue <= TOP_PADDING * 1.25f) {
        mState = STATE_SCROLL
        mAnimator.start()
    }
}

// 移动画面的动画
private lateinit var mAnimator: ValueAnimator
private var mScrollVauleLast = 0f
private var mScorllValue = 0f

// 页面动画
mAnimator = ValueAnimator.ofFloat(0f, h - BUTTOM_PADDING - TOP_PADDING.toFloat()).apply {
    duration = 3000
    addUpdateListener {
        mScorllValue = mScrollVauleLast + it.animatedValue as Float
    }
    addListener(onEnd = {
        mState = STATE_NORMAL
        mScrollVauleLast += h - BUTTOM_PADDING - TOP_PADDING.toFloat()
    })
}

通过ValueAnimator完成移动后,我们就能在绘制的时候加上这个页面偏移值mScorllValue,这里选择重写drawSprite方法,其他的就不用管了:

kotlin 复制代码
// 在原来的基础上增加页面滚动值
override fun drawSprite(sprite: Sprite, canvas: Canvas, paint: Paint) {
    sprite.mask?.let { mask ->
        canvas.drawBitmap(mask, sprite.posX - mask.width / 2f,
            sprite.posY - mask.height / 2f + mScorllValue, paint)
    }
}

暂停游戏,用了个状态,多加点状态,对自定义view还是挺重要的:

kotlin 复制代码
override fun handleGame(gameView: BaseGameView): Boolean {
    // 如果页面在滚动,不处理逻辑
    if (mState == STATE_SCROLL) {
        return false
    }
    // ...
}

辅助线设计

第一次写完游戏的时候,自己都过不了关卡,想想还是得搞个辅助线,但是辅助线放鸡蛋上,还是放下一个要跳上去的平台上,我还思考了一会,最后还是用最简单的方法写了:

kotlin 复制代码
private fun drawTip(canvas: Canvas, paint: Paint) {
    // 其实很简单,按segment为间隔画HERO_FLY_COUNT个点就是预测的抛物线
    val x = mHeroSprite.posX.toFloat()
    val y = mHeroSprite.posY.toFloat() + mScorllValue
    val totalWidth = width - HORIZONTAL_PADDING * 2
    val segment = totalWidth / BOAT_MOVE_COUNT

    val oldStrokeWidth = paint.strokeWidth
    paint.strokeWidth = 1f
    for (i in 0..HERO_FLY_COUNT) {
        val cx1 = x + segment * i
        val cx2 = x - segment * i
        val cy = y - getFlyHeight(i)

        // 三条辅助线
        canvas.drawCircle(x, cy, 1f, paint)
        canvas.drawCircle(cx1, cy, 1f, paint)
        canvas.drawCircle(cx2, cy, 1f, paint)
    }
    paint.strokeWidth = oldStrokeWidth
}

这里取了个巧,因为已经写好了getFlyHeight方法,我们根据鸡蛋的飞行时间算得得HERO_FLY_COUNT,就能绘制鸡蛋接下来会出现的位置。

如果,这个moveCount向鸡蛋左右排列,就能得到两条抛物线,虽然看起来和上面平台没什么关系,实际逆向想一下,当上面的平台和抛物线轨迹重合时,鸡蛋起跳,就能准确地落到平台上,等效于从和抛物线轨迹重合时的平台以平台横向速度往下跳的鸡蛋,很抽象,但是确实有用。

源码及Demo

游戏源码:

FlyEggGameView.kt

fly_egg_game_view_style.xml

Gif图对应的demo:

FlyEggGameDemo.kt

fragment_game_fly_egg.xml

总结

这篇文章模仿了我小时候玩的一个飞蛋游戏,整理了一个小游戏基类,封装了一些常用功能,通过游戏内个元素的移动,对Android坐标体系做了实践练习,挺不错的。

相关推荐
拭心8 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王11 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡11 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道11 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库12 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道13 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe13 小时前
Android Hook - 动态加载so库
android
居居飒14 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He16 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗17 小时前
Android笔试面试题AI答之Android基础(1)
android