零经验选手,Compose 一天开发一款小游戏!
![]()
猛男翻卡牌
猛男启动
继上一个 Compose 练习项目 SimpleTodo 之后,又尝试用 Compose 来做了一个翻牌记忆游戏【猛男翻卡牌】。这次是零经验写游戏项目,连原型都没有做设计,问了 ChatGPT 游戏大概是怎么个玩法,就一步一步着手去写了。
本文号称一天开发完毕确实不假,从立案(问 ChatGPT)到开发完成(今天凌晨 4 点),所用时间共计 20 小时。(牛马的一天是 24 小时)
其中相当大部分的时间都用在了素材搜集、UX 试错方面,技术方面遇到的阻碍反而没有多少,毕竟这个游戏的设计实现还是比较简单粗暴。
游戏玩法很简单,开局随机布置 8 组 16 张卡牌,预览展示各个卡牌所在位置后全部盖牌,让玩家凭记忆力翻开配对的卡牌组。
您的浏览器不支持 video 标签。
️ 高能预警:作者我没有任何游戏开发经验,本项目仅用于练习 Compose,游戏开发专家天黑请闭眼
做游戏与做 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 小清新风格,但是从卡牌牌面图案颜色选用粉红的那一刻开始,路 就走歪了...
技术分解
项目源码与APK:https://github.com/wavky/MemoryCardGame-Compose

所有逻辑都在上面这几个文件中:
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 中首先被初始化:
// 通过 remember 缓存 Config 对象,实现全局单例
val config = remember { Config(...) }
关卡难度定义在 Config 的字段 levelLimit 中,其类型为 Sequence<LevelLimit> 序列,这个序列在每次通关时计算下一关的难度系数,封装到 LevelLimit 对象中交由相关 UI 节点读取使用。
LevelLimit 定义
data class LevelLimit(
val level: Int,
val isCountdown: Boolean, // 倒计时或正向计时(第一关为正向计时,不限游戏时间)
val countdownMs: Long,
val isCountMaxMiss: Boolean, // 是否计算最大错误次数(第一关不限制错误次数)
val maxMissCount: Int,
val previewTimeMs: Long,
)
关卡难度的 Sequence 序列
val config = remember {
Config(
// 通过 generateSequence 生成序列
levelLimit = generateSequence(
// 第一关难度设置为新手村
LevelLimit(
level = 1, // 关卡序号
isCountdown = false, // 第一关为正向计时,不限游戏时间
countdownMs = 75_000, // 为第二关计算出 60 秒倒计时提供基础数值
isCountMaxMiss = false, // 第一关不限制错误次数
maxMissCount = 22, // 为第二关计算出 20 次错误次数提供基础数值
previewTimeMs = 5_000 // 5 秒预览时间
)
) { last ->
// 第二关开始每一关的难度系数的计算
val minCountdownMs = 3000L // 最小倒计时时间固定为 3 秒
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,
)
}
)
}
// 通过 remember 缓存序列迭代器对象
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 来指定卡牌朝向,更改朝向时,自动通过动画来完成卡牌翻转。
@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 度(玩家看不到卡牌牌面)时切换卡牌显示的背景。

翻转的动画脚本:
// 根据参数 `flipToFront` 的值,决定翻转动画的正向(前翻)还是反向(后翻)
LaunchedEffect(flipToFront) {
// 翻转动画的时长
// 参数中传入的是从 A 面开始翻转至 90 度(玩家看不到卡牌牌面),再继续翻转至 180 度完整显示 B 面的完整时长
// 这里我们要获取翻转至 90 度时所使用的时长
val halfDuration = flipDuration.toInt() / 2
// 动画分成上下两部分,例如上半部分是从 0 ~ 90 度,下半部分则是从 90 ~ 180 度
// 在上半部分动画执行完毕后,玩家看不见卡牌的状态下,切换卡牌显示的背景图案,然后再执行下半部分的动画
fun <T> upperTween(): TweenSpec<T> = tween(halfDuration, easing = FastOutLinearInEasing)
fun <T> lowerTween(): TweenSpec<T> = tween(halfDuration, easing = LinearOutSlowInEasing)
// 上半部分动画同时进行翻转和放大
val upperRotate = async {
rotationAnimatable.animateTo(
// 如果是前翻,则从 0 旋转至 90 度,如果是后翻,则从 180 旋转至 270 度
if (flipToFront) 90f else 270f,
animationSpec = upperTween()
)
}
val upperScale = async {
scaleAnimatable.animateTo(
1.5f,
animationSpec = upperTween()
)
}
// 等待上半部分动画执行完毕
upperRotate.await()
upperScale.await()
// 切换卡牌显示的背景图案,如果是 true 则 Image 显示 cardFace 图片,否则显示 cardBack 图片
showCardFace = flipToFront
// 下半部分动画同时进行翻转和缩小
val lowerRotate = async {
rotationAnimatable.animateTo(
// 如果是前翻,则从 90 旋转至 180 度,如果是后翻,则从 270 旋转至 360 度
if (flipToFront) 180f else 360f,
animationSpec = lowerTween()
)
}
val lowerScale = async {
scaleAnimatable.animateTo(
1f,
animationSpec = lowerTween()
)
}
// 等待下半部分动画执行完毕
lowerRotate.await()
lowerScale.await()
// 如果是后翻,则在翻转完成后将旋转角度重置为 0,否则下次前翻时会从 360 度翻转到 90 度(逆向疯狂转体)
if (!flipToFront) {
rotationAnimatable.snapTo(0f)
}
}
卡牌翻转动画实现 —— 绘制
伴随着动画脚本的执行,Animatable 的值会在每一帧中被实时计算更新,在卡牌的容器中通过 Modifier.drawWithContent 函数应用更新后的绘制参数值,重新绘制卡牌中每一帧的翻转效果。
这一节内容用于引流,有兴趣的同学请移步至小站:https://wavky.top/MemoryCard/

