当前位置:   article > 正文

Unity学习笔记(五)2D游戏制作入门_unity2d游戏制作

unity2d游戏制作

官方教程:Ruby's Adventure: 2D Beginner - Unity Learn

官方资源:2D Beginner: Tutorial Resources | Unity Asset Store


目录

目录

1、准备工作

2、主场景和主角

3、键盘控制

4、瓦片地图

5、装饰世界

6、物理系统

7、收集物

8、敌人和伤害区域

9、动画Animator

10、飞弹

11、摄像机

12、粒子系统

13、UI界面/抬头显示HUD

14、NPC和对话

15、声音系统

 16、构建游戏


1、准备工作

        新建项目:通过2D模板新建工程。

        下载安装资源:具体见:Unity的界面和操作

        了解Unity编辑器的界面和基础操作。

2、主场景和主角

        创建新场景:菜单栏File -> New Scene或者快捷键Ctrl + N,并命名为MainScene。

        导入资源:将角色的png图片拖进资源的Art -> Sprites文件夹中,在2D模板下自动导入为Sprite。(png图片转化为可用格式。在Inspector窗口中的Texture Type属性为Sprite(精灵),否则无法编辑。)

        使用精灵创建游戏对象:在Project窗口中点击图片旁的箭头,将显示该图片的精灵。将精灵拖入Hierarchy窗口中,该游戏对象会自动添加一个Sprite Renderer组件。

        距离单位:单位为1,在不同游戏场景下代表不同的长度。

        创建脚本:在Project中新建Scripts文件夹,右击选择Create -> C# Script,并命名为CharController,同时会用默认编译器打开脚本。其中,Start()函数是仅在游戏开始时运行的函数,Update()是帧刷新时运行的函数。

        编辑脚本:在Update()函数中输入以下内容,并保存。

  1. void Update(){
  2. Vector2 position = transform.position; // 声明变量
  3. position.x = position.x + 0.1f; // 移动位置
  4. transform.position = position; // 存储位置
  5. }

        使用脚本:在游戏对象的Inspector属性栏中点击Add Component,选择刚刚编辑的CharController脚本。(在Play中演示可看到角色移动。)

3、键盘控制

        Unity默认输入设置:在Edit -> Project Settings -> Input查看Unity的默认输入设置。在Input中列出了所有的输入控件的Axes值,一个轴上的negative button表示负向(向左left 值-1),positive button表示正向(向右right 值+1)。

        编辑脚本:将CharController脚本的Update()函数修改为以下内容,并保存。

  1. void Update(){
  2. float horizontal = Input.GetAxis("Horizontal"); // 调用API
  3. float vertical = Input.GetAxis("Vertical"); // Y轴
  4. Debug.Log(horizontal); // 在Console打印log
  5. Vector2 position = transform.position;
  6. position.x = position.x + 0.1f * horizontal; // left为-1,right为1
  7. position.y = position.y + 0.1f * vertical;
  8. transform.position = position;
  9. }

        测试脚本:在Play中测试脚本,Console窗口将打印log。坐标从0到1默认有平滑的过程,禁用此功能可参考Unity - Manual: Input Manager

        帧率:Unity默认为每秒60帧。在Start()函数中修改帧率,输入以下代码并保存。

  1. void Start()
  2. {
  3. QualitySettings.vSyncCount = 0;
  4. Application.targetFrameRate = 10; // 目标帧率
  5. }

        (修改后会发现移动速度变慢,因为默认为60帧,Update()函数一帧执行一次,一秒移动60个单位,而修改后帧率是10,一秒只移动10个单位。因此电脑性能会影响游戏体验。)

        移动速度:将移动速度从 单位/帧 改为 单位/秒 可以避免不同帧率带来的问题。通过将x/y轴的偏移乘上一个每帧时间间隔,使 单位/帧 改为 单位/秒。将脚本CharController的代码修改如下,并保存。

  1. void Start() { }
  2. void Update()
  3. {
  4. float horizontal = Input.GetAxis("Horizontal");
  5. float vertical = Input.GetAxis("Vertical");
  6. Vector2 position = transform.position;
  7. position.x = position.x + 0.1f * horizontal * Time.deltaTime; // 每帧的时间间隔
  8. position.y = position.y + 0.1f * vertical * Time.deltaTime;
  9. transform.position = position;
  10. }

4、瓦片地图

        角色所在的世界可能非常广阔,绘制整个世界的工作量非常大。如果要根据游戏行为而改变世界,还需要重绘各种资源。瓦片地图是将世界分割为一个个网格,为每个网格设置不同的Sprite,在视觉上连接成一个更大的世界,同时便于在编辑器中更改。

        创建瓦片地图:在Hierarchy中右击选择2D Object -> Tilemap,将生成两个游戏对象,一个Gird(用于均匀放置游戏对象),一个Tilemap(Grid的子游戏对象,由Tile组成,类似于特殊的Sprite)。

        创建瓦片:在Project窗口的Assets -> Art下新建Tiles文件夹,在Tiles文件夹中右击选择Create -> Tile,并命名为FirstTile。在Inspector中可以看到FirstTile有一个Sprite属性。

        为瓦片分配图片Sprite:首先将图片保存在Assets中的Sprite文件夹,再将该图片从Sprite拖进FirstTile的Sprite属性,或者点击FirstTile的Sprite属性旁的圆点,选择该图片的Sprite。

         绘制瓦片地图:使用Palette面板定义瓦片地图的每个瓦片内容。在菜单栏的Window中选择2D -> Tile Palette打开Tile Palette窗口。点击Create New Palette创建新的面板,命名为TileMapPalette,并保存在Project的Tiles文件夹中。之后,将FirstTile瓦片拖进面板中的某个网格,通过画笔工具绘制瓦片地图。

