UI

UI设置思路

准备工作

  1. 在Project窗口中创建重要文件夹:
    • Resources
    • Scripts
    • StreamingAssets:用于存放json数据文件
    • ArtRes
  2. 创建UGUI对象,设置Canvas(可以把EventSystem和用于渲染UI的摄像机作为Canvas的子对象,防止被删除)
    • 设置渲染模式,关联UI摄像机(只渲染UI层,Depth Only,移除音效组件)
    • 修改主摄像机参数(取消渲染UI层)
    • Canvas设置分辨率自适应(缩放模式)
    • 分辨率根据美术的sprite图来设置,没有就是1920x1080
    • 分析是竖屏游戏还是横屏游戏,横屏高不变,分辨率自适应来Match高

面板基类

假设我们的面板都需要实现显示、隐藏自己、淡入淡出的功能。我们可以用一个面板基类来控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
using UnityEngine.Events;

public abstract class BasePanel : MonoBehaviour
{
// 整体控制淡入淡出的画布组 组件
private CanvasGroup canvasGroup;
// 淡入淡出的速度
private float alphaSpead = 10;

// 是否开始显示
private bool isShow;

// 当自己淡出成功的时候执行的函数
private UnityAction hideCallBack;

protect virtual void Awake()
{
// 一开始获取面板上挂载的组件
// 如果没有 我们通过代码为他添加一个
canvasGroup = this.GetComponent<CanvasGroup>();
if(canvasGroup == null)
canvasGroup = this.gameObject.AddComponent<CanvasGroup>();
}

protect virtual void Start()
{
Init();
}

// 主要用于初始化按钮事件的监听等等
public abstract void Init();

public virtual void ShowMe()
{
isShow = true;
canvasGroup.alpha = 0;
}

public virtual void HideMe(UnityAction callBack)
{
isShow = false;
canvasGroup.alpha = 1;
// 记录当淡出成功时要执行的委托函数
hideCallBack = callBack;
}

void Update()
{
// 淡入
if(isShow && canvasGroup.alpha != 1)
{
canvasGroup.alpha += alphaSpeed * Time.deltaTime;
if(canvasGroup.alpha >= 1)
canvasGroup.alpha = 1;
}
// 淡出
else if(!isShow)
{
canvasGroup.alpha -= alphaSpeed * Time.deltaTime;
if(canvasGroup.alpha <= 0)
{
canvasGroup.alpha = 0;
// 应该让管理器 删除自己
hideCallBack?.Invoke();
}
}
}
}

UI管理器

因为UGUI制作的面板都是动态的创建和删除的,所以我们可以用一个UI管理器来整体管理面板的显示、隐藏、获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class UIManager
{
private static UIManager instance = new UIManager();
public static UIManager Instance => instance;

// 存储面板的容器
private Dictionary<string, BasePanel> panelDic = new Dictionary<string, BasePanel>();
// 应该一开始 就得到我们的Canvas对象
private Transform canvasTrans;

// 构造函数
private UIManager()
{
// 得到场景上创建好的Canvas对象
canvasTrans = GameObject.Find("Canvas").transform;
// 让Canvas对象 过场景不移除
// 我们都是通过 动态创建和动态删除来显示、隐藏面板的 所以不用删除它
GameObject.DontDestroyOnLoad(canvasTrans.gameObject);
}

// 显示面板
public T ShowPanel<T>() where T:BasePanel
{
// 我们只需要保证 泛型T的类型和面板名一致
string panelName = typeof(T).Name;

// 是否已经有显示着的该面板了 如果有直接给外部使用
if(panelDic.ContainsKey(panelName))
return panelDic[panelName] as T;

// 显示面板 就是动态创建面板预设体 设置父对象
// 根据得到的类名 就是我们的预设体面板名 直接动态创建它
GameObject panelObj = GameObject.Instantiate(Resources.Load<GameObject>("UI/" + canvasTrans));
panelObj.transform.SetParent(canvasTrans, false);

// 接着 就是得到对应的面板脚本 存储起来
T panel = panelObj.GetComponent<T>();
// 把面板脚本存储到 对应容器中 之后方便获取
panelDic.Add(panelName, panel);
// 调用显示自己的逻辑
panel.ShowMe();

return panel;
}

// 隐藏面板
// 参数:如果希望淡出就默认传true 如果希望直接隐藏(删除)面板就传false
public void HidePanel<T>(bool isFade = true) where T:BasePanel
{
// 根据泛型类型得到面板名字
string panelName = typeof(T).Name;
// 判断当前显示的面板存不存在于字典中
if(panelDic.ContainsKey(panelName))
{
if(isFade)
{
panelDic[panelName].HideMe(() =>
{
// 面板淡出成功后 希望删除面板
GameObject.Destroy(panelDic[panelName].gameObject);
// 删除面板后从字典移除
panelDic.Remove(panelName)
});
}
else
{
// 删除面板
GameObject.Destroy(panelDic[panelName].gameObject);
// 删除面板后从字典移除
panelDic.Remove(panelName)
}
}
}
// 获得面板
public T GetPanel<T>() where T:BasePanel
{
string panelName = typeof(T).Name;
if(panelDic.ContainsKey(panelName))
return panelDic[panelName] as T;
// 如果没有 直接返回空
return null;
}
}

