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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user