工具Win快捷键功能
选择S选择一个或框选多个瓦片进行绘制
移动M将瓦片移动到其他位置
笔刷B将选中的瓦片绘制在Scene上
填充盒U在选定的矩形范围内填充选中的瓦片
拾取器I选择瓦片后,立即转为笔刷
橡皮擦D清除已经绘制在Scene中的瓦片
填充G将选中的一个或多个瓦片填充世界
平移Alt+左键拖动 / 滚轮键拖动
缩放旋转滚轮

        瓦片适应网格:在Inspector窗口中,查看Grid的属性,Cell Size为x=1 y=1,即1个单位。查看FirstTile的属性,Pixels Per Unit = 100,即100像素每单位。而FirstTile中的Sprite的大小为64x64像素,所以瓦片无法填充满网格,因此将FirstTile的Pixels Per Unit设为64即可。

        瓦片集:用于分配给瓦片的不同图片Sprite可以是同一张图片的不同部分,即一张图片可以分割成多个不同的瓦片,称为瓦片集。

        设置瓦片集:在Project中选择Art -> Sprites -> Environment文件夹,打开某一个瓦片集(图片Sprite),在Inspector中将Sprite Mode改为Multiple,Pixel Per Unit改为64。点击Sprite Editor,在打开的窗口中选择Slice,将Type字段设为Grid by Cell Count,将Column设为3,Row设为3,最后点击Slice按钮,应用修改。在Project中将看到从该图片创建的9个图片Sprite。

        快速分配瓦片集:将瓦片集图片拖进Tile Palette中,选择保存瓦片的文件夹,将自动将瓦片集分配给瓦片。

        更改图层顺序:在Hierarchy窗口中选择Tilemap,在Inspector窗口中将Tilemap Renderer中的Order in Layer改为-10,确保角色绘制在地图之上(值越大,图层越靠上)。

5、装饰世界

        添加装饰:同创建游戏对象,此处放置项目资源中的MetalCube。

        图层顺序问题:为了模拟立体的效果,靠前的对象应该优先绘制,而不是依赖瓦片的Order in Layer。即Unity应该先绘制y轴较小的对象,后绘制y轴较大的对象。可以模拟出角色在物体前、物体后的立体效果。

        透明排序模式:将透明排序改为y轴。在菜单栏的Edit中,将Project Setting -> Graphics -> Camera Setting中的Transparent Sort Mode设为Custom Axis,Transparent Sort Axis设为Y=1。

        精灵排序点:将排序点改为轴心Pivot。选中主角在Inspector中,将Sprite Renderer中的Sprite Sort Point设为Pivot。(轴心是可以自定义的锚点,将会围绕该点来操作精灵。)

        调整轴心:在Project中选择精灵,在Inspector中将Sprite Mode中的Pivot设为Buttom。若要修改瓦片集的轴心,可以在Inspector中打开Sprite Editor,点击图片并拖动出现的蓝色圆圈到任意需要的位置,或者在旁边的Sprite窗口中将Pivot设为Custom,再设置Custom Pivot的X=0,Y=-1。

6、物理系统

        Unity抽象出一系列的物理定律来模拟重力、相互作用力和摩擦力等,这就是物理系统。为了避免额外的数学计算,物理效果只对附加了Rigidbody 2D、Box Collider 2D等组件的游戏对象执行。

        Rigidbody 2D组件:包含重力、摩擦力等,详见Unity学习笔记(二)2.3创建互动性

        Box Collider 2D组件:包含碰撞(相互作用力)。调整碰撞体积的大小,在Box Collider 2D组件中的Editor Collider中调整(主角只需要下半身发生碰撞,更贴近真实。)。此外还可以使用适应物理形状的多边形碰撞组件Polygon Collider 2D,详见Unity学习笔记(二)2.4添加障碍物

        预制件:使用预制件可以为同一对象的不同副本同时添加组件、重写属性值,避免重复工作。详见Unity学习笔记(四)预制件Prefabs

        限制轴:在Rigidbody 2D中将属性Constraints的Freeze Rotation勾选Z,避免角色碰撞后绕z轴旋转。

        仅碰撞场景会发生角色抖动:物理系统处理碰撞的过程是,首先主角移动过程中与其他物体接触,施加作用力,接着计算碰撞,最后将主角移动到计算出的新位置。如果在帧更新过程中移动角色,物理系统将主角移动到新位置后,发现其发生了新的碰撞,再此同步新位置。代码执行的操作与物理系统执行的操作发生冲突导致发生抖动。

        移动刚体而非变换组件:通过将Transform组件的移动改为刚体Rigidbody2D的移动,并让物理系统将Transform组件的位置同步到刚体Rigidbody2D的位置。Transform组件是Unity内置变量,在所有脚本中都可直接使用,而Rigidbody2D组件是按需添加的,不是内置变量。为了避免物理系统的更新速度与帧更新的调用速度不一致,使用FixedUpdate()函数进行定期更新,从而操作刚体Rigidbody2D的移动。

        修改脚本:将CharController脚本修改为如下并保存。

  1. public class CharController : MonoBehaviour
  2. {
  3. Rigidbody2D rigidbody2d;
  4. float horizontal;
  5. float vertical;
  6. void Start()
  7. {
  8. rigidbody2d = GetComponent<Rigidbody2D>(); // 获取刚体对象
  9. }
  10. void Update()
  11. {
  12. horizontal = Input.GetAxis("Horizontal"); // 获取键盘控制
  13. vertical = Input.GetAxis("Vertical");
  14. }
  15. void FixedUpdate()
  16. {
  17. Vector2 position = rigidbody2d.position; // 操作刚体移动
  18. position.x = position.x + 3.0f * horizontal * Time.deltaTime;
  19. position.y = position.y + 3.0f * vertical * Time.deltaTime;
  20. rigidbody2d.MovePosition(position);
  21. }
  22. }

        瓦片集的碰撞:在Hierarchy中选择Tilemap游戏对象,添加组件Tilemap Collider 2D,将为瓦片集中的全部瓦片添加碰撞。最后在Project的Tiles文件夹中,选择不需要碰撞的瓦片Sprite,将Collider Type属性设为None。(这种批量设置碰撞存在问题,世界中大量的瓦片被添加了碰撞,会导致物理系统的计算量巨大,导致运行变慢;此外,由于瓦片是连续排列而不是一体的,微小缝隙可能会导致预期外的碰撞情况。)

        复合碰撞体Composite Collider 2D组件可以获取对象上的所有碰撞体并创建一个更大的复合碰撞体。在Hierarchy中选择Tilemap游戏对象,添加组件Composite Collider 2D(会自动添加Rigidbody 2D组件),勾选Used By Composite属性,并将Rigidbody 2D中的Body Type属性设为Static(该属性值将阻止世界移动,并简化物理系统的计算)。

