概述
重阳已过,中秋将至,想起农村老家,这个季节到了晚上,偶尔会比较凉爽,甚至有些凉意。不禁想吟词一首:
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()
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 private var maskView: MoonView 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
| var countOnce = 1
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() post(this) }
override fun run() { repeat(countOnce) { if (!isRunning) { return } val mouseView = MouseView( context, goneInterval = speed, container = this, listener = onMouseListener ) mouseView.show() }
postDelayed(this, speed) }
|
上面注释比较清晰,这个控件主要就是用来生成老鼠,以及将玩家的点击或者漏过事件通知给外部调用者,将老鼠的自动展示和消失逻辑封装在内部。
游戏Activity
最后就是游戏的主 MainActivity 实现了,在这里会把上面的控件都组合起来,实现游戏功能。游戏的布局文件就不贴了,布局效果如上的 Gif 图。
1 2 3 4 5 6 7 8
| class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { 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
| 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
| 渔歌子·重九 苍耳叔叔 陌上闲枝半过秋,重阳当饮酒难休。 杯入曲,晚归悠。炊烟袅袅正秋收。
|