安卓小游戏:飞蛋游戏

前言

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

我这便是仿照了下游戏逻辑,资料和音效都没加,玩起来仍是挺有趣的,便是难度有点高了。

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

需求

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

  • 1,载入装备,读取游戏信息及掩图
  • 2,发动游戏,各个渠道按必定逻辑运动
  • 3,点击屏幕,鸡蛋起飞,校验和上面一层渠道的磕碰
  • 4,假如到屏幕最顶层,则暂停游戏,移动页面到适宜方位
  • 5,加入游戏完毕、游戏经过的提示
  • 6,添加辅助线,下降游戏难度

效果图

安卓小游戏:飞蛋游戏

代码

游戏基类封装

有了前面四个游戏的经历,我在做这个小游戏的时分,先对游戏代码做了个笼统封装,让它能复用大部分逻辑,在新游戏里边只需规划新的想法就行,下面是我封装的BaseGameView:

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)
    }
}

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

飞蛋游戏编写

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

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 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

<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

<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的布局可以看我源码:

    <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办法内,

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的距离,由于移动时要的是差值,所以还得核算下。

渠道移动

渠道的类型比较多,这儿就看下斜着运动的渠道:

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,是它的两倍,要让它做周期改变,咱们只需用取余操作就能完结:

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值,没有差值,需求额定处理下。

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办法,其他的就不用管了:

// 在本来的基础上添加页面翻滚值
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仍是挺重要的:

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

辅助线规划

第一次写完游戏的时分,自己都过不了关卡,想想仍是得搞个辅助线,可是辅助线放鸡蛋上,仍是放下一个要跳上去的渠道上,我还思考了一会,最后仍是用最简略的办法写了:

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坐标系统做了实践练习,挺不错的。