7、收集物

        主角的生命值:在脚本中为主角添加生命值,将CharController修改为如下并保存。(在脚本中定义为public的成员属性,会在Inspector窗口中显示,并可以修改。)

  1. // CharController脚本新增
  2. public class CharController : MonoBehaviour
  3. { // ...
  4. public int maxHealth = 5; // 最大生命值
  5. int currentHealth; // 当前生命值
  6. public int CurrentHealth { get=>currentHealth; } // 可以从外部访问,但无法修改
  7. void Start()
  8. { // ...
  9. currentHealth = maxHealth; // 运行前将当前生命值设为最大生命值
  10. }
  11. public void ChangeHealth(int amount) // 其他脚本会访问这个函数
  12. { // 生命值变化
  13. currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth); // 确保生命值在0~maxHealth之间
  14. Debug.Log(currentHealth + "/" + maxHealth);
  15. }
  16. }

        触发器:一种特殊的碰撞体,不会阻止角色移动,但会检测碰撞,并触发事件。

        创建收集物:创建一个名为CollectableHealth的游戏对象并添加Box Collider 2D组件,并勾选Is Trigger属性。此时该物品不会阻挡角色移动,但没有触发事件,因此需要一个脚本。

        收集物脚本:创建一个名为HealthCollectable脚本,并添加到CollectableHealth对象。编辑脚本如下并保存。当角色进入触发器时,Unity会在第一帧调用OnTriggerEnter2D()函数。

  1. public class HealthCollectible : MonoBehaviour
  2. {
  3. void OnTriggerEnter2D(Collider2D other)
  4. {
  5. CharController controller = other.GetComponent<CharController>(); // 获取触碰的刚体
  6. if (controller != null)
  7. {
  8. if (controller.CurrentHealth < controller.maxhealth)
  9. { // 仅当当前生命值小于最大生命值时执行
  10. controller.ChangeHealth(1); // 生命值改变量为+1
  11. Destroy(gameObject); // 销毁被触碰的游戏对象
  12. }
  13. }
  14. }
  15. }

8、敌人和伤害区域

        创建伤害地形:通过将Project中名为Damageable的Sprite拖进Hierarchy窗口中创建一个伤害区域游戏对象,并分配Sprite Renderer、Box Collider 2D组件,勾选Is Trigger属性。

        区域伤害的脚本:新建名为DamageZone的脚本,脚本内容与生命值脚本HealthCollectable类似。将函数由OnTriggerEnter2D()改为OnTriggerStay2D()函数,使角色进入伤害区域后的每一帧都触发伤害事件(而不是只有进入的那一帧)。将角色的Rigidbody2D组件的Sleeping Mode属性设为Never Sleep,避免物理系统在角色停止时为节约资源而不检测角色的碰撞,即角色静止时不会受到伤害。

  1. public class DamageZone : MonoBehaviour
  2. {
  3. void OnTriggerStay2D(Collider2D other) // 在刚体内时触发
  4. {
  5. CharController controller = other.GetComponent<CharController>(); // 获取触碰的刚体
  6. if (controller != null)
  7. {
  8. controller.ChangeHealth(-1); // 生命值改变量为-1
  9. }
  10. }
  11. }

        主角的无敌状态:由于OnTriggerStay2D()函数是物体在刚体内部每帧都会执行一次,为了避免角色过快死亡,可以通过为角色设置无敌状态。在CharController脚本中修改如下:

  1. bool isInvincible; // 是否无敌
  2. float invincibleTimer; // 无敌时间
  3. void Updat()
  4. {
  5. // ...
  6. if (isInvincible)
  7. {
  8. invincibleTimer -= Time.deltaTime; // 无敌时间倒计时
  9. if (invincibleTimer < 0)
  10. {
  11. isInvincible = False; // 进入非无敌状态
  12. }
  13. }
  14. }
  15. public void ChangeHealth(int amount)
  16. {
  17. // ...
  18. if (amount < 0)
  19. {
  20. if (isInvincible) // 无敌状态不损失生命值
  21. return;
  22. isInvincible = true; // 非无敌状态,损失生命,重设为无敌状态
  23. invincibleTimer = timeInvincible;
  24. }
  25. }

        伤害区域的尺寸:直接通过矩形工具(T)拉伸会导致变形,需要在Sprite Renderer中将Draw Mode设为TiledTiled Mode设为Adaptive,此时Sprite Renderer将平铺敌人Sprite(确保其Transform中Scale为1,1,1)。在Project中将敌人Sprite的导入设置中的Mesh Type设为Full Rect,防止显示错误。为了让碰撞体积自适应敌人Sprite的体积,勾选Box Collider 2D中的Auto Tilling属性。

        创建敌人:敌人更像是一种移动的伤害地形/区域。敌人的机器人Sprite的设置与主角类似,包括Pixel Per Unit使显示完全,Sprite Sort Point设为Pivot,碰撞体积在Collider Editor中设为下半身,Gravity Scale设为0,Constraints设为Freeze Rotation Z等,最后将敌人Sprite设为预制件

        敌人的自动移动:创建一个脚本名为EnemyController,并编辑如下。

  1. public class EnemyController: MonoBehaviour
  2. {
  3. public float Speed = 3.0f; // 移动速度
  4. public bool Vertical; // 移动方向
  5. public float ChangeTime = 3.0f; // 定时器
  6. float timer;
  7. int direction = 1; // 移动顺序(正向/反向)
  8. Rigidbody2D rigidbody2D;
  9. void Start() {
  10. rigidbody2D = GetComponent<Rigidbody2D>();
  11. timer = ChangeTime;
  12. }
  13. void Update() {
  14. timer -= Time.deltaTime; // 计时
  15. if (timer <= 0) { // 逆转顺序
  16. direction = -direction;
  17. timer = ChangeTime;
  18. }
  19. }
  20. void FixedUpdate() {
  21. Vector2 position = rigidbody2D.position;
  22. if (Vertical){
  23. position.y = position.y + Time.deltaTime * speed * direction; // 垂直移动
  24. } else {
  25. position.x = position.x + Time.deltaTime * speed * direction; // 水平移动
  26. }
  27. rigidbody2D.MovePosition(position);
  28. }
  29. }

        敌人伤害的脚本:首先主角与敌人发生的是碰撞,而非触发,使用OnCollisionEnter2D()函数;其次敌人要判断与其碰撞的是不是主角,需要获取碰撞的对象并判断,使用gameObject.GetComponent()。

  1. void OnCollisionEnter2D(Collison2D other) // 参数类型为Collison2D
  2. {
  3. CharController player = other.gameObject.GetComponent<CharController>();
  4. if( player != null)
  5. {
  6. player.ChangeHealth(-1);
  7. }
  8. }