注意:使用这种方式必须保证类名和预设体名字一致

面板实例

有了上述的面板基类和UI管理器,我们可以正式来实现面板逻辑了。以下以一个提示面板作为示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using UnityEngine.UI;

public class TipPanel : BasePanel
{
// 确定按钮
public Button btnSure;
// 提示文字
public Text txtInfo;

public override void Init()
{
// 初始化 按钮事件监听
btnSure.onClick.AddListener(() => {
// 隐藏自己
UIManager.Instance.HidePanel<TipPanel>();
});
}

// 改变提示内容的方法 提供给外部使用
public void ChangeInfo(string info)
{
txtInfo.text = info;
}
}

另外,由于我们的代码没有入口,还需要写一个Main函数,之后我们只需要创建一个空物体取名为Main,再将此脚本拖上去,UI面板就都可以显示了。

1
2
3
4
5
6
7
8
9
public class Main : MonoBehaviour
{
void Start()
{
// 显示 提示面板
TipPanel tipPanel = UIManager.Instance.ShowPanel<TipPanel>();

}
}

特殊部件

蓄能条

蓄能条的表现形式是长按蓄能,我们可以通过UGUI里的长按监听事件来完成。

其中,这里的实现思路是,GamePanel上有一个长按按钮,通过长按它来使GamePanel上的蓄能条改变,为了更好的体现面向对象的思路,我们可以这样操作:

  • 按钮单独处理抬起、按下逻辑
  • 按钮中有抬起、按下的事件装载了抬起、按下要实现的具体逻辑
  • GamePanel中实现这里面的具体逻辑

下面是处理长按按钮的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using UnityEngine.Events;
using UnityEngine.EventSystems;

// 长按按钮脚本 提供两个事件给外部 让外部去处理对应的逻辑
public class LongPress : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
public event UnityAction upEvent;
public event UnityAction downEvent;

// 实现IPointerDownHandler的函数
public void OnPointerDown(PointEventData eventData)
{
downEvent?.Invoke();// 当事件不为空的时候执行
}

// 实现IPointerUpHandler的函数
public void OnPointerDown(PointEventData eventData)
{
upEvent?.Invoke();
}
}

然后再GamePanel脚本上处理具体逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 关联长按功能按钮
public LongPress longPress;

// 进度条根对象 用于控制显隐
public GameObject imgRoot;
// 进度条对象 用于控制进度
public RectTransform imgBk;

public void Start()
{
// 向长按功能按钮中的事件加函数
longPress.downEvent += BtnDown;
longPress.upEvent += BtnUp;

// 最开始隐藏蓄能条
imgRoot.gameObject.SetActive(false);
}

// 是否按下
private bool isDown = false;
// 计时
private float nowTime = 0;
// 增加速度
public float addSpeed = 10;

private void BtnDown()
{
isDown = true;
nowTime = 0;
// 蓄能条清空
imgBk.sizeDelta = new Vector2(0, 40);
}

private void BtnUp()
{
isDown = false;
imgRoot.gameObject.SetActive(false);
}

private void Update()
{
if(isDown)
{
// 计时
nowTime += Time.deltaTime;
if( nowTime >= 0.2f)
{
imgRoot.gameObject.SetActive(true);
// 蓄能条增加
imgBk.sizeDelta += new Vector2(imgBk.sizeDelta * Time.deltaTime, 0);
if( imgBk.sizeDelta.x >= 800 )
{
// 蓄满后逻辑略
// 蓄能条清空
imgBk.sizeDelta = new Vector2(0, 40);
}
}
}
}

进度条

有两种情况可以做进度条。

  1. 有美术资源(不易被拉伸)—— Image改为填充模式
  2. 无美术资源 —— 按住shift键左对齐,改变宽

背包

