一、项目概述

1.1 项目定位

基于 uni-app + Vue3 组合式 API 开发的识字教育小游戏,面向一年级学生,通过"加油"主题场景帮助学生学习汉字识别与组词配对。

1.2 技术栈

  • 框架: uni-app (跨平台:H5/小程序/App)
  • UI 框架: Vue3 组合式 API (Composition API)
  • 样式: SCSS + vw/vh 响应式单位
  • 资源管理: 阿里云 OSS 静态资源托管
  • 动画: CSS Transition + 帧动画 (FrameAnim) + GIF 动图

1.3 游戏结构

游戏包含两个关卡 (Level),通过视频和转场页面串联:

开场视频 → 转场加载 → 第一关 → 过渡视频 → 转场加载 → 第二关 → 结束视频

二、目录结构

lesson_2/
├── page/
│   └── game.vue              # 游戏主页面(流程控制中枢)
├── components/
│   ├── L1.vue                # 第一关:单字识别加油游戏
│   └── L2.vue                # 第二关:组词配对游戏
├── config/
│   ├── game1Oss.js           # OSS 资源地址统一配置
│   ├── l1Config.js           # 第一关游戏配置(卡片、时序、音频映射)
│   └── l2Config.js           # 第二关游戏配置(配对、时序、位移计算)
└── hooks/
    ├── useGameTimer.js       # 通用定时器管理钩子
    ├── useL1Preload.js       # 第一关资源预加载钩子
    ├── useL1GameFlow.js      # 第一关游戏核心流程逻辑
    ├── useL2Preload.js       # 第二关资源预加载钩子
    └── useL2GameFlow.js      # 第二关游戏核心流程逻辑

三、主页面流程控制 (game.vue)

3.1 核心职责

  • 管理游戏全局阶段切换
  • 协调视频播放与关卡加载
  • 处理资源预加载与转场
  • 上报学习进度到后端

3.2 阶段状态机

const phase = ref('video0')
// 阶段枚举:
// video0      → 开场引导视频
// transition1 → 第一关预加载转场页
// l1          → 第一关游戏
// video1      → 第一关结束过渡视频
// transition2 → 第二关预加载转场页
// l2          → 第二关游戏
// video2      → 第二关结束收尾视频

3.3 流程控制逻辑

视频0结束 → 显示转场页 → 预加载L1资源 → 进入L1
L1完成   → 播放视频1  → 显示转场页 → 预加载L2资源 → 进入L2
L2完成   → 播放视频2  → 上报学习记录 → 返回课程列表

3.4 预加载容错机制

const enterLevelAfterPreload = async (startPreload, targetPhase) => {
  try {
    await startPreload()        // 等待资源加载完成
    phase.value = targetPhase   // 切换到目标关卡
  } catch (e) {
    phase.value = targetPhase   // 容错:强制进入,避免卡死
  }
}

3.5 学习进度上报

游戏完成后调用 addStudentGame API 上报学习记录,成功后重定向至课程列表页,失败则返回上一页。


四、第一关:单字识别游戏 (L1)

4.1 游戏玩法

学生需要按顺序点击正确的汉字卡片,每点击正确一张卡片,会触发"加油"动画,将油滴加入油表,完成所有卡片识别即通关。

4.2 核心组件

4.2.1 页面元素
  • 游戏背景: 全屏背景图
  • 瓶子列表: 6个颜色瓶子(红黄蓝白黑绿)
  • 卡片: 6张汉字卡片(春、红、火、年、岁、夕)
  • 人物动画: 小达达角色(待机/说话状态)
  • 宝箱: 右下角宝箱(带打开帧动画)
  • 油滴动画: 卡片正确后切换为油滴飞向宝箱
  • 油表进度: 静态帧 + 动态GIF交替显示进度
  • 错误提示弹窗: 达达小课堂错误讲解

4.3 游戏流程状态机

const phase = ref('ruleIntro')
// 状态枚举:
// ruleIntro → 规则介绍音频播放
// hint      → 卡片提示引导音频播放
// playing   → 可点击交互状态
// resolving → 点击判定处理中
// done      → 关卡完成

4.4 卡片交互逻辑

4.4.1 点击正确流程
点击正确卡片
  ↓
卡片移动到屏幕中心 (TIMING.cardMove = 800ms)
  ↓
卡片发光 + 播放读音音频 (success1Audio)
  ↓
等待读音播放结束 (TIMING.cardShow = 1000ms)
  ↓
卡片缩小淡出 (TIMING.cardShrink = 300ms)
  ↓
卡片隐藏 + 启动油滴帧动画 (重叠衔接 TIMING.oilDropOverlap = 125ms)
  ↓
油滴帧动画播放结束 → 定格最后一帧
  ↓
切换静态油滴 → 飞向宝箱 (TIMING.oilDropMove = 800ms)
  ↓
油滴消失 → 更新油表进度
  ↓
播放动作音频 (success2Audio)
  ↓
判断是否全部完成 → 是:通关 / 否:进入下一步提示
4.4.2 点击错误流程
点击错误卡片
  ↓
累加 failCount
  ↓
判断错误次数:
  ├─ failCount >= 2: 触发达达小课堂弹窗
  │   ├─ 显示错误提示图片
  │   ├─ 播放讲解音频
  │   ├─ 5秒后自动关闭弹窗
  │   └─ 重置所有卡片状态
  └─ failCount == 1: 首次错误提醒
      ├─ 播放错误音效
      ├─ 正确卡片边框抖动 (10秒)
      ├─ 保持可点击状态
      └─ 7秒后恢复错误卡片状态

4.5 动画系统

4.5.1 卡片位移动画
  • 实现方式: CSS Transition + 内联 style 控制
  • 坐标计算: DOM 查询 + rpx 换算
  • 目标点: 屏幕中心锚点 (.center-position)
  • 动画参数: translateX, translateY, scale, opacity, moveDuration
const measureCenterOffset = (char) => new Promise(resolve => {
  nextTick(() => {
    const query = uni.createSelectorQuery()
    query.select('.center-position').boundingClientRect()
    query.select(`#card-${char}`).boundingClientRect()
    query.exec(res => {
      // 计算中心点差值并转换为 rpx
      const rpx = 750 / sys.windowWidth
      resolve({
        translateX: (center.left + center.width/2 - cardRect.left - cardRect.width/2) * rpx,
        translateY: (center.top + center.height/2 - cardRect.top - cardRect.height/2) * rpx,
        scale: CENTER_TARGET.scale
      })
    })
  })
})
4.5.2 油滴帧动画
  • 组件: FrameAnim 自定义帧动画组件
  • 帧序列: boxOpenImg (10帧 PNG)
  • 单帧时长: 20ms
  • 播放控制: playing 响应式驱动
  • 衔接策略: v-show 保持组件挂载,避免 ref 时序问题
4.5.3 油表进度动画

采用"淡出 → GIF动画 → 淡出 → 下一静态帧"的过渡流程:

// 第一步:当前静态帧淡出
fuelProgressOpacity.value = 0

// 第二步:切换到GIF动画并淡入 (300ms后)
fuelProgressPhase.value = 'anim'
fuelProgressOpacity.value = 1

// 第三步:GIF动画淡出 (1500ms后)
fuelProgressOpacity.value = 0

// 第四步:切换到下一张静态帧并淡入 (300ms后)
fuelProgressPhase.value = 'static'
fuelProgressIndex.value = newIndex
fuelProgressOpacity.value = 1

4.6 音频调度系统

4.6.1 音频类型枚举
const AudioType = {
  RULE_INTRO: 'ruleIntro',  // 游戏规则介绍
  HINT: 'hint',             // 卡片提示引导
  SUCCESS1: 'success1',     // 正确选中读音
  SUCCESS2: 'success2',     // 正确动作配套
  CLASS: 'class',           // 错误讲解
  FAIL: 'fail'              // 首次错误提示
}
4.6.2 音频播放控制
const playAudio = (src, type) => {
  currentAudioType.value = type
  currentAudioSrc.value = src
  nextTick(() => audioRef.value?.play?.())
}
4.6.3 音频结束回调分发

根据 currentAudioType 分发不同后续逻辑,音频加载失败时执行容错跳过,防止流程卡死。

4.7 时序配置 (TIMING)

export const TIMING = {
  cardMove: 800,              // 卡片移动时长 (ms)
  cardShow: 1000,             // 卡片展示时长
  cardShrink: 300,            // 卡片缩小时长
  oilDropMove: 800,           // 油滴移动时长
  oilDropFade: 500,           // 油滴淡入淡出时长
  boxOpenFrameDuration: 20,   // 宝箱帧动画单帧时长
  oilDropOverlap: 125,        // 卡片消失与帧动画重叠时间
  gasPumpShow: 500,           // 加油机显示时长
  fuelProgressDelay: 300,     // 油表延迟时长
  fuelProgressFade: 300,      // 油表淡入淡出时长
  fuelProgressAnim: 1500,     // 油表动画时长
  gifDuration: 1180,          // GIF播放时长
  gifHold: 280,               // GIF定格时长
  stepGap: 500,               // 步骤间隔时长
  hintLock: 2000,             // 提示锁定时长
}

五、第二关:组词配对游戏 (L2)

5.1 游戏玩法

左右两侧各6张卡片,学生需要点击左侧单字和右侧对应汉字组成词语(如:春+天=春天),配对成功后触发车辆组装动画。

5.2 配对配置

export const L2_PAIRS = [
  { left: { char: '春' }, right: { char: '天' }, wordIndex: 0 },
  { left: { char: '夕' }, right: { char: '阳' }, wordIndex: 1 },
  { left: { char: '火' }, right: { char: '苗' }, wordIndex: 2 },
  { left: { char: '岁' }, right: { char: '月' }, wordIndex: 3 },
  { left: { char: '红' }, right: { char: '色' }, wordIndex: 4 },
  { left: { char: '年' }, right: { char: '轻' }, wordIndex: 5 },
]

5.3 核心组件

5.3.1 页面元素
  • 游戏背景: 全屏背景图
  • 人物动画: 小达达角色(待机/说话状态)
  • 小轿车: 双缓冲图层(交叉淡入淡出)
  • 仪表盘: 双缓冲图层(交叉淡入淡出)
  • 骨架图: 双缓冲图层(组装进度展示)
  • 卡片: 左右各6张,带选中高亮效果
  • 卡片消失帧动画: 能量球序列帧 (24帧)
  • 错误提示弹窗: 达达小课堂组词讲解

5.4 卡片交互逻辑

5.4.1 点击选择机制
点击左侧卡片 → 标记为 selected 状态 → 记录 firstClickKey/Side
点击右侧卡片 → 标记为 selected 状态
  ↓
两侧都有选中卡片 → 进行配对判定
  ↓
判定结果:
  ├─ 正确配对 → 触发成功流程
  └─ 错误配对 → 触发错误流程
5.4.2 配对成功流程
配对成功
  ↓
播放配对成功音频 (success audio)
  ↓
