找回密码
 立即注册
首页 业界区 安全 鸿蒙特效教程08-幸运大转盘抽奖

鸿蒙特效教程08-幸运大转盘抽奖

聚怪闩 2025-6-1 20:21:54
鸿蒙特效教程08-幸运大转盘抽奖

本教程将带领大家从零开始,一步步实现一个完整的转盘抽奖效果,包括界面布局、Canvas绘制、动画效果和抽奖逻辑等。
开发环境准备


  • DevEco Studio 5.0.3
  • HarmonyOS Next API 15
1. 需求分析与整体设计

温馨提醒:本案例有一定难度,建议先收藏起来。
在开始编码前,让我们先明确转盘抽奖的基本需求:

  • 展示一个可旋转的奖品转盘
  • 转盘上有多个奖品区域,每个区域有不同的颜色和奖品名称
  • 点击"开始抽奖"按钮后,转盘开始旋转
  • 转盘停止后,指针指向的位置即为抽中的奖品
  • 每个奖品有不同的中奖概率
整体设计思路:

  • 使用HarmonyOS的Canvas组件绘制转盘
  • 利用动画效果实现转盘旋转
  • 根据概率算法确定最终停止位置
1.gif

2. 基础界面布局

首先,我们创建基础的页面布局,包括标题、转盘区域和结果显示。
  1. @Entry
  2. @Component
  3. struct LuckyWheel {
  4.   build() {
  5.     Column() {
  6.       // 标题
  7.       Text('幸运大转盘')
  8.         .fontSize(28)
  9.         .fontWeight(FontWeight.Bold)
  10.         .fontColor(Color.White)
  11.         .margin({ bottom: 20 })
  12.       // 抽奖结果显示
  13.       Text('点击开始抽奖')
  14.         .fontSize(20)
  15.         .fontColor(Color.White)
  16.         .backgroundColor('#1AFFFFFF')
  17.         .width('90%')
  18.         .textAlign(TextAlign.Center)
  19.         .padding(15)
  20.         .borderRadius(16)
  21.         .margin({ bottom: 30 })
  22.       // 转盘容器(后续会添加Canvas)
  23.       Stack({ alignContent: Alignment.Center }) {
  24.         // 这里稍后会添加Canvas绘制转盘
  25.         
  26.         // 中央开始按钮
  27.         Button({ type: ButtonType.Circle }) {
  28.           Text('开始\n抽奖')
  29.             .fontSize(18)
  30.             .fontWeight(FontWeight.Bold)
  31.             .textAlign(TextAlign.Center)
  32.             .fontColor(Color.White)
  33.         }
  34.         .width(80)
  35.         .height(80)
  36.         .backgroundColor('#FF6B6B')
  37.       }
  38.       .width('90%')
  39.       .aspectRatio(1)
  40.       .backgroundColor('#0DFFFFFF')
  41.       .borderRadius(16)
  42.       .padding(15)
  43.     }
  44.     .width('100%')
  45.     .height('100%')
  46.     .justifyContent(FlexAlign.Center)
  47.     .backgroundColor(Color.Black)
  48.     .linearGradient({
  49.       angle: 135,
  50.       colors: [
  51.         ['#1A1B25', 0],
  52.         ['#2D2E3A', 1]
  53.       ]
  54.     })
  55.   }
  56. }
复制代码
这个基础布局创建了一个带有标题、结果显示区和转盘容器的页面。转盘容器使用Stack组件,这样我们可以在转盘上方放置"开始抽奖"按钮。
3. 定义数据结构

接下来,我们需要定义转盘上的奖品数据结构:
  1. // 奖品数据接口
  2. interface PrizesItem {
  3.   name: string     // 奖品名称
  4.   color: string    // 转盘颜色
  5.   probability: number // 概率权重
  6. }
  7. @Entry
  8. @Component
  9. struct LuckyWheel {
  10.   // 奖品数据
  11.   private prizes: PrizesItem[] = [
  12.     { name: '谢谢参与', color: '#FFD8A8', probability: 30 },
  13.     { name: '10积分', color: '#B2F2BB', probability: 20 },
  14.     { name: '5元红包', color: '#D0BFFF', probability: 10 },
  15.     { name: '优惠券', color: '#A5D8FF', probability: 15 },
  16.     { name: '免单券', color: '#FCCFE7', probability: 5 },
  17.     { name: '50积分', color: '#BAC8FF', probability: 15 },
  18.     { name: '会员月卡', color: '#99E9F2', probability: 3 },
  19.     { name: '1元红包', color: '#FFBDBD', probability: 2 }
  20.   ]
  21.   // 状态变量
  22.   @State isSpinning: boolean = false // 是否正在旋转
  23.   @State rotation: number = 0 // 当前旋转角度
  24.   @State result: string = '点击开始抽奖' // 抽奖结果
  25.   // ...其余代码
  26. }
