## 新增功能 ### 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>
116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
/**
|
||
* 卡路里计算工具
|
||
* 基于标准 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')}`
|
||
}
|