diff --git a/TUICallKit-Vue3/components/dev-training-entry/index.vue b/TUICallKit-Vue3/components/dev-training-entry/index.vue index cc851e33..f5a82248 100644 --- a/TUICallKit-Vue3/components/dev-training-entry/index.vue +++ b/TUICallKit-Vue3/components/dev-training-entry/index.vue @@ -3,7 +3,7 @@ - + diff --git a/TUICallKit-Vue3/training/hooks/useMetronome.ts b/TUICallKit-Vue3/training/hooks/useMetronome.ts index e86df632..a4a8c629 100644 --- a/TUICallKit-Vue3/training/hooks/useMetronome.ts +++ b/TUICallKit-Vue3/training/hooks/useMetronome.ts @@ -8,6 +8,7 @@ export interface MetronomeOptions { accentSrc?: string accentEvery?: number poolSize?: number + volume?: number // 节拍音音量 0-1 onBeat?: (beatIndex: number, isAccent: boolean) => void } @@ -21,11 +22,15 @@ class AudioPool { private list: UniApp.InnerAudioContext[] = [] private cursor = 0 private warmedUp = false + private targetVolume = 1 constructor( private src: string, private size: number, - ) {} + volume = 1, + ) { + this.targetVolume = volume + } private create() { for (let i = 0; i < this.size; i++) { @@ -33,10 +38,20 @@ class AudioPool { ctx.src = this.src ctx.obeyMuteSwitch = false ctx.autoplay = false + ctx.volume = this.targetVolume this.list.push(ctx) } } + setVolume(volume: number) { + this.targetVolume = Math.max(0, Math.min(1, volume)) + this.list.forEach((ctx) => { + try { + ctx.volume = this.targetVolume + } catch (_) {} + }) + } + /** * 预热:短暂播放,让音频文件下载/解码到内存 * 真正首次 play 不会再卡冷启动 @@ -52,7 +67,7 @@ class AudioPool { setTimeout(() => { try { ctx.stop() - ctx.volume = 1 // 恢复音量 + ctx.volume = this.targetVolume // 恢复目标音量 } catch (_) {} }, 60) } catch (_) {} @@ -103,6 +118,7 @@ export function useMetronome(options: MetronomeOptions = {}) { clickSrc = DEFAULT_CLICK_SRC, accentSrc, poolSize = 4, + volume = 1, onBeat, } = options @@ -121,11 +137,11 @@ export function useMetronome(options: MetronomeOptions = {}) { const ensureAudio = () => { if (!clickPool) { - clickPool = new AudioPool(clickSrc, poolSize) + clickPool = new AudioPool(clickSrc, poolSize, volume) clickPool.warmUp() } if (accentSrc && !accentPool) { - accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2))) + accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)), volume) accentPool.warmUp() } } diff --git a/TUICallKit-Vue3/training/pages/components/crush-canvas.vue b/TUICallKit-Vue3/training/pages/components/crush-canvas.vue index 2681af98..b286236d 100644 --- a/TUICallKit-Vue3/training/pages/components/crush-canvas.vue +++ b/TUICallKit-Vue3/training/pages/components/crush-canvas.vue @@ -1,10 +1,9 @@ @@ -12,7 +11,13 @@ @@ -220,7 +414,7 @@ defineExpose({ left: 0; width: 100%; height: 100%; - pointer-events: none; /* 不阻挡点击事件 */ + pointer-events: none; z-index: 10; } diff --git a/TUICallKit-Vue3/training/pages/components/grip-ring-canvas.vue b/TUICallKit-Vue3/training/pages/components/grip-ring-canvas.vue index 443bc934..a6361748 100644 --- a/TUICallKit-Vue3/training/pages/components/grip-ring-canvas.vue +++ b/TUICallKit-Vue3/training/pages/components/grip-ring-canvas.vue @@ -1,33 +1,36 @@ @@ -64,6 +67,9 @@ defineExpose({ diff --git a/TUICallKit-Vue3/training/pages/grip-ring.vue b/TUICallKit-Vue3/training/pages/grip-ring.vue index dd5387ef..300aecb6 100644 --- a/TUICallKit-Vue3/training/pages/grip-ring.vue +++ b/TUICallKit-Vue3/training/pages/grip-ring.vue @@ -8,6 +8,11 @@ :is-playing="isPlaying" @toggle-play="onTogglePlay" /> + + + + {{ bgmEnabled ? '🎵' : '🔇' }} + @@ -54,27 +59,52 @@ - 训练完成 🎉 + + 🎉 + 训练完成 + 辛苦啦,休息一下吧 + + - 总次数 + 💪 {{ totalReps }} + 总次数 - 总时长 + ⏱️ {{ formatTime(elapsedSeconds) }} + 总时长 - 消耗糖分 + 🔥 {{ totalSugar.toFixed(1) }}g + 消耗糖分 - - 相当于消耗了 - {{ sugarComparison }} + + + + 相当于消耗了 + 这些食物的糖分 🍭 + + + + + {{ food.emoji }} + + {{ food.count }} + {{ food.unit }}{{ food.name }} + + + @@ -130,16 +160,16 @@ const REWARDS: RewardConfig[] = [ title: '捏碎鸡蛋', desc: '握力不错!', itemType: 'egg', - sound: '', // 音效文件暂未上传 + sound: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/202605280937077bad76716.mp3', minReps: 8, maxReps: 12 }, { - emoji: '🥜', + emoji: '🌰', title: '捏碎核桃', desc: '力量惊人!', itemType: 'walnut', - sound: '', // 音效文件暂未上传 + sound: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/20260528093707b021f5794.mp3', minReps: 15, maxReps: 20 }, @@ -148,7 +178,7 @@ const REWARDS: RewardConfig[] = [ title: '捏扁易拉罐', desc: '太强了!', itemType: 'can', - sound: '', // 音效文件暂未上传 + sound: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/20260528093707ebd4d5733.mp3', minReps: 25, maxReps: 30 }, @@ -157,19 +187,24 @@ const REWARDS: RewardConfig[] = [ title: '捏爆气球', desc: '完美!', itemType: 'balloon', - sound: '', // 音效文件暂未上传 + sound: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/20260528093707016f07427.mp3', minReps: 40, maxReps: 50 }, ] +// 背景音乐 URL +const BGM_URL = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260528/20260528093709c669f4659.mp3' + // 食物糖分对照表 (每100g含糖量) const FOOD_SUGAR_TABLE = [ - { name: '苹果', sugar: 10.3, unit: '个', weight: 200 }, // 1个苹果约200g - { name: '香蕉', sugar: 12.2, unit: '根', weight: 120 }, // 1根香蕉约120g - { name: '米饭', sugar: 25.9, unit: '碗', weight: 150 }, // 1碗米饭约150g - { name: '巧克力', sugar: 51.5, unit: '块', weight: 50 }, // 1块巧克力约50g - { name: '可乐', sugar: 10.6, unit: '罐', weight: 330 }, // 1罐可乐330ml + { emoji: '🍬', name: '糖果', sugar: 95.0, unit: '颗', weight: 5 }, // 1颗糖果约5g + { emoji: '🍪', name: '饼干', sugar: 65.0, unit: '块', weight: 10 }, // 1块饼干约10g + { emoji: '🍫', name: '巧克力', sugar: 51.5, unit: '块', weight: 50 }, // 1块巧克力约50g + { emoji: '🍎', name: '苹果', sugar: 10.3, unit: '个', weight: 200 }, // 1个苹果约200g + { emoji: '🍌', name: '香蕉', sugar: 12.2, unit: '根', weight: 120 }, // 1根香蕉约120g + { emoji: '🍚', name: '米饭', sugar: 25.9, unit: '碗', weight: 150 }, // 1碗米饭约150g + { emoji: '🥤', name: '可乐', sugar: 10.6, unit: '罐', weight: 330 }, // 1罐可乐330ml ] const currentPreset = ref('medium') @@ -178,6 +213,8 @@ const showRewardPopup = ref(false) const showSummary = ref(false) const currentReward = ref(REWARDS[0]) const crushAudioCtx = ref(null) +const bgmAudioCtx = ref(null) +const bgmEnabled = ref(true) // BGM 开关 // 训练统计 const totalReps = ref(0) // 总次数 @@ -196,6 +233,7 @@ const currentBpm = computed(() => preset.value.bpm) const metronome = useMetronome({ initialBpm: currentBpm.value, accentEvery: 2, + volume: 0.35, // 节拍音音量调小,避免木鱼声太大 onBeat: onBeat, }) const { isPlaying } = metronome @@ -206,19 +244,29 @@ const rootStyle = computed(() => ({ '--beat-duration': `${intervalMs.value}ms`, })) -// 糖分对比 -const sugarComparison = computed(() => { - if (totalSugar.value === 0) return '' +// 糖分对比 - 返回多个食物对照(显示用) +const sugarComparisons = computed(() => { + if (totalSugar.value === 0) return [] + + const results: Array<{ emoji: string; name: string; count: string; unit: string }> = [] for (const food of FOOD_SUGAR_TABLE) { const foodSugar = (food.sugar * food.weight) / 100 const count = totalSugar.value / foodSugar - if (count >= 0.3 && count <= 10) { - return `${count.toFixed(1)} ${food.unit}${food.name}的糖分` + + // 选择 0.1 ~ 50 范围内的食物展示 + if (count >= 0.1 && count <= 50) { + results.push({ + emoji: food.emoji, + name: food.name, + count: count >= 10 ? Math.round(count).toString() : count.toFixed(1), + unit: food.unit, + }) } } - return `${totalSugar.value.toFixed(1)}g 糖分` + // 最多展示 3 个 + return results.slice(0, 3) }) // 节拍回调 @@ -260,15 +308,10 @@ function triggerReward() { const reward = REWARDS[rewardIndex.value] currentReward.value = reward - // 触发粒子特效 + // 立即触发粒子特效和音效 gripCanvasRef.value?.triggerCrushEffect(reward.itemType) - - // 播放碎裂音效 playCrushSound(reward.sound) - // 显示奖励弹窗 - showRewardPopup.value = true - // 递进到下一个奖励等级 rewardIndex.value++ @@ -278,6 +321,11 @@ function triggerReward() { const range = nextReward.maxReps - nextReward.minReps nextRewardAt.value = totalReps.value + nextReward.minReps + Math.floor(Math.random() * (range + 1)) } + + // 延迟 1200ms 显示弹窗,让粒子动画先完整播放 + setTimeout(() => { + showRewardPopup.value = true + }, 1200) } function closeRewardPopup() { @@ -304,6 +352,48 @@ function playCrushSound(sound: string) { } catch (_) {} } +// 启动背景音乐 +function startBgm() { + if (!bgmEnabled.value) return + + // 如果已存在,直接 play + if (bgmAudioCtx.value) { + try { + bgmAudioCtx.value.play() + } catch (_) {} + return + } + + bgmAudioCtx.value = uni.createInnerAudioContext() + bgmAudioCtx.value.src = BGM_URL + bgmAudioCtx.value.loop = true + bgmAudioCtx.value.obeyMuteSwitch = false + bgmAudioCtx.value.volume = 0.4 // 背景音乐音量较低,不抢戏 + + try { + bgmAudioCtx.value.play() + } catch (_) {} +} + +// 停止背景音乐 +function stopBgm() { + if (bgmAudioCtx.value) { + try { + bgmAudioCtx.value.pause() + } catch (_) {} + } +} + +// 切换 BGM 开关 +function toggleBgm() { + bgmEnabled.value = !bgmEnabled.value + if (bgmEnabled.value && isPlaying.value) { + startBgm() + } else { + stopBgm() + } +} + // 档位切换 function onPresetTap(id: PresetId) { if (isPlaying.value) return @@ -316,6 +406,7 @@ function onTogglePlay() { if (isPlaying.value) { // 暂停 - 显示总结 metronome.stop() + stopBgm() stopTimer() uni.setKeepScreenOn({ keepScreenOn: false }) showSummary.value = true @@ -330,6 +421,7 @@ function onTogglePlay() { } metronome.start() + startBgm() startTimer() uni.setKeepScreenOn({ keepScreenOn: true }) } @@ -340,6 +432,7 @@ function onRestart() { showSummary.value = false resetStats() metronome.start() + startBgm() startTimer() uni.setKeepScreenOn({ keepScreenOn: true }) } @@ -386,6 +479,7 @@ onShow(() => { onHide(() => { if (isPlaying.value) { metronome.stop() + stopBgm() stopTimer() uni.setKeepScreenOn({ keepScreenOn: false }) } @@ -394,6 +488,12 @@ onHide(() => { crushAudioCtx.value.destroy() } catch (_) {} } + if (bgmAudioCtx.value) { + try { + bgmAudioCtx.value.destroy() + } catch (_) {} + bgmAudioCtx.value = null + } }) @@ -407,9 +507,9 @@ $card-border: rgba(15, 23, 42, 0.06); $text-1: #0f172a; $text-2: #475569; $text-3: #94a3b8; -$brand: #22c55e; -$brand-soft: #d1fae5; -$brand-deep: #047857; +$brand: #14b8a6; /* 主品牌色:青绿(健康主题) */ +$brand-soft: #ccfbf1; /* 浅青绿背景 */ +$brand-deep: #0f766e; /* 深青绿文字 */ $radius-card: 24rpx; $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); @@ -436,8 +536,12 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); width: 100vw; margin-left: -28rpx; margin-right: -28rpx; + margin-top: -30rpx; /* 向上拉,贴近导航栏 */ flex-shrink: 0; - height: 600rpx; + /* 占主屏 60%,确保下方统计栏和档位可见 */ + height: 60vh; + min-height: 720rpx; + max-height: 900rpx; display: flex; justify-content: center; align-items: center; @@ -445,6 +549,37 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); position: relative; } +/* ============================================================ + * BGM 开关按钮 + * ============================================================ */ +.bgm-toggle { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 72rpx; + height: 72rpx; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + box-shadow: + 0 4rpx 16rpx rgba(0, 0, 0, 0.1), + 0 0 0 2rpx rgba(20, 184, 166, 0.2); + display: flex; + align-items: center; + justify-content: center; + z-index: 20; + transition: all 0.2s ease; + + &:active { + transform: scale(0.92); + background: #f0fdfa; + } +} + +.bgm-icon { + font-size: 36rpx; + line-height: 1; +} + /* ============================================================ * 进度显示 * ============================================================ */ @@ -483,32 +618,61 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); width: 100%; background: $card-bg; border-radius: $radius-card; - padding: 20rpx 24rpx; - box-shadow: $shadow-sm; + padding: 24rpx 28rpx; + box-shadow: + 0 4rpx 16rpx rgba(15, 23, 42, 0.04), + 0 1rpx 2rpx rgba(15, 23, 42, 0.04); display: flex; justify-content: space-around; align-items: center; gap: 16rpx; + border: 2rpx solid rgba(20, 184, 166, 0.08); + position: relative; + overflow: hidden; +} + +.stats-bar::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3rpx; + background: linear-gradient(90deg, transparent, $brand, transparent); } .stat-item-inline { display: flex; flex-direction: column; align-items: center; - gap: 6rpx; + gap: 8rpx; + flex: 1; + position: relative; +} + +.stat-item-inline:not(:last-child)::after { + content: ''; + position: absolute; + right: -8rpx; + top: 20%; + bottom: 20%; + width: 2rpx; + background: linear-gradient(to bottom, transparent, $card-border, transparent); } .stat-label-inline { font-size: 22rpx; color: $text-3; font-weight: 500; + letter-spacing: 1rpx; } .stat-value-inline { - font-size: 28rpx; + font-size: 32rpx; color: $brand-deep; - font-weight: 700; + font-weight: 800; font-variant-numeric: tabular-nums; + letter-spacing: 0; } /* ============================================================ @@ -565,8 +729,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); background: $brand-soft; border-color: $brand; box-shadow: - 0 8rpx 20rpx rgba(16, 185, 129, 0.18), - inset 0 0 0 2rpx rgba(16, 185, 129, 0.4); + 0 8rpx 20rpx rgba(20, 184, 166, 0.18), + inset 0 0 0 2rpx rgba(20, 184, 166, 0.4); .preset-name { color: $brand-deep; @@ -608,7 +772,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); .reward-popup { width: 560rpx; - background: linear-gradient(135deg, #fff 0%, #f0fdf4 100%); + background: linear-gradient(135deg, #fff 0%, #f0fdfa 100%); border-radius: 32rpx; padding: 48rpx 32rpx; display: flex; @@ -674,12 +838,12 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); border-radius: 999rpx; font-size: 30rpx; font-weight: 600; - box-shadow: 0 8rpx 20rpx rgba(34, 197, 94, 0.3); + box-shadow: 0 8rpx 20rpx rgba(20, 184, 166, 0.3); transition: all 0.2s; &:active { transform: scale(0.97); - box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.2); + box-shadow: 0 4rpx 12rpx rgba(20, 184, 166, 0.2); } } @@ -688,29 +852,66 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); * ============================================================ */ .completion-card { width: 100%; - background: linear-gradient(135deg, #dbeafe, #ede9fe); + background: linear-gradient(135deg, #f0fdfa, #ecfeff); border-radius: $radius-card; - padding: 32rpx 24rpx; + padding: 40rpx 28rpx 32rpx; display: flex; flex-direction: column; - gap: 24rpx; + gap: 28rpx; align-items: center; - box-shadow: $shadow-sm; + box-shadow: + 0 8rpx 32rpx rgba(20, 184, 166, 0.08), + 0 2rpx 8rpx rgba(15, 23, 42, 0.04); + border: 2rpx solid rgba(20, 184, 166, 0.1); +} + +.completion-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; +} + +.completion-emoji { + font-size: 80rpx; + line-height: 1; + margin-bottom: 8rpx; + animation: emoji-bounce 1.5s ease-in-out infinite; +} + +@keyframes emoji-bounce { + 0%, 100% { + transform: scale(1) rotate(0deg); + } + 25% { + transform: scale(1.1) rotate(-5deg); + } + 75% { + transform: scale(1.1) rotate(5deg); + } } .completion-title { - font-size: 30rpx; + font-size: 36rpx; color: $text-1; - font-weight: 700; + font-weight: 800; + letter-spacing: 2rpx; +} + +.completion-subtitle { + font-size: 24rpx; + color: $text-3; + margin-top: 4rpx; } .completion-content { width: 100%; display: flex; flex-direction: column; - gap: 20rpx; + gap: 24rpx; } +/* 核心统计网格 */ .stats-grid { display: flex; gap: 16rpx; @@ -719,48 +920,123 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); .stat-item { flex: 1; - background: rgba(255, 255, 255, 0.8); - border-radius: 16rpx; - padding: 20rpx 12rpx; + background: rgba(255, 255, 255, 0.85); + border-radius: 20rpx; + padding: 24rpx 12rpx 20rpx; display: flex; flex-direction: column; align-items: center; - gap: 8rpx; + gap: 6rpx; + box-shadow: + 0 4rpx 12rpx rgba(15, 23, 42, 0.04), + inset 0 1rpx 0 rgba(255, 255, 255, 0.8); + backdrop-filter: blur(8rpx); +} + +.stat-icon { + font-size: 40rpx; + line-height: 1; + margin-bottom: 4rpx; } .stat-label { - font-size: 24rpx; - color: #64748b; + font-size: 22rpx; + color: $text-3; + font-weight: 500; } .stat-value { - font-size: 32rpx; - font-weight: 700; + font-size: 36rpx; + font-weight: 800; color: $text-1; + font-variant-numeric: tabular-nums; + letter-spacing: 0; } -.sugar-card { - background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); - border-radius: 16rpx; - padding: 24rpx; +/* ============================================================ + * 食物对照卡片 + * ============================================================ */ +.food-comparison-card { + background: linear-gradient(135deg, #fff7ed 0%, #fffbeb 100%); + border-radius: 20rpx; + padding: 28rpx 24rpx; + width: 100%; + box-shadow: + 0 4rpx 16rpx rgba(251, 191, 36, 0.1), + inset 0 1rpx 0 rgba(255, 255, 255, 0.8); + border: 2rpx solid rgba(251, 191, 36, 0.15); +} + +.food-comparison-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 4rpx; + margin-bottom: 24rpx; +} + +.food-comparison-title { + font-size: 26rpx; + color: #92400e; + font-weight: 700; + letter-spacing: 1rpx; +} + +.food-comparison-hint { + font-size: 22rpx; + color: #b45309; + opacity: 0.8; +} + +.food-list { + display: flex; + justify-content: space-around; + align-items: center; + gap: 12rpx; +} + +.food-item { + flex: 1; display: flex; flex-direction: column; align-items: center; gap: 8rpx; - width: 100%; + padding: 16rpx 8rpx; + background: rgba(255, 255, 255, 0.7); + border-radius: 16rpx; + transition: transform 0.3s ease; } -.sugar-label { - font-size: 24rpx; - color: #065f46; +.food-item:active { + transform: scale(0.95); +} + +.food-emoji { + font-size: 56rpx; + line-height: 1; + filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.1)); +} + +.food-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rpx; +} + +.food-count { + font-size: 32rpx; + font-weight: 800; + color: #92400e; + line-height: 1.2; + font-variant-numeric: tabular-nums; +} + +.food-name { + font-size: 20rpx; + color: #b45309; font-weight: 500; -} - -.sugar-comparison { - font-size: 28rpx; - color: #047857; - font-weight: 700; - text-align: center; + letter-spacing: 1rpx; } .action-row { diff --git a/TUICallKit-Vue3/training/static/grip-ring-audio-shopping-list.md b/TUICallKit-Vue3/training/static/grip-ring-audio-shopping-list.md new file mode 100644 index 00000000..041c06cd --- /dev/null +++ b/TUICallKit-Vue3/training/static/grip-ring-audio-shopping-list.md @@ -0,0 +1,276 @@ +# 握力环训练音效采集清单 + +> 更新日期: 2026-05-28 +> 用途: 为握力环训练页面添加完整音效体系 + +--- + +## 📋 总体规划 + +| 类型 | 数量 | 单文件大小 | 总大小预算 | 优先级 | +|------|------|-----------|-----------|--------| +| 节拍音效 | 1-3 个音色 | 5-10 KB | < 30 KB | P0 | +| 捏碎音效 | 4 个 | 20-50 KB | < 200 KB | P0 | +| 背景音乐 | 1-2 首 | 200-500 KB | < 1 MB | P1 | +| 语音鼓励 | 4-8 句 | 10-30 KB | < 200 KB | P1 | + +**总计预算**: < 1.5 MB(都放 CDN,不影响小程序包体积) + +--- + +## 🥁 一、节拍音效 (P0 - 必需) + +### 用途 +训练时按 BPM 节奏播放,引导用户握紧/松开。 + +### 推荐音色 (任选 1-3 个) + +| 音色 | 推荐度 | 描述 | Pixabay 搜索词 | +|------|--------|------|---------------| +| 木鱼 | ⭐⭐⭐⭐⭐ | 温暖、东方意境,适合养生主题 | `wood block`, `wooden tap`, `muyu` | +| 心跳 | ⭐⭐⭐⭐ | 有运动感,代入感强 | `heart beat`, `heartbeat single` | +| 轻柔敲击 | ⭐⭐⭐⭐ | 不刺耳,适合长时间训练 | `soft tap`, `gentle knock` | +| 竹片声 | ⭐⭐⭐ | 清脆,有节奏感 | `bamboo tap`, `bamboo click` | +| 鼓点 | ⭐⭐⭐ | 有力量感,适合"重度"档位 | `kick drum soft`, `tom drum tap` | + +### 技术要求 +- **格式**: mp3 +- **时长**: 50-150ms (不能太长,会重叠) +- **采样率**: 22050Hz 或 44100Hz +- **声道**: 单声道 +- **大小**: < 10 KB + +### 命名规范 +``` +grip-tick-wood.mp3 # 木鱼 +grip-tick-heart.mp3 # 心跳 +grip-tick-soft.mp3 # 轻柔 +grip-tick-bamboo.mp3 # 竹片 +``` + +--- + +## 💥 二、捏碎音效 (P0 - 必需) + +### 用途 +达成奖励里程碑时播放,与粒子特效同步。 + +### 音效清单 + +| 奖励 | 触发次数 | Pixabay 搜索词 | 时长 | 备注 | +|------|---------|---------------|------|------| +| 🥚 鸡蛋 | 8-12 | `egg crack`, `eggshell break`, `crack sound` | 0.5-1s | 清脆短促 | +| 🥜 核桃 | 15-20 | `nut crack`, `walnut break`, `wood snap` | 0.5-1s | 木质爆裂感 | +| 🥫 易拉罐 | 25-30 | `can crush`, `aluminum crush`, `metal crunch` | 1-2s | 金属挤压 | +| 🎈 气球 | 40-50 | `balloon pop`, `pop burst`, `balloon explosion` | 0.3-0.5s | 短促爆裂 | + +### 技术要求 +- **格式**: mp3 +- **时长**: 0.3-2s +- **大小**: < 50 KB +- **音量**: 适中(避免突然吓到用户) + +### 命名规范 +``` +crush-egg.mp3 +crush-walnut.mp3 +crush-can.mp3 +crush-balloon.mp3 +``` + +### 试听建议 +下载前一定要试听,优先选择: +- ✅ 干净,无杂音 +- ✅ 中等音量(峰值不爆音) +- ✅ 短促有力(不拖沓) +- ❌ 避免: 太长、太尖锐、有人声 + +--- + +## 🎵 三、背景音乐 BGM (P1 - 推荐) + +### 用途 +训练时持续播放,营造轻松氛围,缓解握力训练的枯燥感。 + +### 风格推荐 + +| 风格 | 描述 | Pixabay 搜索词 | 适合人群 | +|------|------|---------------|---------| +| **Lo-Fi** | 慵懒电子,流行选择 | `lofi calm`, `lofi study`, `lofi chill` | 年轻人 | +| **冥想轻音乐** | 钢琴 + 自然音,放松 | `meditation`, `calm piano`, `relaxing` | 中老年 | +| **轻爵士** | 优雅放松,有质感 | `light jazz`, `cafe jazz`, `bossa nova` | 通用 | +| **自然环境音** | 鸟叫/流水/雨声 | `nature ambient`, `forest sounds`, `rain bgm` | 喜欢自然的用户 | +| **东方禅意** | 古筝/笛子,养生 | `chinese zen`, `oriental calm`, `guqin` | 养生主题契合 | + +### 推荐场景搭配 +- **轻度档位 (60 BPM)**: 冥想轻音乐 (节奏舒缓) +- **中度档位 (80 BPM)**: 轻爵士 / Lo-Fi (适中) +- **重度档位 (100 BPM)**: 轻快电子 / 健身流行 + +### 技术要求 +- **格式**: mp3 +- **时长**: 30s - 2min (循环播放) +- **比特率**: 128 kbps (单声道 64 kbps 也可) +- **大小**: < 500 KB +- **循环点**: 选择起止点接近的片段,避免明显接缝 + +### 命名规范 +``` +bgm-grip-light.mp3 # 轻度档位 BGM +bgm-grip-medium.mp3 # 中度档位 BGM (可选) +bgm-grip-heavy.mp3 # 重度档位 BGM (可选) +``` + +或简化: +``` +bgm-grip-default.mp3 # 默认 BGM (所有档位通用) +``` + +--- + +## 🎙️ 四、语音鼓励 (P1 - 推荐) + +### 用途 +达成奖励时播放鼓励语音,增强成就感。 + +### 两种方案 + +#### 方案 A: 小米 MiMo TTS 自动生成 (推荐) + +项目已有 `useVoiceCoach.ts` + `generate-voice.mjs` 脚本支持。 + +**操作步骤**: +1. 申请小米 MiMo API Key: https://platform.xiaomimimo.com/ +2. 配置环境变量: `export MIMO_API_KEY=sk-xxx` +3. 在 `generate-voice.mjs` 添加握力环话术 +4. 运行脚本自动生成 + +**话术清单**: +```javascript +const gripPrompts = [ + { id: 'grip-egg', text: '握力不错!' }, + { id: 'grip-walnut', text: '力量惊人!' }, + { id: 'grip-can', text: '太强了!' }, + { id: 'grip-balloon', text: '完美!继续保持!' }, + { id: 'grip-start', text: '开始训练!' }, + { id: 'grip-pause', text: '休息一下!' }, + { id: 'grip-encourage-1', text: '加油,再来!' }, + { id: 'grip-encourage-2', text: '坚持住!' }, +] +``` + +**音色推荐**: `茉莉` (清爽利落,有"运动博主"感) + +#### 方案 B: 手工录制 + +如果不想用 TTS,可以自己录制(质感更好,但费时): +- 工具: 手机录音 / Audacity +- 环境: 安静的房间,距离麦克风 15-30cm +- 后期: 去噪 + 标准化音量 + +--- + +## 🛠️ 五、采集工具推荐 + +### 在线裁剪 +- [audiotrimmer.com](https://audiotrimmer.com) - 简单的 mp3 裁剪 +- [mp3cut.net](https://mp3cut.net) - 功能更全 + +### 桌面工具(免费) +- **Audacity** (Mac/Windows/Linux) - 专业级,可以剪辑、降噪、标准化 +- **Logic Pro** (Mac, 付费) - 更专业的音频处理 + +### 音量标准化 +统一音量(避免有的太响有的太轻): +```bash +# 使用 ffmpeg (Mac: brew install ffmpeg) +ffmpeg -i input.mp3 -af "loudnorm=I=-16:LRA=11:TP=-1.5" output.mp3 +``` + +或在 Audacity 里: Effect → Normalize (峰值 -3dB) + +--- + +## ☁️ 六、上传到 CDN + +### 项目使用的 COS 信息 +- **域名**: `gz-1349751149.cos.ap-guangzhou.myqcloud.com` +- **目录建议**: `uploads/training/grip-ring/` + +### 上传途径 +1. **腾讯云控制台**: https://console.cloud.tencent.com/cos +2. **管理后台** (如果项目有上传入口) +3. **找团队 COS 管理员上传** + +### 上传后获得的 URL 格式 +``` +https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/training/grip-ring/crush-egg.mp3 +``` + +--- + +## 📝 七、最终采集清单 (一次性收齐) + +### 最小可行版本 (MVP) - 4 个文件 +``` +☐ grip-tick-wood.mp3 # 节拍音 - 木鱼 +☐ crush-egg.mp3 # 鸡蛋 +☐ crush-walnut.mp3 # 核桃 +☐ crush-can.mp3 # 易拉罐 +☐ crush-balloon.mp3 # 气球 +``` + +### 完整版本 - 10 个文件 +``` +节拍音 (3 选 1 或全部): +☐ grip-tick-wood.mp3 +☐ grip-tick-heart.mp3 +☐ grip-tick-soft.mp3 + +捏碎音效 (必需 4 个): +☐ crush-egg.mp3 +☐ crush-walnut.mp3 +☐ crush-can.mp3 +☐ crush-balloon.mp3 + +BGM (1-3 首): +☐ bgm-grip-default.mp3 +☐ bgm-grip-meditation.mp3 (可选) +☐ bgm-grip-lofi.mp3 (可选) + +语音鼓励 (TTS 自动生成,8 个): +☐ grip-egg.mp3 +☐ grip-walnut.mp3 +☐ grip-can.mp3 +☐ grip-balloon.mp3 +☐ grip-start.mp3 +☐ grip-pause.mp3 +☐ grip-encourage-1.mp3 +☐ grip-encourage-2.mp3 +``` + +--- + +## ✅ 八、采集完成后 + +把所有 URL 整理给我,我会: +1. 更新 `grip-ring.vue` 中的 REWARDS 配置 (替换捏碎音效 URL) +2. 创建/更新 `useMetronome.ts` 的音色配置 (支持切换节拍音色) +3. 在 `grip-ring.vue` 集成 `useTrainingBgm` (添加 BGM) +4. 在 `grip-ring.vue` 集成 `useVoiceCoach` (添加语音鼓励) +5. 在 UI 上添加音色/BGM 切换选项 (可选) + +预计代码改动: 50-100 行,1-2 小时工作量。 + +--- + +## 📚 参考链接 + +- [Pixabay 音效](https://pixabay.com/sound-effects/) - 免费可商用 +- [Pixabay 音乐](https://pixabay.com/music/) - 免费可商用 +- [Freesound](https://freesound.org) - 大量音效,部分需署名 +- [Mixkit](https://mixkit.co/free-sound-effects/) - 免费音效库 +- [Bensound](https://bensound.com) - 免费音乐(需署名) +- [Free Music Archive](https://freemusicarchive.org) - 免费音乐 + +**版权提示**: 务必确认音效/音乐是"免费商用"(Public Domain 或 CC0),避免侵权风险。