复制代码
这里我们定义了转盘上的8个奖品,每个奖品包含名称、颜色和概率权重。同时定义了三个状态变量来跟踪转盘的状态。
4. 初始化Canvas

现在,让我们初始化Canvas来绘制转盘:
  1. @Entry
  2. @Component
  3. struct LuckyWheel {
  4.   // Canvas 相关设置
  5.   private readonly settings: RenderingContextSettings = new RenderingContextSettings(true); // 启用抗锯齿
  6.   private readonly ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  7.   
  8.   // 转盘相关属性
  9.   private canvasWidth: number = 0 // 画布宽度
  10.   private canvasHeight: number = 0 // 画布高度
  11.   
  12.   // ...其余代码
  13.   
  14.   build() {
  15.     Column() {
  16.       // ...之前的代码
  17.       
  18.       // 转盘容器
  19.       Stack({ alignContent: Alignment.Center }) {
  20.         // 使用Canvas绘制转盘
  21.         Canvas(this.ctx)
  22.           .width('100%')
  23.           .height('100%')
  24.           .onReady(() => {
  25.             // 获取Canvas尺寸
  26.             this.canvasWidth = this.ctx.width
  27.             this.canvasHeight = this.ctx.height
  28.             // 初始绘制转盘
  29.             this.drawWheel()
  30.           })
  31.          
  32.         // 中央开始按钮
  33.         // ...按钮代码
  34.       }
  35.       // ...容器样式
  36.     }
  37.     // ...外层容器样式
  38.   }
  39.   
  40.   // 绘制转盘(先定义一个空方法,稍后实现)
  41.   private drawWheel(): void {
  42.     // 稍后实现
  43.   }
  44. }
复制代码
这里我们创建了Canvas绘制上下文,并在onReady回调中获取Canvas尺寸,然后调用drawWheel方法绘制转盘。
5. 实现转盘绘制

接下来,我们实现drawWheel方法,绘制转盘:
  1. // 绘制转盘
  2. private drawWheel(): void {
  3.   if (!this.ctx) return
  4.   
  5.   const centerX = this.canvasWidth / 2
  6.   const centerY = this.canvasHeight / 2
  7.   const radius = Math.min(centerX, centerY) * 0.85
  8.   
  9.   // 清除画布
  10.   this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  11.   
  12.   // 保存当前状态
  13.   this.ctx.save()
  14.   
  15.   // 移动到中心点
  16.   this.ctx.translate(centerX, centerY)
  17.   // 应用旋转
  18.   this.ctx.rotate((this.rotation % 360) * Math.PI / 180)
  19.   
  20.   // 绘制转盘扇形
  21.   const anglePerPrize = 2 * Math.PI / this.prizes.length
  22.   for (let i = 0; i < this.prizes.length; i++) {
  23.     const startAngle = i * anglePerPrize
  24.     const endAngle = (i + 1) * anglePerPrize
  25.    
  26.     this.ctx.beginPath()
  27.     this.ctx.moveTo(0, 0)
  28.     this.ctx.arc(0, 0, radius, startAngle, endAngle)
  29.     this.ctx.closePath()
  30.    
  31.     // 填充扇形
  32.     this.ctx.fillStyle = this.prizes[i].color
  33.     this.ctx.fill()
  34.    
  35.     // 绘制边框
  36.     this.ctx.strokeStyle = "#FFFFFF"
  37.     this.ctx.lineWidth = 2
  38.     this.ctx.stroke()
  39.   }
  40.   
  41.   // 恢复状态
  42.   this.ctx.restore()
  43. }
复制代码
这段代码实现了基本的转盘绘制:

  • 计算中心点和半径
  • 清除画布
  • 平移坐标系到转盘中心
  • 应用旋转角度
  • 绘制每个奖品的扇形区域
运行后,你应该能看到一个彩色的转盘,但还没有文字和指针。
6. 添加奖品文字

