2013-10-14 19:49:57 u011838628 阅读数 9034
  • 虚拟仿真案例讲解

    虚拟仿真案例讲解这门课程包含了引擎的基础入门,并且讲解了制作虚拟仿真项目的制作流程,包括机械虚拟仿真拆装等制作方法,从模型到动画,再到脚本的编写,我们这里还讲解了可视化编程PLAYMAKER的使用方法,即使你不会编程,学完本期课程也会制作出虚拟仿真项目。

    805 人正在学习 去看看 王哲

      最近在看Unity3D的人物模型和动画。所以今天先说下人物的换装吧。相信大家都玩过网游吧,没有玩过的也相信见过,就是网游或者单机游戏里的人物会有更换服装,更换武器的功能。如果外形(mesh)是一样的,那么把贴图换下就好,但是如果外形不一样甚至骨骼都变了的话,就需要我们今天讨论的技术了。

  一.Unity3D里的3D模型

Unity3D里的基础模型是从3D工具中导出来的fbx文件。把fbx放在Assets目录下会自动生成一个Materials文件夹,里面会生成一个材质球。在Unity3D项目里的显示是这样

注意:Textrue需要另外添加进去。

         模型的主要文件就是那个蓝色的方块,前面两个(WGKS_wugang,WGKS_yaodai)这两个文件是模型的Mesh信息,一个是人物本身的,一个是腰带的,因为比较简单的模型,所以只有两个。下面的一坨是动作信息,动作是可以自己设置的,这个以后文章会讲。而紧接着的

“Bip001”这个是模型的骨骼信息,你拖到Scene里就能层层打开看到了。而WGKS_yaodai则是蒙皮信息的一些玩意。选择到它会在Inspector属性栏里看到SkinnedMeshRenderer组件,这里会看到它牵扯到的mesh,material,bone信息。

  

  二.换装方案1组合模型的显示和隐藏

  这个题目可能说的不合适,但是想不到更合适的了。我们的第一种换装的方法很简单,就是让模型或者Mesh进行显示和隐藏。就比如让人物穿两套衣服,显示第一套的时候隐藏第二套,显示第二套的时候显示第一套。

         例如这个模型:

         

这里有TBJ_SR_01和TBJ_SR_03两个SkinnedMeshRenderer.那么我们可以分别设置Active来设置是否可见。代码如下:

public GameObject clothA = null;
public GameObject clothB = null;

if (GUI.Button(new Rect(5, 5, 100, 50), "ClothA"))
{
    clothA.SetActive(true);
    clothB.SetActive(false);
}

if (GUI.Button(new Rect(5, 60, 100, 50), "ClothB"))
{
    clothA.SetActive(false);
    clothB.SetActive(true);
}


运行起来的效果如下:

ClothA

ClothB

      如果角色是换武器而不是换装备的话,原理是一样的,只不过我们要找到附属的武器GameObject然后设置让它显示和隐藏就可以了。

  三.换装方案2 替换Skin信息

      方案1的方法用起来比较简单,比较适合简单的模型替换,比如一个模型来来回回就两把武器的话,就可以用。但是如果一个模型有十来套衣服或者十几件装备的话,这么做会比较悲剧的,因为太占资源了,创建一个模型,牵扯的不用的东西也整进内存了,暴殄天物。

      所以我想能不能游戏运行的时候替换模型的Skin信息达到我们的目的。一开始我觉得,其实我们只需要把sharedmesh,material替换掉就行了。实验证明不行的。大家可以自己尝试下,Unity3D会报错,即使不报错模型也会变形的。模型中还有一块是骨骼没有动,所以我想尝试下如果把骨骼去掉能不能拿到mesh信息等,然后再替换。结果是从3DMAX里去掉骨骼导出的话,蒙皮信息也没有了。所以我确定了一点是,被换装的模型必须有骨架,而源Mesh信息中必须包含骨架信息,按照我的理解就是Mesh中保存着这个点依附的是哪个骨头,权重是多少等。

 

  这里我准备了两个模型:

            

带有完整的信息(骨头,动作,蒙皮信息等,只不过模型只有一个mesh而已)。

