feat(training): 握力环训练趣味化 - 捏碎特效 + 卡路里统计

## 新增功能

### 1. Canvas 粒子捏碎特效
- 新建 crush-canvas.vue 组件(透明叠加层)
- 4 种捏碎物品:鸡蛋🥚、核桃🥜、易拉罐🥫、气球🎈
- 粒子物理模拟:径向扩散 + 重力 + 空气阻力 + 生命值衰减
- 性能优化:固定 60fps 时间步长,粒子边界清理

### 2. 随机触发逻辑
- 平均每 8-12 次随机触发捏碎特效
- 在握力环压缩峰值(scale 0.65)时触发
- 随机选择 4 种物品之一
- 资源清理:训练暂停/结束时清理所有定时器

### 3. 卡路里统计与完成页面
- 标准 MET 公式计算卡路里(MET = 3.5,默认 60kg)
- 5 种食物自动匹配(苹果、鸡蛋、巧克力、香蕉、米饭)
- 完成页面显示:组数、次数、时长、卡路里、食物对比
- 样式设计:浅色清爽风格,绿色渐变卡片

### 4. 预留接口
- useTTS.ts:预留 MiMo TTS 语音鼓励接口

## 技术实现

- Canvas 粒子系统:20-30 个粒子,物理模拟流畅
- 类型安全:完整的 TypeScript 类型定义
- 性能优化:固定时间步长、边界清理、资源管理
- 响应式布局:适配不同屏幕尺寸

## 相关任务

Task: .trellis/tasks/05-27-grip-ring-gamification
PRD: prd.md
Research: research/calorie-calculation.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 16:52:41 +08:00
co-authored by Claude Opus 4.7
parent ffe4849c27
commit 261f8315de
6 changed files with 601 additions and 8 deletions
+118 -3
View File
@@ -39,6 +39,7 @@
<view v-if="selected" class="exercise-detail">
<exercise-anim
ref="exerciseAnimRef"
:animation-type="selected.animationType"
:bpm="bpm"
:beats-per-rep="selected.beatsPerRep"
@@ -126,7 +127,32 @@
<view v-if="isDone" class="status-card done">
<text class="status-title">训练完成 🎉</text>
<text class="status-line"> {{ totalSets }} × {{ totalReps }} </text>
<view v-if="trainingStats" class="completion-content">
<view class="stats-grid">
<view class="stat-item">
<text class="stat-label">组数</text>
<text class="stat-value">{{ trainingStats.totalSets }}</text>
</view>
<view class="stat-item">
<text class="stat-label">次数</text>
<text class="stat-value">{{ trainingStats.totalReps }}</text>
</view>
<view class="stat-item">
<text class="stat-label">时长</text>
<text class="stat-value">{{ formatDuration(trainingStats.durationSeconds) }}</text>
</view>
</view>
<view class="calorie-card">
<text class="calorie-label">消耗卡路里</text>
<text class="calorie-value">{{ trainingStats.calories }} kcal</text>
<text class="food-comparison">
{{ trainingStats.foodComparison.message }}
</text>
</view>
</view>
<view class="action-row">
<button class="btn-primary" @click="onStart">再来一次</button>
</view>
@@ -153,8 +179,11 @@ import { EXERCISE_PRESETS, getPresetById } from './exercises'
import type { ExercisePreset } from './exercises'
import { useMetronome } from '../hooks/useMetronome'
import { useTrainingSession } from '../hooks/useTrainingSession'
import type { CrushItemType, TrainingStats } from '../hooks/useTrainingSession'
import { useVoiceCoach } from '../hooks/useVoiceCoach'
import { useTrainingBgm } from '../hooks/useTrainingBgm'
import { useTTS } from '../hooks/useTTS'
import { formatDuration } from '../utils/calorie'
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
type Equipment = (typeof equipmentList)[number]
@@ -189,8 +218,11 @@ watch(
const voice = useVoiceCoach()
const bgm = useTrainingBgm()
const tts = useTTS()
const session = useTrainingSession()
const exerciseAnimRef = ref()
const session = useTrainingSession(bpm.value)
const {
currentSet,
currentRep,
@@ -200,15 +232,21 @@ const {
isTraining,
isResting,
isDone,
getStats,
} = session
const trainingStats = ref<TrainingStats | null>(null)
const metronome = useMetronome({
initialBpm: bpm.value,
onBeat: () => session.onBeat(),
})
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
watch(bpm, (v) => metronome.setBpm(v))
watch(bpm, (v) => {
metronome.setBpm(v)
session.setBpm(v)
})
const onSwitchEquipment = (eq: Equipment) => {
if (isTraining.value || isResting.value) return
@@ -234,6 +272,14 @@ const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
}
const onCrushTrigger = (item: CrushItemType) => {
// 触发粒子特效
exerciseAnimRef.value?.triggerCrushEffect(item)
// 播放语音鼓励
tts.playEncouragement()
}
const onStart = () => {
if (!selected.value) return
uni.setKeepScreenOn({ keepScreenOn: true })
@@ -267,7 +313,10 @@ const onStart = () => {
bgm.switchTo('none')
voice.speakPrompt('good-job')
uni.setKeepScreenOn({ keepScreenOn: false })
// 获取训练统计数据
trainingStats.value = getStats()
},
onCrushTrigger: onCrushTrigger,
})
bgm.switchTo('training')
@@ -563,6 +612,7 @@ onUnmounted(() => {
}
&.done {
background: linear-gradient(135deg, #dbeafe, #ede9fe);
gap: 24rpx;
}
.status-title {
@@ -581,6 +631,71 @@ onUnmounted(() => {
}
}
.completion-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.stats-grid {
display: flex;
gap: 16rpx;
width: 100%;
}
.stat-item {
flex: 1;
background: #f8fafc;
border-radius: 16rpx;
padding: 20rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #64748b;
}
.stat-value {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
}
.calorie-card {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-radius: 16rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
width: 100%;
}
.calorie-label {
font-size: 24rpx;
color: #065f46;
font-weight: 500;
}
.calorie-value {
font-size: 48rpx;
font-weight: 800;
color: #047857;
line-height: 1.2;
}
.food-comparison {
font-size: 24rpx;
color: #065f46;
margin-top: 4rpx;
}
.tips-card {
background: #fff;
border-radius: 20rpx;