零经验选手,Compose 一天开发一款小游戏!

news/2025/3/18 13:48:04/文章来源:https://www.cnblogs.com/wavky/p/18775843

猛男翻卡牌
猛男翻卡牌

猛男启动

继上一个 Compose 练习项目 SimpleTodo 之后,又尝试用 Compose 来做了一个翻牌记忆游戏【猛男翻卡牌】。这次是零经验写游戏项目,连原型都没有做设计,问了 ChatGPT 游戏大概是怎么个玩法,就一步一步着手去写了。

本文号称一天开发完毕确实不假,从立案(问 ChatGPT)到开发完成(今天凌晨 4 点),所用时间共计 20 小时。(牛马的一天是 24 小时)
其中相当大部分的时间都用在了素材搜集、UX 试错方面,技术方面遇到的阻碍反而没有多少,毕竟这个游戏的设计实现还是比较简单粗暴。

游戏玩法很简单,开局随机布置 8 组 16 张卡牌,预览展示各个卡牌所在位置后全部盖牌,让玩家凭记忆力翻开配对的卡牌组。

⚠️ 高能预警:作者我没有任何游戏开发经验,本项目仅用于练习 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// 在玩家每次点击时判断游戏胜负、错误次数过多、倒计时结束等情况时,更新为 falsevar 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) { ... }
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/900228.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

ftp替代品,如何提升数据交换的安全性与高效性?

文件传输协议(FTP)是一个跨平台的、简单且易于实现的协议,用于在网络上的服务器和客户端之间传输文件,也是企业会经常选择的一种传输方式。 业务场景一: 基于信息相关安全要求,医院会采用防火墙、网闸等将网络隔离为内网和外网,但网络隔离后,医院仍存在将报告资料等文…

【分享】常见的几种数据摆渡系统介绍

随着企业数字化转型的逐步深入,企业投入了大量资源进行信息系统建设,信息化程度日益提升。在这一过程中,企业也越来越重视核心数据资产的保护,数据资产的安全防护成为企业面临的重大挑战。 一、网络隔离实施的背景 1、互联网的广泛应用:随着互联网的飞速发展,企业与外部的…

010 Element-Plus集成

React+AntDesign+MUI Vue3+VantUI UI组件库(框架)->封装通用组件->后台管理系统=>element(UI(vue2)/Plus(vue3))=>饿了么团队(阿里)=>Vue AntDesignUI=>蚂蚁金服=>React框架 AntDesignVue=>Vue框架一、Element Plus UI组件库 Element Plus 基于Vue3、…

『Plotly实战指南』--折线图绘制基础篇

在数据分析的世界中,折线图是一种不可或缺的可视化工具。 它能够清晰地展示数据随时间或其他变量的变化趋势,帮助我们快速发现数据中的模式、趋势和异常。 无论是金融市场分析、气象数据监测,还是业务增长趋势预测,折线图都能以直观的方式呈现关键信息。 本文将从基础开始,…

Arrays工具类教你优雅地管理数组数据

数组专用工具类指的是 java.util.Arrays 类,基本上常见的数组操作,这个类都提供了静态方法可供直接调用。毕竟数组本身想完成这些操作还是挺麻烦的,有了这层封装,就方便多了。 package java.util; /*** @author Josh Bloch* @author Neal Gafter* @author John Rose* @sinc…

ROCm技术解析概述

3.2 ROCm技术解析 ROCm是第一个用于GPU计算的开源HPC/Hyperscale级平台,也是独立于编程语言的。将UNIX的选择哲学、极简主义和模块化软件开发引入GPU计算。新的ROCm基础允许为应用程序选择甚至开发工具和语言运行时。 1)[n1] ROCm是为规模而构建的;它支持通过RDMA进行服务器…

AMD GPU上对比语言图像预训练(CLIP)模型的交互

AMD GPU上对比语言图像预训练(CLIP)模型的交互 3.1.1 介绍 对比语言图像预训练(CLIP)是一种连接视觉和自然语言的多模态深度学习模型。它是在OpenAI的论文从自然语言监督中学习可转移的视觉模型(2021)中介绍的,并在大量(4亿)图像字幕对的网络抓取数据上进行了对比训练…

推荐书1《AI芯片开发核心技术详解》、2《智能汽车传感器:原理设计应用》、3《TVM编译器原理与实践》、4《LLVM编译器原理与实践》,谢谢

4本书推荐《AI芯片开发核心技术详解》、《智能汽车传感器:原理设计应用》、《TVM编译器原理与实践》、《LLVM编译器原理与实践》由清华大学出版社资深编辑赵佳霓老师策划编辑的新书《AI芯片开发核心技术详解》已经出版,京东、淘宝天猫、当当等网上,相应陆陆续续可以购买。该…

我的世界 GTNH 传送权限单独指定

起因 在 GTNH 2.7.2 上,对于 Journey Map 传送点的支持需要 op 管理员权限,给管理员权限容易刷物件和其他意外,所以需要限制权限同时又能方便移动。 解决方案 使用 ForgeEssentials 来管理权限,从 Forge-essentials-curseforge 获取 1.7.10 版本的插件,放到服务端 mods 目…

洛谷 P3131 [USACO16JAN] Subsequences Summing to Sevens S(前缀和+模运算性质)

前缀和,模运算性质。做题历程拿到手的时候就是考虑前缀和,毕竟要求区间和,如果暴力做就是N3,那么就开始做,写了个O(n2)复杂度代码,交上去80分...迷了,心想这题难道dp啊,懒得想的我直接看题解,发现大佬用了一个很简单的模运算性质就过去了,即(a - b) mod 7 == 0 那么 …

FastAPI性能优化指南:参数解析与惰性加载

扫描二维码关注或者微信搜一搜:编程智域 前端至全栈交流与成长 探索数千个预构建的 AI 应用,开启你的下一个伟大创意第一章:参数解析性能原理 1.1 FastAPI请求处理管线 async def app(scope, receive, send):# 1. 请求解析阶段body = await receive()# 2. 参数验证阶段valid…

MYSQL-索引入门

介绍:结构:语法:create index i on tb_emp(name);show index from tb_emp;drop index i on tb_emp;