放在Scene的样子:

            

  我的目的是想让左边的那个模型读取右边模型的SkinnedMeshRenderer信息,然后赋值给自己。首先我把右边的模型除了骨架的所以资源赋值给一个prefab,然后加载prefab,读取信息给左边的模型。

  代码如下:

// 声明两个SkinnedMeshRenderer
public SkinnedMeshRenderer MyskinMeshRender = null;
public SkinnedMeshRenderer SrcSkinMeshRender = null;

//加载资源
Object Src = Resources.Load("Res/Materials2");
objSrc = Instantiate(Src) as GameObject;

//找到目标模型的SkinnedMeshRenderer
MyskinMeshRender = GameObject.Find("TBJ_SR_01").GetComponent<SkinnedMeshRenderer>();
//获取加载的SkinnedMeshRenderer
SrcSkinMeshRender = objSrc.GetComponent<SkinnedMeshRenderer>();
//获取目标模型的骨骼
Transform[] bonesMy = gameObject.GetComponentsInChildren<Transform>();
Debug.Log(bonesMy.Length);
//Transform[] bonesSrc = GameObject.Find("TBJ_SR_03").GetComponentsInChildren<Transform>();
//获得源SkinMesh中依附的骨骼信息
Transform[] bonesSrc = SrcSkinMeshRender.bones;
Debug.Log(bonesSrc.Length);

//根据源SkinMesh依附的骨骼信息重新排列目标骨骼
List<Transform> bones = new List<Transform>();
foreach (Transform boneS in bonesSrc)
{
    foreach (Transform boneM in bonesMy)
    {
        if (boneS != null && boneM != null)
        {
            if (boneS.name != boneM.name)
            {
                continue;
            }
            bones.Add(boneM);
        }
    }
}
MyskinMeshRender.bones = bones.ToArray();
MyskinMeshRender.sharedMesh = SrcSkinMeshRender.sharedMesh;
MyskinMeshRender.sharedMaterials =SrcSkinMeshRender.sharedMaterials;     


运行之后发现皮肤换了,而且动画信息没有丢失掉,也就是说还能动,但是有个奇葩的现象,人物是倒着的,如图:

  这个我分析后觉得是因为骨骼的问题。两个模型的骨骼有点不一样。因为我是读取了右边的模型的蒙皮信息对应的骨骼信息而重组了左边的骨骼,但是左边的动画信息还是按照原来的。虽然不是很清楚动画数据里面数据结构,但是我觉得就是记录的骨骼或者mesh或者当前模型的世界坐标的某个点对应的Mesh或者骨骼移动到哪个位置。

 

  接着我换了一个思路,用一个人物模型,一套骨骼,一套动作,不过蒙皮信息有两个,导出fbx。

如图:

 

  然后在3DMax里面导出一个没有蒙皮信息只有骨骼和动作的fbx,如图:

  然后添加脚本,代码如下: 

//声明两个SkinMeshRender 声明为public是为了方便观察 真正需要private
public SkinnedMeshRenderer MyskinMeshRender = null;
public SkinnedMeshRenderer SrcSkinMeshRender = null;

//添加SkinnedMeshRenderer组件
MyskinMeshRender = gameObject.AddComponent<SkinnedMeshRenderer>();
	
if (Input.GetKeyDown(KeyCode.Z))
{
    GameObject obj = GameObject.Find("TBJ_SR_01") as GameObject;
    SrcSkinMeshRender = GameObject.Find("TBJ_SR_01").GetComponent<SkinnedMeshRenderer>();

    Transform[] bonesMy = gameObject.GetComponentsInChildren<Transform>();
    Transform[] bonesSrc = SrcSkinMeshRender.bones;

    List<Transform> bones = new List<Transform>();
    foreach (Transform boneM in bonesSrc)
    {
        foreach (Transform boneS in bonesMy)
        {
            if (boneM != null && boneS != null)
            {
                if (boneM.name != boneS.name)
                {
                    continue;
                }
                bones.Add(boneS);
            }
        }
    }
    MyskinMeshRender.bones = bones.ToArray();
    MyskinMeshRender.sharedMesh = SrcSkinMeshRender.sharedMesh;
    MyskinMeshRender.sharedMaterials = SrcSkinMeshRender.sharedMaterials;
}


 

  运行,结果没有问题了。如图:

  

  按下Z之后:

  而且动作保留。

 

  总结:

  虽然这个例子不是很经典,也存在些许问题,希望大家指出问题,共同进步。在添加skinnedMeshRenderer组件的时候有个问题,如果模型选择Generic的时候,Unity3D会挂掉。

