找回密码
 立即注册
查看: 445|回复: 0

1.15 从0开始学习Unity游戏开发--游戏UI

[复制链接]
发表于 2023-4-19 07:40 | 显示全部楼层 |阅读模式
上一章中,我们剩下最后一个任务,需要支持鼠标控制准心来进行设计,那么准心本质上就是一个始终呈现在屏幕上的一个图片,你当然可以用一个3D物体来制作,之前讲解渲染概念的时候也提到过,我们的屏幕就是相机的近裁面,只要我们将3D物体保持放在近裁面的地方,我们就可以保证这个物体看起来就像是一直在屏幕上一样。
但是这样做需要你在可能极小的区域(近裁面往往只有0.1或者更小)进行物体摆放,非常的不方便,而且3D物体本身也没有专门针对UI所需的内容进行处理,还得自己造轮子,那么Unity为了方便游戏内制作UI,提供了一套内置的方案:UGUI。
UGUI

UGUI其实就是个名字,本质上是Unity提供了一套特殊的组件,这套组件的渲染流程走的不是Mesh Renderer,而是专门针对渲染在屏幕上的内容进行特化,如果之前做过传统软件的UI开发,那么类似Qt,WPF,MFC之类的框架都会有很多控件例如Button,Label等,方便快速的构建一个界面出来,显然UGUI也应该会提供类似的东西。
那么我们现在开始创建UI内容,同样的和创建普通的3D物体一样的路径,我们也是在Hierarchy里面右键选择UI->Image,会突然多出来好多东西:


我们慢慢解释:

  • Canvas物体:
之所以我这里叫物体,是因为这个物体里面有个组件也叫Canvas,为了区分,我们把Hierarchy里面存在的GameObject叫Canvas物体,我们分析这个物体自动给创建的组件有哪些:


这里RectTransform组件其实就是继承自Transform组件,这里Unity覆盖了原本的Transform组件面板的数据显示,直接显示RectTransform,也算是Unity默认搞了点潜规则,毕竟这里编辑UI的3D坐标数据没啥意义。
而RectTransform里面其实配置的参数就跟我们做传统软件UI上的锚点,定位,像素位置,大小,就类似了,这里之所以不能改,因为这个Canvas物体上层没有更多拥有Canvas组件的父元素了,那这个Canvas本身就代表了全屏,所以你试着调整Game窗口的大小,这个里面的数值就会跟着变,如果是子元素的话,则这里是可以修改的,后面我们会看到。
Canvas组件就是类似Mesh Renderer组件的功能,承载物体下面所有子元素里面包含的UI内容的渲染,相比于我们渲染3D物体需要提供Mesh,Material之类的东西,这里Unity屏蔽了这些细节,只需要我们指定RenderMode,渲染到哪个显示目标等等。这里我们只需要暂时关注Render Mode,默认情况下是Screen Space - Overlay,说人话其实就是Unity会用一套潜规则逻辑,独立的将你这坨UI内容渲染到屏幕上,至于这个潜规则是啥,以及其他RenderMode又怎么用,等到进阶教程里面会详细聊使用场景,现在我们只需要知道这个组件就是承载UI显示用的。
Canvas Scaler和Graphics Raycaster暂时可以不管,遇到了我们再讲。


  • EventSystem,这个其实就是UI系统需要使用的输入事件如何处理的逻辑,里面有两个组件,但是我们现在先不用关注它,反正只要知道UI需要这个组件才能响应点击之类的操作。


  • Image
最后就是我们这次需要画准心用的Image


可以看到,RectTransform组件已经可以修改值了,我们可以修改里面的数据来调整位置大小和布局。
Canvas Renderer组件则是告诉父元素里面的Canvas组件,这里有一个需要渲染的UI元素。
Image组件则是提供需要渲染的内容,可以直观的看到这里Source Image肯定就是我们要显示的图片赋值的地方,Color则是给这个图片叠加一个颜色,这里默认是白色,也就是说我们即使不赋值图片,也会显示白色。其他的参数部分我们先不管。

解释完默认创建的组件,其实这里Canvas和EventSystem都是因为要显示一个Image所必须的东西,Unity检查你场景里面没有这两个东西就默认给创建了,如果你现在再到Canvas物体里面创建一个Image,则只会出现一个新的Image,不会再继续额外创建Canvas和EventSystem。
那么现在我们看一下场景内的现象:


可以看到我们场景里面出现了一个硕大的白色,Game里面左下角也显示了一个白色的块,这其实就是我们的Image显示出来的效果。
那么如何编辑呢?我们滑动鼠标滚轮,把Scene场景的视角拉远多一些:


可以看到我们UI显示的部分(白色线框)以及我们的白色Image显示的位置,而我们的Cube和Wall都远到看不清了。这其实就是Unity给我们默认做的一个效果,RenderMode为Screen Sapce -Overlay的情况下,会在坐标原点的位置生成一个硕大的屏幕来承载我们的UI显示内容,当然这只是在Scene窗口里面是这样,而Game窗口则会老老实实的显示在屏幕的位置上。
知道了这一点,其实我们就知道了,UI的编辑跟编辑3D场景物体没有什么两样,我们同样可以拖动坐标轴箭头来移动我们的Image,当然我们在3D视角下是有透视投影的,如果我们希望做平面设计,那么Scene窗口顶部的工具条上有个2D的按钮,按下去会直接变成2D的模式,这样更方便调整UI。
制作准心图片

当然我们目前的需求是做一个准心,不是白块,所以我们需要找一张准心图片,直接随便搜一张吧:
这个链接是个jpg图片,自己存下来,放到工程的Assets文件夹下任意位置。
我这里改了个名字叫aim,选中它,然后在Inspector面板上指定这个图片的用途:


选择Sprite(2D and UI),这个类型是我们的Image组件Source Image需要的类型,然后不要忘记点Apply让这个选择生效,生效后可以看到我们的图片正常显示了透明背景。
然后接下来就是需要将这个图片拖拽赋值给Image组件:


如果发现拖拽赋值不上,那看看是不是上一步没有做好,类型不对是不会让赋值的。
赋值成功后可以看到左侧Game窗口里面显示了我们的准心图片。
如果觉得不够显眼,可以尝试自己换其他图片,或者将Image组件里面的Color参数改成红色之类的叠加上去,这样更鲜艳。
动态修改UI元素的位置

上面讲了,我们只需要修改RectTransform的PosX或者PosY就可以修改准心的位置,我们需要的是运行的时候根据鼠标的位置来动态的调整准心位置。
既然我们需要动态的根据逻辑调整,当然是需要新建一个组件放在Image这里来通过代码调整,很快啊,我让GPT给我写了一个
using UnityEngine;

public class AimController : MonoBehaviour
{
    private RectTransform rectTransform;

    private void Start()
    {
        rectTransform = GetComponent<RectTransform>();
    }

    private void Update()
    {
        rectTransform.anchoredPosition = Input.mousePosition;
    }
}
这里指的注意的是,虽然RectTransform继承自Transform,但是我们不会直接使用position,应为这个坐标是3D空间的坐标,不是我们的UI系统下的坐标,取而代之的是使用anchoredPosition,这个是个Vector2,正好就是我们屏幕坐标X和Y,我们直接把鼠标坐标给赋值上去。
跑起来看看?


奇怪,为什么准心跟鼠标固定有一个间隔呢?我们再看看这个anchoredPosition的说明:
The position of the pivot of this RectTransform relative to the anchor reference point.
翻译过来就是,这个坐标是这个组件的pivot点相对于anchor点的坐标。
说人话:
pivot就是所谓的中心点,不论是移动,旋转,这个Image肯定是需要有个中心点的,那么这些都是靠这个中心点计算,Image本身是有形状大小的,那么中心点可以相对于真实的形状中心做调整。
anchors感觉很神秘,其实就是相对于父元素的布局,点开里面这个地方可以很直观的选择几种布局方法:


详细的定义可以阅读官方文档:
可以看到我们默认的布局是相对于父元素居中,但是Input.mousePosition是从左下角开始为坐标原点,所以我们这里相当于在以屏幕中心点偏移一个鼠标相对于左下角的偏移量,那么鼠标永远会和准心相差半个屏幕。
那么我们可以简单的选择左下角的对齐方式来修改Anchor:


修改完毕后再跑一下游戏,看看是不是准心已经跟手了。
发射子弹