左右卡片移动到中心槽位 (measureSlotOffset)
  ↓
卡片停留展示 (TIMING.slotStay = 3500ms)
  ↓
卡片淡出缩小 + 变白效果 (fadeOutCardsAtSlots)
  ↓
卡片隐藏 → 启动消失帧动画 (cardDisappearTransition, 24帧)
  ↓
播放车辆+仪表盘组装动画 (playCarDashboardStep)
  ├─ 当前静态图 → GIF动画 → 下一张静态图
  └─ 双缓冲交叉淡入淡出,避免白屏闪烁
  ↓
播放组装讲解音频 (contAudio)
  ↓
判断是否全部完成 → 是:通关 / 否:继续下一对
5.4.3 配对错误流程
配对错误
  ↓
重置两侧选中状态
  ↓
累加 failCount
  ↓
判断错误次数:
  ├─ failCount >= 1: 触发达达小课堂
  │   ├─ 显示正确配对提示图片
  │   ├─ 播放讲解音频 (5秒)
  │   ├─ 自动关闭弹窗
  │   └─ 重置状态恢复交互
  └─ 首次错误:边框抖动提醒正确卡片

5.5 双缓冲图层技术

5.5.1 原理

使用两个图层 (a/b) 交叉淡入淡出,避免资源切换时的白屏闪烁:

const contLayers = reactive({
  a: { src: L2_OSS.cont[0], opacity: 1, zIndex: 11 },  // 激活图层
  b: { src: L2_OSS.cont[0], opacity: 0, zIndex: 10 }   // 备用图层
})
5.5.2 切换流程
1. 预加载新图片 (preloadImage)
2. 备用图层设置新地址,opacity = 0
3. nextTick 等待 DOM 更新
4. 备用图层 opacity = 1 (淡入)
5. 原激活图层 opacity = 0 (淡出)
6. 更新激活图层标记
5.5.3 防竞态机制

使用 fadeToken 序号防止异步预加载乱序覆盖:

let contActiveLayer = 'a'
let carFadeToken = 0

const crossfadeCar = (index) => {
  const token = ++carFadeToken  // 递增请求序号
  // ...
  return preloadImage(newSrc).then(() => new Promise((resolve) => {
    if (token !== carFadeToken) {  // 忽略过期请求
      resolve()
      return
    }
    // 执行切换逻辑
  }))
}

5.6 卡片位置测量

与 L1 类似的 DOM 查询方案,但针对左右槽位分别计算:

const measureSlotOffset = (side, char, cardLayout) => new Promise((resolve) => {
  nextTick(() => {
    const slotSelector = isLeft 
      ? '.center-position .card-wrap-left' 
      : '.center-position .card-wrap-right'
    const cardSelector = `#card-${side}-${char}`
    
    query.select(slotSelector).boundingClientRect()
    query.select(cardSelector).boundingClientRect()
    query.exec((res) => {
      // 计算差值并转换为 rpx
      const rpx = 750 / sys.windowWidth
      resolve({
        translateX: (target.left + target.width/2 - cardRect.left - cardRect.width/2) * rpx,
        translateY: (target.top + target.height/2 - cardRect.top - cardRect.height/2) * rpx,
      })
    })
  })
})

5.7 车辆组装动画流程

const playCarDashboardStep = async (stepIndex) => {
  const carGifIndex = stepIndex * 2 + 1
  const carStaticIndex = stepIndex * 2 + 2
  const dashGifIndex = stepIndex * 2 + 1
  const dashStaticIndex = stepIndex * 2 + 2

  // 1) 切换到 GIF 动画
  await Promise.all([
    crossfadeCar(carGifIndex),
    crossfadeDashboard(dashGifIndex)
  ])

  // 2) 播放 GIF 动画时长
  await waitMs(TIMING.contGif)

  // 3) 切换到下一张静态 PNG
  await Promise.all([
    crossfadeCar(carStaticIndex),
    crossfadeDashboard(dashStaticIndex)
  ])
}

六、资源预加载系统

6.1 预加载钩子架构

两个关卡均采用相同的预加载模式:

export function useL1Preload() {
  const isReady = ref(false)
  const progress = ref(0)
  const loadTip = ref('正在加载图片与音频…')

  const startPreload = () => new Promise((resolve) => {
    // 1. 收集资源
    const { images, audios } = collectL1Assets()
    
    // 2. 创建加载任务
    const tasks = [
      ...images.map(src => ({ type: 'image', src })),
      ...audios.map(src => ({ type: 'audio', src }))
    ]
    
    // 3. 并行加载 + 进度更新
    // 4. 超时容错 (MAX_LOADING_MS = 15000ms)
    // 5. 最小加载时间保障 (MIN_LOADING_MS = 600ms)
    // 6. 成功率判定 (>= 80% 视为成功)
  })

  return { isReady, progress, loadTip, startPreload }
}

6.2 资源加载策略

  • 图片: uni.getImageInfo() 触发加载并校验
  • 音频: uni.downloadFile() 下载缓存
  • 字体: loadGameFont() 加载游戏字体
  • 去重: 使用 Set 去除重复资源地址

6.3 进度提示分级

if (pct < 30) loadTip.value = '正在加载图片…'
else if (pct < 70) loadTip.value = '正在加载动画…'
else loadTip.value = '正在加载音频…'

6.4 容错机制

  • 超时保护: 15秒强制结束,避免无限等待
  • 最小时间: 600ms 最小加载时间,避免闪烁
  • 成功率阈值: 80% 资源加载成功即视为可用
  • 降级进入: 预加载失败仍强制进入关卡,不阻塞流程

七、定时器管理系统 (useGameTimer)

7.1 核心功能

统一管理所有 setTimeout,避免组件销毁时定时器继续执行导致内存泄漏或异常。

7.2 使用方式

const { addTimer, cleanup: cleanupTimers } = useGameTimer()

// 添加受控定时器
addTimer(() => {
  // 定时任务逻辑
}, 1000)

// 组件销毁时批量清理
onUnmounted(() => cleanupTimers())

八、响应式布局方案

8.1 单位体系

  • vw/vh: 主要布局单位,基于视口宽高百分比
  • rpx: 动画位移换算单位,750rpx = 屏幕宽度

8.2 屏幕适配

const sysInfo = uni.getSystemInfoSync()
const screenRatio = sysInfo.windowWidth / sysInfo.windowHeight
const designRatio = 16 / 9

// 特殊屏幕尺寸处理
const cardsWrapperTop = computed(() => {
  return getCardsWrapperTop(sysInfo, screenRatio, designRatio)
})

8.3 卡片尺寸配置

export const CARD_SIZE = {
  width: 58,   // 卡片宽度 (设计稿 px)
  height: 70   // 卡片高度 (设计稿 px)
}

九、OSS 资源管理

9.1 资源目录结构

OSS_BASE = YOUR_OSS_BASE_URL/game/grade_1/book_1/chapter_1/lesson_2/
├── L1/                          # 第一关资源
│   ├── bg.png                   # 背景图
│   ├── bottle.png               # 瓶子图
│   ├── box_close_anim_10.png    # 宝箱关闭
│   ├── box_open_anim_01~10.png  # 宝箱打开帧序列
│   ├── person_standby.gif       # 人物待机
│   ├── talk_person_*.gif        # 人物说话动画
│   ├── fuelProgress_static_*.png # 油表静态帧
│   ├── fuelProgress_*.gif       # 油表动态帧
│   ├── *.mp3                    # 音频文件
│   └── *.png                    # 错误提示图片
└── L2/                          # 第二关资源
    ├── bg.png                   # 背景图
    ├── car_start.png            # 车辆初始状态
    ├── car_anim_step*.gif       # 车辆动画
    ├── car_check_*.png          # 车辆校对帧
    ├── cont_*.png/gif           # 骨架图序列
    ├── sedan_dashboard_*.png    # 仪表盘序列
    ├── energy_ball_*.png        # 能量球帧序列
    ├── *.mp3                    # 音频文件
    └── error_feedback_*.png     # 错误提示图片

9.2 视频资源

export const VIDEOS = {
  v0: `${OSS}/video0.mp4`,  // 开场视频
  v1: `${OSS}/video1.mp4`,  // 第一关过渡视频
  v2: `${OSS}/video2.mp4`,  // 结束视频
  v3: `${OSS}/video3.mp4`,  // 预留视频
  v4: `${OSS}/transition.mp4` // 转场视频
}

十、关键组件说明

10.1 Card 组件

  • 显示汉字卡片,支持拼音展示
  • 支持选中高亮、抖动、发光、淡出等效果
  • 通过 props 控制位移、缩放、透明度动画

10.2 Audio 组件

  • 封装 uni.createInnerAudioContext()
  • 支持播放、暂停、停止、销毁
  • 内置重试机制 (最多3次)
  • 提供 ended/error 事件回调

10.3 FrameAnim 组件

  • 帧动画播放器,支持 PNG 序列帧
  • 支持 playing 响应式驱动
  • 支持 preload/waitPreload 预加载
  • 支持 hold-last 定格最后一帧
  • 提供 ended 事件回调

10.4 ReImage 组件

  • 增强型图片组件,支持多种 mode
  • 支持 position/z-index 定位
  • 支持 transition 过渡动画

10.5 GameVideo 组件

  • 视频播放组件,支持播放/跳过/异常回调
  • 支持播放遮罩层
  • 提供 ended/error/skip 事件

十一、性能优化策略

11.1 资源预加载

  • 关卡进入前预加载全部资源
  • 图片使用 uni.getImageInfo() 提前加载
  • 音频使用 uni.downloadFile() 缓存

11.2 双缓冲技术

  • 避免资源切换时的白屏闪烁
  • 使用 fadeToken 防止异步竞态

11.3 v-show vs v-if

  • 帧动画使用 v-show 保持组件挂载
  • 避免 ref 时序问题导致动画不显示

11.4 防抖处理

const debouncedHandleCardClick = debounce(handleCardClick, 300, true)

11.5 DOM 查询优化

  • 使用 nextTick 确保 DOM 更新后查询
  • 提供兜底计算方案避免查询失败阻塞流程

十二、错误处理与容错

12.1 音频容错

  • 音频加载失败时跳过并推进流程
  • 内置 3 次重试机制
  • 各音频类型均有独立的 error 兜底逻辑

12.2 预加载容错

  • 15秒超时强制进入
  • 80% 成功率阈值判定
  • 失败仍允许进入关卡

12.3 DOM 查询容错

  • 查询失败使用预设坐标兜底
  • 避免流程中断

12.4 接口调用容错

  • 学习记录上报失败时返回上一页
  • 不阻塞用户操作

十三、状态管理总结

13.1 L1 核心状态

状态类型说明
phaseref游戏阶段 (ruleIntro/hint/playing/resolving/done)
currentStepref当前步骤索引
failCountref当前步骤错误次数
cardStatesreactive卡片状态集合 (normal/error/success/hidden)
cardAnimreactive卡片动画参数集合
personIndexref人物动画索引
fuelProgressIndexref油表进度索引
showClassTipref是否显示错误弹窗

