GameRes游资网

 找回密码
 立即注册
查看: 4376|回复: 9

[经验心得]提炼出游戏设计中真实的需求是高效开发的基础

[复制链接]
发表于 2016-12-3 11:36:49 | 显示全部楼层 |阅读模式
  策划的文案总是在说谎

  在项目开发的时候,策划会发现一个有趣的问题——程序做的功能总是不那么顺应自己的设计。也由此推论出了一个道理——好的程序员是懂的策划心事的人——因为他们总是能做出看起来和策划案需求一样的东西。但是藏在这个问题背后的更深层次的问题是什么?是程序的阅读能力不行还是策划的表达能力不足呢?事实上问题的关键在于——策划写的文案总是在撒谎。并不是策划真心想玩弄谁,也不是因为策划想卖弄文笔而最终表达能力不足。唯一的原因就是——文字描述的内容是表面现象,而非真实的需求。下面我们就来举一段实际例子来深入分析这个合作中的问题,策划设计了一个玩法(此处我们不讨论以下举例中的玩法是否OK):

  这是一个横板的动作射击游戏,玩法类似于魂斗罗。游戏中玩家可以收集4种不同的武器,每一关开始前,玩家都可以选择携带2把武器进入关卡,一旦进入关卡,就只能在选择的2把武器之间做切换,而无法选择另2把武器。

  武器
  
  说明
  
  弩箭
  
  最基础的武器,直线发出弩箭,攻击路线上命中的第一个敌人,造成单体伤害。
  
  投掷发射器
  
  投出抛物线爆弹,爆弹落地后会弹跳2次,当2次弹跳结束后或者轨迹中碰到敌人时,爆弹就会爆炸,对范围内的所有敌人造成伤害。
  
  自跟踪武器
  
  可以发射出锁定目标的子弹,自动锁定最近的敌人,子弹一旦发出就会追踪目标而去,直到击中目标,对目标造成较大单体伤害。
  
  火箭炮
  
  重型武器,在较长的蓄力(表现为填装炮弹并瞄准)后,发射出火箭炮,火箭炮慢速向前飞行,击中路线上的第一个敌人或撞上障碍物后会爆炸,对大范围内的敌人造成伤害。
  

  分析背后真正的需求

  我们可以看到,这是一个标准的国游策写出来的策划案,最多可能产生的不同就是“文字戏法”,但是核心内容还是如此。在这里,我们就会简单的分析出游戏的玩法:

t1.jpg


  当我们列出这个脑图的时候,我们看似已经想的非常全面了,不仅仅是对已知的需求进行了分析,还追加了可能延伸出来的其他需求。这个时候我们就会直接开始动手进入下一环工作——设计游戏中的表格,我们的游戏中会有一个武器表,表结构为:

  表项
  
  作用
  
  Id
  
  武器的id,唯一的标识。
  
  武器名称
  
  武器名称。
  
  icon
  
  武器图标的资源文件。
  
  说明
  
  武器的说明文字。
  
  子弹数量
  
  武器可以装载的最大子弹数量,-1代表子弹无限。
  
  子弹轨迹
  
  子弹发射出去的轨迹:0=直线,1=抛物线,2=追踪。
  
  子弹速度
  
  子弹飞行的速度。
  
  子弹参数
  
  如果子弹是抛物线,那么这个参数就是顶点高度;如果子弹是追踪的,这个就是追踪规则(blabla大段)。
  
  伤害范围
  
  子弹打中敌人后的伤害范围,0代表单体目标,>0代表子弹的爆炸半径。
  
  伤害系数
  
  子弹的杀伤力,用于带到伤害计算公式中计算伤害。
  
  聚气时间
  
  开枪时候需要预先聚气的时间,此时角色会做出举枪聚气的动作,待聚气时间结束后,子弹发射出去,玩家在聚气时间是不能操作角色的。
  
  冷却时间
  
  子弹发射出去多久以后可以再次发射。
  
  武器图形
  
  武器拿在手里的图形。
  
  子弹图形
  
  子弹的图形。
  
  命中图形
  
  子弹击中目标时,播放的图形效果,主要用于爆炸。
  
  发射音效
  
  子弹发射出来的音效。
  
  命中音效
  
  子弹命中目标时候的音效。
  
  被击表现
  
  被子弹击中的敌人做出的动作名。
  
  最大等级
  
  武器的最大等级,强化武器最终不会超过这个等级。
  
  价格
  
  武器卖店的价格。
  
  稀有程度
  
  武器的稀有程度,0=白色,1=绿色,2=蓝色,3=紫色,4=橙色。
  

  设计到这里,我们大概可以满意的说,武器系统已经设计完成了,程序实现了这个表以后,再把对应功能一做,游戏中的武器系统就算完成了。我们也看到了分析这个需求的人是具有一定经验的,能够分析出可能扩展的玩法非常棒!虽然在设计表的时候发生了Magic Number、二义性名词等错误,但是因为有了这样一个表,策划可以无限制的设计武器。

  但是!上面的分析全都是错的!这正是一个典型的被策划善意欺骗的结果!

  乍一看,我们要做的就是一个简单的武器系统,武器在战斗中可以发射出子弹,还能强化。但事实上,这个需求真正的概念是:

