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
+115
View File
@@ -0,0 +1,115 @@
/**
* 卡路里计算工具
* 基于标准 MET (Metabolic Equivalent of Task) 公式
*/
export interface FoodItem {
name: string
emoji: string
calories: number
unit: string
}
export interface FoodComparison {
food: FoodItem
ratio: number
message: string
}
export interface CalorieCalculationConfig {
totalReps: number
bpm: number
userWeight?: number // 默认 60kg
}
// 食物卡路里数据库
export const FOOD_CALORIES: Record<string, FoodItem> = {
apple: { name: '苹果', emoji: '🍎', calories: 52, unit: '个(100g' },
egg: { name: '鸡蛋', emoji: '🥚', calories: 70, unit: '个' },
chocolate: { name: '巧克力', emoji: '🍫', calories: 54, unit: '块(10g' },
banana: { name: '香蕉', emoji: '🍌', calories: 89, unit: '根' },
rice: { name: '米饭', emoji: '🍚', calories: 116, unit: '碗(100g' },
}
/**
* 计算卡路里消耗
* 公式:卡路里 = MET × 体重(kg) × 时长(小时)
*
* @param config 计算配置
* @returns 卡路里消耗(kcal,保留一位小数)
*/
export function calculateCalories(config: CalorieCalculationConfig): number {
const MET = 3.5 // 握力环训练的标准 MET 值
const weight = config.userWeight || 60 // 默认 60kg
// 边界情况:无效输入
if (config.totalReps <= 0 || config.bpm <= 0) {
return 0
}
const secondsPerRep = 60 / config.bpm
const activeTimeHours = (config.totalReps * secondsPerRep) / 3600
const calories = MET * weight * activeTimeHours
return Math.round(calories * 10) / 10 // 保留一位小数
}
/**
* 自动匹配最接近的食物对比
*
* @param burnedCalories 消耗的卡路里
* @returns 食物对比信息
*/
export function getFoodComparison(burnedCalories: number): FoodComparison {
const foods = Object.values(FOOD_CALORIES)
// 边界情况:食物数据库为空
if (foods.length === 0) {
throw new Error('食物数据库为空')
}
// 边界情况:卡路里为 0 或负数
if (burnedCalories <= 0) {
return {
food: foods[0],
ratio: 0,
message: `暂无消耗`,
}
}
// 找到卡路里最接近的食物
const closest = foods.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.calories - burnedCalories)
const currDiff = Math.abs(curr.calories - burnedCalories)
return currDiff < prevDiff ? curr : prev
})
const ratio = burnedCalories / closest.calories
const ratioRounded = Math.round(ratio * 100) / 100 // 保留两位小数
// 格式化显示:ratio < 1 时显示分数形式更直观
let displayText: string
if (ratioRounded < 1) {
displayText = `相当于 ${ratioRounded.toFixed(2)}${closest.name} ${closest.emoji}`
} else {
displayText = `相当于 ${ratioRounded.toFixed(2)} ${closest.unit}${closest.name} ${closest.emoji}`
}
return {
food: closest,
ratio: ratioRounded,
message: displayText,
}
}
/**
* 格式化时长(秒 → 分:秒)
*
* @param seconds 秒数
* @returns 格式化字符串(如 "3:45"
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}