13.2 L2 核心状态

状态类型说明
currentPairref当前配对索引
completedPairsref已完成配对数量
cardStatesreactive卡片状态集合 (normal/selected/error/moving/hidden)
cardMoveAnimreactive卡片移动动画参数
contLayersreactive骨架图双缓冲图层
carLayersreactive小轿车双缓冲图层
dashboardLayersreactive仪表盘双缓冲图层
selectedLeftKeyref左侧选中卡片键
selectedRightKeyref右侧选中卡片键
failCountref连续失败次数
showClassTipref是否显示分类提示

十四、扩展建议

14.1 可配置化

  • 将 TIMING 时序参数提取为外部配置
  • 支持动态调整游戏难度

14.2 状态持久化

  • 保存游戏进度到本地存储
  • 支持断点续玩

14.3 数据分析

  • 记录学生答题正确率
  • 记录每步耗时分析学习行为

14.4 多端适配

  • 增加更多屏幕比例适配规则
  • 优化小程序端性能表现

十五、常见问题排查

15.1 音频播放失败

  • 检查 OSS 资源是否存在
  • 检查音频格式是否为标准 MP3
  • 检查 OSS CORS 跨域配置
  • 查看控制台错误日志

15.2 动画不显示

  • 检查 v-show/v-if 使用是否正确
  • 检查帧序列图片是否全部加载
  • 检查 FrameAnim 组件 playing 状态

15.3 卡片位置偏移异常

  • 检查 DOM 查询时机 (是否使用 nextTick)
  • 检查 rpx 换算比例
  • 检查屏幕适配逻辑

文档版本: v1.0
更新日期: 2026-07-26
适用项目: 加油识字游戏 - Lesson 2


十六、完整代码实现

16.1 游戏主页面 (game.vue)

游戏主页面是整个关卡流程的控制中枢,负责管理四个关卡的切换和学习进度上报。

<template>
	<view class="game1-root">
		<GameGoBackBt />
		<Level1 />
		<!-- 进度条 -->
		<ProgressStarBar :step="currentStep" />
	</view>
</template>

<script setup>
	import {
		ref
	} from 'vue';
	import {
		onLoad
	} from '@dcloudio/uni-app';
	import {
		addStudentGame
	} from '@/api/api.js';
	import GameGoBackBt from '@/pages/game/components/GameGoBackBt.vue';
	import Level1 from '../components/L1.vue';
	import Level2 from '../components/L2.vue';
	import Level3 from '../components/L3.vue';
	import Level4 from '../components/L4.vue';
	import ProgressStarBar from '@/pages/game/grade_1/HanyuPinyin/components/progressBar.vue';

	const phase = ref('l1');
	const currentStep = ref(0);

	const chapterId = ref('');
	const bookId = ref('');
	const bookName = ref('');
	const bookChapterCount = ref('');
	const bookImgUrl = ref('');
	const userBannerImgPath = ref('');

	onLoad((option) => {
		bookId.value = option?.id || '';
		bookName.value = option?.title || '';
		bookChapterCount.value = option?.chapterCount || '';
		bookImgUrl.value = option?.bookImg || '';
		userBannerImgPath.value = option?.userBannerImgPath || '';
		chapterId.value = option?.chapterId || '';
	});

	const onL1Complete = () => {
		currentStep.value = 1;
		phase.value = 'l2';
	};

	const onL2Complete = () => {
		currentStep.value = 2;
		phase.value = 'l3';
	};

	const onL3Complete = () => {
		currentStep.value = 3;
		phase.value = 'l4';
	};

	const onL4Complete = () => {
		currentStep.value = 4;
		goHome();
	};

	const goHome = () => {
		if (!chapterId.value) {
			uni.navigateBack({
				delta: 1
			});
			return;
		}
		addStudentGame({
			chapterId: chapterId.value
		}).then((res) => {
			if (res?.code === 200) {
				uni.redirectTo({
					url: `/pages/business/user/lessonPreparation/menu?id=${bookId.value}&title=${bookName.value}&chapterCount=${bookChapterCount.value}&bookImg=${bookImgUrl.value}&userBannerImgPath=${userBannerImgPath.value || ''}`,
				});
			} else {
				uni.navigateBack({
					delta: 1
				});
			}
		}).catch(() => {
			uni.navigateBack({
				delta: 1
			});
		});
	};
</script>

<style scoped>
	.game1-root {
		width: 100vw;
		height: 100vh;
		position: relative;
		overflow: hidden;
	}
</style>

核心功能说明

  • phase 控制当前显示的关卡组件
  • currentStep 控制进度条星星点亮数量
  • onLoad 接收路由参数,用于完成后跳转
  • goHome 调用 addStudentGame API 上报学习记录,成功后跳转课程菜单

16.2 第一关:拼音识别游戏 (L1.vue)

第一关主要让学生识别拼音字母 b、p、d、q。

<template>
	<view class="game-content">
		<!-- 背景 -->
		<ReImage :src="L1_OSS.bg" mode="aspectFill" position="absolute" z-index="0" width="100vw" height="100vh" />

		<!-- 拼音卡片 -->
		<PinyinCard text="b" width="10.7vw" height="15vh" top="20vh" left="40vw" outerBgColor="#6F90EF"
			outerRadius="5rpx" borderPadding="8rpx" innerBgColor="#fff" innerRadius="5rpx" fontSize="52rpx"
			topBarColor="#b0e8ff" topBarHeight="2rpx" />
		<PinyinCard text="p" width="10.7vw" height="15vh" top="20vh" left="65vw" outerBgColor="#FFD97A"
			outerRadius="5rpx" borderPadding="8rpx" innerBgColor="#fff" innerRadius="5rpx" fontSize="52rpx"
			topBarColor="#b0e8ff" topBarHeight="2rpx" textOffsetY="-10rpx" />
		<PinyinCard text="d" width="10.7vw" height="15vh" top="51vh" left="40vw" outerBgColor="#FF915A"
			outerRadius="5rpx" borderPadding="8rpx" innerBgColor="#fff" innerRadius="5rpx" fontSize="52rpx"
			topBarColor="#b0e8ff" topBarHeight="2rpx" />
		<PinyinCard text="q" width="10.7vw" height="15vh" top="51vh" left="65vw" outerBgColor="#D792FF"
			outerRadius="5rpx" borderPadding="8rpx" innerBgColor="#fff" innerRadius="5rpx" fontSize="52rpx"
			topBarColor="#b0e8ff" topBarHeight="2rpx" textOffsetY="-10rpx" />

		<!-- 人物 -->
		<ReImage position="absolute" z-index="3" width="22.8vw" height="55.965116vh" top="39vh" left="8.8vw"
			:src="personSrc" @click="() => failCount >= 1 && !showClassTip && triggerClassTip()" />
		<!-- 音频 -->
		<Audio ref="audioRef" :src="currentAudioSrc" @ended="onAudioEnded" @error="onAudioError" />
	</view>
</template>

<script setup>
	import {
		ref,
		onMounted,
		onUnmounted,
		nextTick
	} from 'vue'
	import Audio from '@/pages/game/components/Audio.vue';
	import ReImage from '@/pages/game/components/ReImage.vue';
	import PinyinCard from '@/pages/game/grade_1/HanyuPinyin/components/card.vue';
	import {
		L1_OSS
	} from '../config/l1Config.js';
	import {
		useL1GameFlow
	} from '../hooks/useL1GameFlow.js';

	const emit = defineEmits(['level-complete']);
	const audioRef = ref(null);

	const {
		personSrc,
		showClassTip,
		currentAudioSrc,
		failCount,
		triggerClassTip,
		onAudioEnded,
		onAudioError,
		startLevel,
		cleanup,
	} = useL1GameFlow(audioRef, emit);

	onMounted(() => nextTick(startLevel));
	onUnmounted(() => cleanup());
</script>

<style lang="scss" scoped>
	.game-content {
		width: 100vw;
		height: 100vh;
		position: relative;
		z-index: 0;
		overflow: hidden;
	}
</style>

核心功能说明

  • 展示四个拼音字母 b、p、d、q,每个字母有不同的颜色背景
  • 使用 PinyinCard 组件显示拼音卡片
  • 人物角色可点击触发错误提示
  • 使用 useL1GameFlow 钩子管理游戏流程

16.3 第二关:拖拽配对游戏 (L2.vue)

第二关让学生将拼音字母拖拽到正确位置完成配对。

<template>
	<view class="game-content">
		<!-- 背景 -->
		<ReImage :src="L2_OSS.bg" mode="aspectFill" position="absolute" z-index="0" width="100vw" height="100vh" />

		<!-- 拖拽目标容器,靠近时放大 -->
		<ReImage :src="L2_OSS.container" mode="aspectFill" position="absolute" z-index="0" width="39vw" height="45vh"
			top="35vh" left="40vw"
			:style="{ transform: containerHover ? 'scale(1.05)' : 'scale(1)', transition: 'transform 0.2s ease' }" />

		<!-- 固定不可拖动的 b 字母 -->
		<ReImage :src="L2_OSS.b" mode="aspectFill" position="absolute" z-index="0" width="11vw" height="22vh" top="10vh"
			left="19vw" />

		<!-- 可拖拽项:a o e i u ü -->
		<view v-for="(item, idx) in dragList" :key="idx" class="drag-item" :style="itemStyle(item)"
			@touchstart="touchStart($event, item)" @touchmove="touchMove($event, item)" @touchend="touchEnd(item)">
			<ReImage :src="item.src" mode="aspectFill" :width="item.width" :height="item.height" />
		</view>

		<!-- 人物交互按钮 -->
		<ReImage position="absolute" z-index="3" width="22.8vw" height="55.965116vh" top="39vh" left="8.8vw"
			:src="personSrc" @click="() => failCount >= 1 && !showClassTip && triggerClassTip()" />

		<!-- 吸附锚点目标位置 -->
		<view class="tip-mark" ref="tipMark"></view>
	</view>
</template>

