Wavky
Until Retirement: 30 Years
作者我没有任何游戏开发经验,本项目仅用于练习 Compose,游戏开发专家天黑请闭眼
猛男启动 继上一个 Compose 练习项目 SimpleTodo 之后,又尝试用 Compose 来做了一个翻牌记忆游戏。这次是零经验写游戏项目,连原型都没有做设计,问了 ChatGPT 游戏大概是怎么个玩法,就一步一步着手去写了。
本文号称一天开发完毕确实不假,从立案(问 ChatGPT)到开发完成(今天凌晨 4 点),所用时间共计 20 小时。(牛马的一天是 24 小时) 其中相当大部分的时间都用在了素材搜集、UX 试错方面,技术方面遇到的阻碍反而没有多少,毕竟这个游戏的设计实现还是比较简单粗暴。
游戏玩法很简单,开局随机布置 8 组 16 张卡牌,预览展示各个卡牌所在位置后全部盖牌,让玩家凭记忆力翻开配对的卡牌组。
您的浏览器不支持 video 标签。
做游戏与做 App 应用的区别 在一般 App 的开发中,UI/UX 的设计与互动实现方面占比大概只有 50% 前后,业务逻辑的工作量跟 UI/UX 不相上下。
而游戏开发,能够很明显感觉到 UI/UX 工作占比非常之重,就该项目而言近乎 100%(出于偷懒等客观原因,该项目没有提供数据存储及任何联网功能)。
开发的大部分时间里,都在与 Compose 的动画、状态对象、协程之间斗智斗勇,生死决斗之后留下了一个 800 行代码的 Activity。(几乎所有游戏逻辑都封装在这个 Activity 中)
因此与普通 App 应用相比,游戏开发更能体现出 Compose 在动画、交互方面的特点,通过开发游戏能够很大程度上加深对 Compose 结构的理解,从而更好地掌握 Compose 的 UI 设计方式。
在本项目中,所用素材全部来自互联网:
App 图标和封面图来自 ChatGPT 图片生成(封面图后期合成)
封面图底图、卡牌图、游戏背景、按钮背景来自 pixabay
GameOver 小恐龙来自 Pinterest
字体采用 精品点阵体7×7
其实一开始走的是 OPPO 小清新风格,但是从卡牌牌面图案颜色选用粉红的那一刻开始,路 就走歪了…
技术分解
所有逻辑都在上面这几个文件中:
1 2 3 4 5 6 7 8 app ├── common │ └── res:自定义的资源文件,包括字体,颜色等 ├── ui │ └── PlayCard.kt:游戏中的单个卡牌组件 ├── Config.kt:游戏配置,包括各个 UI 元素的尺寸、动画时长等,以及游戏难度设置 ├── Element.kt:卡牌牌面图案资源的枚举 └── MainActivity.kt:游戏主 Activity,所有游戏交互逻辑都在这个 Activity 中
游戏关卡难度设计 游戏难度主要体现在这些维度:
游戏开始前卡牌牌面预览时间
倒计时时间
容许犯错次数
游戏难度在原则上按关卡层层递增,为了简便,使用简单的数学公式在前一关基础上缩减时间、犯错次数来实现。 第一关作为新手教学关,不限时间,犯错次数无限制。
关卡难度设计如下表
第二关开始,游戏时间进入倒计时模式,60 秒计时开始,最大犯错次数为 20 次,卡牌预览时间为 5 秒
之后每关倒计时缩减为上一关的 80%,最大犯错次数减少 2 次
从第六关开始,卡牌预览时间减少 0.5 秒,直至第十五关缩减至 0 秒
卡牌预览时间是指,在所有卡牌牌面依次打开直至全部打开完毕起,到所有卡牌开始依次盖牌为止的时间段,不包括卡牌开牌、盖牌动画时间
因此在卡牌预览时间归零时,仍然可以依靠开牌盖牌动画时间,和惊为天人的最强大脑记忆力来进行游戏
最大犯错次数从第九关的 6 次起,逐次减少 1 次直至归零
倒计时时间从第十五关起固定为 3 秒
实测 16 张牌可以轻松实现在 2.7 秒内全部打开
测试时使用了作弊模式,实际游戏中最快手速需要配合最强大脑使用
因此,从第十五关开始进入魔鬼死斗模式…
Lv
倒计时秒数 (取整数秒;缩减比例 80%)
失手次数
预览秒数
1
无限 (75)
无限 (22)
5
2
60 (每关缩减至 80%)
20 (每关 -2)
5
3
48
18
5
4
38
16
5
5
30
14
5
6
24
12
4.5 (每关 -0.5)
7
19
10
4
8
15
8
3.5
9
12
6
3
10
10
5
2.5
11
8
4
2
12
6
3
1.5
13
5
2
1
14
4
1
0.5
15
3 (以后固定为 3)
0
0
16~
3
0
0
游戏关卡难度实现 游戏中所有参数通过一个全局 Config 数据对象来管理,这个对象在 Composable 中首先被初始化:
1 2 val config = remember { Config(...) }
关卡难度定义在 Config 的字段 levelLimit 中,其类型为 Sequence<LevelLimit>
序列,这个序列在每次通关时计算下一关的难度系数,封装到 LevelLimit 对象中交由相关 UI 节点读取使用。
LevelLimit 定义
1 2 3 4 5 6 7 8 data class LevelLimit ( val level: Int , val isCountdown: Boolean , val countdownMs: Long , val isCountMaxMiss: Boolean , val maxMissCount: Int , val previewTimeMs: Long , )
关卡难度的 Sequence 序列
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 val config = remember { Config( levelLimit = generateSequence( LevelLimit( level = 1 , isCountdown = false , countdownMs = 75_000 , isCountMaxMiss = false , maxMissCount = 22 , previewTimeMs = 5_000 ) ) { last -> val minCountdownMs = 3000L val nextCountdownMs = ... val nextMaxMissCount = ... val nextPreviewTimeMs = ... LevelLimit( level = last.level + 1 , isCountdown = true , countdownMs = if (nextCountdownMs < minCountdownMs) minCountdownMs else nextCountdownMs, isCountMaxMiss = true , maxMissCount = nextMaxMissCount, previewTimeMs = nextPreviewTimeMs, ) } ) } val levelIterator by remember { mutableStateOf(config.levelLimit.iterator()) }var level by remember { mutableStateOf(levelIterator.next()) }StartButton("LEVEL UP" ) { level = levelIterator.next() ... }
单张卡牌实现 单张卡牌的定义在 PlayCard.kt 中,可通过参数指定卡牌的牌面图案元素、前后背景图、卡牌高度(宽度将根据高度自动计算)等。 卡牌中自实现一套翻转动画,动画中使用的翻转时长、延迟时间也通过参数指定。 参数中通过 flipToFront: Boolean
来指定卡牌朝向,更改朝向时,自动通过动画来完成卡牌翻转。
1 2 3 4 5 6 7 8 9 10 11 12 @Composable fun PlayCard ( element: Element , @DrawableRes cardFace: Int , @DrawableRes cardBack: Int , flipToFront: Boolean , height: Dp , modifier: Modifier = Modifier, initialDelay: Long , initialFlipDuration: Long , flipDuration: Long , )
卡牌翻转动画实现 —— 动画脚本 卡牌沿着 Y 轴中线进行前后 180 度翻转,模拟 3D 效果(实际上是 2D 的视差旋转 + 背景切换显示实现)。 动画中当卡牌旋转至 90 度(玩家看不到卡牌牌面)时切换卡牌显示的背景。
翻转的动画脚本:
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 50 51 52 53 54 55 56 57 LaunchedEffect(flipToFront) { val halfDuration = flipDuration.toInt() / 2 fun <T> upperTween () : TweenSpec<T> = tween(halfDuration, easing = FastOutLinearInEasing) fun <T> lowerTween () : TweenSpec<T> = tween(halfDuration, easing = LinearOutSlowInEasing) val upperRotate = async { rotationAnimatable.animateTo( if (flipToFront) 90f else 270f , animationSpec = upperTween() ) } val upperScale = async { scaleAnimatable.animateTo( 1.5f , animationSpec = upperTween() ) } upperRotate.await() upperScale.await() showCardFace = flipToFront val lowerRotate = async { rotationAnimatable.animateTo( if (flipToFront) 180f else 360f , animationSpec = lowerTween() ) } val lowerScale = async { scaleAnimatable.animateTo( 1f , animationSpec = lowerTween() ) } lowerRotate.await() lowerScale.await() if (!flipToFront) { rotationAnimatable.snapTo(0f ) } }
卡牌翻转动画实现 —— 绘制 伴随着动画脚本的执行,Animatable 的值会在每一帧中被实时计算更新,在卡牌的容器中通过 Modifier.drawWithContent 函数应用更新后的绘制参数值,重新绘制卡牌中每一帧的翻转效果。
在 Compose 的 Canvas 中,旋转操作是基于平面上一个点的顺时针或逆时针旋转,而非我们需要的 3D 透视旋转。 要实现 3D 透视旋转效果,则需要借助 Android SDK 中 android.graphics.Camera 的三维定位功能,来对画布进行视差旋转,从而达到远小近大的效果。
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 val camera by remember { mutableStateOf(Camera()) }Card( modifier = Modifier .drawWithContent { drawIntoCanvas { canvas -> val nativeCanvas = canvas.nativeCanvas canvas.translate(size.width / 2 , size.height / 2 ) camera.save() camera.rotateY(rotationAnimatable.value) camera.applyToCanvas(nativeCanvas) camera.restore() canvas.translate(-size.width / 2 , -size.height / 2 ) } drawContent() } .scale(scaleAnimatable.value) ) { }
在 Activity 中完成游戏逻辑 在 Activity 中,实现了卡牌游戏的整套流程逻辑,主要就是根据游戏中各个阶段的状态,控制 UI 元素的显示,执行各类动画。 同时响应玩家点击卡牌的动作,判断卡牌配对情况,并执行胜负判定等逻辑。
这里需要反思研究的一点是,由于缺乏游戏设计经验,导致后期各种状态标记满天飞,缺乏可维护性,逻辑实现时也容易出现状态切换错误等疏漏,量产各种难以调试的 bug,在下次创建游戏项目时,应该提前通盘设计好游戏的流程状态变换,设计出合理的状态机制来避免这样的事故发生。
这里不会再重构成状态机形式,但列举一些状态标记的实现方式。
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 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 fun Content () { val config = remember { Config() } var restartGame by remember { mutableIntStateOf(0 ) } var startGame by remember { mutableStateOf(false ) } var complete by remember { mutableStateOf(false ) } var gameOver by remember { mutableIntStateOf(0 ) } val flipToFrontList = remember(restartGame) { mutableStateListOf<Boolean >().apply { repeat(config.count) { add(false ) } } } val staticFlipList by rememberUpdatedState(flipToFrontList) var isStartButtonVisible by remember { mutableStateOf(false ) } ... var showPauseMessage by remember { mutableStateOf(false ) } ... var timeCount by remember(restartGame) { mutableIntStateOf(0 ) } var timeText by remember(restartGame) { mutableStateOf("00:00" ) } var miss by remember(restartGame) { mutableIntStateOf(0 ) } var lastFlipIndex by remember(restartGame) { mutableIntStateOf(-1 ) } LaunchedEffect(Unit ) { ... } LaunchedEffect(restartGame) { ... } LaunchedEffect(startGame) { ... } PlayCard( modifier = Modifier.pointerInput(Unit ) { detectTapGestures { ... } } ) BackHandler(startGame) { ... } }