0%

Android开发守护月饼小游戏

概述

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

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

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

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

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

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

吃月饼控件

月饼MoonView

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

首先看下月饼 View 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 控件来实现这个吃月饼控件。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 来实现被吃效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 自身,并用来判断游戏是否在进行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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 就是游戏底部的控件,它在开始游戏后用来控制老鼠的出现和消失,同时将老鼠的点击消失和自动消失回调给外部,其内有三个属性参数:

1
2
3
4
5
6
7
8
9
// 同时生成的 View 数
var countOnce = 1

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 图。

1
2
3
4
5
6
7
8
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ... 初始化 View
initData()
initListener()
initObserver()
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 的数据,用来展示当前关卡的文案。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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一下吧~

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