<script setup>
	import {
		ref,
		reactive,
		nextTick
	} from 'vue'
	import {
		L2_OSS
	} from '../config/l2Config.js'
	import {
		COMMON_ASSETS
	} from '../config/game1Oss.js'
	import ReImage from '@/pages/game/components/ReImage.vue'

	const emit = defineEmits(['level-complete'])
	const personSrc = ref(COMMON_ASSETS.personIdle)
	const showClassTip = ref(false)
	const failCount = ref(0)
	const containerHover = ref(false)
	const tipMark = ref(null)

	// 可拖拽列表
	const dragList = reactive([{
			name: 'a',
			src: L2_OSS.a,
			width: '7vw',
			height: '15vh',
			initTop: '15vh',
			initLeft: '33.5vw',
			top: '15vh',
			left: '33.5vw',
			scale: 1,
			isDragging: false
		},
		{
			name: 'o',
			src: L2_OSS.o,
			width: '7vw',
			height: '15vh',
			initTop: '9vh',
			initLeft: '43vw',
			top: '9vh',
			left: '43vw',
			scale: 1,
			isDragging: false
		},
		{
			name: 'e',
			src: L2_OSS.e,
			width: '7vw',
			height: '15vh',
			initTop: '15vh',
			initLeft: '52.5vw',
			top: '15vh',
			left: '52.5vw',
			scale: 1,
			isDragging: false
		},
		{
			name: 'i',
			src: L2_OSS.i,
			width: '7vw',
			height: '15vh',
			initTop: '9vh',
			initLeft: '62vw',
			top: '9vh',
			left: '62vw',
			scale: 1,
			isDragging: false
		},
		{
			name: 'u',
			src: L2_OSS.u,
			width: '7vw',
			height: '15vh',
			initTop: '15vh',
			initLeft: '71.5vw',
			top: '15vh',
			left: '71.5vw',
			scale: 1,
			isDragging: false
		},
		{
			name: 'ü',
			src: L2_OSS.ü,
			width: '7vw',
			height: '15vh',
			initTop: '9vh',
			initLeft: '81vw',
			top: '9vh',
			left: '81vw',
			scale: 1,
			isDragging: false
		}
	])

	// 计算样式
	const itemStyle = (item) => {
		return {
			position: 'absolute',
			top: item.top,
			left: item.left,
			transform: `scale(${item.scale})`,
			transition: item.isDragging ? 'none' : 'all 0.2s ease',
			zIndex: item.isDragging ? 100 : 1
		}
	}

	// 触摸开始
	const touchStart = (e, item) => {
		item.isDragging = true
		item.scale = 1.1
	}

	// 触摸移动
	const touchMove = (e, item) => {
		const touch = e.touches[0]
		const sysInfo = uni.getSystemInfoSync()
		const rpx = 750 / sysInfo.windowWidth
		
		item.left = (touch.clientX * rpx - 35) + 'rpx'
		item.top = (touch.clientY * rpx - 35) + 'rpx'
		
		// 检测是否靠近容器
		// 简化逻辑,实际需要根据容器位置计算距离
		containerHover.value = false
	}

	// 触摸结束
	const touchEnd = (item) => {
		item.isDragging = false
		item.scale = 1
		
		// 判断是否放置成功
		// 如果放置失败,回到初始位置
		// 如果放置成功,触发后续逻辑
		
		// 简化逻辑,实际需要判断位置
		item.left = item.initLeft
		item.top = item.initTop
	}

	const triggerClassTip = () => {
		if (failCount.value < 1 || showClassTip.value) return
		showClassTip.value = true
	}

	const cleanup = () => {}

	onUnmounted(() => cleanup())
</script>

<style lang="scss" scoped>
	.game-content {
		width: 100vw;
		height: 100vh;
		position: relative;
		z-index: 0;
		overflow: hidden;
	}
	
	.drag-item {
		position: absolute;
		z-index: 1;
	}
</style>

核心功能说明

  • 展示六个可拖拽的拼音字母 a、o、e、i、u、ü
  • 中间有固定的 b 字母作为拼合目标
  • 拖拽时字母放大 1.1 倍,提升交互反馈
  • 靠近容器时容器放大提示
  • 使用触摸事件实现拖拽功能

16.4 第三关:图片选择游戏 (L3.vue)

第三关让学生根据音频选择正确的图片。

<template>
	<view class="game-content">
		<!-- 背景图片 -->
		<ReImage :src="L3_OSS.bg" mode="aspectFill" position="absolute" z-index="0" width="100vw" height="100vh" />
		<!-- 人物 -->
		<ReImage position="absolute" z-index="3" width="22.8vw" height="55.965116vh" top="39vh" left="8.8vw"
			:src="personSrc" @click="() => failCount >= 1 && !showClassTip && triggerClassTip()" />
		<!-- 巴士 -->
		<ReImage position="absolute" z-index="4" width="18vw" height="8vh" top="63vh" left="32vw"
			:src="L3_OSS.bashi_pic" />
		<!-- 菠萝 -->
		<ReImage position="absolute" z-index="4" width="18vw" height="8vh" top="63vh" left="51vw"
			:src="L3_OSS.boluo_pic" />
		<!-- 钢笔 -->
		<ReImage position="absolute" z-index="4" width="18vw" height="8vh" top="63vh" left="70vw"
			:src="L3_OSS.gangbi_pic" />
		<!-- 正确的钢笔图片 -->
		<ReImage position="absolute" z-index="4" width="22vw" height="33vh" top="19vh" left="48vw"
			:src="L3_OSS.pic_gangbi" />
		<!-- 音频 -->
		<Audio ref="audioRef" :src="currentAudioSrc" @ended="onAudioEnded" @error="onAudioError" />
	</view>
</template>

<script setup>
	import {
		ref,
		onUnmounted
	} from 'vue'
	import {
		L3_OSS
	} from '../config/l3Config.js'
	import {
		COMMON_ASSETS
	} from '../config/game1Oss.js'
	import ReImage from '@/pages/game/components/ReImage.vue'

	const emit = defineEmits(['level-complete'])

	const personSrc = ref(COMMON_ASSETS.personIdle)
	const showClassTip = ref(false)
	const failCount = ref(0)

	const triggerClassTip = () => {
		if (failCount.value < 1 || showClassTip.value) return
		showClassTip.value = true
	}

	const cleanup = () => {}

	onUnmounted(() => cleanup())
</script>

<style lang="scss" scoped>
	@import '@/static/css/global.scss';

	.game-content {
		width: 100vw;
		height: 100vh;
		position: relative;
		z-index: 0;
		overflow: hidden;
	}
</style>

核心功能说明

  • 展示三张图片选项:巴士、菠萝、钢笔
  • 播放音频让学生选择对应图片
  • 上方显示正确答案图片

16.5 第四关:判断对错游戏 (L4.vue)

第四关让学生判断拼音是否正确。

<template>
	<view class="game-content">
        <!-- 背景 -->
		<ReImage :src="L4_OSS.bg" mode="aspectFill" position="absolute" z-index="0" width="100vw" height="100vh" />
        <!-- 对的拼音 -->
		<ReImage :src="L4_OSS.dui_b" mode="aspectFill" position="absolute" z-index="0" width="21vw" height="38vh"
			top="27vh" left="36vw" />
        <!-- 错的拼音 -->
		<ReImage :src="L4_OSS.cuo_b" mode="aspectFill" position="absolute" z-index="0" width="21vw" height="38vh"
			top="27vh" left="61vw" />
		<ReImage position="absolute" z-index="3" width="22.8vw" height="55.965116vh" top="39vh" left="8.8vw"
			:src="personSrc" @click="() => failCount >= 1 && !showClassTip && triggerClassTip()" />
	</view>
</template>

<script setup>
	import { ref, onUnmounted } from 'vue'
	import { L4_OSS } from '../config/l4Config.js'
	import { COMMON_ASSETS } from '../config/game1Oss.js'
	import ReImage from '@/pages/game/components/ReImage.vue'

	const emit = defineEmits(['level-complete'])

	const personSrc = ref(COMMON_ASSETS.personIdle)
	const showClassTip = ref(false)
	const failCount = ref(0)

	const triggerClassTip = () => {
		if (failCount.value < 1 || showClassTip.value) return
		showClassTip.value = true
	}

	const cleanup = () => {}

	onUnmounted(() => cleanup())
</script>

<style lang="scss" scoped>
	@import '@/static/css/global.scss';

	.game-content {
		width: 100vw;
		height: 100vh;
		position: relative;
		z-index: 0;
		overflow: hidden;
	}
</style>

核心功能说明

  • 左右两侧分别显示"对"和"错"的提示
  • 学生需要判断拼音是否正确并点击对应区域

16.6 OSS 资源配置 (game1Oss.js)

统一管理所有关卡的 OSS 资源地址。

export const OSS = 'YOUR_OSS_BASE_URL'; // 替换为实际的阿里云 OSS 地址

// 全局公共资源(进度条、星星、人物)
export const COMMON_ASSETS = {
  EmptyProgressBar: `${OSS}/EmptyProgressBar.png`,
  FullProgressBar: `${OSS}/FullProgressBar.png`,
  star: `${OSS}/star.png`,
  star_active: `${OSS}/star_active.png`,
  personIdle: `${OSS}/L1/talk_person_free.gif`,
  personTalk2s: `${OSS}/L1/talk_person_2s.gif`,
  personTalk3s: `${OSS}/L1/talk_person_3s.gif`,
  personTalk5s: `${OSS}/L1/talk_person_5s.gif`,
  personTalk7s: `${OSS}/L1/talk_person_7s.gif`,
};

const OSS_BASE_L1 = `${OSS}/L1`;

export const L1_OSS = {
	bg: `${OSS_BASE_L1}/bg.png`,
	b: `${OSS_BASE_L1}/b.png`,
	d: `${OSS_BASE_L1}/d.png`,
	p: `${OSS_BASE_L1}/p.png`,
	q: `${OSS_BASE_L1}/q.png`,
	yp_3b: `${OSS_BASE_L1}/yp_3b.mp3`,
	personIdle: `${OSS_BASE_L1}/talk_person_free.gif`,
	personTalk2s: `${OSS_BASE_L1}/talk_person_2s.gif`,
	personTalk3s: `${OSS_BASE_L1}/talk_person_3s.gif`,
	personTalk5s: `${OSS_BASE_L1}/talk_person_5s.gif`,
	personTalk7s: `${OSS_BASE_L1}/talk_person_7s.gif`,
};

const OSS_BASE_L2 = `${OSS}/L2`;
export const L2_OSS = {
	bg: `${OSS}/bg.png`,
	a: `${OSS_BASE_L2}/a.png`,
	b: `${OSS_BASE_L2}/b.png`,
	container: `${OSS_BASE_L2}/container.png`,
	e: `${OSS_BASE_L2}/e.png`,
	i: `${OSS_BASE_L2}/i.png`,
	o: `${OSS_BASE_L2}/o.png`,
	u: `${OSS_BASE_L2}/u.png`,
	ü: `${OSS_BASE_L2}/ü.png`,
};

const OSS_BASE_L3 = `${OSS}/L3`;
export const L3_OSS = {
	bg: `${OSS}/bg.png`,
  bashi_pic: `${OSS_BASE_L3}/bashi.png`,
  bashi_audio: `${OSS_BASE_L3}/yp_bashi.mp3`,
  boluo_pic: `${OSS_BASE_L3}/boluo.png`,
  boluo_audio: `${OSS_BASE_L3}/yp_boluo.mp3`,
  gangbi_pic: `${OSS_BASE_L3}/gangbi.png`,
  gangbi_audio: `${OSS_BASE_L3}/yp_gangbi.mp3`,
  yp_gangbi1: `${OSS_BASE_L3}/yp_gangbi1.mp3`,
  paobu_pic: `${OSS_BASE_L3}/paobu.png`,
  paobu_audio: `${OSS_BASE_L3}/yp_paobu.mp3`,
  play_btn: `${OSS_BASE_L3}/play_btn.png`,
  pic_gangbi: `${OSS_BASE_L3}/pic_gangbi.png`,
};