(我看看是谁在白嫖)
在 Activity 中完成游戏逻辑
在 Activity 中,实现了卡牌游戏的整套流程逻辑,主要就是根据游戏中各个阶段的状态,控制 UI 元素的显示,执行各类动画。
同时响应玩家点击卡牌的动作,判断卡牌配对情况,并执行胜负判定等逻辑。
这里需要反思研究的一点是,由于缺乏游戏设计经验,导致后期各种状态标记满天飞,缺乏可维护性,逻辑实现时也容易出现状态切换错误等疏漏,量产各种难以调试的 bug,在下次创建游戏项目时,应该提前通盘设计好游戏的流程状态变换,设计出合理的状态机制来避免这样的事故发生。
这里不会再重构成状态机形式,但列举一些状态标记的实现方式。
fun Content() {
// 创建 Config 对象,指定卡牌的布局数量、游戏关卡难度、卡牌动画参数等基础配置
// 因为 Config 对象设计为不可变,因此仅需通过 remember 缓存,但不需要使用 State 进行包装
val config = remember { Config() }
// 这是几个重要的状态标记,指示游戏的当前流程状态:
// 从 Lv1 重新开始游戏(重置游戏)
var restartGame by remember { mutableIntStateOf(0) }
// 开始游戏(倒计时开始,玩家可以点击卡牌),或游戏结束(玩家不可再点击卡牌)
// 在卡牌预览动画结束时,更新为 true
// 在玩家每次点击时判断游戏胜负、错误次数过多、倒计时结束等情况时,更新为 false
var startGame by remember { mutableStateOf(false) }
// 关卡挑战成功
var complete by remember { mutableStateOf(false) }
// 关卡挑战失败,游戏结束
var gameOver by remember { mutableIntStateOf(0) }
// 这是一个卡牌组的状态标记列表,用于记录每一张卡牌是否显示正面
// 通过 remember 缓存,并且在 restartGame 更新时(挑战失败)重新初始化,重新赋值一个新对象
val flipToFrontList = remember(restartGame) {
mutableStateListOf<Boolean>().apply { repeat(config.count) { add(false) } }
}
// 由于 flipToFrontList 需要在 Effect 类函数中访问
// 因此需要使用 rememberUpdatedState,将 flipToFrontList 对象包装为指向不变的 State
// 避免 Effect 闭包中因缓存 flipToFrontList 的旧对象而产生脏数据问题
val staticFlipList by rememberUpdatedState(flipToFrontList)
// 这是一些 UI 元素的显示、隐藏状态标记
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(
// 由于卡牌使用 Material 组件,但默认在 Modifier.clickable 中点击时会产生一个灰色背景
// 为了避免这种情况,改用更底层的 Modifier.pointerInput 监听并响应点击事件
// 在其他可点击的 UI 组件中也会使用相同做法
modifier = Modifier.pointerInput(Unit) {
detectTapGestures {
... // 处理点击事件
}
}
)
// 响应系统返回键的点按事件
// 仅在开始游戏时有效,此时会询问是否终止游戏
// 其余场合则直接退出应用
BackHandler(startGame) { ... }
}
零经验选手,Compose 一天开发一款小游戏!的更多相关文章
- 带你使用h5开发移动端小游戏
带你使用h5开发移动端小游戏 在JY1.x版本中,你要做一个pc端的小游戏,会非常的简单,包括说,你要在低版本的浏览器IE8中,也不会出现明显的卡顿现象,你只需要关心游戏的逻辑就行了,比较适合逻辑较为 ...
- javascrpit开发连连看记录-小游戏
工作之余,总想做点什么有意思的东西.但是苦于不知道做什么,也就一直没有什么动作.在一个午饭后,跟@jedmeng和@墨尘聊天过程中,发现可以写一些小东西来练练手,有以下几点好处: 1. ...
- Python开发接水果小游戏
我研发的Python游戏引擎Pylash已经更新到1.4了.如今我们就来使用它完毕一个极其简单的小游戏:接水果. 下面是游戏截图: 游戏操作说明:点击屏幕左右两边或者使用键盘方向键控制人物移动.使人物 ...
- DirectX游戏开发——从一个小游戏開始
本系列文章由birdlove1987编写,转载请注明出处. 文章链接: http://blog.csdn.net/zhurui_idea/article/details/26364129 写在前面:自 ...
- Pyqt5开发一款小工具(翻译小助手)
翻译小助手 开发需求 首先五月份的时候,正在学习爬虫的中级阶段,这时候肯定要接触到js逆向工程,于是上网找了一个项目来练练手,这时碰巧有如何进行对百度翻译的API破解思路,仿造网上的思路,我摸索着完成 ...
- C语言编程学习开发的俄罗斯方块小游戏
C语言是面向过程的,而C++是面向对象的 C和C++的区别: C是一个结构化语言,它的重点在于算法和数据结构.C程序的设计首要考虑的是如何通过一个过程,对输入(或环境条件)进行运算处理得到输出(或实现 ...
- python开发_自己开发的一个小游戏
先看看游戏的运行效果: 看完游戏的运行情况,你可能对游戏有了一定了了解: #运行游戏后,玩家首先要进行语音的选择,1选择英语,2选择汉语,其他则默认选择英语 #根据玩家选择的语音,进入不同的语音环境 ...
- 基于cocos2d开发的android小游戏——採花仙
/*cocos 2d 已经成为了如今移动端游戏开发的强有力的工具,眼下主流游戏中多採用cocos 2d游戏引擎. 我也尝试了一下该引擎.我是用的是cocos2d-android,以后要移植到Cocos ...
- IOS学习之路五(SpriteKit 开发飞机大战小游戏一)
参考SpriteKit 创建游戏的教程今天自己动手做了一下,现在记录一下自己怎么做的,今天之做了第一步,一共有三个部分. 第一步,项目搭建. 项目所用图片资源:点击打开链接 1.在Xcode打开之后, ...
- 使用Laya引擎开发微信小游戏(上)
本文由云+社区发表 使用一个简单的游戏开发示例,由浅入深,介绍了如何用Laya引擎开发微信小游戏. 作者:马晓东,腾讯前端高级工程师. 微信小游戏的推出也快一年时间了,在IEG的游戏运营活动中,也出现 ...
随机推荐
- Visual Studio Code启动时总是提示“Code安装似乎损坏。请重新安装。”、标题栏显示“不受支持”等信息的解决办法
我的VSCode一直提示"Code安装似乎损坏.请重新安装."同时标题栏显示"不受支持"就像这样: 反思了一下,应该是我安装的background插件,把vsc ...
- WorldWind源码剖析系列:漫游时四叉树瓦片类QuadTile的运行思路
用户在窗口漫游时,需要加载精细的高程和纹理数据时的处理思路:当用户漫游时直到窗口相机的视场角的1/2小于(瓦片大小*瓦片绘制距离的乘积)时,才初始化四叉树瓦片类QuadTile,或者加载本地缓存中的数 ...
- 抖音技术分享:抖音Android端手机功耗问题的全面分析和详细优化实践
本文由字节跳动技术团队高原.汤中峰分享,原题"抖音功耗优化实践",本文有修订和改动. 一.引言 功耗优化是应用体验优化的一个重要课题,高功耗会引发用户的电量焦虑,也会导致糟糕的发热 ...
- 【译】在分析器中使用 Meter Histogram(直方图)解锁见解
您是否正在与应用程序中的性能瓶颈作斗争?不要再观望了!Visual Studio 2022 在其性能分析套件中引入了 Meter Histogram(直方图)功能,为您提供了前所未有的分析和可视化直方 ...
- 基于 Admission Webhook 实现 Pod DNSConfig 自动注入
本文主要分享如何使用 基于 Admission Webhook 实现自动修改 Pod DNSConfig,使其优先使用 NodeLocalDNS . 1.背景 上一篇部署好 NodeLocal DNS ...
- 《CUDA编程:基础与实践》读书笔记(3):同步、协作组、原子函数
1. 单指令多线程模式 从硬件上看,一个GPU被分为若干个SM.线程块在执行时将被分配到还没完全占满的SM中,一个线程块不会被分配到不同的SM中,一个SM可以有一个或多个线程块.不同线程块之间可以并发 ...
- MAC安装redis的简单方法
part 1:安装redis1.官网下载压缩包https://redis.io/download or brew install redis(太慢了-)我此处选的法一,先去官网上下载包,在解压使用. ...
- manim边做边学--淡入淡出变换
今天介绍Manim中用于淡入淡出变换的3个动画类: FadeToColor:聚焦于对象颜色的平滑转换,通过渐变增强视觉效果 FadeTransform:实现不同对象之间的渐变替换,让元素转换更加连贯 ...
- 阿里云开启ssl证书过程记录 NGINX
作者简介:大家好,我是思无邪,2024 毕业生,某厂 Go 开发工程师.. 我的网站:https://www.yishanicode.top/ ,持续更新,希望对你有帮助. 如果文章或网站知识点有错误 ...
- 路由协议过程概述--ospf-01
路由是数据通信网络中最基本的要素.路由信息就是指导报文发送的路径信息,路由的过程就是报文转发的过程. 根据路由目的地的不同,路由可划分为: 网段路由:目的地为网段,IPv4地址子网掩码长度小于32位或 ...