接下来用UGUI中的滚动视图来制作背包的储存格子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using UnityEngine.UI;

public class BagPanel : MonoBehaviour
{
public static BagPanel panel;

public Button btnClose;

// 滚动视图
public ScrollRect svItems;

private void Awake()
{
panel = this;
}

void Start()
{
// 动态创建n个图标 作为滚动视图中显示的内容
for(int i = 0; i < 30; i ++)
{
GameObject item = Instantiate(Resources.Load<GameObject>("Item"));
// 设置这些图标的父对象 便于管理位置变化
item.transform.SetParent(svItems.content, false);
// 设置格子的位置:初始位置加格子间隔
item.transform.localPosition = new Vector3(10, -10, 0) + new Vector3(i % 4 * 160 , -i / 4 * 160, 0);
}

// 设置content的高
svItem.content.sizeDelta = new Vector2(0, Mathf.CeilToInt(30 / 4f) * 160);

btnClose.onClick.AddListener(() => {
gameObject.SetActive(false);
});

// 一开始隐藏自己
this.gameObject.SetActive(false);
}
}

摇杆

整个功能分为两步:

  • UI界面摇杆的控制(向量、EventTrigger
  • 摇杆移动带动玩家移动的具体逻辑(四元数)

我们先来实现第一步:

  1. 先为摇杆子对象添加EventTrigger
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
using Unity.EventSystems;

public class GamePanel : MonoBehaviour
{
public RectTransform imgJoy;
// 摇杆上的事件相关
public EventTrigger et;
}

void Start()
{
// 为摇杆注册事件
// 拖动中
EventTrigger.Entry en = new EventTrigger.Entry();
en.eventID = EventTriggerType.Drag;
en.callback.AddListener(JoyDrag);
et.triggers.Add(en);

// 结束拖动
EventTrigger.Entry en = new EventTrigger.Entry();
en.eventID = EventTriggerType.EndDrag;
en.callback.AddListener(EndJoyDrag);
et.triggers.Add(en);
}

private void JoyDrag(BaseEventData data)
{
PointerEventData eventData = data as PointerEventData;
// 屏幕坐标转UI坐标
Vector2 nowPos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
imgJoy.parent as RectTransform,
eventData.position,
eventData.enterEventCamera,
out nowPos );
imgJoy.localPosition = nowPos;

// 判断当前位置与中心点的向量模长是否超出边界
if(imgJoy.anchoredPosition.magnitude > 170)
{
// 拉回来
// 单位向量 * 长度 = 临界位置
imgJoy.anchoredPosision = imgJoy.anchoredPosition.normalized * 170;
}

// 让玩家转动
player.Move(imgJoy.anchoredPosition);
}

private void EndJoyDrag(BaseEventData data)
{
// 回归中心点
imgJoy.anchoredPosition = Vector2.zero;
// 停止移动,传零向量即可
player.Move(Vector2.zero);
}

再处理控制玩家移动相关逻辑:

PlayerObject的脚本上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class PlayerObject : MonoBehaviour
{
public float moveSpeed = 10;
public float roundSpeed = 40;

// 当前的方向
private Vector3 nowMoveDir = Vector3.zero;

// 得到摇杆方向 方便处理后续转动逻辑
public void Move(Vector2 dir)
{
nowMoveDir.x = dir.x;
nowMoveDir.y = 0;
nowMoveDir.z = dir.y;
}

void Update()
{
if(nowMoveDir != Vector3.zero)
{
// 玩家朝面朝向移动
this.transform.Translate(Vwctor3.forward * moveSpeed * Time.deltaTime);
// 不停地朝目标方向转向
this.transform.rotation = Quaternion.Slerp(this.transform.rotation, Quaternion.LookRotation(nowMoveDir), roundSpeed * Time.deltaTime);
}
}
}

使用UI Toolkit和UI Builder

UI ToolkitUI Builder 是 Unity 中新一代的 UI 开发工具链 —— 简单来说,UI Toolkit 是底层框架,UI Builder 是可视化编辑器,二者配合替代了传统的 UGUI,尤其适合做编辑器扩展和游戏内 UI。

接下来,我将简单的介绍UI ToolkitUI Builder 的使用方法(以下实例是编辑器扩展相关功能):

  1. 在Asset文件夹下创建一个Editor文件夹(只在编辑器模式下有,不被打包)

  2. 再在Editor文件夹下创建一个UI Builder文件夹,这时就可以创建UI Toolkit - Editor Window

这时,里面就会有3个可以打开的文件:

  • C#
  • UXML
  • USS

比较常用的是C#。