9、动画Animator

        Unity中使用Animator组件在游戏对象上播放动画。Animator中最重要的是Controller属性,Controller负责基于定义的规则来播放动画。

        添加Animator:预制件模式下,在Inspector中点击Add Component,选择Animator。

        创建Controller并添加:在Project的Animations文件夹中右击,选择Create -> Animator Controller,并命名为Robot。在刚刚的Animator组件中单击Controller属性旁的圆圈,或者直接将Robot Controller直接拖到那个位置。

        创建动画:动画是存储在Project文件夹中的资源。Unity使用Animation窗口创建动画,在菜单栏的Window -> Animation -> Animation中打开Animation窗口。在Hierarchy中选中对应的游戏对象或在该对象的预制件模式下,Animation窗口中将显示"To begin animating [游戏对象名]"。点击Create开始创建动画,保存位置是Project/Animations/文件夹,文件后缀名为".anim"。

        编辑动画:Animation窗口的左侧是动画属性,右侧是每个属性的关键帧。可以使用Animator对游戏对象的任何组件的任何属性进行随时间变化的动画处理,可以是颜色、大小,也可以是Sprite Renderer所使用的敌人Sprite,从而模拟移动的视觉效果。在Projcet的Art -> Sprite -> Characters文件夹中包含了精灵图集(Sprite Altas),该图集包含了敌人Sprite的所有动作。选择MrClockworkWalkSides1~4,拖进Animation窗口中,并修改Samples值为4。

        保存动画:单击窗口左侧的当前动画名称,选择Create New Clip,保存在Project/Animations文件夹。

        属性Sprite Renderer:Animation中的Sprite Renderer属性可以辅助完成动画。重复之前创建动画的过程,之后选择Add Property -> Sprite Renderer -> Flip X(旁边的+号),添加Flip属性。在帧0和帧4,勾选刚刚添加的Flip属性,帧号显示在左侧窗口的右上角。勾选后,右侧窗口的同一行的相应帧的位置会出现菱形标记,表示该属性将应用于这些帧。

        构建Controller/动画规则:在相应游戏对象的预制件模式下或者选择对应的Controller,打开Animator窗口(菜单栏Window -> Animation -> Animator)。Animator窗口分为两部分,左侧是LayersParameter,右侧是动画状态机(Animation State Machine)。Layers用于将动画应用于角色的不同部分。Parameter用于通过脚本向Controller提供消息。动画状态机以图形的形式展示了动画的状态以及一段动画如何过渡到另一段动画。

        混合树(Blend Tree):混合树允许使用参数来混合多段动画。首先,删除动画状态机中的所有动画(选中按Del键/右击删除),再右击选中Create State -> From New Blend Tree,双击打开混合树,选中Blend Tree节点。在Inspector窗口中,将Blend Type(控制动画播放的参数个数)设为2D Simple Directional(控制动画在水平、垂直两个方向上的播放),即2个参数来控制动画播放。

        动画播放规则参数:在Animator 窗口中选中Parameters选项卡,点击参数名称进行重命名,点击搜索栏左侧+号新增Float类型参数,创建两个Float类型参数"MoveX"和"MoveY"。此时点击Blend Tree,将在Inspector中看到Parameters中出现了两个参数"MoveX"和"MoveY"。同样在Inspector中点击Motion页右下角的+号,选中Add Motion Field,为每个动画新增一条。将创建好的动画拖进Motion中,并设置对应的Pos XPos Y。Inspector窗口中Parameters下的图像表示混合树,蓝色菱形标记表示一个动画Clip,红点标记是参数MoveX和MoveY的值给出的位置,红点靠近哪个蓝色菱形,就展示相应的动画Clip。(机器人的四个方向移动的动画规则参数分别设为:向上(0, 1) 向下(0, -1) 向左(-1, 0) 向右(1, 0)。)

        发送动画规则参数:通过在敌人组件EnemyController脚本中获取Animator组件来实现动画参数的发送,在Animator组件的函数SetFloat(paraName, paraValue)类设置参数值。新的EnemyController脚本如下,垂直移动时,MoveX应该是0,向上/向下的方向通过参数direction控制,水平移动同理。

  1. public class EnemyController: MonoBehaviour
  2. {
  3. public float Speed = 3.0f;
  4. public bool Vertical;
  5. public float ChangeTime = 3.0f;
  6. float timer;
  7. int direction = 1;
  8. Rigidbody2D rigidbody2D;
  9. Animator animator; // 动画组件
  10. void Start() {
  11. rigidbody2D = GetComponent<Rigidbody2D>();
  12. timer = ChangeTime;
  13. animator = GetComponent<Animator>(); // 获取动画组件
  14. }
  15. void Update() {
  16. timer -= Time.deltaTime;
  17. if (timer <= 0) {
  18. direction = -direction;
  19. timer = ChangeTime;
  20. }
  21. }
  22. void FixedUpdate() {
  23. Vector2 position = rigidbody2D.position;
  24. if (Vertical){
  25. position.y = position.y + Time.deltaTime * speed * direction; // 垂直移动
  26. animator.SetFloat("MoveX", 0); // 设置动画规则的参数
  27. animator.SetFloat("MoveY", direction);
  28. } else {
  29. position.x = position.x + Time.deltaTime * speed * direction; // 水平移动
  30. animator.SetFloat("MoveX", direction);
  31. animator.SetFloat("MoveY", 0);
  32. }
  33. rigidbody2D.MovePosition(position);
  34. }
  35. }

        设置主角的动画:在项目中,已经为主角设定好动画,为主角添加Animator组件,并将RubyController Animator组件分配到Controller属性字段。打开Controller,主角有4个状态,分别是:静止Idle、移动Moving、受伤Hit、攻击Launch。混合树中的白色箭头表示各状态之间的过渡(受伤与攻击之间没有过渡表明主角受伤时不能攻击),点击箭头可以在Inspector中查看该过渡的属性信息。Has Exit Time为取消选中,表示初始状态未结束前立即转变到下一状态。Conditions表示状态转变的条件在,Conditions参数可以是浮点、触发器等(例如,如果设置Speed Less 0.1,则主角移动速度小于0.1是发送移动到静止的转变),若没有设置条件,则第一个状态结束时转变为下一个状态。

        设置主角的动画参数发送

  1. public class CharController : MonoBehaviour
  2. {
  3. public int maxHealth = 5;
  4. int currentHealth;
  5. public int CurrentHealth { get=>currentHealth; }
  6. Rigidbody2D rigidbody2d;
  7. float horizontal;
  8. float vertical;
  9. Animator animator; // 动画组件
  10. Vector2 lookDirection = new Vector2(1, 0); // 定义静止时的角色方向
  11. void Start()
  12. {
  13. rigidbody2d = GetComponent<Rigidbody2D>();
  14. animator = GetComponent<Animator>(); // 获取动画组件
  15. currentHealth = maxHealth;
  16. }
  17. void Update()
  18. {
  19. horizontal = Input.GetAxis("Horizontal");
  20. vertical = Input.GetAxis("Vertical");
  21. Vector2 move = new Vector2(horitional, vertical);
  22. if (!Mathf.Approximately(move.x, 0.0f) || !Mathf.Approxiamtely(move.y, 0.0f))
  23. { // x和y非零,则角色在移动
  24. lookDirection.Set(move.x, move.y); // 观察方向设为移动方向
  25. lookDirection.Normalize(); // 方向变量的归一化
  26. }
  27. animator.SetFloat("Look X", lookDirection.x);
  28. animator.SetFloat("Look Y", lookDirection.y);
  29. animator.SetFloat("Speed", move.magnitude);
  30. }
  31. void FixedUpdate()
  32. {
  33. Vector2 position = rigidbody2d.positon; // 输入之间存储在move中,而非单独的x和y
  34. position = position + move * speed * Time.deltaTime;
  35. rigidbody2d.MovePosition(position);
  36. }
  37. public void ChangeHealth(int amount)
  38. {
  39. currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
  40. Debug.Log(currentHealth + "/" + maxHealth);
  41. animator.SetTrigger("Hit"); // 传递伤害的触发器
  42. }
  43. }

