实战|Android开发守护月饼小游戏

4,307 阅读8分钟

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

概述

重阳已过,中秋将至,想起农村老家,这个季节到了晚上,偶尔会比较凉爽,甚至有些凉意。不禁想吟词一首:

      定风波·湖村晚
        苍耳叔叔
湖面蒹葭荡影重,黄昏渐映水寒清。远处人家声影乱,亲唤,小童归去老村惊。
月上枝头双戏景,微冷,农家秋月夜燃灯。灯影幢幢人影瘦,浊酒,菜花香入梦回轻。

好吧,这其实是一篇技术文章。周末闲来没事,看到了掘金的中秋投稿活动,正好写个“守护月饼”的小游戏来玩玩,游戏名和游戏 UI 都是我瞎扯的~直接上效果图:

UI布局方面就别吐槽了,让一个开发来思考这个问题简直噩梦(😂),里面的色值,样式,布局换了又换,随着视觉效果的越来越诡异,我只好恋恋不舍地放弃了 UI 上的修改(🐶)。

这个小游戏底部会不停出现一些大小随机的老鼠,然后过一会后自动消失,自动消失后上方的月饼会被吃掉对应老鼠体积的一部分,点击老鼠可以增加月饼对应的体积,延长寿命。目前一共设置了 15 关,每一关都设置了 20s 倒计时,在时间内月饼未被吃光则视为胜利!在通关后会有神秘奖品哦~中间的 Banner 广告是瞎加的,不然看上去 UI (我自己)感觉底部的老鼠区域太大了不协调。

接下来看看游戏实现,源码使用 MVVM 架构,github 链接在文末,欢迎 star~

吃月饼控件

月饼MoonView

首先看看顶部这个月饼控件的实现,其实本来一开始是想做“守护月亮”,网上查了查月亮阴晴阳缺的代码,看到是用xxx曲线,椭圆画的,毕业到两年,学霸也无言,我觉得大可不必,还是别去挑战这些数学问题了吧,已经过了争狠斗勇的年纪了(Doge),所以把月亮换成了月饼,用俩月饼来实现这个被吃掉的效果,一个是“正常”月饼,另一个是跟背景色一样的月饼,通过用这个白色的月饼左右移动,来遮挡住正常的月饼,实现视觉上被吃掉的效果。

首先看下月饼 View 的实现:

class MoonView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
    // 半径
    private var radius = SizeUtil.dp2px(30f)
    // 颜色
    private var color = Color.BLUE
    // 绘制的文本
    private var text = ""
    // 圆的画笔
    private val moonPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    // 文本画笔
    private val textPaint = TextPaint()

    // 省略了初始化和 onMeasure 方法

    /**
     * 画圆和文本
     */
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        val padding = (measuredWidth - radius * 2) / 2f
        canvas?.drawCircle(padding + radius, padding + radius, radius.toFloat(), moonPaint)
        if (text.isNotEmpty()) {
            val fontMetrics = textPaint.fontMetricsInt
            canvas?.drawText(
                text,
                radius + padding,
                radius + padding - (fontMetrics.bottom + fontMetrics.top) / 2,
                textPaint
            )
        }
    }
}

这个自定义 View 其实很简单,就是画了个实心圆,然后中心画上文本。这里其实可以直接用一张月饼的图片,也可以用月饼的 emoji 等,不过中心写上“月饼”的文字是不是最直白!

吃月饼MoonEatView

接着就是通过上面的月饼 MoonView 控件来实现这个吃月饼控件。

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

    // 正常月饼
    private var moonView: MoonView
    
    // 用来遮挡正常月饼的 mask 月饼
    private var maskView: MoonView
    
    // mask 月饼的移动量
    private var maskTranX: Float = 0f
}

上面定义了两个月饼:一个正常显示的月饼 moonView,另一个是遮挡的背景色月饼 maskView,通过移动 maskView 来实现被吃效果:

fun translateMask(tranX: Float) {
    maskTranX += tranX
    if (maskTranX < 0) {
        maskTranX = 0f
    }
    if (maskTranX > moonView.width) {
        maskTranX = moonView.width.toFloat()
    }
    maskView.animate().translationX(maskTranX).setDuration(100).setListener(object : AnimatorListenerAdapter() {
        override fun onAnimationEnd(animation: Animator?) {
            if (maskAll()) {
                // 如果全部被吃掉了,则回调监听器
                onMaskListener?.onMaskAll()
            }
        }
    }).start()
    // 根据被吃的大小,展示不同的月饼色
    modifyMoonColor()
}

上面都有注释,就不详细介绍代码逻辑了。在下面的老鼠控件消失后,会计算对应应该吃掉或增加的月饼偏移量,然后设置给 MoonEatView, 来实现这个效果。

老鼠控件

老鼠MouseView

考虑单个老鼠控件的特点:展示一段时间后消失,如果是自动消失则回调 onDismiss 监听方法,如果是点击后消失,则应该回调 onClick 监听方法,注意这些回调方法都应该在游戏正在进行的时候才执行。

因此 MouseView 需要持有其所在的容器 ViewGroup 的引用,用来添加移除老鼠 View 自身,并用来判断游戏是否在进行:

class MouseView constructor(
    context: Context,
    private val goneInterval: Long,  // 超时自动消失的时间,用来控制不同关卡的难度
    private val container: OperateLayout,
    private val listener: OnMouseListener?
) : AppCompatImageView(context, null, 0), Runnable {
    init {
        setImageDrawable(
            AppCompatResources.getDrawable(
                context, when (Random.nextInt(3)) {
                    0 -> R.drawable.mouse1
                    1 -> R.drawable.mouse2
                    else -> R.drawable.mouse3
                }
            )
        )
        // 设置点击后移除自身,并移除超时自动消失的任务
        setOnClickListener {
            removeCallbacks(this)
            container.removeView(this)
            if (container.isRunning) {
                listener?.onClick(size())
            }
        }
    }

    /**
     * 超时自动消失的任务
     */
    override fun run() {
        container.removeView(this)
        if (container.isRunning) {
            listener?.onDismiss(size())
        }
    }

    /**
     * 展示自身,并发送一个超时自动消失的任务
     */
    fun show() {
        val size = mouseSize()
        // 随机大小,随机位置
        val params = FrameLayout.LayoutParams(size, size)
        params.leftMargin = Random.nextInt(0, max(container.width - size, 1))
        params.topMargin = Random.nextInt(0, max(container.height - size, 1))
        container.addView(this, params)
        postDelayed(this, goneInterval)
    }
}

上面代码都有注释,逻辑比较清晰,这里用了一个 goneInterval 属性来控制不同关卡的难度,这个参数表示老鼠超时多久没被点击后会自动消失。

老鼠容器OperateLayout

OperateLayout 就是游戏底部的控件,它在开始游戏后用来控制老鼠的出现和消失,同时将老鼠的点击消失和自动消失回调给外部,其内有三个属性参数:

// 同时生成的 View 数
var countOnce = 1

// 生成 View 的间隔速度
var speed = 1000L

// 游戏是否进行中
var isRunning = false
    private set

countOnce 和 speed 用来控制关卡游戏难度,isRunning 表示游戏是否在进行。然后就是游戏开始的逻辑了:

fun start(listener: MouseView.OnMouseListener) {
    this.onMouseListener = listener
    this.isRunning = true
    removeAllViews()
    // 发送一个任务,会执行下面的 run 方法
    post(this)
}

override fun run() {
    repeat(countOnce) { // 生成 countOnce 数量的老鼠
        if (!isRunning) {
            return
        }
        val mouseView = MouseView(
            context,
            goneInterval = speed,
            container = this,
            listener = onMouseListener
        )
        // 调用老鼠的 show 方法来展示
        mouseView.show()
    }

    // 发送延时任务,一段时间后接着生成老鼠
    postDelayed(this, speed)
}

上面注释比较清晰,这个控件主要就是用来生成老鼠,以及将玩家的点击或者漏过事件通知给外部调用者,将老鼠的自动展示和消失逻辑封装在内部。

游戏Activity

最后就是游戏的主 MainActivity 实现了,在这里会把上面的控件都组合起来,实现游戏功能。游戏的布局文件就不贴了,布局效果如上的 Gif 图。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        // ... 初始化 View
        initData()
        initListener()
        initObserver()
    }
}

上面主要有三个方法,先看 initData 方法:

private fun initData() {
    gameViewModel.initData(this)
    bannerView.isScrollRepeatable = true
    bannerView.highLightColor = Color.GRAY
    bannerView.setContentText(bannerViewModel.getBannerText())
    bannerView.resume(100)
}

class GameViewModel : ViewModel() {
    private val _level: MutableLiveData<Int> = MutableLiveData(1)
    val level: LiveData<Int> = _level

    fun initData(context: Context) {
        _level.value = getLevel(context)
    }
}

首先 gameViewModel.initData() 方法用来在 ViewModel 中初始化当前的关卡是第几关,简单实现,当前关卡是用 SP 存储的。然后就是初始化 Banner 控件了,这个 Banner 控件我在之前的自定义 View 文章中有写过,直接拿来用: 有意思的自定义View | 高亮滚动文本, 有意思的自定义View | 手指平移缩放旋转

接着在 initListener 方法中初始化监听器,具体代码可以看文章末贴出的 GitHub 链接,这里在“开始游戏”的点击事件里,会先判断当前关卡是不是已经通关了,通关则会提醒是否跳转神秘奖品页面,否则会调用 startGame() 开始游戏。

至于 initObserver 方法则是监听 gameViewModel.level 这个 LiveData 的数据,用来展示当前关卡的文案。

最后再看下游戏开始的方法实现:

// startGame()
operateLayout.start(object : MouseView.OnMouseListener {
    override fun onClick(size: Int) {
        moonEatLayout.translateMask(-maskTranslate(size))
    }

    override fun onDismiss(size: Int) {
        moonEatLayout.translateMask(maskTranslate(size))
    }
})
moonEatLayout.onMaskListener = object : MoonEatView.OnMaskListener {
    override fun onMaskAll() {
        stopGame()
    }
}

可以看到游戏开始就是调用了 OperateLayout.start() 方法,底部开始老鼠的出现和消失,然后在其回调方法中调用 MoonEatLayout.translateMask() 方法来控制月饼的被吃掉和增多效果。

总结

看了一圈下来,其实游戏实现是比较简单的,重要的是啊,蹭蹭中秋的热气,图个吉利!

总结一下这个小游戏的逻辑是:开始游戏后,底部老鼠控件 OperateLayout.start() 会控制老鼠的出现和消失,老鼠的点击消失和自动消失会触发对应的回调,在回调方法里通过计算偏移量,然后设置给 MoonEatLayout 吃月饼控件来实现吃月饼的效果。

游戏一个设置了 15 关,通关后有神秘奖品哦~

附上 apk链接, 有兴趣的可以玩玩,GitHub源码链接 在这,欢迎点赞, star~

重九刚过,远在异乡的各种飘们是否想家呢?再来一首词fs一下吧~

      渔歌子·重九
        苍耳叔叔
陌上闲枝半过秋,重阳当饮酒难休。
杯入曲,晚归悠。炊烟袅袅正秋收。

文中内容如有错误欢迎指出,共同进步!觉得不错的同学留个再走哈~

博文链接