接着,在Assets/Editor/UI Builder/Item Editor(Visual Tree Asset)文件下,双击就可以打开编辑窗口。这个窗口相当于是Unity内置的一个编辑器,接下来介绍基础的使用方法。

  1. 将Library中的Visual Element当作一个空物体使用,拖入Hierarchy窗口。(在右侧的Inspector窗口中可以修改命名,拉伸方式等)
  2. Viewport中将想修改的地方选中,可以根据名字旁边的符号查看他的样式,符号的意思分别是:排列方向,对其方式(3种)

主玩家相关

人物移动

  1. 2D横板平台跳跃:AddForce添加一个力
  2. 伪Y轴游戏:移动坐标。

关键代码如下:

1
2
3
4
5
private void Movement()  
{
rb.MovePosition(rb.position + movementInput * speed * Time.deltaTime);
}

其中,rbRigidBody2D组件,调用了内置方法MovePosition,参数传最开始的位置 + 输入方向 * 速度 * 帧间隔时间。

并且,这个函数因为用到了物理系统,必须在FixedUpdate() 中调用,它会在下一个物理步长中平滑地将刚体移动到目标位置并且自动处理碰撞逻辑。

边界判断及摄像机跟随

有些游戏会涉及到有关玩家的移动,这时候可能会因为玩家移动或者分辨率的原因使玩家不能保持在屏幕内,这时需要处理一定的游戏逻辑来控制主玩家在屏幕范围内。


思路如下:

边界判断

记录玩家之前的位置和玩家现在的位置,判断玩家当前的位置,如果当前位置超出了屏幕范围,则把玩家位置拉回之前的位置。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 当前世界坐标转屏幕的位置
private Vector3 nowPos;
// 位移前玩家的位置
private Vector3 frontPos;

void Update()
{
// 其余逻辑省略

// 在位移之前 记录之前的位置
frontPos = this.transform.position;

// 位移逻辑省略

// 移动后进行极限判断
nowPos = Camera.main.WorldToScreen(this.transform.position);
// 左右 溢出判断
    if(nowPos.x <= 0 || nowPos.x >= Screen.width)
    {
    this.transform.position = new Vector3(frontPos.x, this.transform.position.y, this.transform.position.z);
    }
    // 上下 溢出判断
    if(nowPos.y <= 0 || nowPos.y >= Screen.height)
    {
    this.transform.position = new Vector3(this.transform.position.x, this.transform.position.y, frontPos.z);
    }
}

另外,如果人物移动时希望摄像机跟随,直接在Windows -> Package Manager搜索Cinemachine安装。再创建一个Cinemachine,选择2D Camera,设置它的FollowLook At对象。

限制摄像机范围的时候,需要在Cinaemachine VirtualCamera这个组件中的Add Extension中选择Cinemachine Confiner,此时会自动添加两个脚本。

我们的游戏可能采用多个场景,我们需要给这些场景都设置碰撞边界:为一个场景创建空物体命名为Bounds,为其添加Polygon Collider 2D编辑区域,并设置Points的四个角使其为整数点,勾选Is Trigger

不过,我们现在又面临另一个问题:不同场景中的物体不能相互关联,也就是说Bounds物体没有办法与Cinemachine Confiner关联,这时我们就可以通过代码控制每一次加载场景时,通过代码在场景加载好之前将二者关联。以下是实现思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using Cinemachine;  
using UnityEngine;

public class SwitchBounds : MonoBehaviour
{
//T000后面要改
private void Start()
{ SwitchConfinerShape();
} private void SwitchConfinerShape()
{ // 先找到标签一致的物体(代表边界的物体)的碰撞组件
PolygonCollider2D confinerShape = GameObject.FindGameObjectWithTag("BoundsConfiner").GetComponent<PolygonCollider2D>();
// 跟随摄像机的限制范围
CinemachineConfiner confiner = GetComponent<CinemachineConfiner>();
confiner.m_BoundingShape2D = confinerShape;
// 调用该函数在切换场景时清除缓存
confiner.InvalidatePathCache();
}}

跑步与走路切换(融合树实现)

要实现按下Shift键从走路切换到跑步,抬起时从跑步切换到走路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public calss Test : MonoBehaviour
{
private float dValue = 0.5f;

void Start()
{
animator = this.GetComponent<Animator>();
}

void Update()
{
animator.SetFloat("Speed", Input.GetAxis("Vertical") * dValue);

if(Input.GetKeyDown(KeyCode.LeftShift))
dValue = 1;
if(Input.GetKeyUp(KeyCode.LeftShift))
dValue = 0.5f;
}
}