10、飞弹

        创建飞弹:在项目自带的Art -> Sprites -> VFX中选中名为CogBullet的Sprites,通过在Inspector中设置Pixel Per Unit的值该调整大小,最后拖进Hierarchy窗口中创建飞弹游戏对象。为飞弹添加Rigidbody2D和BoxCollider2D组件,为飞弹添加碰撞。将Rigidbody2D的Gravity Sacle属性设为0,防止飞弹下落。

        飞弹的图层:由于飞弹添加了碰撞组件,飞弹将有可能会和主角发生碰撞,因此,需要将主角和飞弹放在不同的图层Layer。在Inspector中点击Layer的下拉框,点击Add Layer。图层0~7是内置图层,无法定义,将图层8定义为Character,图层9定义为Projectile。将主角预制件、飞弹预制件的图层分别设置为Character、Projectile。在菜单栏Edit -> Project Setting ->Physical 2D的Layer Collision Matrix中取消勾选Character和Projectile的交集,即这两图层不再碰撞。

        飞弹的物理系统:为飞弹创建一个脚本,命名为Projectile,脚本如下。为飞弹添加该脚本,并创建预制件,将现有的飞弹对象从Scene中删除(飞弹按需出现)。为了避免null引用异常,这里使用Awake()函数,因为创建飞弹对象时Unity不会运行Start(),而是在下一帧运行,因此调用飞弹对象的Launch()函数时,rigidbody2d仍是null,而Awake()在Instantiate飞弹对象时即被调用。

  1. public class Projectile: MonoBehaviour
  2. {
  3. Rigidbody2D rigidbody2d;
  4. void Awake()
  5. {
  6. rigidbody2d = GetComponent<Rigidbody2D>(); // 获取刚体
  7. }
  8. public void Launch(Vector2 direction, float force)
  9. {
  10. rigidbody2d.Addforce(direction * force); // 施加direction方向、大小为force的力
  11. }
  12. void OnCollisionEnter2D(Collision2D other)
  13. {
  14. Destroy(gameObjecte); // 销毁对象
  15. }
  16. }

        发射飞弹:为组件添加飞弹组件。首先在主角的控制脚本CharController中声明新的GameObject类型的public变量(预制件的类型),再将飞弹预制件拖进该属性中。如果在Scene中已经有飞弹预制件,需要使用Overrides操作来重写预制件。在CharController中添加发射飞弹的函数OnLaunch()。通过Instantiate(对象, 位置, 旋转)函数在指定位置创建飞弹对象。设置发射飞弹的按键,通过Input.GetKeyDown()来检测按键,由于按键检测需要每帧都检测,因此添加在CharController的Update()函数中。

  1. GameObject projectilePrefab; // 预制件对象
  2. void Update()
  3. { // ...
  4. if (Input.GetKeyDown(KeyCode.C))
  5. {
  6. Launch(); // 检测到按键"C"时,执行Launch()
  7. }
  8. }
  9. void OnLaunch()
  10. { // 创建飞弹对象
  11. GameObject projectileObject = Instantiate(projectilePrefab, rigidbody2d.position + Vector2.up * 0.5f, Quaternion.indentify); // 无旋转
  12. Projectile projectile = projectileObject.GetComponent<Projectile>(); // 获取飞弹对象
  13. Projectile.Launch(lookDirection, 300); // 调用飞弹的Launch()函数,设置力的方向和大小
  14. animator.SetTrigger("Launch");
  15. }

        飞弹的功能:可以通过飞弹来修复机器人,使之不再产生伤害。首先为机器人脚本EnemyController新增一个变量broken来记录机器人是否损坏(坏的机器人会对主角产生伤害),新增的代码行如下。在飞弹与机器人碰撞后,获取碰撞对象,执行机器人的Fix()函数,修复机器人,修改的代码如下。(也可以为修复机器人添加动画。)

  1. // EnemyController脚本新增
  2. bool broken = true; // 初始化机器人的状态是损坏
  3. void Update()
  4. { // ...
  5. if (!broken) return; // 修复好的机器人不再执行其他动作
  6. }
  7. void FixedUpdate()
  8. { // ...
  9. if (!broken) return;
  10. }
  11. public void Fix()
  12. {
  13. broken = false;
  14. rigidbody2d.simulated = false; // 修复好的机器人将从物理系统模拟中删除
  15. }
  1. // Projectile脚本新增代码
  2. void OnCollisonEnter2D(Collison2D other)
  3. { // ...
  4. EnemyController enemy = other.collider.GetComponent<EnemyController>();
  5. if (enemy != null) enemy.Fix();
  6. Destroy(gameObject);
  7. }

        清理无用的飞弹:飞弹飞出可视区域外并不会自动销毁,当数量过多,可能会对游戏性能产生不良影响。通过检查飞弹与世界中心的距离(或者与主角的距离、飞行时间等),超过一定门限则销毁飞弹。因此,在飞弹脚本Projectile中新增代码。

  1. // Projectile脚本新增
  2. void Update()
  3. { // ...
  4. if (transform.position.magnitude > 1000.0f) destroy(gameObject); // 自动销毁飞弹
  5. }

