加载中...
【HarmonyOS】卡片配对小游戏
第1节:环境准备&项目创建
第2节:ArkTS语言基础
第3节:常用布局容器组件
第4节:组件状态管理装饰器
第5节:【HarmonyOS】卡片配对小游戏
课文封面

通过前面几节课文的学习,已经掌握了ArkTs的一些基础知识的使用以及布局组件。
这节课我们来实现翻卡片小游戏。

翻卡片小游戏

在实现这个小游戏的功能中,不仅涉及到前面几节课的知识点,还运用了一些新的知识点。

图片资源

附件:图片资源,在 ets 文件夹下,创建 assets 文件夹存放图片;

未命名

知识点

视频🎞️

步骤

第1步:完成页面布局

效果图-1

未命名

实现

  • 渲染背景图;
  • 渲染标题及样式;
  • 添加按钮及样式,当点击按钮切换为开始游戏状态:this.isStart = true,并修改按钮显示文本;
@Entry @Component struct Game { // 游戏开始状态 @State isStart: boolean = false build(){ Column(){ Text("卡片配对") .fontSize(40) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 35 }) Button(this.isStart ? "重新开始" : "开始游戏", { type: ButtonType.Normal }) .fontSize(28) .padding(16) .borderRadius(15) .backgroundColor("#009DE0") .onClick(() => { this.isStart = true }) } .width("100%") .height("100%") .backgroundImage("/assets/bg.png") .justifyContent(FlexAlign.Center) } }

效果图-2

未命名

实现