t2.jpg


  所以藏在这个设计背后的,并不是简简单单的一个武器数据就能满足的需求。我们深入思考几个问题:

  1、子弹是不是必须是武器发出的?会不会地图上某个点也发出类似的子弹?

  2、武器发出的子弹是不是一定的?我有没有可能改装某件武器之后,比如改装弩箭之后,他可以发出榴弹炮来了?

  3、子弹是不是可以改造?弩箭可不可以发射出火矢、毒矢、冰矢?亦或者连续发射出不同属性的箭矢?

  4、子弹的效果仅仅只是伤害吗?我能不能强化之后发生变化?比如跟踪弹在命中之后产生爆炸?或者更进一步跟踪弹击中目标之后散射出一排箭矢,其中一些是爆炸箭矢,命中敌人之后会产生爆炸伤害?

  5、 ……

  当我们进一步深入思考扩展性的时候,就会发现很多有趣的设定,这些设定不仅可以让游戏变得更丰富,也同时可以埋下收费点。但是如果我们按照之前的那种分析,是不是我们就做不成了?当然我相信也不是,因为总有笨办法的,比如你一种武器可以扩展出几十种子弹,就让策划填写几十条数据……但是这样的设计,并不是一个好的设计。

  当我们通过进一步思考总结后,我们再来看上面这个脑图的分析,事实上,我们游戏中会有若干个数据结构:

t3.jpg


  Model与Object

  在上面这个脑图里面,我们会看到好几处用到了Model和Object(或者结尾Obj的)。这其实是设计的一个陷阱——我们通常把静态数据(来自策划填写的)和动态数据混为一谈。在这里,Model就是静态的数据,称为“读表数据”,是策划从通过一些方式(如填表、编辑器)最后转化出来的数据,这些数据始终就是那样的,不会有任何变化。Object则是随着游戏进程变化会逐渐发生变化的数据。

  Object和Model的关系是:Model是产生Object的依据,但是Model无法代表Object。

  数据结构

  1、武器Model(Weapon):这是策划需要配置的武器数据。

  数据
  
  作用
  
  Id
  
  String,武器模板(或者说单种类武器)的唯一标示。
  
  名称
  
  String,武器名称。
  
  说明
  
  String,武器说明。
  
  Icon
  
  String,武器icon文件名。
  
  造型
  
  String,武器拿在手里的造型文件夹名。
  
  冷却时间
  
  Int,单位毫秒,武器使用的冷却时间。
  
  Timeline
  
  String,使用武器(即开火)之后,角色会做出的动作,在这个动作中,会负责决策发射出的子弹等信息。
  
  OnFire
  
  String,武器发射后的回调函数,在武器发射开始执行Timeline的同时,会给出一个回调函数,调用策划编写的逻辑脚本,程序提供的回调参数:
  l  WeaponObj*:使用的武器。
  l  Character*:使用武器的人。
  

  1、武器Object(WeaponObj):这是在游戏运行中,结合当前情况产生出来的数据,玩家拥有的武器等都属于WeaponObj,而不是Weapon。

  属性
  
  说明
  
  UniqueId
  
  String,唯一id,在这个世界上,任何一把武器的实体都是唯一的,即使他们采用同一个模板生成。
  
  Model
  
  Weapon,非指针,而是克隆一个Model,即武器的模板信息,改装等玩法可能改变这个武器的信息。
  
  子弹
  
  Hash<String,  AoE>和Hash<String, Bullet>,Timeline中的子弹发射点作为key,Value就是要发出的子弹对象的模板。
  
  等级
  
  Int,武器当前的等级,当然还会有很多关于武器强化的属性,这里就列几个说明一下问题。包括稀有程度(如果可以被提升),星级(如果可以被提升)都会放在这里,如果不能提升的,  比如“AK47不论怎么升级都是蓝色的”,那“颜色”就应该放到Model中去。
  
  价格
  
  Int,当前武器的卖家,由于强化之后武器的价格会变高,因此他属于Obj而不是Model,Model中顶多可以有一个参数用于获取这个值。
  

  2、角色动作线(Timeline):角色使用枪支、受到攻击等,在游戏中可以归纳一个角色任何的一个动作都是一个Timeline。Timeline有一个宿主,这个宿主就是播放这个Timeline的角色。Timeline上可能存在若干个事件点,每个点可能的事件包括:

  事件类型
  
  说明
  
  播放动作
  
  让Timeline的宿主开始做一个动作1次,需要的参数:
  l  动作名:播放什么动作。
  l  保持:保持某一段的动作(用于死亡动作等)。
  
  场景特效
  
  在场景中指定位置播放一个视觉特效,需要的参数:
  l  特效名:播放什么特效。
  l  位置:在什么位置播放。
  l  循环:1次还是始终。
  l  时间:如果不是1次的,那么播放多久结束。
  
  角色特效
  
  在宿主身上播放一个视觉特效,需要的参数。
  l  特效名:播放什么特效。
  l  绑点:角色身上某个播放这个特效的点。
  l  循环:1次还是始终。
  l  时间:如果不是1次的,那么播放多久结束。
  
  产生AoE
  
  产生出AoEObj,即非锁定类子弹,需要的参数:
  l  AoEId:要产生的AoEObj,他的Model的Id。这个会由Timeline产生的时候决策,是从某个WeaponObj的子弹Hash表中获得,还是直接走读取的值,这是一个有点Magic的用法。
  l  位置:AoEObj初始坐标。
  l  生命周期:AoEObj存在时长。
  l  Tween:AoEObj的运动轨迹和变化方式,不仅仅是坐标的变化,还可以设置如范围等的变化。
  
  产生Bullet
  
  产生出bulletObj,即锁定类子弹,需要的参数:
  l  bulletId:创建这个bulletObj所用的Model的id,和AoEId一样有个Magic用法(我都不好意思再写了)。
  l  位置:bulletObj产生时候的位置。
  l  生命周期:bulletObj最多存在多久。
  l  速度:这发bulletObj的速度。
  l  目标:选择目标的规则,返回一个Character*。
  
  开始僵直
  
  从这个时间点开始,角色将进入僵直,所谓僵直就是不再接受操作指令,直到做其他动作(进入另一个Timeline了,比如受伤、死亡)。无参数。
  
  结束僵直
  
  从这个时间点开始,角色将离开僵直。无参数。
  
  其他
  
  以上只是一些举例,根据实际需要,可以在Timeline中加入更多的动作类型,比如给宿主添加buff等,这个看项目的需要决定,而针对本文中的上述范例,是没有这个需求的。
  

  3、非跟踪子弹Model(AoE):非跟踪的子弹,实际上是通过AoE来实现的,包括子弹命中后产生的爆炸也属于AoE。我们可以在AoE的2个回调函数中写关于伤害等效果的逻辑。

  属性
  
  说明
  
  Id
  
  String,AoE模板的id,每一个模板都是唯一的。
  
  视觉效果
  
  String,位于AoEObj位置上要播放的特效,可以没有即不播放。
  
  循环播放
  
  Boolean,是否要反复循环的播放这个特效,如果没有特效这个就没意义。
  
  范围
  
  Int,AoE的初始有效范围,实际有效范围以AoEObj为准,由于这个游戏的设定,因此范围都是圆形的,这里的数据是圆形的半径。
  
  碰角色结束
  
  Enum,这个AoEObj在碰撞到任何角色时是否会结束?有几个风格:
  1,  任何碰撞角色都不会结束。
  2,  碰撞到Caster同阵营角色结束。
  3,  碰撞到非Caster同阵营角色结束。
  4,  碰撞任何角色结束。
  
  碰障碍结束
  
  Boolean,这个AoEObj在碰撞任何地形的时候是否会结束。
  
  OnEnter
  
  String,当有角色进入AoEObj的瞬间触发的回调,一个角色在一个AoEObj的生命周期内,只能触发一次这个回调。需要的参数:
  l  AoEObj:AoEObj*,这个AoEObj。
  l  EnterCharacter:Character*,进入AoE范围内的这个角色。
  l  CharacterInRange:Array<Character*>,所有当前已经在AoEObj范围内的角色(不包含EnterCharacter)。
  l  DesignParam:Array<Int>,策划配置的一些int值。
  
  OnDestory
  
  String,当AoE结束时执行的函数回调。需要的参数:
  l  AoEObj:AoEObj*,这个AoEObj。
  l  CharacterInRange:Array<Character*>,所有此时在AoEObj范围内的角色。
  l  DesignParam:Array<Int>,策划配置的一些int值。
  

  4、非跟踪子弹Object(AoEObj):在游戏中的实体,根据实际状况产生。

  属性
  
  说明
  
  UniqueId
  
  String,每一个存在于世界上的子弹都是独一无二的。
  
  Model
  
  AoE*,产生这个子弹的Model,由此获取基础信息。
  
  释放者
  
  Character*,这个子弹的创建者,可能是Null,比如来自地形。
  
  位置
  
  Position,当前这个时间点上,这个子弹的位置,对于一个射击游戏而言,大多子弹的位置应该是经常变动的。
  
  范围
  
  Int,子弹当前的逻辑有效范围。
  
  生命周期
  
  Int,还有多久AoEObj就将结束。
  
  其他
  
  根据需要还可以添加一些其他的信息,包括可以给策划一个Param来记录一些动态的数据等。
  

  5、跟踪子弹Model(Bullet):会跟踪的子弹,这种子弹只会追击目标,直到命中、自身生命周期结束或者目标消失(或其他条件比如死亡)之后会结束。

  属性
  
  说明
  
  Id
  
  String,子弹模板的Id,唯一的标识。
  
  造型
  
  String,子弹的图形文件。
  
  范围
  
  Int,子弹的初始有效范围,同样是一个圆形,所以这代表了半径。
  
  碰障碍结束
  
  Boolean,子弹是否会因为碰撞任何障碍物而结束。
  
  OnHit
  
  String,当子弹命中了追踪的目标后执行的回调函数,如造成伤害,产生爆炸等逻辑都编写在这个脚本中。只有子弹真的命中了目标(或者碰障碍而结束的时候)才会进入这个回调,如果子弹提前消失就不会进入。需要的参数:
  BulletObj:BulletObj*,这个子弹。
  TargetCharacter:Character*,被击中的角色,可能是Null,因为存在碰撞障碍物结束的可能性。
  DesignParams:Array<int>,策划填写的一些int值。
  

  6、跟踪子弹Object(BulletObj):跟踪子弹在游戏中的实体。

  属性
  
  说明
  
  UniqueId
  
  String,世界上任何一个子弹都是唯一的。
  
  Model
  
  Bullet*,创建这个Obj的模板。
  
  释放者
  
  Character*,释放这个子弹的目标,可能是Null,比如GM指令创建的。
  
  追踪目标
  
  Character*,子弹追踪的目标,如果这个目标不存在了,子弹也就结束了
  
  位置
  
  Position,子弹在这一时刻所在的世界位置。
  
  范围
  
  Int,子弹当前的有效范围。
  
  生命周期
  
  Int,子弹剩余的时间,如果生命周期小于等于0,那么子弹也就销毁了,不会进入OnHit回调。
  

  玩法相关数据

  到这里,所有跟武器有关的基础玩法的数据都有了。可能会有一个疑惑——那么假如我游戏中有合成武器的玩法,上述数据结构中并没有任何相关的信息,是不是漏了什么?或者说我应该把它们加在哪儿?合成这个例子是典型的、常见的。

  我们可能会有这样一个玩法需求——可以用多种材料或者武器合成一个武器。

  我们直接跳过被欺骗的抽象,来看背后真实的需求:

