Files
zyt/TUICallKit-Vue3/training/hooks/useTrainingSession.ts
T
longandClaude Opus 4.7 261f8315de 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>
2026-05-27 16:52:41 +08:00

211 lines
6.1 KiB
TypeScript

import { ref, computed, onUnmounted } from 'vue'
import { calculateCalories, getFoodComparison } from '../utils/calorie'
import type { FoodComparison } from '../utils/calorie'
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
export type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
export interface TrainingSessionConfig {
sets: number
reps: number
restSec: number
beatsPerRep?: number
onRepComplete?: (currentRep: number, totalReps: number) => void
onSetComplete?: (currentSet: number, totalSets: number) => void
onEnterRest?: (restSec: number) => void
onExitRest?: () => void
onDone?: () => void
onCountdownTick?: (remainingSec: number) => void
onCrushTrigger?: (itemType: CrushItemType) => void
}
export interface TrainingStats {
totalSets: number
totalReps: number
durationSeconds: number
calories: number
foodComparison: FoodComparison
}
// 捏碎特效配置
const CRUSH_TRIGGER_INTERVAL_MIN = 8 // 最小触发间隔
const CRUSH_TRIGGER_INTERVAL_MAX = 12 // 最大触发间隔
const CRUSH_ITEMS: CrushItemType[] = ['egg', 'walnut', 'can', 'balloon']
// 随机触发判断(平均每 8-12 次触发一次)
function shouldTriggerCrush(): boolean {
// 使用随机间隔的平均值:(8 + 12) / 2 = 10
const avgInterval = (CRUSH_TRIGGER_INTERVAL_MIN + CRUSH_TRIGGER_INTERVAL_MAX) / 2
return Math.random() < (1 / avgInterval)
}
// 随机选择物品
function randomCrushItem(): CrushItemType {
return CRUSH_ITEMS[Math.floor(Math.random() * CRUSH_ITEMS.length)]
}
export function useTrainingSession(bpm?: number) {
const phase = ref<SessionPhase>('idle')
const currentSet = ref<number>(0)
const currentRep = ref<number>(0)
const restRemainingSec = ref<number>(0)
const beatCounter = ref<number>(0)
let config: TrainingSessionConfig | null = null
let restTimer: ReturnType<typeof setInterval> | null = null
let crushTimers: ReturnType<typeof setTimeout>[] = [] // 存储所有捏碎特效的 setTimeout
let currentBpm = bpm ?? 80
let startTime = 0 // 训练开始时间戳
const totalReps = computed(() => config?.reps ?? 0)
const totalSets = computed(() => config?.sets ?? 0)
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
const isTraining = computed(() => phase.value === 'training')
const isResting = computed(() => phase.value === 'resting')
const isDone = computed(() => phase.value === 'done')
const setBpm = (newBpm: number) => {
currentBpm = newBpm
}
const start = (cfg: TrainingSessionConfig) => {
config = cfg
phase.value = 'training'
currentSet.value = 1
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
startTime = Date.now() // 记录开始时间
}
// 获取训练统计数据
const getStats = (): TrainingStats => {
const durationSeconds = Math.floor((Date.now() - startTime) / 1000)
const actualTotalReps = (config?.sets ?? 0) * (config?.reps ?? 0)
const calories = calculateCalories({
totalReps: actualTotalReps,
bpm: currentBpm,
userWeight: 60, // 默认 60kg
})
return {
totalSets: config?.sets ?? 0,
totalReps: actualTotalReps,
durationSeconds,
calories,
foodComparison: getFoodComparison(calories),
}
}
const onBeat = () => {
if (phase.value !== 'training' || !config) return
beatCounter.value++
if (beatCounter.value % beatsPerRep.value !== 0) return
currentRep.value++
config.onRepComplete?.(currentRep.value, totalReps.value)
// 随机触发捏碎特效
if (shouldTriggerCrush() && config.onCrushTrigger) {
const item = randomCrushItem()
// 延迟到动画峰值(50%)触发
const repDurationMs = (60000 / currentBpm) * beatsPerRep.value
const timer = setTimeout(() => {
config?.onCrushTrigger?.(item)
}, repDurationMs / 2)
crushTimers.push(timer)
}
if (currentRep.value >= totalReps.value) {
config.onSetComplete?.(currentSet.value, totalSets.value)
if (currentSet.value >= totalSets.value) {
phase.value = 'done'
config.onDone?.()
return
}
enterRest()
}
}
const enterRest = () => {
if (!config) return
phase.value = 'resting'
restRemainingSec.value = config.restSec
config.onEnterRest?.(config.restSec)
restTimer = setInterval(() => {
restRemainingSec.value--
config?.onCountdownTick?.(restRemainingSec.value)
if (restRemainingSec.value <= 0) {
exitRest()
}
}, 1000)
}
const exitRest = () => {
if (restTimer) {
clearInterval(restTimer)
restTimer = null
}
if (!config) return
currentSet.value++
currentRep.value = 0
beatCounter.value = 0
phase.value = 'training'
config.onExitRest?.()
}
const skipRest = () => {
if (phase.value !== 'resting') return
exitRest()
}
const stop = () => {
if (restTimer) {
clearInterval(restTimer)
restTimer = null
}
// 清理所有待触发的捏碎特效
crushTimers.forEach(timer => clearTimeout(timer))
crushTimers = []
phase.value = 'idle'
currentSet.value = 0
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
}
onUnmounted(() => {
if (restTimer) clearInterval(restTimer)
crushTimers.forEach(timer => clearTimeout(timer))
crushTimers = []
})
return {
phase,
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
start,
stop,
skipRest,
onBeat,
setBpm,
getStats,
}
}