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 @@