const OSS_BASE_L4 = `${OSS}/L4`;
export const L4_OSS = {
	bg: `${OSS}/bg.png`, 
	cuo_b: `${OSS_BASE_L4}/cuo.png`,
	dui_b: `${OSS_BASE_L4}/dui.png`,
};

核心功能说明

  • 使用模板字符串统一管理 OSS 资源地址
  • 分离公共资源和各关卡专属资源
  • 便于后续维护和资源替换

16.7 定时器管理钩子 (useGameTimer.js)

统一管理所有 setTimeout,避免组件销毁时定时器继续执行。

/** 统一管理 setTimeout,组件卸载时清理 */
export function useGameTimer() {
	const timerList = []
	
	const addTimer = (fn, delay) => {
		const t = setTimeout(fn, delay)
		timerList.push(t)
		return t
	}
	
	const cleanup = () => {
		timerList.forEach((t) => clearTimeout(t))
		timerList.length = 0
	}
	
	return {
		addTimer,
		cleanup
	}
}

核心功能说明

  • addTimer 添加受控定时器并记录
  • cleanup 批量清理所有定时器
  • 避免组件卸载后定时器继续执行导致内存泄漏

16.8 第一关游戏流程钩子 (useL1GameFlow.js)

管理第一关的游戏流程逻辑。

import { ref } from 'vue'
import { L1_OSS } from '../config/l1Config.js'
import { useGameTimer } from './useGameTimer.js'

export function useL1GameFlow(audioRef, emit) {
	const { addTimer, cleanup: cleanupTimers } = useGameTimer()

	const personSrc = ref(L1_OSS.personIdle)
	const showClassTip = ref(false)
	const isClassTipLeaving = ref(false)
	const classTipImg = ref('')
	const currentAudioSrc = ref('')
	const failCount = ref(0)

	const playAudio = (src) => {
		if (!src) return
		currentAudioSrc.value = src
		setTimeout(() => {
			audioRef.value?.play()
		}, 300)
	}
	
	const startLevel = () => {
		playAudio(L1_OSS.yp_3b)
	}

	const onAudioEnded = () => {
		currentAudioSrc.value = ''
	}

	const onAudioError = () => {
		currentAudioSrc.value = ''
	}

	const triggerClassTip = () => {
		if (failCount.value < 1 || showClassTip.value) return
		showClassTip.value = true
		addTimer(() => {
			isClassTipLeaving.value = true
			addTimer(() => {
				showClassTip.value = false
				isClassTipLeaving.value = false
				failCount.value = 0
			}, 500)
		}, 5000)
	}

	const cleanup = () => {
		cleanupTimers()
		audioRef.value?.destroy?.()
	}

	return {
		personSrc,
		showClassTip,
		isClassTipLeaving,
		classTipImg,
		currentAudioSrc,
		failCount,
		triggerClassTip,
		onAudioEnded,
		onAudioError,
		startLevel,
		cleanup,
	}
}

核心功能说明

  • 管理人物动画状态和音频播放
  • 错误提示弹窗的显示和自动关闭
  • 音频播放结束后的状态重置

16.9 第一关预加载钩子 (useL1Preload.js)

负责第一关资源的预加载管理。

import { ref } from 'vue'
import { loadGameFont } from '@/pages/game/components/loadGameFont.js'

const MIN_LOADING_MS = 600
const MAX_LOADING_MS = 15000

const preloadImage = (src) =>
	new Promise((resolve) => {
		if (!src) {
			resolve(false)
			return
		}
		uni.getImageInfo({
			src,
			success: () => resolve(true),
			fail: () => resolve(false),
		})
	})

const preloadAudio = (src) =>
	new Promise((resolve) => {
		if (!src) {
			resolve(false)
			return
		}
		uni.downloadFile({
			url: src,
			success: () => resolve(true),
			fail: () => resolve(false),
		})
	})

const collectL1Assets = () => {
	const images = [
		L1_OSS.bg,
		L1_OSS.b,
		L1_OSS.d,
		L1_OSS.p,
		L1_OSS.q,
	].filter(Boolean)

	const audios = [
		L1_OSS.yp_3b,
	].filter(Boolean)

	return {
		images: [...new Set(images)],
		audios: [...new Set(audios)],
	}
}

export function useL1Preload() {
	const isReady = ref(false)
	const progress = ref(0)
	const loadTip = ref('正在加载图片与音频…')

	const startPreload = () =>
		new Promise((resolve) => {
			const { images, audios } = collectL1Assets()
			const tasks = [
				...images.map((src) => ({ type: 'image', src })),
				...audios.map((src) => ({ type: 'audio', src })),
			]
			const total = Math.max(tasks.length, 1)
			let done = 0
			let successCount = 0
			let finished = false

			const updateProgress = (success) => {
				done += 1
				if (success) successCount += 1
				const pct = Math.min(99, Math.round((done / total) * 100))
				progress.value = pct
				if (pct < 30) loadTip.value = '正在加载图片…'
				else if (pct < 70) loadTip.value = '正在加载动画…'
				else loadTip.value = '正在加载音频…'
			}

			const finish = (force = false) => {
				if (finished) return
				finished = true
				progress.value = 100
				const successRate = tasks.length > 0 ? successCount / tasks.length : 1
				isReady.value = force || successRate >= 0.8 || successCount === tasks.length
				loadTip.value = isReady.value ? '加载完成' : '部分资源加载失败'
				resolve(isReady.value)
			}

			const startTime = Date.now()
			const completeWithMinDelay = (force = false) => {
				const wait = Math.max(0, MIN_LOADING_MS - (Date.now() - startTime))
				setTimeout(() => finish(force), wait)
			}

			const fontPromise = loadGameFont().then(() => {
				loadTip.value = '正在加载字体…'
			})

			const timeoutId = setTimeout(() => completeWithMinDelay(true), MAX_LOADING_MS)

			tasks.forEach((task) => {
				const loader = task.type === 'image' ? preloadImage : preloadAudio
				loader(task.src).then((success) => {
					updateProgress(success)
					if (done === total) {
						clearTimeout(timeoutId)
						completeWithMinDelay()
					}
				})
			})

			fontPromise.finally(() => {
				// 字体加载不影响进度计算
			})
		})

	return { isReady, progress, loadTip, startPreload }
}

核心功能说明

  • 收集所有图片和音频资源
  • 使用 uni.getImageInfouni.downloadFile 预加载资源
  • 进度提示分级显示
  • 15秒超时保护和80%成功率阈值判定
  • 最小加载时间保障避免闪烁

16.10 音频组件 (Audio.vue)

封装 uni.createInnerAudioContext 的音频播放组件。

<script setup>
	import {
		ref,
		onMounted,
		onBeforeUnmount,
		watch
	} from 'vue'

	const props = defineProps({
		src: {
			type: String,
			default: ''
		}
	})

	const audioCtx = ref(null)
	const isCanPlay = ref(false)
	const isPlaying = ref(false)
	const pendingPlay = ref(false)
	const retryCount = ref(0)
	const maxRetries = 3
	const currentSessionId = ref(0)

	const emit = defineEmits(['ended', 'error'])

	const onCanplayHandler = () => {
		isCanPlay.value = true
		if (pendingPlay.value) {
			pendingPlay.value = false
			safePlay()
		}
	}

	const onEndedHandler = () => {
		isPlaying.value = false
		retryCount.value = 0
		emit('ended')
	}

	const onErrorHandler = (err) => {
		console.error('音频播放异常:', err)
		isPlaying.value = false

		if (retryCount.value < maxRetries) {
			retryCount.value++
			console.warn(`音频播放失败,重试第 ${retryCount.value} 次`)
			setTimeout(() => {
				if (props.src && !isPlaying.value) {
					safeRetry()
				}
			}, 500 * retryCount.value)
			return
		}

		retryCount.value = 0
		emit('error', err)
	}

	const safeRetry = () => {
		try {
			if (audioCtx.value) {
				audioCtx.value.stop()
				currentSessionId.value++
				audioCtx.value.src = props.src
				setTimeout(() => {
					safePlay()
				}, 300)
			} else {
				initAudio()
				setTimeout(() => {
					safePlay()
				}, 450)
			}
		} catch (e) {
			console.error('音频重试失败:', e)
			onErrorHandler({ type: 'retryError', error: e })
		}
	}

	const initAudio = () => {
		if (audioCtx.value) return
		try {
			audioCtx.value = uni.createInnerAudioContext()
			audioCtx.value.obeyMuteSwitch = false

			audioCtx.value.onCanplay(onCanplayHandler)
			audioCtx.value.onEnded(onEndedHandler)
			audioCtx.value.onError(onErrorHandler)

			if (props.src) {
				audioCtx.value.src = props.src
			}
		} catch (e) {
			console.error('音频实例创建失败:', e)
		}
	}

	const setSrc = (url) => {
		if (!url) return
		isCanPlay.value = false
		pendingPlay.value = false
		retryCount.value = 0
		currentSessionId.value++
		
		if (audioCtx.value) {
			audioCtx.value.stop()
			audioCtx.value.src = url
		}
	}

	const safePlay = () => {
		if (!audioCtx.value || !props.src) return
		try {
			isPlaying.value = true
			audioCtx.value.play()
		} catch (e) {
			console.error('音频播放失败:', e)
			onErrorHandler(e)
		}
	}

	const play = () => {
		if (isCanPlay.value) {
			safePlay()
		} else {
			pendingPlay.value = true
			initAudio()
		}
	}

	const destroy = () => {
		if (audioCtx.value) {
			audioCtx.value.destroy()
			audioCtx.value = null
		}
	}

	watch(() => props.src, (newVal) => {
		if (newVal) {
			setSrc(newVal)
		}
	})

	onMounted(() => {
		if (props.src) {
			initAudio()
		}
	})

	onBeforeUnmount(() => {
		destroy()
	})

	defineExpose({ play, destroy })
</script>

核心功能说明

  • 封装 uni.createInnerAudioContext API
  • 内置最多3次重试机制
  • 支持播放、暂停、停止、销毁操作
  • 提供 ended 和 error 事件回调
  • 自动处理音频加载状态

16.11 帧动画组件 (FrameAnim.vue)

双缓冲序列帧动画播放器。

<template>
	<view class="frame-anim-wrapper" :style="wrapperStyle">
		<image
			class="frame-anim-image"
			:src="layerA.src"
			:mode="mode"
			:lazy-load="false"
			:style="{ opacity: visible && layerA.opacity ? 1 : 0 }"
		/>
		<image
			class="frame-anim-image"
			:src="layerB.src"
			:mode="mode"
			:lazy-load="false"
			:style="{ opacity: visible && layerB.opacity ? 1 : 0 }"
		/>
	</view>
</template>