这个例子可以运用到骨骼和动作基本一样,但是外形不同的模型中,减少游戏中的资源重复占用。网上还有个更经典的例子,运用的是Character Customization 包,运用的原理是一样的,这里给大家看下运行结果:

  

      

  这个模型是由各个部分的mesh组成,而我的例子是用的一个mesh。在复杂的模型中,我们可以查找子组件的SkinnedMeshRenderer,然后进行操作就可以了。想换哪里就替换哪里的SkinnedMeshRenderer的信息,更新对应的骨骼列表和组合一下网格就行了。

List<CombineInstance> combineInstances = new List<CombineInstance>();
CombineInstance ci = new CombineInstance();
ci.mesh = SrcSkinMeshRender.sharedMesh;
combineInstances.Add(ci);


         还有一个想法是这样的,源资源中是否需要骨头。我发现重组骨头结构的时候其实只是读取了骨头的列表信息,所以我觉得可以写成一个配置信息,直接读取配置信息来重组。这个想法还没有得到验证,等有时间的吧。

 

 

2019-10-27 00:44:50 weixin_39820793 阅读数 81
  • 虚拟仿真案例讲解

    虚拟仿真案例讲解这门课程包含了引擎的基础入门,并且讲解了制作虚拟仿真项目的制作流程,包括机械虚拟仿真拆装等制作方法,从模型到动画,再到脚本的编写,我们这里还讲解了可视化编程PLAYMAKER的使用方法,即使你不会编程,学完本期课程也会制作出虚拟仿真项目。

    805 人正在学习 去看看 王哲

游戏设计要求:

  • 创建一个地图和若干巡逻兵(使用动画);
  • 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
  • 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
  • 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
  • 失去玩家目标后,继续巡逻;
  • 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
  • 程序设计要求:
    • 必须使用订阅与发布模式传消息
    • 工厂模式生产巡逻兵

实现的代码地址为:Github

视频的地址为:腾讯视频

首先是找到巡逻兵的资源,我是在Asset Store上找到的资源,大家自己找自己喜欢的就好了。

然后进行具体的代码实现,首先在实现好对各个物体的具体控制类之后,然后就是实现GameEventManager类,也是来进行订阅者模式的代码。

public class GameEventManager : MonoBehaviour
{
    public delegate void EscapeEvent(GameObject patrol);
    public static event EscapeEvent OnGoalLost;
    public delegate void FollowEvent(GameObject patrol);
    public static event FollowEvent OnFollowing;
    public delegate void GameOverEvent();
    public static event GameOverEvent GameOver;
    public delegate void WinEvent();
    public static event WinEvent Win;
    public void PlayerEscape(GameObject patrol) {
        if (OnGoalLost != null) {
            OnGoalLost(patrol);
        }
    }
    public void FollowPlayer(GameObject patrol) {
        if (OnFollowing != null) {
            OnFollowing(patrol);
        }
    }
    public void OnPlayerCatched() {
        if (GameOver != null) {
            GameOver();
        }
    }
    public void TimeIsUP() {
        if (Win != null) {
            Win();
        } 
    }
}


然后这次也有一些比较需要实现的内容,镜头跟随的内容,没有这个的话,游戏进行的体验不是非常的好。

public class CameraFollowAction : MonoBehaviour
{
    public GameObject player;            
    public float smothing = 5f;         
    Vector3 offset;                     

    void Start() {
        offset = new Vector3(0, 5, -5);
    }