  • 添加两个变量:
    一个为存放随机图片名数组 cards
    另一个为存放默认图片名数组 cardsBack,并填充数据;
    再添加一个方法 reset(),用来初始化/打乱“随机图片名数组”的数据;
// 默认图片名数组 >bg>primary cardBack: Array<string> = ["1.png", "2.png", "3.png", "4.png", "5.png", "6.png"] // 随机图片名数组 >bg>primary @State cards: Array<string> = [] // 游戏开始状态 @State isStart: boolean = false // 初始化/开始游戏 >bg>primary reset() { >bg>primary // 将图片数量x2,并且打乱数据,存入“随机图片名数组” >bg>primary this.cards = this.cardBack.concat(this.cardBack) >bg>primary .sort(() => { >bg>primary return 0.5 - Math.random() >bg>primary }) >bg>primary }
  • 渲染 cards 的数据;

先添加卡片组件 MyCard,放在自定义组件 Game 上方;这边将单个卡片的渲染抽成组件,方便后续使用修改。

// 卡片组件 @Component struct MyCard { // 图片名 imgName: string build(){ // 卡片正面 Image(`/assets/${this.imgName}`) .backgroundColor("#fff") .border({ width: 4, color: "#004765" }) .borderRadius(12) .width("30.4%") .aspectRatio(1) .margin("1.5%") } }

在自定义组件 Game ,使用Flex组件+ForEach组件渲染出 cards 数据(放在标题和按钮中间)。

Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.cards, (imgName: string, index: number) => { // 引入使用卡片组件 MyCard({ imgName: imgName, }) }) }
  • 按钮添加 reset() 方法,点击时触发 reset() 方法;
Button(this.isStart ? "重新开始" : "开始游戏", { type: ButtonType.Normal }) .fontSize(28) .padding(16) .borderRadius(15) .backgroundColor("#009DE0") .onClick(() => { this.isStart = true this.reset() >bg>primary })

第2步:卡片的翻转动画及卡片的3种状态

效果图

未命名

实现

  • 在卡片组件 MyCard 添加卡片反面状态图(放在卡片正面下)。

这时需要先在图片外层增加 Stack 堆叠组件,后续可以把状态图片全部放在同一个布局位置来进行处理;还要将之前图片设置的最后三个属性挪到堆叠组件上,这样就不用在图片上重复设置。

// 卡片组件 build(){ Stack() { // 卡片正面 Image(`/assets/${this.imgName}`) .backgroundColor("#fff") .border({ width: 4, color: "#004765" }) .borderRadius(12) } .width("30.4%") .aspectRatio(1) .margin("1.5%") }

接着在卡片正面下,放置卡片反面状态图,卡片图的样式一样,我们可以把它抽离成通用样式来进行使用,这样避免样式代买重复写。
使用装饰器 @Styles 装饰方法 shape,在图片上使用 shape

// 图片通用样式 @Styles shape(){ .backgroundColor("#fff") .border({ width: 4, color: "#004765" }) .borderRadius(12) } build(){ Stack() { // 卡片正面 Image(`/assets/${this.imgName}`) .shape() >bg>primary // 卡片反面 Image(`/assets/back.png`) >bg>primary .shape() >bg>primary } .width("30.4%") .aspectRatio(1) .margin("1.5%") }
  • 实现卡片正反面的翻转效果;

在自定义组件 Game 添加存放翻开的卡片索引数组。

// 翻开的卡片索引数组 @State flippedArr: Array<number> = []

给自定义组件 Game 里引用的卡片组件添加点击事件,将点击的卡片索引存入 flippedArr,并且每次只能存两张卡片数据。

.onClick(() => { // 当已翻卡片数量达到2张 或者 当前点击的卡片已被翻开或已配对成功则return,不进行处理。 if (this.flippedArr.length === 2 || this.flippedArr.includes(index) ) { return } // 当前点击的卡片符合条件则存放到翻开的数组里 this.flippedArr.push(index) })

自定义组件 Game 里引用的卡片组件 Card 添加参数 flipped 并传值,作为当前卡片是否翻转的依据。

// ... MyCard({ imgName: imgName, flipped: this.flippedArr.includes(index), >bg>primary }) // ...

卡片组件 Card 接收值。

// ... // 图片名 imgName: string // 是否翻开当前卡片 @Prop flipped: boolean >bg>primary // ...

先给卡片反面的图片添加图形变换属性(rotate)和属性动画(animation),通过 flipped 值进行控制,这时点击卡片就能使卡片背面翻转。

// 卡片反面 .rotate({ y: 1, angle: this.flipped ? 90 : 0 }) .animation({ duration: 200, })

同样,给卡片正面的图片也添加图形变换属性(rotate)和属性动画(animation),不过卡片的正面需要默认从后面开始旋转到前面,也就是90->180;因为要等卡片反面动画结束,卡片正面动画才可以开始,所以卡片正面动画需要延迟200毫秒(根据当前旋转速度)。
现在卡片背面和正面的旋转就能连接起来了。

.rotate({ y: 1, angle: this.flipped ? 180 : 90 }) .animation({ duration: 200, delay: this.flipped ? 200 : 0 })

如果当前翻开的卡片不是同一种,则需要把卡片给原路旋转回去,就需要在卡片点击事件里添加延迟判断下:不同则清除 flippedArr 里的数据,方面记录下一次翻开的卡片数据。

在自定义组件 Game 增加 timer 变量,方便后面清除定时器的需要。

timer: number = 0

在卡片点击事件内最底下添加。

// 翻开第2张卡片后,判断是否配对成功 if (this.flippedArr.length === 2) { this.timer = setTimeout(() => { // 清空存放翻开的卡片数据,方便存放下一次翻开的卡片数据 this.flippedArr = [] }, 1000) }

当反向翻转回去时,卡片反面动画同样需要等待卡片正面动画完成才能开始,所以卡片反面的动画属性(animation)需要添加 delay: !this.flipped ? 200 : 0
到这卡片正反面翻转就完成了。

.animation({ duration: 200, delay: !this.flipped ? 200 : 0 >bg>primary })
  • 当翻开卡片相同时则展示配对成功状态图;

在自定义组件 Game 里添加存放配对成功的卡片索引数组。

// 配对成功的卡片索引数组 @State finishArr: Array<number> = []

在卡片点击事件的延迟判断内修改:在清空 flippedArr 数据前,处理当前翻开的卡片配对成功时则存入 finishArr ;而配对成功的卡片再次点击需要阻止,不能进行任何操作,卡片点击事件最后修改如下。

.onClick(() => { // 当已翻卡片数量达到2张 或者 当前点击的卡片已被翻开或已配对成功则return,不进行处理。 if (this.flippedArr.length === 2 || this.flippedArr.includes(index) || this.finishArr.includes(index)) { return } // 当前点击的卡片符合条件则存放到翻开的数组里 this.flippedArr.push(index) // 翻开第2张卡片后,判断是否配对成功 if (this.flippedArr.length === 2) { this.timer = setTimeout(() => { // 若配对成功,则存放到配对成功的数组里 if (this.cards[this.flippedArr[0]] === this.cards[this.flippedArr[1]]) { this.finishArr.push(...this.flippedArr) } // 清空存放翻开的卡片数据,方便存放下一次翻开的卡片数据 this.flippedArr = [] }, 1000) } })

同样,在自定义组件 Game 里把判断配对成功状态依据的参数传给卡片组件。

MyCard({ imgName: imgName, flipped: this.flippedArr.includes(index), finish: this.finishArr.includes(index) >bg>primary })

卡片组件接收值。

// ... // 图片名 imgName: string // 是否翻开当前卡片 @Prop flipped: boolean // 是否消除当前卡片 @Prop finish: boolean >bg>primary // ...

添加配对成功状态时展示的图片,通过 finish 控制图片的透明度来进行展示。

// 卡片配对成功时显示的图片 Image("/assets/ok.png") .shape() .opacity(this.finish ? 1 : 0)

由于清空 flippedArr 数据清空时,会导致卡片正反面属性的变化和动画的执行,所以需要通过 finish 来判断在配对成功情况下,把正反面状态图片进行隐藏,最后卡片正反面修改如下。

// 卡片正面 Image(`/assets/${this.imgName}`) .shape() .rotate({ y: 1, angle: this.flipped ? 180 : 90 }) .animation({ duration: 200, delay: this.flipped ? 200 : 0 }) .opacity(!this.finish ? 1 : 0) >bg>primary // 卡片反面 Image(`/assets/back.png`) .shape() .rotate({ y: 1, angle: this.flipped ? 90 : 0 }) .animation({ duration: 200, delay: !this.flipped ? 200 : 0 }) .opacity(!this.finish ? 1 : 0) >bg>primary
  • 当点击重新开始,要将所有卡片都设置到最初状态,

reset 方法添加初始化/清空卡片相关状态变量的数据;

// 初始化/开始游戏 reset() { clearTimeout(this.timer) >bg>primary this.finishArr = [] >bg>primary this.flippedArr = [] >bg>primary // 将图片数量x2,并且打乱数据,存入“随机图片名数组” this.cards = this.cardBack.concat(this.cardBack) .sort(() => { return 0.5 - Math.random() }) }

第3步:增加游戏时间限制-倒计时

效果图

未命名

实现

在自定义组件 Game 里,定义控制器的绑定。

textTimerController: TextTimerController = new TextTimerController()

在标题下添加倒计时组件,开始游戏状态为 true 才展示,当前游戏限制时间为45秒(该组件的详细参数设置可点击上面链接前往官网查看)。

TextTimer({ isCountDown: true, count: 45000, controller: this.textTimerController }) .format(`倒计时:ss 秒`) .fontColor('#333') .fontSize(28) .opacity(this.isStart ? 1 : 0)
  • 点击 “开始游戏/重新开始” 按钮,重置计时器,1秒后开始计时;

reset 方法最后添加。

// 初始化/开始游戏 reset() { // ... // 重置计时器数据 this.textTimerController.reset() >bg>primary // 1秒后再开始倒计时 setTimeout(() => { >bg>primary this.textTimerController.start() >bg>primary }, 1000) >bg>primary }
  • 需要通过 TextTimer组件的事件 onTimer ,处理游戏挑战成功和挑战失败场景下,出现弹窗(AlertDialog 组件)提示。
.onTimer((utc: number, elapsedTime: number) => { /* * 在时间内,全部配对成功则暂停倒计时,提示挑战成功; * 若时间到还未全部配对成功则暂停倒计时,提示挑战失败; * */ if (elapsedTime >= 45000 || this.finishArr.length === this.cards.length) { let text = elapsedTime >= 45000 ? "游戏失败" : "挑战成功" this.textTimerController.pause() AlertDialog.show({ title: "提示", message: text , autoCancel: false, confirm: { value: "再玩一次", action: () => { this.reset() } }, }) } })

最终效果

完整代码

// 卡片组件 @Component struct MyCard { // 图片名 imgName: string // 是否翻开当前卡片 @Prop flipped: boolean // 是否消除当前卡片 @Prop finish: boolean // 图片通用样式 @Styles shape(){ .backgroundColor("#fff") .border({ width: 4, color: "#004765" }) .borderRadius(12) } build() { Stack() { // 卡片正面 Image(`/assets/${this.imgName}`) .shape() .rotate({ y: 1, angle: this.flipped ? 180 : 90 }) .animation({ duration: 200, delay: this.flipped ? 200 : 0 }) .opacity(!this.finish ? 1 : 0) // 卡片反面 Image(`/assets/back.png`) .shape() .rotate({ y: 1, angle: this.flipped ? 90 : 0 }) .animation({ duration: 200, delay: !this.flipped ? 200 : 0 }) .opacity(!this.finish ? 1 : 0) // 卡片配对成功时显示的图片 Image("/assets/ok.png") .shape() .opacity(this.finish ? 1 : 0) } .width("30.4%") .aspectRatio(1) .margin("1.5%") } } @Entry @Component struct Game { // 默认图片名数组 cardBack: Array<string> = ["1.png", "2.png", "3.png", "4.png", "5.png", "6.png"] timer: number = 0 textTimerController: TextTimerController = new TextTimerController() // 随机图片名数组 @State cards: Array<string> = [] // 游戏开始状态 @State isStart: boolean = false // 翻开的卡片索引数组 @State flippedArr: Array<number> = [] // 配对成功的卡片索引数组 @State finishArr: Array<number> = [] // 初始化/开始游戏 reset() { clearTimeout(this.timer) this.finishArr = [] this.flippedArr = [] // 将图片数量x2,并且打乱数据,存入“随机图片名数组” this.cards = this.cardBack.concat(this.cardBack) .sort(() => { return 0.5 - Math.random() }) // 重置计时器数据 this.textTimerController.reset() // 1秒后再开始倒计时 setTimeout(() => { this.textTimerController.start() }, 1000) } build() { Column() { Text("卡片配对") .fontSize(40) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 35 }) TextTimer({ isCountDown: true, count: 45000, controller: this.textTimerController }) .format("倒计时:ss 秒") .fontColor("#333") .fontSize(28) .opacity(this.isStart ? 1 : 0) .onTimer((utc: number, elapsedTime: number) => { /* * 在时间内,全部配对成功则暂停倒计时,提示挑战成功; * 若时间到还未全部配对成功则暂停倒计时,提示挑战失败; * */ if (elapsedTime >= 45000 || this.finishArr.length === this.cards.length) { let text = elapsedTime >= 45000 ? "游戏失败" : "挑战成功" this.textTimerController.pause() AlertDialog.show({ title: "提示", message: text , autoCancel: false, confirm: { value: "再玩一次", action: () => { this.reset() } }, }) } }) Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.cards, (imgName: string, index: number) => { // 引入使用卡片组件 MyCard({ imgName: imgName, flipped: this.flippedArr.includes(index), finish: this.finishArr.includes(index) }) .onClick(() => { // 当已翻卡片数量达到2张 或者 当前点击的卡片已被翻开或已配对成功则return,不进行处理。 if (this.flippedArr.length === 2 || this.flippedArr.includes(index) || this.finishArr.includes(index)) { return } // 当前点击的卡片符合条件则存放到翻开的数组里 this.flippedArr.push(index) // 翻开第2张卡片后,判断是否配对成功 if (this.flippedArr.length === 2) { this.timer = setTimeout(() => { // 若配对成功,则存放到配对成功的数组里 if (this.cards[this.flippedArr[0]] === this.cards[this.flippedArr[1]]) { this.finishArr.push(...this.flippedArr) } // 清空存放翻开的卡片数据,方便存放下一次翻开的卡片数据 this.flippedArr = [] }, 1000) } }) }) } .margin({ top: 20, bottom: 50, left: 12, right: 12 }) Button(this.isStart ? "重新开始" : "开始游戏", { type: ButtonType.Normal }) .fontSize(28) .padding(16) .borderRadius(15) .backgroundColor("#009DE0") .onClick(() => { this.isStart = true this.reset() }) } .width("100%") .height("100%") .backgroundImage("/assets/bg.png") .justifyContent(FlexAlign.Center) } }