继续完善drawWheel方法,添加奖品文字:
  1. // 绘制转盘扇形
  2. const anglePerPrize = 2 * Math.PI / this.prizes.length
  3. for (let i = 0; i < this.prizes.length; i++) {
  4.   // ...之前的扇形绘制代码
  5.   
  6.   // 绘制文字
  7.   this.ctx.save()
  8.   this.ctx.rotate(startAngle + anglePerPrize / 2)
  9.   this.ctx.textAlign = 'center'
  10.   this.ctx.textBaseline = 'middle'
  11.   this.ctx.fillStyle = '#333333'
  12.   this.ctx.font = '24px sans-serif'
  13.   
  14.   // 旋转文字,使其可读性更好
  15.   // 第一象限和第四象限的文字需要额外旋转180度,保证文字朝向
  16.   const needRotate = (i >= this.prizes.length / 4) && (i < this.prizes.length * 3 / 4)
  17.   if (needRotate) {
  18.     this.ctx.rotate(Math.PI)
  19.     this.ctx.fillText(this.prizes[i].name, -radius * 0.6, 0, radius * 0.5)
  20.   } else {
  21.     this.ctx.fillText(this.prizes[i].name, radius * 0.6, 0, radius * 0.5)
  22.   }
  23.   
  24.   this.ctx.restore()
  25. }
复制代码
这里我们在每个扇形区域添加了奖品文字,并根据位置进行适当旋转,确保文字朝向正确,提高可读性。
7. 添加中心圆盘和指针

继续完善drawWheel方法,添加中心圆盘和指针:
  1. // 恢复状态
  2. this.ctx.restore()
  3. // 绘制中心圆盘
  4. this.ctx.beginPath()
  5. this.ctx.arc(centerX, centerY, radius * 0.2, 0, 2 * Math.PI)
  6. this.ctx.fillStyle = '#FF8787'
  7. this.ctx.fill()
  8. this.ctx.strokeStyle = '#FFFFFF'
  9. this.ctx.lineWidth = 3
  10. this.ctx.stroke()
  11. // 绘制指针 - 固定在顶部中央
  12. this.ctx.beginPath()
  13. // 三角形指针
  14. this.ctx.moveTo(centerX, centerY - radius - 10)
  15. this.ctx.lineTo(centerX - 15, centerY - radius * 0.8)
  16. this.ctx.lineTo(centerX + 15, centerY - radius * 0.8)
  17. this.ctx.closePath()
  18. this.ctx.fillStyle = '#FF6B6B'
  19. this.ctx.fill()
  20. this.ctx.strokeStyle = '#FFFFFF'
  21. this.ctx.lineWidth = 2
  22. this.ctx.stroke()
  23. // 绘制中心文字
  24. this.ctx.textAlign = 'center'
  25. this.ctx.textBaseline = 'middle'
  26. this.ctx.fillStyle = '#FFFFFF'
  27. this.ctx.font = '18px sans-serif'
  28. // 绘制两行文字
  29. this.ctx.fillText('开始', centerX, centerY - 10)
  30. this.ctx.fillText('抽奖', centerX, centerY + 10)
复制代码
这段代码添加了:

  • 中心的红色圆盘
  • 顶部的三角形指针
  • 中心的"开始抽奖"文字
现在转盘的静态部分已经完成。下一步,我们将实现转盘的旋转动画。
8. 实现抽奖逻辑

在实现转盘旋转前,我们需要先实现抽奖逻辑,决定最终奖品:
  1. // 生成随机目标索引(基于概率权重)
  2. private generateTargetIndex(): number {
  3.   const weights = this.prizes.map(prize => prize.probability)
  4.   const totalWeight = weights.reduce((a, b) => a + b, 0)
  5.   const random = Math.random() * totalWeight
  6.   
  7.   let currentWeight = 0
  8.   for (let i = 0; i < weights.length; i++) {
  9.     currentWeight += weights[i]
  10.     if (random < currentWeight) {
  11.       return i
  12.     }
  13.   }
  14.   return 0
  15. }
复制代码
这个方法根据每个奖品的概率权重生成一个随机索引,概率越高的奖品被选中的机会越大。
9. 实现转盘旋转