    void FixedUpdate() {
        Vector3 target = player.transform.position + offset;
        transform.position = Vector3.Lerp(transform.position, target, smothing * Time.deltaTime);
    }
}
2018-10-09 16:02:51 qq_42850166 阅读数 2938
  • 虚拟仿真案例讲解

    虚拟仿真案例讲解这门课程包含了引擎的基础入门,并且讲解了制作虚拟仿真项目的制作流程,包括机械虚拟仿真拆装等制作方法,从模型到动画,再到脚本的编写,我们这里还讲解了可视化编程PLAYMAKER的使用方法,即使你不会编程,学完本期课程也会制作出虚拟仿真项目。

    805 人正在学习 去看看 王哲

Unity3D中下载的模型动画自带位移,就是说没有写任何脚本,在动画控制器上添加动画,模型会自己移动,在模型Animator组件的属性上有一个Apply Root Motion的值,将勾选去掉的话就不会再自带位移了,勾上就是模型自带位移。
在这里插入图片描述

2019-10-29 22:42:49 Metamorphosis_Anja 阅读数 31
  • 虚拟仿真案例讲解

    虚拟仿真案例讲解这门课程包含了引擎的基础入门,并且讲解了制作虚拟仿真项目的制作流程,包括机械虚拟仿真拆装等制作方法,从模型到动画,再到脚本的编写,我们这里还讲解了可视化编程PLAYMAKER的使用方法,即使你不会编程,学完本期课程也会制作出虚拟仿真项目。

    805 人正在学习 去看看 王哲

智能巡逻兵

  • 提交要求:
  • 游戏设计要求:
    • 创建一个地图和若干巡逻兵(使用动画);
    • 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
    • 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
    • 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
    • 失去玩家目标后,继续巡逻;
    • 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
  • 程序设计要求:
    • 必须使用订阅与发布模式传消息
      • subject:OnLostGoal
      • Publisher: GameEventManager
      • Subscriber: FirstSceneController
    • 工厂模式生产巡逻兵
订阅与发布模式

在“发布者-订阅者”模式中,称为发布者的消息发送者不会将消息编程为直接发送给称为订阅者的特定接收者。这意味着发布者和订阅者不知道彼此的存在。存在第三个组件,称为代理或消息代理或事件总线,它由发布者和订阅者都知道,它过滤所有传入的消息并相应地分发它们。换句话说,pub-sub是用于在不同系统组件之间传递消息的模式,而这些组件不知道关于彼此身份的任何信息。经纪人如何过滤所有消息?实际上,有几个消息过滤过程。最常用的方法有:基于主题和基于内容的。

在这里插入图片描述

#### UML

在之前的UML图基础上修改,主要应用订阅与发布模式。定义一个发布的manager来发布消息,在firstscencecontroller里接收消息。

在这里插入图片描述

玩家

UserGUI

玩家使用带动画的预制体,用方向键来输入。

void Update()
    {
        if (Input.GetKey(KeyCode.UpArrow)) {
            action.MovePlayer(Diretion.UP);
        }
        if (Input.GetKey(KeyCode.DownArrow)) {
            action.MovePlayer(Diretion.DOWN);
        }
        if (Input.GetKey(KeyCode.LeftArrow)) {
            action.MovePlayer(Diretion.LEFT);
        }
        if (Input.GetKey(KeyCode.RightArrow)) {
            action.MovePlayer(Diretion.RIGHT);
        }
    }
FirstSceneController

根据输入控制小人的前进和转向,并做出跑步的动作。