t4.jpg


  在我们游戏中可能会同时存在这两种合成方式,虽然可能早期策划并不这么考虑,但是随着项目的推进,运营的介入,这些都可能会成为最终的需求。因此我们要考虑到的是一个武器,可能会有多个“配方”来合成他。所以,我们需要设计的结构是:

  数据
  
  说明
  
  id
  
  String,每一个配方需要独特的标识。
  
  产出结果
  
  Weapon,最后产出的结果的Model(但是产出的还是WeaponObj,是由这个Model为依据产生的)。
  
  材料
  
  Array<ItemObj>,需要的材料的。
  
  资源
  
  Array<Resource>,需要的资源,如金币,石油等等。
  

  数据中,只有id是唯一的,意味着产出结果可以相同,因此“同一个武器会有多种合成方式”的需求是满足的。但是这里会延伸出一个问题——为什么这个要独立建一个数据?而不把它放在Weapon下,既然我是产生这个Weapon,那么当然产生他的“零件”,也属于他了。这里有一个非常核心的原因——逻辑依赖性

  单向依赖图

  我们在研发游戏的过程中,往往忽略了很多软件工程最重要的一步——建立单向依赖图(DAG,也称单环依赖图)。这往往是因为游戏策划或者程序员脑子里都是浆糊,所以在设计数据和结构的时候,并没有一个依赖关系的思路,最终导致做出来的东西是高耦合性的,至于高耦合有多危险,这个我就不多说了。

  在上面这个问题中,我之所以把合成玩法的数据提出来,就是因为依赖性问题——在这个游戏中,武器合成玩法依赖于武器,而(或者说也应该“因此”)武器不依赖于武器合成。你可以简单的理解为:游戏在没有武器合成玩法的情况下并不影响有武器,但是游戏在没有游戏的情况下就不可能有武器合成玩法,因此武器合成玩法依赖于武器,而武器不依赖于武器合成玩法,假如我们强行让武器也依赖于武器合成玩法,他们互相依赖,就形成了一个耦合,这是在设计任何玩法的时候都必须避免的。

  因此当我们设计玩法的时候,需要设计一个单向依赖图(而不要去想设计一个毫无合理性可言的UML,至于具体喷UML的,这就不该是本篇的内容了),在这个游戏系统中,单向依赖关系是:

t5.jpg


  每个玩法和元素之间的关系,都是单向依赖的,不会因为删除或者改动上层的内容,而使得下层的内容需要作出调整,比如改变了武器合成玩法的规则,产生一些武器合成的数据要改动,我们只需要改动武器合成的数据,而不需要(也本不应该需要)改动武器数据结构。

  从数据结构出发双向发展

  说到这里,你可能觉得这样的工作有一个问题——策划有太多的数据表要处理。事实上,这是另外一个问题,我是这么来归纳从策划填写的数据到游戏中的数据的:

t6.jpg


  在上图中有3个元素:

  1、Input:即策划填写的数据表。

  2、Data:即本文上述分析的一些数据结构。

  3、Logic:即游戏中使用的逻辑数据结构,其中一些恰好“长得和Data中对应部分一样”。

  我们通常的工作中,会直接把策划填写的数据当做最后逻辑用的数据来处理,因此建立了一张几乎无法维护的数据表,当然这个无法维护不仅仅是因为直接使用,还因为上述分析的各种问题。这样的工作中,我们漏掉了最终要的一环——Data的分析,也正是本篇上述范例中最大笔墨所做的内容。

  为什么我们需要一个这样的Data?因为Data才是被研发真正依赖的东西:

t7.jpg

  我们可以看到,在这个依赖关系下,我们只需要在系统设计的时候定出这个Data,就可以分头开始工作了,程序根据这个Data来coding逻辑,而策划设计一种input方式来产出这个data,程序本不应该坐等策划先配完表(至少配一个测试数据)才能工作,程序的工作并不应该依赖于策划的工作。

  而作为策划,我们最关注的还是input的环节,我们看到上面的实例中,有这么多要我们去填写的数据,我们真的需要配这么多表吗?回答可以是no,回到最初我们的需求,那个带有欺骗性的需求,我们可以用最好的语法来填写这个表,这个表只有一列:

b.jpg


  当策划填写好这些数据后,通过工具转换,就能产生出我们上述需要的Data:

