翻卡片小游戏
在实现这个小游戏的功能中,不仅涉及到前面几节课的知识点,还运用了一些新的知识点。
图片资源
附件:图片资源,在 ets
文件夹下,创建 assets
文件夹存放图片;
知识点
- 基础装饰器:
@State
、@Prop
; - 布局容器组件:
Column
、Flex
、Stack
; - 基础组件:
Text
、Button
; - 循环渲染组件:
ForEach
; - 定义组件重用样式装饰器:
@Styles
; - 基础组件:
TextTimer
、Image
; - 警告弹窗组件:
AlertDialog
; - 属性动画:
animation
,组件的某些通用属性发生变化时,可以通过属性动画实现渐变过渡效果;
视频🎞️
步骤
第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步:增加游戏时间限制-倒计时
效果图
实现
- 应用倒计时组件:TextTimer组件 ;
在自定义组件 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)
}
}