public void MovePlayer(int dir)
    {
        if(!game_over) {
            if (dir == 0 || dir == 1 || dir == -1 || dir == 2) {
                player.GetComponent<Animator>().SetBool("run", true);
            } else {
                player.GetComponent<Animator>().SetBool("run", false);
            }
            player.transform.rotation = Quaternion.Euler(new Vector3(0, dir * 90, 0));
        		switch (dir) {
            	case Diretion.UP:
                player.transform.position += new Vector3(0, 0, 0.1f);
                break;
            	case Diretion.DOWN:
                player.transform.position += new Vector3(0, 0, -0.1f);
                break;
            	case Diretion.LEFT:
                player.transform.position += new Vector3(-0.1f, 0, 0);
                break;
            	case Diretion.RIGHT:
                player.transform.position += new Vector3(0.1f, 0, 0);
                break;
        		}
        
    }

有一个相机是跟随玩家的。这与我之前在牧师与魔鬼中实现的完全一样,就不赘述了。

main_camera.GetComponent<CameraFlow>().follow = player;

巡逻兵

PropFactory

巡逻兵使用工厂模式来生产,九个巡逻兵分别出生在不同的位置。

public List<GameObject> GetPatrols()
{
  int[] pos_x = { -6, 4, 13 };
  int[] pos_z = { -4, 6, -13 };
  int index = 0;
  //生成不同的巡逻兵初始位置
  for(int i=0;i < 3;i++) {
		for(int j=0;j < 3;j++) {
			vec[index] = new Vector3(pos_x[i], 0, pos_z[j]);
			index++;
		}
	}
	for(int i=0; i < 9; i++) {
		patrol = Instantiate(Resources.Load<GameObject>("Prefabs/Patrol"));
		patrol.transform.position = vec[i];
		patrol.GetComponent<PatrolData>().sign = i + 1;
		patrol.GetComponent<PatrolData>().start_position = vec[i];
		used.Add(patrol);
  }  
	return used;
}
GoPatrolAction

巡逻兵在没有侦查玩家时到按照四边形来移动。

void Gopatrol() {
	if (move_sign) {
//不需要转向则设定一个目的地,按照矩形移动
	switch (dirction) {
		case Dirction.EAST:
			pos_x -= move_length;
			break;
		case Dirction.NORTH:
			pos_z += move_length;
			break;
		case Dirction.WEST:
			pos_x += move_length;
			break;
		case Dirction.SOUTH:
			pos_z -= move_length;
			break;
		}
		move_sign = false;
	}
	this.transform.LookAt(new Vector3(pos_x, 0, pos_z));
	float distance = Vector3.Distance(transform.position, new Vector3(pos_x, 0, pos_z));
//距离0.9是保证巡逻兵不会转向而到墙里
	if (distance > 0.9) {
		transform.position = Vector3.MoveTowards(this.transform.position, new Vector3(pos_x, 0, pos_z), move_speed * Time.deltaTime);
	} else {//按照顺时针方向来改变
		dirction = dirction + 1;
			if(dirction > Dirction.SOUTH) {
				dirction = Dirction.EAST;
			}
			move_sign = true;
		}
	}
}
PatrolFollowAction

侦查到之后跟随玩家。

void Follow() {
		transform.position = Vector3.MoveTowards(this.transform.position, player.transform.position, speed * Time.deltaTime);
		this.transform.LookAt(player.transform.position);
}
SSActionManager

这两种状态之间的切换是通过callback来实现的。通过回调函数中不同的intParam,可以判断巡逻兵的不同运动情况。

public void SSActionEvent(SSAction source, int intParam = 0, GameObject objectParam = null) {
	if(intParam == 0) {
//侦查兵跟随玩家
		PatrolFollowAction follow = PatrolFollowAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().player);
		this.RunAction(objectParam, follow, this);
	} else {
 //侦察兵按照初始位置开始继续巡逻
		GoPatrolAction move = GoPatrolAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().start_position);
		this.RunAction(objectParam, move, this);
	//玩家逃脱,发送消息
		Singleton<GameEventManager>.Instance.PlayerEscape();
	}
}

地图

地图的每个格子都有自己的检测器,能判断玩家是否进入了自己的格子。

public class AreaCollide : MonoBehaviour
{
    public int sign = 0;
    FirstSceneController sceneController;
    private void Start()
    {
        sceneController = SSDirector.GetInstance().CurrentScenceController as FirstSceneController;
    }
    void OnTriggerEnter(Collider collider)
    {
        //标记玩家进入自己的区域
        if (collider.gameObject.tag == "Player")
        {
            sceneController.wall_sign = sign;
        }
    }
}

订阅与发布模式

GameEventManager-Publisher

当其他类发生改变的时候,就可以通过这个类来发布消息。

public class GameEventManager : MonoBehaviour
{
    //分数变化
    public delegate void ScoreEvent();
    public static event ScoreEvent ScoreChange;
    //游戏结束变化
    public delegate void GameoverEvent();
    public static event GameoverEvent GameoverChange;
  