现在,让我们实现转盘旋转的核心逻辑:
  1. // 转盘属性
  2. private spinDuration: number = 4000 // 旋转持续时间(毫秒)
  3. private targetIndex: number = 0 // 目标奖品索引
  4. private spinTimer: number = 0 // 旋转定时器
  5. // 开始抽奖
  6. private startSpin(): void {
  7.   if (this.isSpinning) return
  8.   
  9.   this.isSpinning = true
  10.   this.result = '抽奖中...'
  11.   
  12.   // 生成目标奖品索引
  13.   this.targetIndex = this.generateTargetIndex()
  14.   
  15.   console.info(`抽中奖品索引: ${this.targetIndex}, 名称: ${this.prizes[this.targetIndex].name}`)
  16.   
  17.   // 计算目标角度
  18.   // 每个奖品占据的角度 = 360 / 奖品数量
  19.   const anglePerPrize = 360 / this.prizes.length
  20.   
  21.   // 因为Canvas中0度是在右侧,顺时针旋转,而指针在顶部(270度位置)
  22.   // 所以需要将奖品旋转到270度位置对应的角度
  23.   // 目标奖品中心点的角度 = 索引 * 每份角度 + 半份角度
  24.   const prizeAngle = this.targetIndex * anglePerPrize + anglePerPrize / 2
  25.   
  26.   // 需要旋转到270度位置的角度 = 270 - 奖品角度
  27.   // 但由于旋转方向是顺时针,所以需要计算为正向旋转角度
  28.   const targetAngle = (270 - prizeAngle + 360) % 360
  29.   
  30.   // 获取当前角度的标准化值(0-360范围内)
  31.   const currentRotation = this.rotation % 360
  32.   
  33.   // 计算从当前位置到目标位置需要旋转的角度(确保是顺时针旋转)
  34.   let deltaAngle = targetAngle - currentRotation
  35.   if (deltaAngle <= 0) {
  36.     deltaAngle += 360
  37.   }
  38.   
  39.   // 最终旋转角度 = 当前角度 + 5圈 + 到目标的角度差
  40.   const finalRotation = this.rotation + 360 * 5 + deltaAngle
  41.   
  42.   console.info(`当前角度: ${currentRotation}°, 奖品角度: ${prizeAngle}°, 目标角度: ${targetAngle}°, 旋转量: ${deltaAngle}°, 最终角度: ${finalRotation}°`)
  43.   
  44.   // 使用基于帧动画的方式旋转,确保视觉上平滑旋转
  45.   let startTime = Date.now()
  46.   let initialRotation = this.rotation
  47.   
  48.   // 清除可能存在的定时器
  49.   if (this.spinTimer) {
  50.     clearInterval(this.spinTimer)
  51.   }
  52.   
  53.   // 创建新的动画定时器
  54.   this.spinTimer = setInterval(() => {
  55.     const elapsed = Date.now() - startTime
  56.    
  57.     if (elapsed >= this.spinDuration) {
  58.       // 动画结束
  59.       clearInterval(this.spinTimer)
  60.       this.spinTimer = 0
  61.       this.rotation = finalRotation
  62.       this.drawWheel()
  63.       this.isSpinning = false
  64.       this.result = `恭喜获得: ${this.prizes[this.targetIndex].name}`
  65.       return
  66.     }
  67.    
  68.     // 使用easeOutExpo效果:慢慢减速
  69.     const progress = this.easeOutExpo(elapsed / this.spinDuration)
  70.     this.rotation = initialRotation + progress * (finalRotation - initialRotation)
  71.    
  72.     // 重绘转盘
  73.     this.drawWheel()
  74.   }, 16) // 大约60fps的刷新率
  75. }
  76. // 缓动函数:指数减速
  77. private easeOutExpo(t: number): number {
  78.   return t === 1 ? 1 : 1 - Math.pow(2, -10 * t)
  79. }