11、摄像机

        Unity通过包Package的形式来提供非必须的其他功能,使用Cinemachine 可以创建复杂的 3D 摄像机设置,从而允许在多个摄像机之间移动和切换。

        下载Cinemachine包:在菜单栏中打开Window -> Package Manager,搜索Cinamachine并安装。

        Cinamachine设置:在菜单栏中打开Cinemachine -> Create 2D Camera,添加2D摄像机CM vcam 1。Cinemachine使用虚拟摄像机,可以对多个虚拟摄像机进行不同的设置,并告诉真实摄像机(Main Camera)当前需要哪个虚拟摄像机,并复制其设置。

        摄像机模式:透视模式(所有线汇聚于一点,近大远小)、正交模式(平行线永远平行,没有透视效果)。在2D模式中,并不希望物体近大远小,因此2D模式的项目默认是正交模式。Orthographic size是摄像机一半高度能容纳的单位数量,即设置为5将在屏幕垂直方向上看到10各单位(宽度会根据游戏窗口的分辨率变化)。

        摄像机跟随主角:将主角从Hierarchy拖进摄像机Inspector的Follow属性中。

        摄像机边界:阻止摄像机显示世界之外的内容,通过Cinemachine Confiner为摄像机设定边界。在摄像机Inspector中点击Add Extension,选择Cinemachine Confiner。该组件需要配合Collider2D游戏对象使用。首先创建一个空游戏对象,在Hierarchy窗口中选择 Create -> Create Empty,再为这个空对象添加Polygon Collider 2D组件。最后在Inspector中点击Edit Collider,调整多边形边界至至适当的位置,或编辑Element的XY坐标。将该Collider2D对象分配给Cinemachine Confiner的Bounding Shape 2D属性。由于这个多边形对象是碰撞体,并且覆盖整个世界,会碰撞所有的物体,需要将其放置在不同的层Layer。层设置与10.飞弹的图层设置相同,但需要在Layer Collision Matrix中取消勾选所有的与该图层的碰撞。

12、粒子系统

        将Sprite放置在粒子上以创建不同的效果。

        创建粒子系统:在Hierarchy中选择Create -> Effects -> Particle System,创建默认的粒子系统,并重命名为SmokeEffect。在Inspector中,粒子系统包含多个子系统,定义粒子系统的所有属性,勾选子系统名称左侧的圆圈以启用该子系统。烟雾效果需要启用Texture Sheet Animation子系统。

        粒子系统的配置:在Texture Sheet Animation子系统中,将Mode设为Sprite,单击精灵条目右侧的+号可以新增精灵条目,将Project中的Art->Sprites->VFX中的精灵图集分配给精灵条目。将Start Frame设置为Random Between Two Constants,分别输入0和2,即随机使用第0个、第1个精灵作为起始帧。取消粒子系统的帧变化,点击Frame Over Time,删除第二个关键帧。在Shape子系统中,将Radius设为0.0001(不能为0),使烟雾在同一点创建。将Angle设为5左右,使烟雾固定方向减少分散。

        粒子的随机性:设置随机的生命周期,使烟雾整体消失地更加自然,将粒子系统的Start Lifetime属性设为Random Between Two Constants,输入1.5和3(适当的数值)。设置随机的尺寸,将Start Size属性设为Random Between Two Constants,输入0.3和0.5(适当的数值)。设置随机的速度,将Start Speed设置为Random Between Two Constants,输入0.3和0.5(适当的数值)。

        粒子的渐变:为了使烟雾在最后逐渐消失而不是突然消失,对粒子的透明度在生命周期上进行渐变。启用Color Over Time子系统,打开Gradient Editor,下方滑块是颜色,上方滑块是透明度Alpha,左侧和右侧分别表示粒子生命周期的开始和结束。选中右上角的滑块,将Alpha的值设为0,实现烟雾的渐变效果。为了使烟雾的体积不断变小,对粒子的尺寸在生命周期上进行渐变。启用Size Over Time子系统,横轴表示生命周期,纵轴表示尺寸Size,通过调节曲线来时间渐变。为了模拟真实的烟雾消失,将尺寸-生命周期曲线调整至对数函数曲线。

        为对象添加粒子效果:将配置好的粒子系统制作成预制件,并将该粒子预制件设为机器人预制件的子对象,并调整到合适的位置。为了避免烟雾随着机器人移动,将烟雾的粒子系统中的Simulation Space属性设为World。

        代码控制粒子系统:为了实现机器人被修复后,不再冒出烟雾的效果,将在EnemyController脚本中新增一个ParticleSystem类型的新变量smokeEffect。在预制件模式下的Inspector中,将烟雾粒子系统分配给该属性。最后,在EnemyController脚本的Fix()函数中新增粒子系统停止函数Stop()。(使用Stop()使粒子系统停止后,剩余粒子仍能走完生命周期,若使用Destroy(),则烟雾会突然消失,不协调。)

  1. public void Fix()
  2. { // ...
  3. smokeEffect.Stop();
  4. }

        粒子系统的其他可能:1、固定的生命周期而非循环:取消勾选Looping,将Duration设为合适的时间,并将Stop Action设为Destory(所有粒子走完生命周期才销毁粒子系统)。2、爆发模式:Bursts子系统中添加爆发,禁用Looping并将Stop Action 设置为 Destroy,Rate Over Time设为0,爆发时间设为0.0。3、实例化粒子效果:做出类似飞弹的一次性的粒子系统,通过Instantiate()函数。