好了,现在我们准心有了,那么就剩下发射子弹了,想象一下,如果我们要对准准心发射子弹,那么准星应该就是对准的远处被预期击中的位置,也就是说准心是覆盖在被瞄准的物体上,再说直白一点就是你的眼睛到准心的连线的延长线能和被瞄准的物体相交。
眼睛的位置其实就是相机的位置,这个很好理解。
但是我们准心在屏幕上啊?没关系,我们之前也说了,屏幕就是近裁面,屏幕上的任何点可以换算到3D空间坐标下。
这种常见的算法,Unity都给我们封装了:
看到我们需要传入屏幕坐标xy,但是还有一个z是什么呢?就是距离相机的距离(其实就是说屏幕上一个点可以覆盖3D世界里面无数个点,那这些点就由离相机的距离来决定),屏幕上的UI固定是距离一个近裁面的距离,也即是我们之前有看过Camera组件里面的那个Near的值。
而Camera就是我们用的MainCamera,好我们修改一下脚本:
AimController里面我们计算一下相机到准心的方向,并且传递给我们之前的开火控制代码,一样的,如果我们需要引用其他功能模块,除了传统的代码设计中通过直接传实例,如果类本身是MonoBehaviour,那我们可以通过设置成public成员来提供给编辑器里面拖拽赋值(虽然对程序员来说并不是很友好)。
using UnityEngine;

public class AimController : MonoBehaviour
{
    public Camera mainCamera;
    public FireController fireController;

    private RectTransform rectTransform;

    private void Start()
    {
        rectTransform = GetComponent<RectTransform>();
    }

    private void Update()
    {
        rectTransform.anchoredPosition = Input.mousePosition;
        // 获取屏幕上当前鼠标位置(也就是准心位置)所在的3D空间位置
        Vector3 aimWorldPosition = mainCamera.ScreenToWorldPoint(new Vector3(Input.mousePosition.x,
            Input.mousePosition.y, mainCamera.nearClipPlane));
        // 通过坐标相减可以得到方向向量
        Vector3 fireDirection = aimWorldPosition - mainCamera.transform.position;
        // 归一化后传递给开火控制脚本
        fireController.SetDirection(fireDirection.normalized);
    }
}
注意这里新增了一个public成员,用来让我能在编辑器里面赋值具体调用SetDirection是对谁调用的,所以我们需要在Image的AimController里面拖拽赋值上我们之前创建的FireController物体,需要注意的是,我们这里并不是因为物体叫FireController才可以拖拽,而是这个GameObject拥有一个FireController类的组件才可以拖拽,这也意味着我们可以不仅限于赋值GameObject,直接赋值组件本身也是ok并且更推荐的。
好,接下来就是改写一下FireController,之前我们只是单纯创建子弹,并没有提供发射方向,现在接收到了来自瞄准功能的方向,我们需要让子弹创建的时候设入飞行方向:
using UnityEngine;

public class FireController : MonoBehaviour
{
    private bool isMouseDown = false;
    private float lastFireTime = 0f;
    private Vector3 fireDirection;
    public float fireInterval = 0.1f;
    public AddVelocity bullet;

    void Update()
    {
        if (Input.GetButton("Fire1"))
        {
            if (!isMouseDown)
            {
                isMouseDown = true;
                lastFireTime = Time.time;
                Fire();
            }
            else if (Time.time - lastFireTime > fireInterval)
            {
                lastFireTime = Time.time;
                Fire();
            }
        }
        else
        {
            isMouseDown = false;
        }
    }

    void Fire()
    {
        // 在这里实现每次触发的逻辑

        // 创建新的子弹,每次都是从模板bullet复制一个出来
        AddVelocity newBullet = Object.Instantiate(bullet);
        newBullet.SetDirection(fireDirection);
    }

    public void SetDirection(Vector3 direction)
    {
        fireDirection = direction;
    }
}
可以看到我们补上了SetDirection函数的实现,将值存储为成员变量,然后我们需要给每个新创建的子弹都传入这个速度,所以子弹也需要有接口,但是我们之前创建子弹是直接用GameObject,这里我们直接偷个懒,直接用上面仅有的我们自己写的组件类AddVelocity,注意看,我们的bullet成员从原先的GameObject类变为了AddVelocity,这样我们将能够直接用AddVelocity类型实例化GameObject,并且返回值是AddVelocity,这样做的效果相当于实例化GameObject后又使用GetComponent获取到了上面的AddVelocity组件,但是写法更加优雅高效(每次动态调用GetComponent是相对更慢的)。
那么最后我们要做的就是在AddVelocity里面也实现SetDirection来实现给子弹赋予飞行方向:
using UnityEngine;

public class AddVelocity : MonoBehaviour
{
    public float speed; // 初始速度
    public float lifeTime = 5.0f;
    private float lifeStartTime;
    private Vector3 fireDirection;

    void Start()
    {
        Rigidbody rb = GetComponent<Rigidbody>();
        if (rb != null)
        {
            rb.velocity = fireDirection * speed;
        }

        lifeStartTime = Time.time;
    }