t8.jpg


  至于如何将这一句话提炼分析成4个文件,这是文件转化工具的工作,当然你别找我要这样的工具,因为我们尝试了5年让人可以用自然语言编程(至少是填表),但都没成功。也不要吐槽为什么图标是Delphi,这毫无意义……当然,这么做的最重要的因素,不仅仅是整理了研发的思路,更重要的是做到了工作内容的解耦。

  最后总结

  当策划提出一个需求的时候,其实背后蕴藏着很深的真正的需求,我们需要深入的思考和分析,总结提炼出这真正的需求,或者策划能很好地提出这些真正的需求,最后做出来的东西才不至于变味或者不够灵活。

  但是,我们与其花这么大的力气去总结、沟通,还要面对沟通必然带来的损失,为什么不换个思路,把逻辑代码交给策划去写呢?要知道我写上面这么大一段花费的力气,足以把整个内容开发出来了……
发表于 2016-12-3 23:35:08 | 显示全部楼层
图片看不到
发表于 2016-12-3 23:56:20 来自手机 | 显示全部楼层
那得看这个游戏是用来干什么的。
有人会说游戏就是赚钱,那就需要这些。
-
只是为了做游戏,可能就不需要这些了。
-
游戏有对抗性的,就不能普通子弹发射的时候像魂斗罗的S弹,因为对方很难躲。
超级魂斗罗的F弹就是击中后AOE。
-
设计一个游戏,从引擎开始。
你写的这些就是怎么让游戏的判定关系比较简单。
-
应该把所有东西视为角色。
区别只是这个角色的移动状态,能否被某些东西挡住,碰到X东西后会产生什么状态。
例如人物自己控制,碰到墙不能继续行动。
例如子弹弹跳2次后爆炸。
子弹碰到对方又发生什么状态。
我在知乎写了个文章(毕竟这里的人不懂欣赏)
https://zhuanlan.zhihu.com/p/23207174?refer=herosone1
当一个人物,使用跳跃技能的时候,途中可以受到伤害,这个角色就是当跳跃的时候,转换成一个z轴移动的另一个角色而已,同时血量继承。
跳跃的时候无敌,则只是跳跃的角色删除了受击状态。
同样道理,一个箱子和你控制的人物其实都是同样的角色,受伤什么的。
人物速度不同,子弹速度不同,就是赋值不同,子弹击中东西会产生AOE爆炸是子弹的附加值,但是受损则是人物的附加值。
发表于 2016-12-4 00:16:12 来自手机 | 显示全部楼层
假如我会编程的话,游戏都做出来了,问题在于我做的游戏是网络游戏,需要的不仅是客户端,还有服务器端,我本来打算让客户端P2P的,可是不会做。
-
P2P游戏假如没有门户网站,应该只需要知道自己的IP,然后通过QQ告诉别人自己的IP就能连接才对。
问题是IPV4好像有重叠IP。
可惜我不会编程,太可惜了,不然整个游戏界都可以打败。
发表于 2016-12-4 08:49:04 | 显示全部楼层
楼主能被图么?图丢了
发表于 2016-12-4 15:25:35 | 显示全部楼层
herosone 发表于 2016-12-4 00:16
假如我会编程的话,游戏都做出来了,问题在于我做的游戏是网络游戏,需要的不仅是客户端,还有服务器端,我 ...

可惜我不会降龙十八掌,不然整个游戏界都可以打败
发表于 2016-12-4 16:22:28 来自手机 | 显示全部楼层
__BlueGuy__ 发表于 2016-12-4 15:25
可惜我不会降龙十八掌,不然整个游戏界都可以打败

你会降龙十巴掌。
发表于 2016-12-4 17:02:58 | 显示全部楼层
1. 花果山的提到的依赖关系思维很好,可以有助于程序和策划更能看清楚需求。
2. 文中提到的Data, 想法是很不错的,但现实愿意这么搞得团队至少也得合作过几年了,团队磨合很不错为基础。

不会编程思维的的策划不是一个号程序员
发表于 2016-12-5 10:11:19 | 显示全部楼层
马克,下班慢慢看
发表于 2016-12-5 13:53:41 | 显示全部楼层
干货十足!谢谢!
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|稿件投递|广告合作|关于本站|GameRes游资网 ( 闽ICP备05005107-1 )

GMT+8, 2018-6-18 19:50

快速回复 返回顶部 返回列表