uniapp - 少儿学前小游戏
一、项目概述
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 核心状态
| 状态 | 类型 | 说明 |
|---|---|---|
| phase | ref | 游戏阶段 (ruleIntro/hint/playing/resolving/done) |
| currentStep | ref | 当前步骤索引 |
| failCount | ref | 当前步骤错误次数 |
| cardStates | reactive | 卡片状态集合 (normal/error/success/hidden) |
| cardAnim | reactive | 卡片动画参数集合 |
| personIndex | ref | 人物动画索引 |
| fuelProgressIndex | ref | 油表进度索引 |
| showClassTip | ref | 是否显示错误弹窗 |
13.2 L2 核心状态
| 状态 | 类型 | 说明 |
|---|---|---|
| currentPair | ref | 当前配对索引 |
| completedPairs | ref | 已完成配对数量 |
| cardStates | reactive | 卡片状态集合 (normal/selected/error/moving/hidden) |
| cardMoveAnim | reactive | 卡片移动动画参数 |
| contLayers | reactive | 骨架图双缓冲图层 |
| carLayers | reactive | 小轿车双缓冲图层 |
| dashboardLayers | reactive | 仪表盘双缓冲图层 |
| selectedLeftKey | ref | 左侧选中卡片键 |
| selectedRightKey | ref | 右侧选中卡片键 |
| failCount | ref | 连续失败次数 |
| showClassTip | ref | 是否显示分类提示 |
十四、扩展建议
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调用addStudentGameAPI 上报学习记录,成功后跳转课程菜单
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.getImageInfo和uni.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 技术亮点
- 组合式 API 架构:使用 Vue3 组合式 API 将游戏逻辑拆分为可复用的 hooks,提高代码可维护性
- 双缓冲技术:帧动画和资源切换使用双缓冲交叉淡入淡出,避免白屏闪烁
- 资源预加载系统:进入关卡前预加载所有资源,提供进度提示和容错机制
- 定时器统一管理:useGameTimer 钩子统一管理所有 setTimeout,避免内存泄漏
- 响应式布局:使用 vw/vh + rpx 实现多端适配
- 容错机制完善:音频重试、预加载降级、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 开发建议
- 新增关卡:参考现有关卡结构,创建对应的 Vue 组件、config 配置和 hooks 逻辑
- 资源管理:所有 OSS 资源地址统一在 game1Oss.js 中配置
- 状态管理:使用组合式 API 将状态和逻辑封装到 hooks 中
- 动画实现:优先使用 FrameAnim 组件实现帧动画
- 容错处理:所有异步操作都需要添加容错处理,避免流程卡死
十八、核心组件技术实现详解
18.1 FrameAnim 双缓冲帧动画组件
18.1.1 技术背景与痛点
在 uni-app 中实现序列帧动画时,传统的单层 image 方案会遇到以下问题:
- 闪白问题:切换
src时,新图片需要解码,导致短暂白屏 - 卡顿问题:图片未预加载完成就切换,造成帧率不稳定
- 拖影问题:使用 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 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| frameList | Array | [] | 帧图片地址列表(按播放顺序) |
| frameDuration | Number | 80 | 单帧停留时长(毫秒) |
| fps | Number | 0 | 帧率(>0 时覆盖 frameDuration) |
| loop | Boolean | false | 是否循环播放 |
| autoPlay | Boolean | false | 挂载后是否自动播放 |
| playing | Boolean | false | 声明式播放控制 |
| holdLast | Boolean | true | 播完是否停在最后一帧 |
| preload | Boolean | true | 是否预加载全部帧 |
| waitPreload | Boolean | true | 开播前是否等待预加载完成 |
| visible | Boolean | true | 显隐控制(不卸载组件) |
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 性能优化要点
- 禁用 CSS transition:
transition: none !important;避免拖影 - backface-visibility: hidden:启用 GPU 加速
- lazy-load=“false”:禁用懒加载,确保帧图片立即解码
- nextTick 预载:在切换后立即预载下一帧,保证下次切换零等待
18.2 ReImage 图片自动重连组件
18.2.1 技术背景与痛点
在移动端网络环境中,图片加载失败是常见问题:
- 网络波动:弱网环境下图片请求超时
- CDN 缓存:部分 CDN 节点缓存异常
- OSS 限流:高并发时 OSS 可能返回 503
- 用户体验:直接显示裂图图标体验极差
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 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| src | String | ‘’ | 图片原始资源地址 |
| maxRetry | Number | 3 | 加载失败最大重试次数 |
| retryDelay | Number | 1000 | 每次重试间隔时长(毫秒) |
| fallbackSrc | String | ‘’ | 兜底默认图片地址 |
| mode | String | ‘aspectFill’ | 图片裁剪/填充模式 |
| lazyLoad | Boolean | false | 是否开启懒加载 |
| fadeShow | Boolean | true | 图片加载是否淡入显示 |
| width | String | ‘’ | 图片宽度 |
| height | String | ‘’ | 图片高度 |
| position | String | ‘’ | 定位方式(absolute/relative/fixed) |
| zIndex | Number/String | ‘’ | 层级 |
| opacity | Number/String | ‘’ | 透明度 |
| transition | String | ‘’ | 过渡动画 |
18.2.5 事件说明
| 事件名 | 参数 | 触发时机 |
|---|---|---|
| load | Event | 图片加载成功 |
| error | Event | 单次加载失败 |
| 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 的音频播放存在以下问题:
- 异步就绪:
createInnerAudioContext()创建后需要时间加载音频 - 播放失败:网络波动或格式问题导致播放失败
- 状态混乱:快速切换音频时状态不同步
- 内存泄漏:组件卸载后音频实例未销毁
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 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| src | String | ‘’ | 音频资源地址 |
18.3.5 事件说明
| 事件名 | 参数 | 触发时机 |
|---|---|---|
| ended | - | 音频播放完成 |
| error | Error | 音频播放失败(达到最大重试次数) |
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 帧动画使用建议
- 必须开启预加载:
:preload="true" :waitPreload="true" - 控制帧数量:建议不超过 50 帧,避免内存占用过大
- 使用合适的帧率:一般 12-24fps 即可,过高浪费性能
- 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 图片组件使用建议
- 设置合理的重试次数:一般 3 次即可
- 配置兜底图:避免裂图影响用户体验
- 监听 finalFail 事件:记录失败日志
<!-- ✅ 推荐用法 -->
<ReImage
:src="imageUrl"
:maxRetry="3"
:retryDelay="1000"
fallbackSrc="/static/placeholder.png"
@finalFail="logImageError"
/>
18.5.3 音频组件使用建议
- 播放前延迟 300ms:给音频实例初始化时间
- 监听 error 事件:处理播放失败情况
- 组件卸载自动销毁:无需手动调用 destroy
<!-- ✅ 推荐用法 -->
<script setup>
const playAudio = (src) => {
if (!src) return
currentAudioSrc.value = src
setTimeout(() => {
audioRef.value?.play()
}, 300) // 给音频实例初始化时间
}
</script>
十九、项目总结
更多推荐

所有评论(0)