<script setup>
	defineOptions({
		options: {
			virtualHost: true,
		},
	})

	import {
		ref,
		reactive,
		computed,
		watch,
		onMounted,
		onUnmounted,
		nextTick
	} from 'vue'

	const props = defineProps({
		frameList: {
			type: Array,
			default: () => []
		},
		frameDuration: {
			type: Number,
			default: 80
		},
		fps: {
			type: Number,
			default: 0
		},
		duration: {
			type: Number,
			default: 0
		},
		loop: {
			type: Boolean,
			default: false
		},
		autoPlay: {
			type: Boolean,
			default: false
		},
		playing: {
			type: Boolean,
			default: false
		},
		holdLast: {
			type: Boolean,
			default: true
		},
		preload: {
			type: Boolean,
			default: true
		},
		waitPreload: {
			type: Boolean,
			default: true
		},
		visible: {
			type: Boolean,
			default: true
		},
		mode: {
			type: String,
			default: 'aspectFit'
		},
		width: {
			type: String,
			default: ''
		},
		height: {
			type: String,
			default: ''
		}
	})

	const emit = defineEmits(['ended'])

	const layerA = reactive({ src: '', opacity: 0 })
	const layerB = reactive({ src: '', opacity: 0 })
	const currentFrame = ref(0)
	const isPreloaded = ref(false)
	const isPlaying = ref(false)
	let timer = null

	const wrapperStyle = computed(() => ({
		position: 'relative',
		width: props.width || '100%',
		height: props.height || '100%'
	}))

	const effectiveDuration = computed(() => {
		if (props.fps > 0) return 1000 / props.fps
		if (props.duration > 0) return props.duration
		return props.frameDuration
	})

	const preloadFrames = () => {
		if (!props.preload || props.frameList.length === 0) {
			isPreloaded.value = true
			return
		}

		let loaded = 0
		const total = props.frameList.length

		props.frameList.forEach((src) => {
			uni.getImageInfo({
				src,
				success: () => {
					loaded++
					if (loaded === total) {
						isPreloaded.value = true
					}
				},
				fail: () => {
					loaded++
					if (loaded === total) {
						isPreloaded.value = true
					}
				}
			})
		})
	}

	const playFrame = (index) => {
		if (index >= props.frameList.length) {
			if (props.loop) {
				currentFrame.value = 0
				playNext()
			} else {
				stop()
				if (props.holdLast) {
					const lastSrc = props.frameList[props.frameList.length - 1]
					if (layerA.opacity) {
						layerA.src = lastSrc
					} else {
						layerB.src = lastSrc
					}
				}
				emit('ended')
			}
			return
		}

		const newSrc = props.frameList[index]
		
		if (layerA.opacity) {
			layerB.src = newSrc
			nextTick(() => {
				layerB.opacity = 1
				layerA.opacity = 0
			})
		} else {
			layerA.src = newSrc
			nextTick(() => {
				layerA.opacity = 1
				layerB.opacity = 0
			})
		}
	}

	const playNext = () => {
		timer = setTimeout(() => {
			currentFrame.value++
			playFrame(currentFrame.value)
		}, effectiveDuration.value)
	}

	const play = () => {
		if (props.waitPreload && !isPreloaded.value) {
			return
		}
		
		isPlaying.value = true
		currentFrame.value = 0
		playFrame(0)
	}

	const stop = () => {
		isPlaying.value = false
		if (timer) {
			clearTimeout(timer)
			timer = null
		}
	}

	watch(() => props.playing, (val) => {
		if (val) {
			play()
		} else {
			stop()
		}
	})

	watch(() => props.frameList, () => {
		stop()
		currentFrame.value = 0
		layerA.opacity = 0
		layerB.opacity = 0
		if (props.autoPlay || props.playing) {
			play()
		}
	})

	onMounted(() => {
		preloadFrames()
		if (props.autoPlay) {
			play()
		}
	})

	onUnmounted(() => {
		stop()
	})

	defineExpose({ play, stop, preloadFrames })
</script>

<style scoped>
	.frame-anim-wrapper {
		position: relative;
		overflow: hidden;
	}

	.frame-anim-image {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		transition: opacity 0.05s linear;
	}
</style>

核心功能说明

  • 使用 A/B 双缓冲图层实现交叉淡入淡出
  • 避免单层改 src 导致的闪白卡顿
  • 支持 playing 响应式驱动播放
  • 支持预加载和等待预加载完成
  • 支持循环播放和定格最后一帧

16.12 增强图片组件 (ReImage.vue)

支持自动重试和兜底占位的图片组件。

<template>
	<view class="re-image-wrapper" :style="wrapperStyle" @click="$emit('click', $event)" @tap="$emit('tap', $event)">
		<image :src="currentSrc" :mode="mode" :lazy-load="lazyLoad" :show-menu-by-longpress="showMenuByLongpress"
			:webp="webp" :fade-show="fadeShow" @load="handleLoad" @error="handleError" />
	</view>
</template>

<script setup>
	defineOptions({
		options: {
			virtualHost: true,
		},
	})

	import {
		ref,
		watch,
		onBeforeUnmount,
		computed
	} from 'vue'

	const props = defineProps({
		src: {
			type: String,
			default: ''
		},
		maxRetry: {
			type: Number,
			default: 3
		},
		retryDelay: {
			type: Number,
			default: 1000
		},
		fallbackSrc: {
			type: String,
			default: ''
		},
		mode: {
			type: String,
			default: 'aspectFill'
		},
		lazyLoad: {
			type: Boolean,
			default: false
		},
		showMenuByLongpress: {
			type: Boolean,
			default: false
		},
		webp: {
			type: Boolean,
			default: false
		},
		fadeShow: {
			type: Boolean,
			default: true
		},
		width: {
			type: String,
			default: ''
		},
		height: {
			type: String,
			default: ''
		},
		zIndex: {
			type: [Number, String],
			default: ''
		},
		position: {
			type: String,
			default: ''
		},
		top: {
			type: String,
			default: ''
		},
		left: {
			type: String,
			default: ''
		},
		transition: {
			type: String,
			default: ''
		}
	})

	const currentSrc = ref(props.src)
	const retryCount = ref(0)
	let retryTimer = null

	const wrapperStyle = computed(() => {
		const style = {
			width: props.width || '100%',
			height: props.height || '100%'
		}
		
		if (props.position) {
			style.position = props.position
		}
		if (props.zIndex) {
			style.zIndex = props.zIndex
		}
		if (props.top) {
			style.top = props.top
		}
		if (props.left) {
			style.left = props.left
		}
		if (props.transition) {
			style.transition = props.transition
		}
		
		return style
	})

	const handleError = () => {
		if (retryCount.value < props.maxRetry) {
			retryCount.value++
			retryTimer = setTimeout(() => {
				currentSrc.value = props.src + '?t=' + Date.now()
			}, props.retryDelay)
		} else if (props.fallbackSrc) {
			currentSrc.value = props.fallbackSrc
		}
	}

	const handleLoad = () => {
		retryCount.value = 0
	}

	watch(() => props.src, (newVal) => {
		currentSrc.value = newVal
		retryCount.value = 0
	})

	onBeforeUnmount(() => {
		if (retryTimer) {
			clearTimeout(retryTimer)
		}
	})
</script>

<style scoped>
	.re-image-wrapper {
		display: inline-block;
	}

	.re-image-wrapper image {
		width: 100%;
		height: 100%;
	}
</style>

核心功能说明

  • 封装 uni-app 原生 image 组件
  • 加载失败自动延时重试(最多3次)
  • 支持兜底占位图显示
  • 支持 position/z-index 定位
  • 支持 transition 过渡动画

十七、项目总结

17.1 技术亮点

  1. 组合式 API 架构:使用 Vue3 组合式 API 将游戏逻辑拆分为可复用的 hooks,提高代码可维护性
  2. 双缓冲技术:帧动画和资源切换使用双缓冲交叉淡入淡出,避免白屏闪烁
  3. 资源预加载系统:进入关卡前预加载所有资源,提供进度提示和容错机制
  4. 定时器统一管理:useGameTimer 钩子统一管理所有 setTimeout,避免内存泄漏
  5. 响应式布局:使用 vw/vh + rpx 实现多端适配
  6. 容错机制完善:音频重试、预加载降级、DOM 查询兜底等多层容错

17.2 项目结构

pages/game/grade_1/HanyuPinyin/b/
├── page/
│   └── game.vue              # 游戏主页面(流程控制中枢)
├── components/
│   ├── L1.vue                # 第一关:拼音识别游戏
│   ├── L2.vue                # 第二关:拖拽配对游戏
│   ├── L3.vue                # 第三关:图片选择游戏
│   └── L4.vue                # 第四关:判断对错游戏
├── config/
│   ├── game1Oss.js           # OSS 资源地址统一配置
│   ├── l1Config.js           # 第一关游戏配置
│   ├── l2Config.js           # 第二关游戏配置
│   ├── l3Config.js           # 第三关游戏配置
│   └── l4Config.js           # 第四关游戏配置
└── hooks/
    ├── useGameTimer.js       # 通用定时器管理钩子
    ├── useL1Preload.js       # 第一关资源预加载钩子
    └── useL1GameFlow.js      # 第一关游戏核心流程逻辑

17.3 核心组件库

项目使用了一套完善的自定义组件库:

组件名功能路径
Audio音频播放组件,支持重试@/pages/game/components/Audio.vue
FrameAnim双缓冲帧动画播放器@/pages/game/components/FrameAnim.vue
ReImage增强图片组件,支持重试@/pages/game/components/ReImage.vue
GameGoBackBt返回按钮组件@/pages/game/components/GameGoBackBt.vue
PinyinCard拼音卡片组件@/pages/game/grade_1/HanyuPinyin/components/card.vue
ProgressBar进度条组件@/pages/game/grade_1/HanyuPinyin/components/progressBar.vue

17.4 开发建议

  1. 新增关卡:参考现有关卡结构,创建对应的 Vue 组件、config 配置和 hooks 逻辑
  2. 资源管理:所有 OSS 资源地址统一在 game1Oss.js 中配置
  3. 状态管理:使用组合式 API 将状态和逻辑封装到 hooks 中
  4. 动画实现:优先使用 FrameAnim 组件实现帧动画
  5. 容错处理:所有异步操作都需要添加容错处理,避免流程卡死

十八、核心组件技术实现详解

18.1 FrameAnim 双缓冲帧动画组件

18.1.1 技术背景与痛点

在 uni-app 中实现序列帧动画时,传统的单层 image 方案会遇到以下问题:

  1. 闪白问题:切换 src 时,新图片需要解码,导致短暂白屏
  2. 卡顿问题:图片未预加载完成就切换,造成帧率不稳定
  3. 拖影问题:使用 CSS transition 淡入淡出,会导致帧与帧之间模糊过渡
18.1.2 双缓冲架构设计

FrameAnim 组件采用 A/B 双缓冲图层 架构,核心思想是:

┌─────────────────────────────────────┐
│         FrameAnim 组件               │
├─────────────────────────────────────┤
│  Layer A (layerA)                   │
│  - src: 当前帧图片地址               │
│  - opacity: 1 (显示)                 │
│                                     │
│  Layer B (layerB)                   │
│  - src: 下一帧图片地址(预加载)      │
│  - opacity: 0 (隐藏)                 │
│                                     │
│  activeLayer = 'a' 或 'b'           │
│  标记当前可见层                      │
└─────────────────────────────────────┘

核心原理

  • 两个 <image> 元素绝对定位重叠
  • 当前帧显示在 Layer A,Layer B 在后台预加载下一帧
  • 切换时只做 opacity 互换(1 ↔ 0),避免改 src 导致的闪白
  • 切换后,原隐藏层立即预加载再下一帧
18.1.3 核心实现代码解析

1. 双缓冲状态管理

const layerA = reactive({
  src: '',
  opacity: 1  // 初始可见
})
const layerB = reactive({
  src: '',
  opacity: 0  // 初始隐藏
})
let activeLayer = 'a'  // 标记当前可见层

2. 帧应用逻辑(applyFrame)

这是双缓冲切换的核心函数:

const applyFrame = (index, reset = false) => {
  const list = props.frameList || []
  if (!list.length) return

  const src = list[index] || ''           // 当前帧
  const nextSrc = list[index + 1] || (props.loop ? list[0] : src)  // 下一帧

  if (reset) {
    // 初始化:A层显示当前帧,B层预载下一帧
    layerA.src = src
    layerA.opacity = 1
    layerB.src = nextSrc
    layerB.opacity = 0
    activeLayer = 'a'
    return
  }

  // 正常切换:找出隐藏层和可见层
  const showKey = activeLayer === 'a' ? 'b' : 'a'
  const hideKey = activeLayer
  const showLayer = showKey === 'a' ? layerA : layerB
  const hideLayer = hideKey === 'a' ? layerA : layerB

  // 确保隐藏层已预载目标帧(兜底校验)
  if (showLayer.src !== src) {
    showLayer.src = src
  }

  // 执行切换:隐藏层变可见,可见层变隐藏
  showLayer.opacity = 1
  hideLayer.opacity = 0
  activeLayer = showKey

  // 空闲层预载再下一帧,保证下次切换零等待
  nextTick(() => {
    hideLayer.src = nextSrc
  })
}

切换流程图解

帧 0: Layer A [帧0] opacity=1  ← 可见
      Layer B [帧1] opacity=0  ← 预载下一帧

帧 1: Layer A [帧0] opacity=0  ← 变隐藏
      Layer B [帧1] opacity=1  ← 变可见(切换!)
      → activeLayer = 'b'
      → nextTick: Layer A 预载 [帧2]

帧 2: Layer A [帧2] opacity=1  ← 变可见(切换!)
      Layer B [帧1] opacity=0  ← 变隐藏
      → activeLayer = 'a'
      → nextTick: Layer B 预载 [帧3]

3. 时间轴对齐算法(scheduleAdvance)

为了保证帧率稳定,组件采用 时间轴对齐 而非简单的递归 setTimeout:

const scheduleAdvance = (token) => {
  const list = props.frameList || []
  if (!list.length || token !== playToken) return

  const duration = resolvedFrameDuration.value
  const nextIndex = frameIndex.value + 1
  
  // 按开播时间轴对齐,迟到也不跳帧(逐帧追上)
  const targetAt = startAt + nextIndex * duration
  const delay = Math.max(0, targetAt - Date.now())

  timer = setTimeout(() => {
    if (token !== playToken) return  // 防止过期请求

    if (nextIndex >= list.length) {
      if (props.loop) {
        // 循环播放:重置索引,重新计算 startAt
        frameIndex.value = 0
        applyFrame(0, true)
        startAt = Date.now()
        scheduleAdvance(token)
        return
      }
      finishPlay(list)  // 播放结束
      return
    }

    frameIndex.value = nextIndex
    applyFrame(nextIndex, false)
    scheduleAdvance(token)
  }, delay)
}

时间轴对齐的优势

  • 即使某一帧延迟,后续帧也会逐帧追上,不会跳帧
  • 避免"少一张"的观感问题
  • 循环播放时重新对齐时间轴

4. 防竞态机制(playToken)

let playToken = 0

const stopAnim = () => {
  clearTimer()
  isPlaying.value = false
  playToken += 1  // 递增令牌,使旧请求过期
}

const startAnim = async () => {
  const token = playToken  // 记录当前令牌
  // ...
  if (token !== playToken) return 0  // 忽略过期请求
  // ...
}

作用:当快速调用 stopAnim()startAnim() 时,旧的异步任务会因为 token !== playToken 而自动失效,避免状态混乱。

5. 预加载策略

const loadOne = (src) =>
  new Promise((resolve) => {
    if (!src) {
      resolve(false)
      return
    }
    uni.getImageInfo({
      src,
      success: () => resolve(true),
      fail: () => resolve(false)
    })
  })

const preloadFrames = (list) => {
  const frames = list || []
  if (!frames.length) {
    preloadPromise = Promise.resolve(true)
    isReady.value = true
    return preloadPromise
  }
  // 并行预加载所有帧
  preloadPromise = Promise.all(frames.map((src) => loadOne(src))).then(() => {
    isReady.value = true
    return true
  })
  return preloadPromise
}

使用方式

  • preload: true - 自动预加载所有帧
  • waitPreload: true - 等待预加载完成后再播放(强烈推荐)
18.1.4 组件 Props 完整说明
Prop类型默认值说明
frameListArray[]帧图片地址列表(按播放顺序)
frameDurationNumber80单帧停留时长(毫秒)
fpsNumber0帧率(>0 时覆盖 frameDuration)
loopBooleanfalse是否循环播放
autoPlayBooleanfalse挂载后是否自动播放
playingBooleanfalse声明式播放控制
holdLastBooleantrue播完是否停在最后一帧
preloadBooleantrue是否预加载全部帧
waitPreloadBooleantrue开播前是否等待预加载完成
visibleBooleantrue显隐控制(不卸载组件)
18.1.5 使用示例
<template>
  <FrameAnim
    :frameList="boxOpenFrames"
    :frameDuration="20"
    :playing="isPlaying"
    :holdLast="true"
    :preload="true"
    :waitPreload="true"
    @ended="onAnimEnded"
    @frame-change="onFrameChange"
  />
</template>

<script setup>
import { ref } from 'vue'
import FrameAnim from '@/pages/game/components/FrameAnim.vue'

const boxOpenFrames = [
  'https://example.com/box_01.png',
  'https://example.com/box_02.png',
  // ... 更多帧
]

const isPlaying = ref(false)

const playBoxOpen = () => {
  isPlaying.value = true
}

const onAnimEnded = ({ total, totalDuration }) => {
  console.log(`动画完成,共${total}帧,时长${totalDuration}ms`)
}

const onFrameChange = ({ index, src, total }) => {
  console.log(`当前播放第${index + 1}帧`)
}
</script>
18.1.6 性能优化要点
  1. 禁用 CSS transitiontransition: none !important; 避免拖影
  2. backface-visibility: hidden:启用 GPU 加速
  3. lazy-load=“false”:禁用懒加载,确保帧图片立即解码
  4. nextTick 预载:在切换后立即预载下一帧,保证下次切换零等待

18.2 ReImage 图片自动重连组件

18.2.1 技术背景与痛点

在移动端网络环境中,图片加载失败是常见问题:

  1. 网络波动:弱网环境下图片请求超时
  2. CDN 缓存:部分 CDN 节点缓存异常
  3. OSS 限流:高并发时 OSS 可能返回 503
  4. 用户体验:直接显示裂图图标体验极差
18.2.2 自动重连架构设计

ReImage 组件实现了 智能重试 + 兜底降级 策略:

图片加载失败
    ↓
retryCount < maxRetry?
    ├─ 是 → 延时重试(添加时间戳参数绕过缓存)
    │        ↓
    │     retryCount++
    │        ↓
    │     继续判断...
    │
    └─ 否 → 显示兜底图(fallbackSrc)
             ↓
          抛出 finalFail 事件
18.2.3 核心实现代码解析

1. 状态管理

const currentSrc = ref(props.src)  // 实际渲染的地址(会拼接重试参数)
const retryCount = ref(0)          // 重试计数器
let retryTimer = null              // 重试延时定时器

2. 加载失败处理逻辑

function handleError(e) {
  // 判断是否还可以继续重试
  if (retryCount.value < props.maxRetry) {
    retryCount.value++
    
    // 派发单次重试事件,携带当前次数与原地址
    emit('retry', {
      count: retryCount.value,
      src: currentSrc.value
    })

    // 延时后拼接随机参数强制重新请求(绕过浏览器/小程序缓存)
    retryTimer = setTimeout(() => {
      const separator = props.src.includes('?') ? '&' : '?'
      currentSrc.value = `${props.src}${separator}_retry=${Date.now()}_${retryCount.value}`
    }, props.retryDelay)
  } else {
    // 已用尽重试次数,清空定时器
    clearTimer()
    
    // 如果配置了兜底图,则替换展示兜底资源
    if (props.fallbackSrc) {
      currentSrc.value = props.fallbackSrc
    }
    
    // 抛出单次失败与最终彻底失败事件
    emit('error', e)
    emit('finalFail', {
      src: props.src,
      retryCount: retryCount.value
    })
  }
}

3. 缓存绕过技术

重试时添加唯一参数,避免浏览器/小程序使用缓存的失败结果:

const separator = props.src.includes('?') ? '&' : '?'
currentSrc.value = `${props.src}${separator}_retry=${Date.now()}_${retryCount.value}`

// 示例:
// 原始地址:https://example.com/image.png
// 第1次重试:https://example.com/image.png?_retry=1690000000000_1
// 第2次重试:https://example.com/image.png?_retry=1690000001000_2
// 第3次重试:https://example.com/image.png?_retry=1690000002000_3

4. 状态重置逻辑

watch(() => props.src, (newSrc) => {
  resetState()      // 清空定时器,重试计数归零
  currentSrc.value = newSrc
})

function resetState() {
  clearTimer()
  retryCount.value = 0
}

function clearTimer() {
  if (retryTimer) {
    clearTimeout(retryTimer)
    retryTimer = null
  }
}

5. 生命周期清理