    //玩家逃脱
    public void PlayerEscape()
    {
        if (ScoreChange != null)
        {
            ScoreChange();
        }
    }
    //玩家被捕
    public void PlayerGameover()
    {
        if (GameoverChange != null)
        {
            GameoverChange();
        }
    }
}
FirstSceneController

消息的订阅者是这个类,当事件发生,场景控制器会收到来自事件管理器的消息。

void OnEnable()
{
    //注册事件
    GameEventManager.ScoreChange += AddScore;
    GameEventManager.GameoverChange += Gameover;
}
void OnDisable()
{
    //取消注册事件
    GameEventManager.ScoreChange -= AddScore;
    GameEventManager.GameoverChange -= Gameover;
}
void AddScore()
{
    recorder.AddScore();
}
void Gameover()
{
    game_over = true;
    patrol_factory.StopPatrol();
    action_manager.DestroyAllAction();
}
PlayerCollide-one of subject

当玩家碰撞到巡逻兵,就会发送游戏结束的消息。

void OnCollisionEnter(Collision other) {
        //当玩家与巡逻兵相撞
		if (other.gameObject.tag == "Player") {
        other.gameObject.GetComponent<Animator>().SetTrigger("death");
        this.GetComponent<Animator>().SetTrigger("shoot");
            //游戏结束,发布消息
        Singleton<GameEventManager>.Instance.PlayerGameover();
    }
}

至此就完成了整个项目,因为觉得学长项目里的预置挺可爱的,就直接用了,一开始发现计分的时候有点问题,应该是检测器检测的边缘问题,后来修改了参数,解决了,逻辑上还是没有问题的。整个项目其实挺复杂的,也确实是参考学习了很多,也有收获,很感谢。

在这里插入图片描述

参考博客

项目仓库

视频链接

2019-12-04 18:15:00 u013628121 阅读数 30
  • 虚拟仿真案例讲解

    虚拟仿真案例讲解这门课程包含了引擎的基础入门,并且讲解了制作虚拟仿真项目的制作流程,包括机械虚拟仿真拆装等制作方法,从模型到动画,再到脚本的编写,我们这里还讲解了可视化编程PLAYMAKER的使用方法,即使你不会编程,学完本期课程也会制作出虚拟仿真项目。

    805 人正在学习 去看看 王哲

为模型动画片段上设置动画事件

反正麻烦,u3d有两种动画播放系统Animation\Animator,

没有办法预览
有的模型和动画是分开的,这时点击动画就没有办法播放,因为它找不到对应的模型.

只读动画片段 改 可写
大多数模型动画片段是只读的,这时候就没办法编辑动画.从而设置动画事件. 
所以需要:在Project窗口上选中动画片段->按CTRL+D ,
这样就会复制一个新的动画片段就在当前目录,这个新的动画片段才是可编辑的.

设置动画事件方法1, 缺点:动画片段要可写的
在Project窗口上选中动画片段->
点击Inspector窗口上的Open按钮->
在Animation窗口时间栏上选择合适的时间(事件触发点)处右键->
点击Add Animation Event 按钮

设置动画事件方法2  通过代码

       AnimationClip gunMove = animation.GetClip("left");//动画名
        AnimationEvent aevent = new AnimationEvent();
        aevent.functionName = "setInfo"; //方法名
        aevent.time = 1.05f;
        aevent.objectReferenceParameter=GameObject; // object类型参数 
        // aevent.intParameter = 9;   // int类型参数  
        gunMove.AddEvent(aevent);


        
设置动画事件方法3 通过代码,监听这个播放进度值,用回调写事件
Animation["anim"].normalizedTime 的值始终都是从0f到0.97f的,不会到1f.

Animator 

    public float AnimPlayNormalizedTime(string animName)
    {
        var infos = Animator.GetCurrentAnimatorStateInfo(0);
        var str = "Base Layer." + animName;

        if (infos.IsName(str) )
        {
            return infos.normalizedTime;
        }
        else
        {
            Debug.LogError("找不到 正在播放的动画");
        }
        return 0;
    }

 

Unity3d 换装 之 模型动画分离

博文 来自: qq_43667944

Unity3d模型问题

阅读数 369

没有更多推荐了,返回首页