复制代码
这段代码实现了转盘旋转的核心逻辑:

  • 根据概率生成目标奖品
  • 计算目标奖品对应的角度
  • 计算需要旋转的总角度(多转几圈再停在目标位置
  • 使用定时器实现转盘的平滑旋转
  • 使用缓动函数实现转盘的减速效果
  • 旋转结束后显示中奖结果
10. 连接按钮点击事件

现在我们需要将"开始抽奖"按钮与startSpin方法连接起来:
  1. // 中央开始按钮
  2. Button({ type: ButtonType.Circle }) {
  3.   Text('开始\n抽奖')
  4.     .fontSize(18)
  5.     .fontWeight(FontWeight.Bold)
  6.     .textAlign(TextAlign.Center)
  7.     .fontColor(Color.White)
  8. }
  9. .width(80)
  10. .height(80)
  11. .backgroundColor('#FF6B6B')
  12. .onClick(() => this.startSpin())
  13. .enabled(!this.isSpinning)
  14. .stateEffect(true) // 启用点击效果
复制代码
这里我们给按钮添加了onClick事件处理器,点击按钮时调用startSpin方法。同时使用enabled属性确保在转盘旋转过程中按钮不可点击。
11. 添加资源释放

为了防止内存泄漏,我们需要在页面销毁时清理定时器:
  1. aboutToDisappear() {
  2.   // 清理定时器
  3.   if (this.spinTimer !== 0) {
  4.     clearInterval(this.spinTimer)
  5.     this.spinTimer = 0
  6.   }
  7. }
复制代码
12. 添加底部概率说明(可选)

最后,我们在页面底部添加奖品概率说明:
  1. // 底部说明
  2. Text('奖品说明:概率从高到低排序')
  3.   .fontSize(14)
  4.   .fontColor(Color.White)
  5.   .opacity(0.7)
  6.   .margin({ top: 20 })
  7. // 概率说明
  8. Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
  9.   ForEach(this.prizes, (prize: PrizesItem, index) => {
  10.     Text(`${prize.name}: ${prize.probability}%`)
  11.       .fontSize(12)
  12.       .fontColor(Color.White)
  13.       .backgroundColor(prize.color)
  14.       .borderRadius(12)
  15.       .padding({
  16.         left: 10,
  17.         right: 10,
  18.         top: 4,
  19.         bottom: 4
  20.       })
  21.       .margin(4)
  22.   })
  23. }
  24. .width('90%')
  25. .margin({ top: 10 })
复制代码
这段代码在页面底部添加了奖品概率说明,直观展示各个奖品的中奖概率。
13. 美化优化

为了让转盘更加美观,我们可以进一步优化转盘的视觉效果:
  1. // 绘制转盘
  2. private drawWheel(): void {
  3.   // ...之前的代码
  4.   
  5.   // 绘制转盘外圆边框
  6.   this.ctx.beginPath()
  7.   this.ctx.arc(centerX, centerY, radius + 5, 0, 2 * Math.PI)
  8.   this.ctx.fillStyle = '#2A2A2A'
  9.   this.ctx.fill()
  10.   this.ctx.strokeStyle = '#FFD700' // 金色边框
  11.   this.ctx.lineWidth = 3
  12.   this.ctx.stroke()
  13.   
  14.   // ...其余绘制代码
  15.   
  16.   // 给指针添加渐变色和阴影
  17.   let pointerGradient = this.ctx.createLinearGradient(
  18.     centerX, centerY - radius - 15,
  19.     centerX, centerY - radius * 0.8
  20.   )
  21.   pointerGradient.addColorStop(0, '#FF0000')
  22.   pointerGradient.addColorStop(1, '#FF6666')
  23.   this.ctx.fillStyle = pointerGradient
  24.   this.ctx.fill()
  25.   
  26.   this.ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
  27.   this.ctx.shadowBlur = 5
  28.   this.ctx.shadowOffsetX = 2
  29.   this.ctx.shadowOffsetY = 2
  30.   
  31.   // ...其余代码
  32. }
复制代码
完整代码