控制角色部分旋转

思路:人物本来就在不停移动,在这个过程中,人物会一直看向一个点。在头部加上一个空对象,指向看向的点,当这个向量旋转时就会得到一个新的点,从而使人物部分旋转

![[Pasted image 20251208204210.png]]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Test : MonoBehaviour
{
private Animator animator;

public Transform headPos;
// x方向鼠标旋转了多少角度
private float chageAngleX;

void Start()
{
animator = this.GetComponent<Animator>();
}
void Update()
{
// 控制左右移动
animator.SetFloat("x", Input.GetAxis("Horizontal"));
animator.SetFloat("y", Input.GetAxis("Vertical"));

// 角度累加
chageAngleX += Input.GetAxis("Mouse X");
changeAngleX = Mathf.Clamp(changeAngleX, -30, 30);// 加紧函数限制范围
}
private void OnAnimatorIK(int layerIndex)
{
animator.SetLookAtWeight(1, 1, 1);
Vector3 pos = Quaternion.AngleAxis(changeAgleX, Vector3.up) * (headPos.position + headPos.forward * 10);
animator.SetLookAtPosition(pos);
}
}

地图

2D层级显示

当做有伪”z”轴的2D游戏时,需要调整各层的显示。

首先,将Edit-Project Setting-Graphics中的渲染模式改为Custom Axis,排序轴改为X 0 Y 1 Z 0

  1. 地板与人物:人物一直显示在地板上方。
    • 地板的Sprite RendererLayer始终低于人物的Sprite RendererLayer
  2. 墙体与人物:人物一直在后方墙体之前,前方墙体之后。
    • 后方墙体:和地板设置为同一层
    • 前方墙体:新建一个Tilemap渲染层,把它的Layer设置到人物的Layer之上
  3. 植物与人物:人物从后方经过植物时会被挡住,从前方经过植物时会挡住植物。
    • 方法1:新建一个Tilemap渲染层,把它的Layer设置与人物的Layer一致。层级一致的情况下会通过轴心点排序。(注意:要把Tilemap上的模式切换为Individual

    • 方法2:直接将植物拖为一个2D对象,更改他的排序方式为Pivot

    • 这两种方法的特点:

      • 方法一:方便快速拖动增加很多植物,不过一个菱形格子只能有一棵植物。
      • 方法二:不是很方便的添加,不过一个菱形格子可以有多棵植物。

景观物体相关

半透明效果

在游戏中,当我们的人物经过一个景观物体后面时,为了让表现效果更好,我们通常采用使前方的景观物体变成半透明来处理,下面是实现半透明效果的操作流程。

首先,我们要得到该景观物体上的SpriteRenderer

接下来,会使用一个常用来做动画的工具——DoTween

在Unity Asset Store下载DoTween,点击SetUP,Apply。

接着我们就能实现这个效果了,核心思路就是:当人物触发碰撞器时,改变alpha通道的值来实现半透明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using UnityEngine;  
using DG.Tweening;

[RequireComponent(typeof(SpriteRenderer))]
public class ItemFader : MonoBehaviour
{
private SpriteRenderer spriteRenderer;

private void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
}

// 逐渐恢复颜色
public void FadeIn()
{
Color targetColor = new Color(1, 1, 1, 1);
// 渐变方法
spriteRenderer.DOColor(targetColor, 0.35f);
}

// 逐渐半透明
public void FadeOut()
{
Color targetColor = new Color(1, 1, 1, 0.45f);
spriteRenderer.DOColor(targetColor, 0.35f);
}
}

坐标转换

屏幕坐标 -> UI本地坐标系

想完成屏幕坐标 -> UI本地坐标系(其中,UI本地坐标系的意思是以UI控件左下角为原点的坐标系),需要用到RectTransformUtility这个类。RectTransformUtilityRectTransform的辅助类。

一般配合拖拽事件使用。

核心方法:RectTransformUtility.ScreenPointToLocalPointInRectangle

直接以一个例子来讲解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using UnityEngine.EventSystem;

public class ScreenToUI : MonoBehaviour, IDragHandler
{
// 实现接口中的方法
public void OnDrag(PointerEventData eventData)
{
// 参数1:相对父对象,类型是RectTransform
// 参数2:屏幕点
// 参数3:摄像机
// 参数4:最终得到的点
Vector2 nowPos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
this.transform.parent as RectTransform,
eventData.position,
eventData.enterEventCamera,
out nowPos );

this.transform.localPosition = nowPos;
}
}