13、UI界面/抬头显示HUD

        Unity中的UI可以使用画布Canvas来渲染用于特定UI的组件,例如图片、滑动和按钮等。Canvas组件的官方文档:Canvas | Unity UI | 1.0.0

        创建UI画布:在Hierarchy中点击Create -> UI -> Canvas(同时创建了一个EventSystem,用于处理UI相关的事件)。

        画布的属性Rect Transform是增强的Transform组件。Render Mode有三种:Screen Space - Overlay(默认模式,UI始终绘制在游戏上层)、Screen Space - Camera(UI绘制在摄像机所在层,显示效果与Overlay相似,但游戏对象可以绘制在UI以上)、World Space(UI可以绘制在任何平面,在3D中还可以有透视效果)。Canvas Scaler用于在不同分辨率下对UI进行缩放,UI Sacle Mode属性有两种模式:Constant Size Pixel/Physical(恒定大小,无论屏幕大小,UI的尺寸不变)、Scale With Screen Size(UI根据屏幕的相对分辨率而进行缩放,始终覆盖屏幕的相同区域,可能会放大而像素化或缩小而看不清)。Graphic Raycaster用于检测玩家点击UI中的对象。

        UI画布添加内容:内容包括:UI图片、主角头像、生命条。

        UI图片:为画布创建一个Image子对象,命名为HealthImage,将Project中的Art->Sprites->UI中的UIHealthFrame拖进Image对象的Source Image属性中。选中图片,使用矩形工具将图片缩小至适当的比例,并放在左上角。在Rect Transform中打开Anchors属性,选择top-left,将图片的锚点设置在左上角(锚点是四个白色三角形标识),避免调整窗口大小时UI发生意料外的位移。

        主角头像:为HealthImage创建一个Image子对象,命名为CharPortrait,将Project中的Art->Sprites->UI中的主角头像分配给该对象,通过Set Native Size设定合适的大小。为了使头像的变形与UI界面保持同步,拖动四个角上的锚点,使其正好包围头像图片(因为锚点不仅定义位置,还定义图片的相对大小)。

        生命条(遮罩、生命条):为了模拟生命值减少,生命条缩短的效果,需要为生命值添加遮罩。首先在HealthImage创建一个Image子对象,命名为Mask,调整至合适的大小和锚点位置。移动轴心至最左侧,保证变形至改变右侧,即从右侧缩短(变形是以轴心为对称中心两侧同时变化)。在Mask下再新建一个Image子对象,将Project中的Art->Sprites->UI中的UIHealthBar分配给该对象,命名为HealthBar。打开锚点,按Alt同时点击stretch-stretch(设置位置与父对象一致),再点击top-left将锚点设置在左上角(遮罩Mask变形时不会影响生命条)。为Mask对象添加Mask组件,取消勾选Show Mask Graphic,以隐藏白色的图形。

        生命条脚本:编写名为UIHealthBar的脚本。由于生命值条需要根据主角的生命值百分比及时更新,为生命条定义一个静态属性instance,方便引用。在主角的CharController脚本中调用生命条类,根据主角生命值修改生命条的长度。

  1. using UnityEngine.UI; // 导入UI命名空间
  2. public class UIHealthBar : MonoBehaviour
  3. {
  4. public static UIHealthBar instance { get; private set; } // 静态属性
  5. public Image Mask; // 遮罩
  6. float originalSize;
  7. void Awake()
  8. {
  9. instance = this; // 获取当前对象的实例
  10. }
  11. void Start()
  12. {
  13. originalSize = mask.rectTransform.rect.width; // 获取宽度
  14. }
  15. public void SetValue(float value)
  16. { // 根据生命值比例设置生命条的宽度
  17. mask.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, originalSize * value);
  18. }
  19. }
  1. // CharController脚本修改
  2. // ...
  3. void ChangeHealth(int amount)
  4. { // ...
  5. UIHealthBar.instance.SetValue(currentHealth/ (float)maxHealth); // 转为float类型
  6. }

        设置生命条脚本:为生命条UIHealthBar对象添加UIHealthBar脚本,并将遮罩Mask拖进脚本中的Mask属性。

14、NPC和对话

        创建NPC:根据Project中已有资源创建一个名为Jambi的青蛙NPC,基本流程:Sprites资源 -> 动画 -> 碰撞 -> NPC图层 -> 预制件。

        射线投射:射线投射是将射线投射到场景中并检查该射线是否与碰撞体相交的行为,射线具有起点、方向和长度,通过Physics2D.Raycast(起点, 方向, 距离, 图层遮罩)函数创建RaycastHit2D类型的对象来实现射线投射。由于时主角触发对话,因此在CharController脚本中新增代码。

  1. // CharController脚本新增
  2. void Update()
  3. { // ...
  4. if (Input.GetKeyDown(KeyCode.X)) // 检测按键X
  5. {
  6. RaycastHit2D hit = Physics2D.Raycast(rigidbody2d.position + Vector2.up * 0.2f, lookDirection, 1.5f, LayerMask.GetMask("NPC")); // 射线投射对象
  7. // 检测以主角碰撞体上方0.2f为起点的NPC图层的lookDirection方向的1.5f内的碰撞体
  8. if (hit.collider != null)
  9. { // 发生碰撞时的事件(对话)
  10. Debug.Log("Raycast has hit the object ");
  11. }
  12. }
  13. }

        对话UI:对话与HUD一样,使用画布Canvas实现。主要包括Canvas画布、对话框图片、文本。由于对话是触发时才展示的,需要在Inspector中禁用。

        Canvas画布:首先为NPC对象Jambi创建一个Canvas子对象,由于对话UI是在世界里的,将Render Mode设为World Space(忽略Event Camera告警,仅对有交互的UI有效果)。通过缩放画布使之在场景中的尺寸合适,在Rect Transform中将PosX和PosY设为0,Width和Height分别设为300、200,Scale的X、Y和Z设为0.01。在Inspector中将画布的Order In Layer设为较高值,确保没有其他对象渲染在其上方。

        对话框图片:为画布新建一个UI->Image子对象,分配Art->Sprites->UI中的UIDialogBox精灵,并在锚点中设置填充父对象。

        文本:为Image对象新建一个UI->Text-TextMeshPro对象,并点击Import TMP Essentials以导入Text对象的资源(首次使用时需导入)。在锚点中设置填充父对象,通过移动四条边上的白色方块调整Text对象的文本框至合适的尺寸、位置。在Inspector中,在Text属性中输入文字以显示需要的文本,并设定合适的字体等。

        显示对话:为NPC添加一个名为NonPlayerChar的脚本。该脚本的功能主要是碰撞发生而显示对话,并在倒计时清零时禁用对话,内容如下。最后为脚本的DialogBox属性分配对话UI的Canvas对象。为CharController脚本添加碰撞触发对话事件。

  1. // NonPlayerChar脚本
  2. public class NonPlayerChar: MonoBehavior
  3. {
  4. public GameObject DialogBox; // 对话框对象(canvas画布)
  5. public float displayTime = 4.0f; // 对话框展示计时器
  6. float timerDisplay;
  7. void Start()
  8. {
  9. dialogBox.SetActive(false); // 确保禁用对话框
  10. timerDisplay = -1.0f; // 初始化计时
  11. }
  12. void Update()
  13. {
  14. if (timerDisplay >= 0)
  15. {
  16. timerDisplay -= Time.deltaTime; // 计时
  17. if (timerDisplay <= 0)
  18. {
  19. dialogBox.SetActive(false); // 计时器再次为0时,禁用对话框
  20. }
  21. }
  22. }
  23. }
  1. // CharController脚本新增
  2. void Update()
  3. { // ...
  4. if (Input.GetKeyDown(KeyCode.X))
  5. {
  6. RaycastHit2D hit = Physics2D.Raycast(rigidbody2d.position + Vector2.up * 0.2f, lookDirection, 1.5f, LayerMask.GetMask("NPC"));
  7. if (hit.collider != null)
  8. { // 发生碰撞时的事件(对话)
  9. NonPlayerCharacter character = hit.collider.GetComponent<NonPlayerCharacter>();
  10. if (character != null)
  11. {
  12. character.DisplayDialog(); // 获取到NPC对象,展示对话
  13. }
  14. }
  15. }
  16. }