onBeforeUnmount(() => {
  clearTimer()  // 组件卸载前清除定时器,防止内存泄漏
})
18.2.4 组件 Props 完整说明
Prop类型默认值说明
srcString‘’图片原始资源地址
maxRetryNumber3加载失败最大重试次数
retryDelayNumber1000每次重试间隔时长(毫秒)
fallbackSrcString‘’兜底默认图片地址
modeString‘aspectFill’图片裁剪/填充模式
lazyLoadBooleanfalse是否开启懒加载
fadeShowBooleantrue图片加载是否淡入显示
widthString‘’图片宽度
heightString‘’图片高度
positionString‘’定位方式(absolute/relative/fixed)
zIndexNumber/String‘’层级
opacityNumber/String‘’透明度
transitionString‘’过渡动画
18.2.5 事件说明
事件名参数触发时机
loadEvent图片加载成功
errorEvent单次加载失败
retry{ count, src }触发一次重试
finalFail{ src, retryCount }达到最大重试次数,彻底加载失败
18.2.6 使用示例
<template>
  <ReImage
    src="https://example.com/game-bg.png"
    :maxRetry="3"
    :retryDelay="1000"
    fallbackSrc="/static/placeholder.png"
    mode="aspectFill"
    width="100vw"
    height="100vh"
    position="absolute"
    z-index="0"
    @load="onImageLoad"
    @retry="onImageRetry"
    @finalFail="onImageFinalFail"
  />
</template>

<script setup>
import ReImage from '@/pages/game/components/ReImage.vue'

const onImageLoad = (e) => {
  console.log('图片加载成功')
}

const onImageRetry = ({ count, src }) => {
  console.log(`图片第${count}次重试:${src}`)
}

const onImageFinalFail = ({ src, retryCount }) => {
  console.error(`图片彻底加载失败,已重试${retryCount}次:${src}`)
}
</script>
18.2.7 小程序虚拟宿主模式
defineOptions({
  options: {
    virtualHost: true,  // 开启虚拟宿主,避免额外 DOM 包裹
  },
})

作用:在小程序中,自定义组件会被包裹在额外的 <view> 中。开启 virtualHost 后,组件的 DOM 会被"提升"到父级,避免影响布局和样式。


18.3 Audio 音频播放组件

18.3.1 技术背景与痛点

uni-app 的音频播放存在以下问题:

  1. 异步就绪createInnerAudioContext() 创建后需要时间加载音频
  2. 播放失败:网络波动或格式问题导致播放失败
  3. 状态混乱:快速切换音频时状态不同步
  4. 内存泄漏:组件卸载后音频实例未销毁
18.3.2 音频播放架构设计
┌─────────────────────────────────────┐
│         Audio 组件                   │
├─────────────────────────────────────┤
│  音频实例管理                        │
│  - audioCtx: InnerAudioContext      │
│  - isCanPlay: 是否就绪               │
│  - isPlaying: 是否正在播放           │
│  - pendingPlay: 是否等待播放         │
│                                     │
│  重试机制                            │
│  - retryCount: 重试计数器            │
│  - maxRetries: 最大重试次数(3)       │
│  - currentSessionId: 会话ID         │
│                                     │
│  生命周期                            │
│  - initAudio → play → ended/destroy │
└─────────────────────────────────────┘
18.3.3 核心实现代码解析

1. 音频实例初始化

const initAudio = () => {
  if (audioCtx.value) return  // 防止重复创建
  try {
    audioCtx.value = uni.createInnerAudioContext()
    audioCtx.value.obeyMuteSwitch = false  // 忽略静音开关

    // 绑定事件监听
    audioCtx.value.onCanplay(onCanplayHandler)
    audioCtx.value.onEnded(onEndedHandler)
    audioCtx.value.onError(onErrorHandler)

    if (props.src) {
      audioCtx.value.src = props.src
    }
  } catch (e) {
    console.error('音频实例创建失败:', e)
  }
}

关键点

  • obeyMuteSwitch = false:即使手机静音也能播放(适用于教育类应用)
  • 事件监听必须在设置 src 之前绑定

2. 安全播放逻辑

const safePlay = () => {
  if (!audioCtx.value) return
  if (isPlaying.value && isCanPlay.value) return  // 防止重复播放

  const sessionId = currentSessionId.value  // 记录当前会话ID

  try {
    audioCtx.value.stop()  // 先停止当前播放

    // 重置播放进度到开头
    if (typeof audioCtx.value.seek === 'function') {
      audioCtx.value.seek(0)
    }

    // 如果音频未就绪,标记等待播放
    if (!isCanPlay.value) {
      pendingPlay.value = true
    }

    isPlaying.value = true
    audioCtx.value.play()

    // 3秒后检查是否就绪,未就绪则重试
    setTimeout(() => {
      if (sessionId === currentSessionId.value && isPlaying.value && !isCanPlay.value) {
        console.warn('音频长时间未就绪,尝试重新加载')
        safeRetry()
      }
    }, 3000)
  } catch (e) {
    console.error('音频播放执行失败:', e)
    isPlaying.value = false
    pendingPlay.value = false
    onErrorHandler({ type: 'playError', error: e })
  }
}

会话ID机制

const currentSessionId = ref(0)

const setSrc = (url) => {
  if (!url) return
  isCanPlay.value = false
  retryCount.value = 0
  currentSessionId.value++  // 递增会话ID

  if (!audioCtx.value) {
    initAudio()
  }

  try {
    audioCtx.value.stop()
    currentSessionId.value++  // 再次递增
    audioCtx.value.src = url
  } catch (e) {
    // ...
  }
}

作用:当快速切换音频时,旧会话的异步回调会因为 sessionId !== currentSessionId.value 而被忽略,避免状态混乱。

3. 重试机制

const safeRetry = () => {
  try {
    if (audioCtx.value) {
      audioCtx.value.stop()
      currentSessionId.value++
      audioCtx.value.src = props.src
      setTimeout(() => {
        safePlay()
      }, 300)
    } else {
      initAudio()
      setTimeout(() => {
        safePlay()
      }, 450)
    }
  } catch (e) {
    console.error('音频重试失败:', e)
    onErrorHandler({ type: 'retryError', error: e })
  }
}

const onErrorHandler = (err) => {
  console.error('音频播放异常:', err)
  isPlaying.value = false

  if (retryCount.value < maxRetries) {
    retryCount.value++
    console.warn(`音频播放失败,重试第 ${retryCount.value}`)
    setTimeout(() => {
      if (props.src && !isPlaying.value) {
        safeRetry()
      }
    }, 500 * retryCount.value)  // 递增延迟:500ms, 1000ms, 1500ms
    return
  }

  retryCount.value = 0
  emit('error', err)  // 达到最大重试次数,抛出错误
}

重试策略

  • 最多重试 3 次
  • 重试延迟递增:500ms → 1000ms → 1500ms
  • 避免频繁重试导致资源浪费

4. 音频销毁

const destroy = () => {
  if (!audioCtx.value) return
  try {
    audioCtx.value.stop()
    
    // 移除事件监听(防止内存泄漏)
    if (typeof audioCtx.value.offCanplay === 'function') {
      audioCtx.value.offCanplay(onCanplayHandler)
    }
    if (typeof audioCtx.value.offEnded === 'function') {
      audioCtx.value.offEnded(onEndedHandler)
    }
    if (typeof audioCtx.value.offError === 'function') {
      audioCtx.value.offError(onErrorHandler)
    }
    
    audioCtx.value.destroy()
  } catch (e) {
    console.error('音频销毁异常:', e)
  } finally {
    audioCtx.value = null
    isCanPlay.value = false
    isPlaying.value = false
    pendingPlay.value = false
    retryCount.value = 0
  }
}

onBeforeUnmount(() => {
  destroy()  // 组件卸载时自动销毁
})

关键点

  • 必须先 off 移除事件监听,再 destroy
  • finally 块确保状态完全重置
18.3.4 组件 Props 完整说明
Prop类型默认值说明
srcString‘’音频资源地址
18.3.5 事件说明
事件名参数触发时机
ended-音频播放完成
errorError音频播放失败(达到最大重试次数)
18.3.6 暴露的方法
defineExpose({
  play,      // 播放音频
  pause,     // 暂停播放
  stop,      // 停止播放
  setSrc,    // 设置音频地址
  destroy,   // 销毁音频实例
  isPlaying, // 是否正在播放
  isCanPlay  // 是否就绪
})
18.3.7 使用示例
<template>
  <Audio
    ref="audioRef"
    :src="currentAudioSrc"
    @ended="onAudioEnded"
    @error="onAudioError"
  />
</template>

<script setup>
import { ref } from 'vue'
import Audio from '@/pages/game/components/Audio.vue'

const audioRef = ref(null)
const currentAudioSrc = ref('')

const playAudio = (src) => {
  if (!src) return
  currentAudioSrc.value = src
  setTimeout(() => {
    audioRef.value?.play()
  }, 300)
}

const onAudioEnded = () => {
  console.log('音频播放完成')
  currentAudioSrc.value = ''
}

const onAudioError = () => {
  console.error('音频播放失败')
  currentAudioSrc.value = ''
}
</script>
18.3.8 纯逻辑组件设计
<template>
  <!-- 纯逻辑组件,无需 DOM -->
</template>

<style scoped>
</style>

说明:Audio 组件不需要 DOM 元素,因为它只管理音频播放逻辑,通过 uni.createInnerAudioContext() API 实现。


18.4 核心组件对比总结

组件核心技术解决的问题关键特性
FrameAnim双缓冲图层 + 时间轴对齐帧动画闪白/卡顿预加载、防竞态、循环播放
ReImage自动重试 + 缓存绕过图片加载失败最多3次重试、兜底图、事件派发
Audio会话ID + 递增重试音频播放失败最多3次重试、状态管理、自动销毁

18.5 组件使用最佳实践

18.5.1 帧动画使用建议
  1. 必须开启预加载:preload="true" :waitPreload="true"
  2. 控制帧数量:建议不超过 50 帧,避免内存占用过大
  3. 使用合适的帧率:一般 12-24fps 即可,过高浪费性能
  4. holdLast 设为 true:避免动画结束后消失
<!-- ✅ 推荐用法 -->
<FrameAnim
  :frameList="frames"
  :frameDuration="80"
  :playing="isPlaying"
  :holdLast="true"
  :preload="true"
  :waitPreload="true"
  @ended="onEnded"
/>

<!-- ❌ 不推荐:未开启预加载,可能导致卡顿 -->
<FrameAnim
  :frameList="frames"
  :playing="isPlaying"
  :preload="false"
  :waitPreload="false"
/>
18.5.2 图片组件使用建议
  1. 设置合理的重试次数:一般 3 次即可
  2. 配置兜底图:避免裂图影响用户体验
  3. 监听 finalFail 事件:记录失败日志
<!-- ✅ 推荐用法 -->
<ReImage
  :src="imageUrl"
  :maxRetry="3"
  :retryDelay="1000"
  fallbackSrc="/static/placeholder.png"
  @finalFail="logImageError"
/>
18.5.3 音频组件使用建议
  1. 播放前延迟 300ms:给音频实例初始化时间
  2. 监听 error 事件:处理播放失败情况
  3. 组件卸载自动销毁:无需手动调用 destroy
<!-- ✅ 推荐用法 -->
<script setup>
const playAudio = (src) => {
  if (!src) return
  currentAudioSrc.value = src
  setTimeout(() => {
    audioRef.value?.play()
  }, 300)  // 给音频实例初始化时间
}
</script>

十九、项目总结

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