以下是完整的实现代码:
  1. interface PrizesItem {
  2.   name: string // 奖品名称
  3.   color: string // 转盘颜色
  4.   probability: number // 概率权重
  5. }
  6. @Entry
  7. @Component
  8. struct Index {
  9.   // Canvas 相关设置
  10.   private readonly settings: RenderingContextSettings = new RenderingContextSettings(true); // 启用抗锯齿
  11.   private readonly ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  12.   // 奖品数据
  13.   private prizes: PrizesItem[] = [
  14.     { name: '谢谢参与', color: '#FFD8A8', probability: 30 },
  15.     { name: '10积分', color: '#B2F2BB', probability: 20 },
  16.     { name: '5元红包', color: '#D0BFFF', probability: 1 },
  17.     { name: '优惠券', color: '#A5D8FF', probability: 15 },
  18.     { name: '免单券', color: '#FCCFE7', probability: 5 },
  19.     { name: '50积分', color: '#BAC8FF', probability: 15 },
  20.     { name: '会员月卡', color: '#99E9F2', probability: 3 },
  21.     { name: '1元红包', color: '#FFBDBD', probability: 2 }
  22.   ]
  23.   // 转盘属性
  24.   @State isSpinning: boolean = false // 是否正在旋转
  25.   @State rotation: number = 0 // 当前旋转角度
  26.   @State result: string = '点击开始抽奖' // 抽奖结果
  27.   private spinDuration: number = 4000 // 旋转持续时间(毫秒)
  28.   private targetIndex: number = 0 // 目标奖品索引
  29.   private spinTimer: number = 0 // 旋转定时器
  30.   private canvasWidth: number = 0 // 画布宽度
  31.   private canvasHeight: number = 0 // 画布高度
  32.   // 生成随机目标索引(基于概率权重)
  33.   private generateTargetIndex(): number {
  34.     const weights = this.prizes.map(prize => prize.probability)
  35.     const totalWeight = weights.reduce((a, b) => a + b, 0)
  36.     const random = Math.random() * totalWeight
  37.     let currentWeight = 0
  38.     for (let i = 0; i < weights.length; i++) {
  39.       currentWeight += weights[i]
  40.       if (random < currentWeight) {
  41.         return i
  42.       }
  43.     }
  44.     return 0
  45.   }
  46.   // 开始抽奖
  47.   private startSpin(): void {
  48.     if (this.isSpinning) {
  49.       return
  50.     }
  51.     this.isSpinning = true
  52.     this.result = '抽奖中...'
  53.     // 生成目标奖品索引
  54.     this.targetIndex = this.generateTargetIndex()
  55.     console.info(`抽中奖品索引: ${this.targetIndex}, 名称: ${this.prizes[this.targetIndex].name}`)
  56.     // 计算目标角度
  57.     // 每个奖品占据的角度 = 360 / 奖品数量
  58.     const anglePerPrize = 360 / this.prizes.length
  59.     // 因为Canvas中0度是在右侧,顺时针旋转,而指针在顶部(270度位置)
  60.     // 所以需要将奖品旋转到270度位置对应的角度
  61.     // 目标奖品中心点的角度 = 索引 * 每份角度 + 半份角度
  62.     const prizeAngle = this.targetIndex * anglePerPrize + anglePerPrize / 2
  63.     // 需要旋转到270度位置的角度 = 270 - 奖品角度
  64.     // 但由于旋转方向是顺时针,所以需要计算为正向旋转角度
  65.     const targetAngle = (270 - prizeAngle + 360) % 360
  66.     // 获取当前角度的标准化值(0-360范围内)
  67.     const currentRotation = this.rotation % 360
  68.     // 计算从当前位置到目标位置需要旋转的角度(确保是顺时针旋转)
  69.     let deltaAngle = targetAngle - currentRotation
  70.     if (deltaAngle <= 0) {
  71.       deltaAngle += 360
  72.     }
  73.     // 最终旋转角度 = 当前角度 + 5圈 + 到目标的角度差
  74.     const finalRotation = this.rotation + 360 * 5 + deltaAngle
  75.     console.info(`当前角度: ${currentRotation}°, 奖品角度: ${prizeAngle}°, 目标角度: ${targetAngle}°, 旋转量: ${deltaAngle}°, 最终角度: ${finalRotation}°`)
  76.     // 使用基于帧动画的方式旋转,确保视觉上平滑旋转
  77.     let startTime = Date.now()
  78.     let initialRotation = this.rotation
  79.     // 清除可能存在的定时器
  80.     if (this.spinTimer) {
  81.       clearInterval(this.spinTimer)
  82.     }
  83.     // 创建新的动画定时器
  84.     this.spinTimer = setInterval(() => {
  85.       const elapsed = Date.now() - startTime
  86.       if (elapsed >= this.spinDuration) {
  87.         // 动画结束
  88.         clearInterval(this.spinTimer)
  89.         this.spinTimer = 0
  90.         this.rotation = finalRotation
  91.         this.drawWheel()
  92.         this.isSpinning = false
  93.         this.result = `恭喜获得: ${this.prizes[this.targetIndex].name}`
  94.         return
  95.       }
  96.       // 使用easeOutExpo效果:慢慢减速
  97.       const progress = this.easeOutExpo(elapsed / this.spinDuration)
  98.       this.rotation = initialRotation + progress * (finalRotation - initialRotation)
  99.       // 重绘转盘
  100.       this.drawWheel()
  101.     }, 16) // 大约60fps的刷新率
  102.   }
  103.   // 缓动函数:指数减速
  104.   private easeOutExpo(t: number): number {
  105.     return t === 1 ? 1 : 1 - Math.pow(2, -10 * t)
  106.   }
  107.   // 绘制转盘
  108.   private drawWheel(): void {
  109.     if (!this.ctx) {
  110.       return
  111.     }
  112.     const centerX = this.canvasWidth / 2
  113.     const centerY = this.canvasHeight / 2
  114.     const radius = Math.min(centerX, centerY) * 0.85
  115.     // 清除画布
  116.     this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  117.     // 保存当前状态
  118.     this.ctx.save()
  119.     // 移动到中心点
  120.     this.ctx.translate(centerX, centerY)
  121.     // 应用旋转
  122.     this.ctx.rotate((this.rotation % 360) * Math.PI / 180)
  123.     // 绘制转盘扇形
  124.     const anglePerPrize = 2 * Math.PI / this.prizes.length
  125.     for (let i = 0; i < this.prizes.length; i++) {
  126.       const startAngle = i * anglePerPrize
  127.       const endAngle = (i + 1) * anglePerPrize
  128.       this.ctx.beginPath()
  129.       this.ctx.moveTo(0, 0)
  130.       this.ctx.arc(0, 0, radius, startAngle, endAngle)
  131.       this.ctx.closePath()
  132.       // 填充扇形
  133.       this.ctx.fillStyle = this.prizes[i].color
  134.       this.ctx.fill()
  135.       // 绘制边框
  136.       this.ctx.strokeStyle = "#FFFFFF"
  137.       this.ctx.lineWidth = 2
  138.       this.ctx.stroke()
  139.       // 绘制文字
  140.       this.ctx.save()
  141.       this.ctx.rotate(startAngle + anglePerPrize / 2)
  142.       this.ctx.textAlign = 'center'
  143.       this.ctx.textBaseline = 'middle'
  144.       this.ctx.fillStyle = '#333333'
  145.       this.ctx.font = '30px'
  146.       // 旋转文字,使其可读性更好
  147.       // 第一象限和第四象限的文字需要额外旋转180度,保证文字朝向
  148.       const needRotate = (i >= this.prizes.length / 4) && (i < this.prizes.length * 3 / 4)
  149.       if (needRotate) {
  150.         this.ctx.rotate(Math.PI)
  151.         this.ctx.fillText(this.prizes[i].name, -radius * 0.6, 0, radius * 0.5)
  152.       } else {
  153.         this.ctx.fillText(this.prizes[i].name, radius * 0.6, 0, radius * 0.5)
  154.       }
  155.       this.ctx.restore()
  156.     }
  157.     // 恢复状态
  158.     this.ctx.restore()
  159.     // 绘制中心圆盘
  160.     this.ctx.beginPath()
  161.     this.ctx.arc(centerX, centerY, radius * 0.2, 0, 2 * Math.PI)
  162.     this.ctx.fillStyle = '#FF8787'
  163.     this.ctx.fill()
  164.     this.ctx.strokeStyle = '#FFFFFF'
  165.     this.ctx.lineWidth = 3
  166.     this.ctx.stroke()
  167.     // 绘制指针 - 固定在顶部中央
  168.     this.ctx.beginPath()
  169.     // 三角形指针
  170.     this.ctx.moveTo(centerX, centerY - radius - 10)
  171.     this.ctx.lineTo(centerX - 15, centerY - radius * 0.8)
  172.     this.ctx.lineTo(centerX + 15, centerY - radius * 0.8)
  173.     this.ctx.closePath()
  174.     this.ctx.fillStyle = '#FF6B6B'
  175.     this.ctx.fill()
  176.     this.ctx.strokeStyle = '#FFFFFF'
  177.     this.ctx.lineWidth = 2
  178.     this.ctx.stroke()
  179.     // 绘制中心文字
  180.     this.ctx.textAlign = 'center'
  181.     this.ctx.textBaseline = 'middle'
  182.     this.ctx.fillStyle = '#FFFFFF'
  183.     this.ctx.font = '18px sans-serif'
  184.     // 绘制两行文字
  185.     this.ctx.fillText('开始', centerX, centerY - 10)
  186.     this.ctx.fillText('抽奖', centerX, centerY + 10)
  187.   }
  188.   aboutToDisappear() {
  189.     // 清理定时器
  190.     if (this.spinTimer !== 0) {
  191.       clearInterval(this.spinTimer) // 改成 clearInterval
  192.       this.spinTimer = 0
  193.     }
  194.   }
  195.   build() {
  196.     Column() {
  197.       // 标题
  198.       Text('幸运大转盘')
  199.         .fontSize(28)
  200.         .fontWeight(FontWeight.Bold)
  201.         .fontColor(Color.White)
  202.         .margin({ bottom: 20 })
  203.       // 抽奖结果显示
  204.       Text(this.result)
  205.         .fontSize(20)
  206.         .fontColor(Color.White)
  207.         .backgroundColor('#1AFFFFFF')
  208.         .width('90%')
  209.         .textAlign(TextAlign.Center)
  210.         .padding(15)
  211.         .borderRadius(16)
  212.         .margin({ bottom: 30 })
  213.       // 转盘容器
  214.       Stack({ alignContent: Alignment.Center }) {
  215.         // 使用Canvas绘制转盘
  216.         Canvas(this.ctx)
  217.           .width('100%')
  218.           .height('100%')
  219.           .onReady(() => {
  220.             // 获取Canvas尺寸
  221.             this.canvasWidth = this.ctx.width
  222.             this.canvasHeight = this.ctx.height
  223.             // 初始绘制转盘
  224.             this.drawWheel()
  225.           })
  226.         // 中央开始按钮
  227.         Button({ type: ButtonType.Circle }) {
  228.           Text('开始\n抽奖')
  229.             .fontSize(18)
  230.             .fontWeight(FontWeight.Bold)
  231.             .textAlign(TextAlign.Center)
  232.             .fontColor(Color.White)
  233.         }
  234.         .width(80)
  235.         .height(80)
  236.         .backgroundColor('#FF6B6B')
  237.         .onClick(() => this.startSpin())
  238.         .enabled(!this.isSpinning)
  239.         .stateEffect(true) // 启用点击效果
  240.       }
  241.       .width('90%')
  242.       .aspectRatio(1)
  243.       .backgroundColor('#0DFFFFFF')
  244.       .borderRadius(16)
  245.       .padding(15)
  246.       // 底部说明
  247.       Text('奖品概率说明')
  248.         .fontSize(14)
  249.         .fontColor(Color.White)
  250.         .opacity(0.7)
  251.         .margin({ top: 20 })
  252.       // 概率说明
  253.       Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
  254.         ForEach(this.prizes, (prize: PrizesItem) => {
  255.           Text(`${prize.name}: ${prize.probability}%`)
  256.             .fontSize(12)
  257.             .fontColor(Color.White)
  258.             .backgroundColor(prize.color)
  259.             .borderRadius(12)
  260.             .padding({
  261.               left: 10,
  262.               right: 10,
  263.               top: 4,
  264.               bottom: 4
  265.             })
  266.             .margin(4)
  267.         })
  268.       }
  269.       .width('90%')
  270.       .margin({ top: 10 })
  271.     }
  272.     .width('100%')
  273.     .height('100%')
  274.     .justifyContent(FlexAlign.Center)
  275.     .backgroundColor(Color.Black)
  276.     .linearGradient({
  277.       angle: 135,
  278.       colors: [
  279.         ['#1A1B25', 0],
  280.         ['#2D2E3A', 1]
  281.       ]
  282.     })
  283.     .expandSafeArea()
  284.   }
  285. }
复制代码
总结

本教程对 Canvas 的使用有一定难度,建议先点赞收藏。
这个幸运大转盘效果包含以下知识点:

  • 使用Canvas绘制转盘,支持自定义奖品数量和概率
  • 平滑的旋转动画和减速效果
  • 基于概率权重的抽奖算法
  • 美观的UI设计和交互效果
在实际应用中,你还可以进一步扩展这个组件:

  • 添加音效
  • 实现3D效果
  • 添加中奖历史记录
  • 连接后端API获取真实抽奖结果
  • 添加抽奖次数限制
希望这篇 HarmonyOS Next 教程对你有所帮助,期待您的点赞、评论、收藏。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册