## 新增功能 ### 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>
211 lines
6.1 KiB
TypeScript
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,
|
|
}
|
|
}
|