    void Update()
    {
        if (Time.time - lifeStartTime > lifeTime)
        {
            Destroy(gameObject);
        }
    }

    public void SetDirection(Vector3 direction)
    {
        fireDirection = direction;
    }
}
这里除了存入方向,我们还修改了之前的初速度Vector3为float类型的speed变量,因为我们不再需要人工指定速度方向,取而代之的我们需要调节这个初速度的强度,也就是我们将会有一个系数乘上去,所以最终我们初速度就是fireDirection * speed。
因为修改了成员,我们同样需要对Bullet prefab修改speed数值到一个比较舒服的值,比如说10。
需要注意的是,因为我们在FireController里面修改了bullet成员的类型,之前编辑器里面拖拽赋值的内容是以GameObject类型绑定上去的,所以当我们改了代码之后,这里赋值已经无效化,Unity会帮我们清空掉,我们不要忘记重新拖拽Bullet prefab去赋值到FireController的bullet成员上去。
好了,跑一下游戏看看?


好家伙,准心和子弹各管各的,子弹一直都没有改变自己的方向,那么问题出在哪里呢?还记得上一章我们讲过,新创建出来的GameObject的位置默认是原点(当然Prefab自己已有位置信息就另说),那么我们无论怎么修改方向,发射其实都是从原点开始,根本不是从我们的眼睛射出的,所以我们需要修改这个发射起点,那么我们目前有两个办法:

  • 看一下目前相机的位置信息,然后手写到子弹创建的代码那里赋值,这样做肯定是不符合后续易于维护的目的,只要相机自己变了位置就完蛋了。
  • 把相机的位置信息传过来
很显然第二种是更好的,那么顺理成章的我们需要在FireController里面新增一个开火起始点:
using UnityEngine;

public class FireController : MonoBehaviour
{
    private bool isMouseDown = false;
    private float lastFireTime = 0f;
    private Vector3 fireDirection;
    public float fireInterval = 0.1f;
    public AddVelocity bullet;
    public Transform fireBeginPosition;

    void Update()
    {
        if (Input.GetButton("Fire1"))
        {
            if (!isMouseDown)
            {
                isMouseDown = true;
                lastFireTime = Time.time;
                Fire();
            }
            else if (Time.time - lastFireTime > fireInterval)
            {
                lastFireTime = Time.time;
                Fire();
            }
        }
        else
        {
            isMouseDown = false;
        }
    }

    void Fire()
    {
        // 在这里实现每次触发的逻辑

        // 创建新的子弹,每次都是从模板bullet复制一个出来
        AddVelocity newBullet = Object.Instantiate(bullet);
        newBullet.transform.position = fireBeginPosition.position;
        newBullet.SetDirection(fireDirection);
    }

    public void SetDirection(Vector3 direction)
    {
        fireDirection = direction;
    }
}
我们新增了一个fireBeginPosition的变量,但是是Transform类型,之前我们也一直说过Transform组件存储物体位置信息,我们大可不必赋值一个相机,我们要的只是位置。
拿到位置后,我们在子弹创建的时候通过newBullet.transform.position = fireBeginPosition.position;赋值给新创建的子弹改变它的初始位置。
改完代码后我们在编辑器里面拖拽Main Camera到FireController的fireBeginPosition成员上赋值。


然后我们再跑一下游戏看看?


https://www.zhihu.com/video/1630713940639543297
看起来并不是发射子弹,而是投石机,不管怎么样至少这个瞄准是起效了。
思考题


  • 上面我们有说到,子弹实际上为了更加符合瞄准哪里就应该打哪里的直觉,应该从眼睛里面射出,但是实际上子弹应该是从枪管射出,而枪管位置肯定不是眼睛的位置,那么让子弹从枪管射出是否可以实现,如果可以实现,当眼睛能瞄准物体但枪管却被墙挡住的时候,应该如何处理?
  • 最后我们成功的向预期方向射出子弹,但是速度有点慢,你尝试加大子弹配置的speed参数,以便让子弹能真飞直一点,但是却发现子弹都穿过墙体飞过去了,为什么没有碰撞反弹?如何解决?
下一章

本章我们详细讲解了Unity中UI制作怎么入门,然后顺带做完了整套瞄准射击的逻辑,并以此理顺了场景中多个逻辑之间交互都是怎么沟通和传递信息的。
下一章我们将会加入人物运动,在不涉及人物骨骼动画等更复杂的东西的前提下,简单的实现一个三人称视角的操作模式和人物运动控制方法,以此可以实现三人称下射击demo。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-23 03:44 , Processed in 0.066778 second(s), 23 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

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