15、声音系统

        Unity的声音系统包括:音频剪辑(声音片段资源)、音频监听器(空间化声音,模拟声音的方向,一般跟随摄像机)、音频源(游戏对象的播放音频剪辑的组件,通过与音频监听器的相对位置可以混合声音制作立体声)。

        背景音乐:创建一个空对象命名为BGM,为其添加一个Audio Source组件。将Project中现有的2DMUSICLOOP的音频剪辑拖进AudioClip属性中。勾选Loop属性,保证bgm会不断地循环。将Spatial Blend属性设为3D,保证声音被空间化(立体声)。通过Volumn属性来调节音量。(在Game视图中点击Mute Audio可以静音。)

        一次性声音:用两个实现方式:第一种是为每个发声对象创建一个音频源,通过脚本控制事件发生时播放,但这种方法需要为每一种对象设置;第二种是音频源添加在主角预制件中,通过为主角脚本CharController添加PlayOneShot函数,事件触发时使用音频源的设置播放一次音频剪辑,即通过主角的音频源播放被碰撞对象上的音频剪辑(在被碰撞对象销毁后也可以播放)。

        第二种添加一次性声音的方法:首先修改主角的CharController脚本,为其添加一个PlaySound()函数以播放其他对象的音频剪辑,再为收集物脚本添加碰撞时触发调用主角脚本的PlaySound()函数。将Project->Audio中现有的Collectable音频剪辑拖进收集物的HealthCollectable脚本组件的audioClip属性中。

  1. // CharController脚本新增
  2. AudioSourece audioSource; // 音频源
  3. void Start()
  4. { // ...
  5. audioSource = GetComponent<AudioSource>(); // 获取音频源对象
  6. }
  7. pulic void PlaySound(AudioClip clip)
  8. {
  9. audioSource.PlayOneShot(clip); // 主角的音频源播放一次音频剪辑
  10. }
  1. // 脚本新增
  2. public AudioClip audioClip; // 音频剪辑的公共变量
  3. void OnTriggerEnter2D(Collider 2D)
  4. { // ...
  5. controller.PlaySound(audioClip); // 调用主角的音频源播放音频剪辑
  6. }

        空间化声音/立体声:对于剧情声音,要根据距离/方向来调整播放的声道/音量,以提高声音的真实感。以敌人的步行声音为例,首先为机器人预制件添加一个音频源组件,并设定AudioClip、Loop、Spatial Blend。在Scene中,机器人的扬声器标识旁出现蓝色圆圈,表示空间声音混合的最小距离(大于该距离,声音将从最大音量衰减,直到最大距离处的音量衰减为0)。最大距离的设定在AudioSource组件的3D Sound Settings部分中,将Min DistanceMax Distance设为合适的值。

        修正音频衰减/修正音频监听器位置:音频监听器Audio Listener是Camera对象的子对象,其位置与Camera一致。将Scene改为3D模式,可以看到Camera并不是在世界中,而是有一定的Z坐标,而音频源的范围是一个球体。这是因为声音系统是3D设计的,因此有可能无法听到机器人的声音。修正方法:为主摄像机MainCamera添加一个空的子对象,为这个子对象添加AudioListener组件,在Tansform中将坐标设为(0, 0, 10)(相对于父对象,摄像机是相对于世界平面-10的),最后将摄像机的AudioListener组件删除。

 16、构建游戏

        Player设置:从编辑器创建的用于将游戏分发给用户的应用程序。在菜单栏Editor->Project Settings -> Player打开Player界面,可以设置Company Name(公司名称,会创建文件夹用于存储在构建游戏过程中创建的文件)、Product Name(游戏名称)、Default Icon(图标)、Default Cursor(系统的光标)以及不同平台(PC/Android/MAC/Linux等)的设置。

        构建游戏:通过菜单栏File->Build Settings打开Build窗口。Scene In Build中会列出游戏包含的所有场景,需要排除用于调试的场景。点击Add Open Scenes可以添加处于打开状态的场景。在Platform中选择游戏运行的平台,要添加其他平台,需要在UnityHub中安装其他版本的Unity组件,详细过程参考:Unity - Manual: Platform development。点击Build按钮后将开始构建过程,选择合适的存储文件夹,之后Unity将压缩打包所有Assets并忽略未使用的资源,构建期间,Unity编辑器无法操作。构建完成后,将在相应的文件夹中找到相应平台的可执行文件。

1111

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/凡人多烦事01/article/detail/122651
推荐阅读
相关标签
  

闽ICP备14008679号