猛男翻卡牌

猛男启动

继上一个 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 一天开发一款小游戏!的更多相关文章

  1. 带你使用h5开发移动端小游戏

    带你使用h5开发移动端小游戏 在JY1.x版本中,你要做一个pc端的小游戏,会非常的简单,包括说,你要在低版本的浏览器IE8中,也不会出现明显的卡顿现象,你只需要关心游戏的逻辑就行了,比较适合逻辑较为 ...

  2. javascrpit开发连连看记录-小游戏

        工作之余,总想做点什么有意思的东西.但是苦于不知道做什么,也就一直没有什么动作.在一个午饭后,跟@jedmeng和@墨尘聊天过程中,发现可以写一些小东西来练练手,有以下几点好处:     1. ...

  3. Python开发接水果小游戏

    我研发的Python游戏引擎Pylash已经更新到1.4了.如今我们就来使用它完毕一个极其简单的小游戏:接水果. 下面是游戏截图: 游戏操作说明:点击屏幕左右两边或者使用键盘方向键控制人物移动.使人物 ...

  4. DirectX游戏开发——从一个小游戏開始

    本系列文章由birdlove1987编写,转载请注明出处. 文章链接: http://blog.csdn.net/zhurui_idea/article/details/26364129 写在前面:自 ...

  5. Pyqt5开发一款小工具(翻译小助手)

    翻译小助手 开发需求 首先五月份的时候,正在学习爬虫的中级阶段,这时候肯定要接触到js逆向工程,于是上网找了一个项目来练练手,这时碰巧有如何进行对百度翻译的API破解思路,仿造网上的思路,我摸索着完成 ...

  6. C语言编程学习开发的俄罗斯方块小游戏

    C语言是面向过程的,而C++是面向对象的 C和C++的区别: C是一个结构化语言,它的重点在于算法和数据结构.C程序的设计首要考虑的是如何通过一个过程,对输入(或环境条件)进行运算处理得到输出(或实现 ...

  7. python开发_自己开发的一个小游戏

    先看看游戏的运行效果: 看完游戏的运行情况,你可能对游戏有了一定了了解: #运行游戏后,玩家首先要进行语音的选择,1选择英语,2选择汉语,其他则默认选择英语 #根据玩家选择的语音,进入不同的语音环境 ...

  8. 基于cocos2d开发的android小游戏——採花仙

    /*cocos 2d 已经成为了如今移动端游戏开发的强有力的工具,眼下主流游戏中多採用cocos 2d游戏引擎. 我也尝试了一下该引擎.我是用的是cocos2d-android,以后要移植到Cocos ...

  9. IOS学习之路五(SpriteKit 开发飞机大战小游戏一)

    参考SpriteKit 创建游戏的教程今天自己动手做了一下,现在记录一下自己怎么做的,今天之做了第一步,一共有三个部分. 第一步,项目搭建. 项目所用图片资源:点击打开链接 1.在Xcode打开之后, ...

  10. 使用Laya引擎开发微信小游戏(上)

    本文由云+社区发表 使用一个简单的游戏开发示例,由浅入深,介绍了如何用Laya引擎开发微信小游戏. 作者:马晓东,腾讯前端高级工程师. 微信小游戏的推出也快一年时间了,在IEG的游戏运营活动中,也出现 ...

随机推荐

  1. Visual Studio Code启动时总是提示“Code安装似乎损坏。请重新安装。”、标题栏显示“不受支持”等信息的解决办法

    我的VSCode一直提示"Code安装似乎损坏.请重新安装."同时标题栏显示"不受支持"就像这样: 反思了一下,应该是我安装的background插件,把vsc ...

  2. WorldWind源码剖析系列:漫游时四叉树瓦片类QuadTile的运行思路

    用户在窗口漫游时,需要加载精细的高程和纹理数据时的处理思路:当用户漫游时直到窗口相机的视场角的1/2小于(瓦片大小*瓦片绘制距离的乘积)时,才初始化四叉树瓦片类QuadTile,或者加载本地缓存中的数 ...

  3. 抖音技术分享:抖音Android端手机功耗问题的全面分析和详细优化实践

    本文由字节跳动技术团队高原.汤中峰分享,原题"抖音功耗优化实践",本文有修订和改动. 一.引言 功耗优化是应用体验优化的一个重要课题,高功耗会引发用户的电量焦虑,也会导致糟糕的发热 ...

  4. 【译】在分析器中使用 Meter Histogram(直方图)解锁见解

    您是否正在与应用程序中的性能瓶颈作斗争?不要再观望了!Visual Studio 2022 在其性能分析套件中引入了 Meter Histogram(直方图)功能,为您提供了前所未有的分析和可视化直方 ...

  5. 基于 Admission Webhook 实现 Pod DNSConfig 自动注入

    本文主要分享如何使用 基于 Admission Webhook 实现自动修改 Pod DNSConfig,使其优先使用 NodeLocalDNS . 1.背景 上一篇部署好 NodeLocal DNS ...

  6. 《CUDA编程:基础与实践》读书笔记(3):同步、协作组、原子函数

    1. 单指令多线程模式 从硬件上看,一个GPU被分为若干个SM.线程块在执行时将被分配到还没完全占满的SM中,一个线程块不会被分配到不同的SM中,一个SM可以有一个或多个线程块.不同线程块之间可以并发 ...

  7. MAC安装redis的简单方法

    part 1:安装redis1.官网下载压缩包https://redis.io/download or brew install redis(太慢了-)我此处选的法一,先去官网上下载包,在解压使用. ...

  8. manim边做边学--淡入淡出变换

    今天介绍Manim中用于淡入淡出变换的3个动画类: FadeToColor:聚焦于对象颜色的平滑转换,通过渐变增强视觉效果 FadeTransform:实现不同对象之间的渐变替换,让元素转换更加连贯 ...

  9. 阿里云开启ssl证书过程记录 NGINX

    作者简介:大家好,我是思无邪,2024 毕业生,某厂 Go 开发工程师.. 我的网站:https://www.yishanicode.top/ ,持续更新,希望对你有帮助. 如果文章或网站知识点有错误 ...

  10. 路由协议过程概述--ospf-01

    路由是数据通信网络中最基本的要素.路由信息就是指导报文发送的路径信息,路由的过程就是报文转发的过程. 根据路由目的地的不同,路由可划分为: 网段路由:目的地为网段,IPv4地址子网掩码长度小于32位或 ...