From 261f8315de5e38d0cd61a32a10a58b03ab68f5f3 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Wed, 27 May 2026 16:52:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(training):=20=E6=8F=A1=E5=8A=9B=E7=8E=AF?= =?UTF-8?q?=E8=AE=AD=E7=BB=83=E8=B6=A3=E5=91=B3=E5=8C=96=20-=20=E6=8D=8F?= =?UTF-8?q?=E7=A2=8E=E7=89=B9=E6=95=88=20+=20=E5=8D=A1=E8=B7=AF=E9=87=8C?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 ### 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 --- TUICallKit-Vue3/training/hooks/useTTS.ts | 32 +++ .../training/hooks/useTrainingSession.ts | 79 +++++- .../pages/components/crush-canvas.vue | 233 ++++++++++++++++++ .../pages/components/exercise-anim.vue | 29 ++- TUICallKit-Vue3/training/pages/index.vue | 121 ++++++++- TUICallKit-Vue3/training/utils/calorie.ts | 115 +++++++++ 6 files changed, 601 insertions(+), 8 deletions(-) create mode 100644 TUICallKit-Vue3/training/hooks/useTTS.ts create mode 100644 TUICallKit-Vue3/training/pages/components/crush-canvas.vue create mode 100644 TUICallKit-Vue3/training/utils/calorie.ts diff --git a/TUICallKit-Vue3/training/hooks/useTTS.ts b/TUICallKit-Vue3/training/hooks/useTTS.ts new file mode 100644 index 00000000..8ebbb07e --- /dev/null +++ b/TUICallKit-Vue3/training/hooks/useTTS.ts @@ -0,0 +1,32 @@ +/** + * TTS 语音鼓励 Hook + * 用于在捏碎特效触发时播放语音鼓励 + * + * 当前状态:预留接口,待集成 MiMo TTS + * TODO: 集成 MiMo TTS SDK + */ + +export function useTTS() { + /** + * 播放语音鼓励 + * 随机播放:"加油"、"太棒了"、"继续"、"很好"等 + * + * 实现计划: + * 1. 引入 MiMo TTS SDK + * 2. 配置语音库(鼓励短语列表) + * 3. 随机选择短语并播放 + */ + const playEncouragement = () => { + // TODO: 集成 MiMo TTS + // 示例实现: + // const phrases = ['加油', '太棒了', '继续', '很好', '坚持'] + // const phrase = phrases[Math.floor(Math.random() * phrases.length)] + // mimoTTS.speak(phrase) + + console.log('[TTS] 播放语音鼓励(待实现 MiMo TTS 集成)') + } + + return { + playEncouragement + } +} diff --git a/TUICallKit-Vue3/training/hooks/useTrainingSession.ts b/TUICallKit-Vue3/training/hooks/useTrainingSession.ts index f33499b8..acdc5df0 100644 --- a/TUICallKit-Vue3/training/hooks/useTrainingSession.ts +++ b/TUICallKit-Vue3/training/hooks/useTrainingSession.ts @@ -1,7 +1,11 @@ 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 @@ -13,9 +17,35 @@ export interface TrainingSessionConfig { onExitRest?: () => void onDone?: () => void onCountdownTick?: (remainingSec: number) => void + onCrushTrigger?: (itemType: CrushItemType) => void } -export function useTrainingSession() { +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('idle') const currentSet = ref(0) const currentRep = ref(0) @@ -24,6 +54,9 @@ export function useTrainingSession() { let config: TrainingSessionConfig | null = null let restTimer: ReturnType | null = null + let crushTimers: ReturnType[] = [] // 存储所有捏碎特效的 setTimeout + let currentBpm = bpm ?? 80 + let startTime = 0 // 训练开始时间戳 const totalReps = computed(() => config?.reps ?? 0) const totalSets = computed(() => config?.sets ?? 0) @@ -33,6 +66,10 @@ export function useTrainingSession() { 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' @@ -40,6 +77,27 @@ export function useTrainingSession() { 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 = () => { @@ -52,6 +110,17 @@ export function useTrainingSession() { 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) @@ -104,6 +173,10 @@ export function useTrainingSession() { clearInterval(restTimer) restTimer = null } + // 清理所有待触发的捏碎特效 + crushTimers.forEach(timer => clearTimeout(timer)) + crushTimers = [] + phase.value = 'idle' currentSet.value = 0 currentRep.value = 0 @@ -113,6 +186,8 @@ export function useTrainingSession() { onUnmounted(() => { if (restTimer) clearInterval(restTimer) + crushTimers.forEach(timer => clearTimeout(timer)) + crushTimers = [] }) return { @@ -129,5 +204,7 @@ export function useTrainingSession() { stop, skipRest, onBeat, + setBpm, + getStats, } } diff --git a/TUICallKit-Vue3/training/pages/components/crush-canvas.vue b/TUICallKit-Vue3/training/pages/components/crush-canvas.vue new file mode 100644 index 00000000..2681af98 --- /dev/null +++ b/TUICallKit-Vue3/training/pages/components/crush-canvas.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/TUICallKit-Vue3/training/pages/components/exercise-anim.vue b/TUICallKit-Vue3/training/pages/components/exercise-anim.vue index c53b4d77..00487b4a 100644 --- a/TUICallKit-Vue3/training/pages/components/exercise-anim.vue +++ b/TUICallKit-Vue3/training/pages/components/exercise-anim.vue @@ -24,9 +24,12 @@ - - - {{ icon }} + + + + {{ icon }} + + @@ -38,8 +41,9 @@