## 新增功能 ### 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>
281 lines
6.4 KiB
Vue
281 lines
6.4 KiB
Vue
<template>
|
||
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
|
||
<view class="stage">
|
||
<!-- 哑铃弯举:摆臂 -->
|
||
<view v-if="animationType === 'curl'" class="curl-arm">
|
||
<view class="curl-forearm">
|
||
<view class="curl-dumbbell">{{ icon }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 哑铃推举:上下移动 -->
|
||
<view v-else-if="animationType === 'press'" class="press-wrap">
|
||
<view class="press-dumbbell left">{{ icon }}</view>
|
||
<view class="press-dumbbell right">{{ icon }}</view>
|
||
</view>
|
||
|
||
<!-- 哑铃侧平举:双臂张开 -->
|
||
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
|
||
<view class="sidefly-arm sidefly-left">
|
||
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||
</view>
|
||
<view class="sidefly-arm sidefly-right">
|
||
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 握力环:缩放 + 粒子特效 -->
|
||
<view v-else-if="animationType === 'grip'" class="grip-container">
|
||
<view class="grip-ring">
|
||
<view class="grip-ring-inner">{{ icon }}</view>
|
||
</view>
|
||
<CrushCanvas ref="crushCanvasRef" />
|
||
</view>
|
||
|
||
<!-- 卷腹:身体折叠 -->
|
||
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
|
||
<view class="crunch-body">{{ icon }}</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
import type { AnimationType } from '../exercises'
|
||
import CrushCanvas from './crush-canvas.vue'
|
||
|
||
interface Props {
|
||
animationType: AnimationType
|
||
bpm: number
|
||
beatsPerRep?: number
|
||
icon?: string
|
||
isPlaying?: boolean
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
beatsPerRep: 2,
|
||
icon: '🏋',
|
||
isPlaying: false,
|
||
})
|
||
|
||
const cssVars = computed(() => {
|
||
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
|
||
return {
|
||
'--rep-duration': `${repDurationMs}ms`,
|
||
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
||
} as Record<string, string>
|
||
})
|
||
|
||
const crushCanvasRef = ref()
|
||
|
||
// 暴露捏碎特效触发方法(供外部测试)
|
||
const triggerCrushEffect = (itemType: 'egg' | 'walnut' | 'can' | 'balloon') => {
|
||
crushCanvasRef.value?.triggerCrush(itemType)
|
||
}
|
||
|
||
defineExpose({
|
||
triggerCrushEffect
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.exercise-anim {
|
||
width: 100%;
|
||
height: 480rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||
border-radius: 24rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.stage {
|
||
width: 320rpx;
|
||
height: 320rpx;
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* ===== 弯举 ===== */
|
||
.curl-arm {
|
||
width: 60rpx;
|
||
height: 240rpx;
|
||
background: #fbbf24;
|
||
border-radius: 30rpx;
|
||
position: relative;
|
||
|
||
.curl-forearm {
|
||
width: 60rpx;
|
||
height: 200rpx;
|
||
background: #f59e0b;
|
||
border-radius: 30rpx;
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
transform-origin: bottom center;
|
||
animation: curl-anim var(--rep-duration) ease-in-out infinite;
|
||
animation-play-state: var(--anim-state);
|
||
}
|
||
|
||
.curl-dumbbell {
|
||
position: absolute;
|
||
top: -20rpx;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 56rpx;
|
||
}
|
||
}
|
||
@keyframes curl-anim {
|
||
0%,
|
||
100% {
|
||
transform: rotate(0deg);
|
||
}
|
||
50% {
|
||
transform: rotate(-115deg);
|
||
}
|
||
}
|
||
|
||
/* ===== 推举 ===== */
|
||
.press-wrap {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-end;
|
||
padding: 0 20rpx;
|
||
}
|
||
.press-dumbbell {
|
||
font-size: 88rpx;
|
||
animation: press-anim var(--rep-duration) ease-in-out infinite;
|
||
animation-play-state: var(--anim-state);
|
||
}
|
||
@keyframes press-anim {
|
||
0%,
|
||
100% {
|
||
transform: translateY(80rpx);
|
||
}
|
||
50% {
|
||
transform: translateY(-80rpx);
|
||
}
|
||
}
|
||
|
||
/* ===== 侧平举 ===== */
|
||
.sidefly-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.sidefly-arm {
|
||
position: absolute;
|
||
width: 140rpx;
|
||
height: 24rpx;
|
||
background: #f59e0b;
|
||
border-radius: 12rpx;
|
||
top: 50%;
|
||
transform-origin: center right;
|
||
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
|
||
animation-play-state: var(--anim-state);
|
||
|
||
&.sidefly-left {
|
||
right: 50%;
|
||
}
|
||
&.sidefly-right {
|
||
left: 50%;
|
||
transform-origin: center left;
|
||
animation-name: sidefly-right;
|
||
}
|
||
}
|
||
.sidefly-dumbbell {
|
||
position: absolute;
|
||
top: -36rpx;
|
||
font-size: 56rpx;
|
||
}
|
||
.sidefly-left .sidefly-dumbbell {
|
||
left: -20rpx;
|
||
}
|
||
.sidefly-right .sidefly-dumbbell {
|
||
right: -20rpx;
|
||
}
|
||
@keyframes sidefly-left {
|
||
0%,
|
||
100% {
|
||
transform: rotate(80deg);
|
||
}
|
||
50% {
|
||
transform: rotate(0deg);
|
||
}
|
||
}
|
||
@keyframes sidefly-right {
|
||
0%,
|
||
100% {
|
||
transform: rotate(-80deg);
|
||
}
|
||
50% {
|
||
transform: rotate(0deg);
|
||
}
|
||
}
|
||
|
||
/* ===== 握力环 ===== */
|
||
.grip-container {
|
||
position: relative;
|
||
width: 240rpx;
|
||
height: 240rpx;
|
||
}
|
||
|
||
.grip-ring {
|
||
width: 240rpx;
|
||
height: 240rpx;
|
||
animation: grip-anim var(--rep-duration) ease-in-out infinite;
|
||
animation-play-state: var(--anim-state);
|
||
border: 16rpx solid #22c55e;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(34, 197, 94, 0.1);
|
||
}
|
||
.grip-ring-inner {
|
||
font-size: 80rpx;
|
||
}
|
||
@keyframes grip-anim {
|
||
0%,
|
||
100% {
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
transform: scale(0.65);
|
||
}
|
||
}
|
||
|
||
/* ===== 卷腹 ===== */
|
||
.crunch-wrap {
|
||
width: 280rpx;
|
||
height: 280rpx;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
}
|
||
.crunch-body {
|
||
font-size: 120rpx;
|
||
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
|
||
animation-play-state: var(--anim-state);
|
||
transform-origin: bottom center;
|
||
}
|
||
@keyframes crunch-anim {
|
||
0%,
|
||
100% {
|
||
transform: scaleY(1) translateY(0);
|
||
}
|
||
50% {
|
||
transform: scaleY(0.6) translateY(-10rpx);
|
||
}
|
||
}
|
||
</style>
|