UGUI小实践

1.准备工作

导入了各种资源,创建所需文件夹,导入了json管理器,在数据持久化分类可详细了解

2.面板基类

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
using UnityEngine;
using UnityEngine.Events;

public abstract class BasePanel : MonoBehaviour
{
protected CanvasGroup canvasGroup;
protected bool isShow = false;
protected float alphaSpeed = 5f;
protected UnityAction onHideComplete;
protected virtual void Awake()
{
canvasGroup = this.GetComponent<CanvasGroup>();
if (canvasGroup == null)
canvasGroup = this.gameObject.AddComponent<CanvasGroup>();
}
protected virtual void Start()
{
//一个基本套路,将Init函数写进基类的start函数里,配合每个面板都必须重写Init函数,这样就做到了每个面板新建时就实现了独属于各自的功能
Init();
}
public abstract void Init();
public virtual void Show()
{
isShow = true;
canvasGroup.alpha = 0;
}
public virtual void Hide(UnityAction unityAction)
{
isShow = false;
canvasGroup.alpha = 1;
onHideComplete = unityAction;
}
void Update()
{
if(isShow&&canvasGroup.alpha<1)
{
canvasGroup.alpha += Time.deltaTime * alphaSpeed;
if (canvasGroup.alpha >= 1)
canvasGroup.alpha = 1;
}
else
{
if(!isShow&&canvasGroup.alpha>0)
{
canvasGroup.alpha -= Time.deltaTime * alphaSpeed;
if (canvasGroup.alpha <= 0)
{
canvasGroup.alpha = 0;
onHideComplete?.Invoke();
}
}
}
}
}

3.UI管理器

因为这是创建(实例化)函数,你手里还没对象,所以你没法传 BasePanel 的对象进去。
你用泛型 ,主要是为了让函数的“返回值”自动变成你想要的子类
,省去了你每次拿到面板后都要写 as LoginPanel 这种恶心的强转代码。

为了每次换场景都不销毁Canvas,加上GameObject.DontDestroyOnLoad(canvasTransform.gameObject);

此外,画布离开EventSystem用不了,因此可以直接拖过来作为画布子对象(把ui摄像机也一并拖来)

image-20260324213329571

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
using System.Collections.Generic;
using UnityEngine;
public class UIManager
{
private Transform canvasTransform;
//存储已经创建的面板,键为面板名称,值为面板脚本组件
private Dictionary<string, BasePanel> panelDic = new Dictionary<string, BasePanel>();

//单例模式
private static UIManager instance=new UIManager();
public static UIManager Instance => instance;
private UIManager()
{
canvasTransform = GameObject.Find("Canvas").transform;
//换场景时不会销毁
GameObject.DontDestroyOnLoad(canvasTransform.gameObject);
}


//创建面板
//约束泛型T必须是BasePanel的子类,后面代码中可以直接使用BasePanel的属性和方法,但是BasePanel不能直接当做T来使用
public T NewPanel<T>() where T : BasePanel{
string panelName = typeof(T).Name;
if (panelDic.ContainsKey(panelName))
{
return panelDic[panelName] as T;
}
GameObject panelPrefab = GameObject.Instantiate(Resources.Load<GameObject>("UI/" + panelName));
//setParent是transform下的函数,第二个参数为false表示保持预设的缩放和旋转
panelPrefab.transform.SetParent(canvasTransform, false);
//Dic中存储的是BasePanel类型的脚本,不是GameObject,所以需要GetComponent来获取脚本组件
T panel = panelPrefab.GetComponent<T>();
panelDic.Add(panelName, panel);
panel.Show();
return panel;
}
//销毁面板
//isFade表示是否淡出销毁,默认为true
public void DestroyPanel<T>(bool isFade=true) where T : BasePanel
{
string panelName = typeof(T).Name;
if (panelDic.ContainsKey(panelName))
{
if(isFade==true)
{
//使用lambda表达式,更简单
panelDic[panelName].Hide(() =>
{
//虽然脚本对象即将失效,但字典中仍然持有这个引用。如果你不调用 Remove,字典会继续保留一个指向已销毁对象的无效引用
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;
}
}

4.提示面板

1.拼面板

根据示意图拼凑图片、文本等内容,并在面板添加Cavans Group组件

注意事项:

  • 需要大幅度拉伸且保持良好效果的图片,需要拖入图片后在image里选切分模式,并在图片本身检查器里修改九宫格(第一次需要安装2d sprite包)

  • 不需要大幅度拉伸直接选简单模式就好,然后点设置原生大小会出现图片出图时的大小

  • 文本一般要放在后面渲染

image-20260310161252158

2.逻辑

TipPanel:

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

public class TipPanel : BasePanel
{
[SerializeField] private Button confirmBtn;
[SerializeField] private Text infoText;
public override void Init()
{
//初始化
confirmBtn.onClick.AddListener(() =>
{
UIManager.Instance.DestroyPanel<TipPanel>();
});
}

public void SetInfo(string info)
{
infoText.text = info;
}
}

Main:做初步测试

1
2
3
4
5
6
7
8
9
10
using UnityEngine;

public class Main : MonoBehaviour
{
private void Start()
{
UIManager.Instance.NewPanel<TipPanel>();
UIManager.Instance.GetPanel<TipPanel>().SetInfo("欢迎来到主界面");
}
}

5.登录面板

1.拼面板

image-20260310200050651

2.逻辑

image-20260316154357801

新建LoginData脚本来存储登录面板数据,新建LoginMgr脚本单例模式来管理LoginData的序列化与反序列化,新建LoginPanel脚本管理面板控件功能

LoginData:

1
2
3
4
5
6
7
8
9
10
using UnityEngine;

public class LoginData
{
public string id;
public string password;
public bool autoLogin;
public bool rememberPW;
//服务器相关
}

LoginMgr:

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

public class LoginMgr
{
private static LoginMgr instance=new LoginMgr();
public static LoginMgr Instance=>instance;
private LoginData loginData;
public LoginData LoginData => loginData;
private LoginMgr()
{
loginData = JsonMgr.Instance.LoadData<LoginData>("LoginData");
}

public void SaveData()
{
JsonMgr.Instance.SaveData(loginData, "LoginData");
}
}

LoginPanel:

基础逻辑是这样的 Init函数(新建面板会执行的函数)规定好各控件的功能,Show函数实现基类功能的基础上,还要加载数据

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
using UnityEngine;
using UnityEngine.UI;

public class LoginPanel : BasePanel
{
public Button registerBtn;
public Button confirmBtn;
public Toggle autoLoginToggle;
public Toggle rememberPWToggle;
public InputField idInput;
public InputField passwordInput;
public override void Init()
{
//点击确定,验证输入的账号密码是否正确
confirmBtn.onClick.AddListener(() =>
{

});
//点击注册,销毁当前面板,打开注册面板
registerBtn.onClick.AddListener(() =>
{
UIManager.Instance.DestroyPanel<LoginPanel>();
//展示注册面板
});

//取消记住密码就取消自动登录
rememberPWToggle.onValueChanged.AddListener((isOn) =>
{
if (!isOn)
{
autoLoginToggle.isOn = false;
}
});
//自动登录就记住密码
autoLoginToggle.onValueChanged.AddListener((isOn) =>
{
if (isOn)
{
rememberPWToggle.isOn = true;
}
});
}
public override void Show()
{
//在重写时保留父类功能
base.Show();
//加载登录数据
LoginData loginData=LoginMgr.Instance.LoginData;
idInput.text = loginData.id;
autoLoginToggle.isOn = loginData.autoLogin;
rememberPWToggle.isOn = loginData.rememberPW;
if (loginData.rememberPW)
{
//如果记住密码,就把密码显示在输入框里
passwordInput.text = loginData.password;
}
if(loginData.autoLogin)
{
//自动登录

}
}
}

6.注册面板

1.拼面板

部分面板有相同的部分可以直接复制粘贴,不要直接在预制体里复制,要把预制体面板拖到场景中再复制,粘贴的控件位置才是原封不动的

2.逻辑

新建注册数据脚本,存储已经注册过的账号和密码,以字典的形式存储

RegisterData:

1
2
3
4
5
6
7
using System.Collections.Generic;
using UnityEngine;

public class RegisterData
{
public Dictionary<string, string> accountDic = new Dictionary<string, string>();
}

更新LoginMgr,新增注册数据类对象,以及序列化注册数据函数,添加注册账号(用于RegisterPanel)和验证账号密码是否正确(用于LoginPanel)的函数

LoginMgr:

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
using UnityEngine;

public class LoginMgr
{
private static LoginMgr instance=new LoginMgr();
public static LoginMgr Instance=>instance;
private LoginData loginData;
public LoginData LoginData => loginData;
private RegisterData registerData;
public RegisterData RegisterData => registerData;
private LoginMgr()
{
loginData = JsonMgr.Instance.LoadData<LoginData>("LoginData");
registerData = JsonMgr.Instance.LoadData<RegisterData>("RegisterData");
}

public void SaveLoginData()
{
JsonMgr.Instance.SaveData(loginData, "LoginData");
}

public void SaveRegisterData()
{
JsonMgr.Instance.SaveData(registerData, "RegisterData");
}

//注册账号
public bool RegisterUser(string id,string password)
{
if (registerData.accountDic.ContainsKey(id))
{
return false;
}
registerData.accountDic.Add(id, password);
SaveRegisterData();
return true;
}
//验证账号密码是否正确
public bool CheckInfo(string id,string password)
{
if (registerData.accountDic.ContainsKey(id))
{
if (registerData.accountDic[id] == password)
return true;
}
return false;
}
}

RegisterPanel:

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
using UnityEngine;
using UnityEngine.UI;

public class RegisterPanel : BasePanel
{
public Button cancelBtn;
public Button confirmBtn;
public InputField idInput;
public InputField passwordInput;
public override void Init()
{
cancelBtn.onClick.AddListener(() =>
{
//销毁当前面板,返回登录界面
UIManager.Instance.DestroyPanel<RegisterPanel>();
UIManager.Instance.NewPanel<LoginPanel>();
});
confirmBtn.onClick.AddListener(() =>
{
//验证输入是否合法
if (idInput.text.Length < 6 || passwordInput.text.Length < 6)
{
//提示输入不合法
TipPanel tipPanel = UIManager.Instance.NewPanel<TipPanel>();
tipPanel.SetInfo("账号和密码必须都大于6位");
//清空输入框
idInput.text = "";
passwordInput.text = "";
return;
}
//调用LoginMgr的注册方法,注册成功则返回登录界面,否则提示账号已存在
if (LoginMgr.Instance.RegisterUser(idInput.text, passwordInput.text))
{
//返回登录界面,将账号密码传回登录界面
LoginPanel loginPanel = UIManager.Instance.NewPanel<LoginPanel>();
loginPanel.SetAccountInfo(idInput.text, passwordInput.text);
//销毁当前面板
UIManager.Instance.DestroyPanel<RegisterPanel>();
}
else
{
//提示账号已存在
TipPanel tipPanel = UIManager.Instance.NewPanel<TipPanel>();
tipPanel.SetInfo("账号已存在");
//清空输入框
idInput.text = "";
passwordInput.text = "";
}

});
}
}

更新LoginPanel,更新点击注册按钮和确定按钮事件,更新show函数,添加设置账号密码函数,供注册成功后直接将账密迁移

LoginPanel:

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
84
85
86
87
88
89
90
91
92
93
94
95
using UnityEngine;
using UnityEngine.UI;

public class LoginPanel : BasePanel
{
public Button registerBtn;
public Button confirmBtn;
public Toggle autoLoginToggle;
public Toggle rememberPWToggle;
public InputField idInput;
public InputField passwordInput;
public override void Init()
{
//点击确定,验证输入的账号密码是否正确
confirmBtn.onClick.AddListener(() =>
{
if(LoginMgr.Instance.CheckInfo(idInput.text, passwordInput.text))
{
//登录成功,保存登录数据
LoginData loginData = LoginMgr.Instance.LoginData;
loginData.id = idInput.text;
loginData.password = passwordInput.text;
loginData.rememberPW = rememberPWToggle.isOn;
loginData.autoLogin = autoLoginToggle.isOn;
LoginMgr.Instance.SaveLoginData();
//提示登录成功
TipPanel tipPanel = UIManager.Instance.NewPanel<TipPanel>();
tipPanel.SetInfo("登录成功");
//销毁当前面板,进入服务器面板
UIManager.Instance.DestroyPanel<LoginPanel>();
}
else
{
//提示账号或密码错误
TipPanel tipPanel = UIManager.Instance.NewPanel<TipPanel>();
tipPanel.SetInfo("账号或密码错误");
//清空密码输入框
idInput.text = "";
passwordInput.text = "";
}
});
//点击注册,销毁当前面板,打开注册面板
registerBtn.onClick.AddListener(() =>
{
UIManager.Instance.DestroyPanel<LoginPanel>();
//展示注册面板
UIManager.Instance.NewPanel<RegisterPanel>();
});

//取消记住密码就取消自动登录
rememberPWToggle.onValueChanged.AddListener((isOn) =>
{
if (!isOn)
{
autoLoginToggle.isOn = false;
}
});
//自动登录就记住密码
autoLoginToggle.onValueChanged.AddListener((isOn) =>
{
if (isOn)
{
rememberPWToggle.isOn = true;
}
});
}
public override void Show()
{
base.Show();
//加载登录数据
LoginData loginData=LoginMgr.Instance.LoginData;
idInput.text = loginData.id;
autoLoginToggle.isOn = loginData.autoLogin;
rememberPWToggle.isOn = loginData.rememberPW;
if (loginData.rememberPW)
{
//如果记住密码,就把密码显示在输入框里
passwordInput.text = loginData.password;
}
if(loginData.autoLogin)
{
//自动登录,销毁当前面板
TipPanel tipPanel = UIManager.Instance.NewPanel<TipPanel>();
tipPanel.SetInfo("自动登录成功");
//UIManager.Instance.DestroyPanel<LoginPanel>();
//进入服务器面板

}
}
public void SetAccountInfo(string id, string password)
{
idInput.text = id;
passwordInput.text = password;
}
}

7.服务器面板

1.拼面板

别忘了每个面板都要加canvas group组件

image-20260317181627950

2.逻辑

ServerPanel:

每次打开这个面板,都要展示上次选过的区服,所以要重载show

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
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
public class ServerPanel : BasePanel
{
public Button backBtn;
public Button enterBtn;
public Button changeBtn;
public Text serverText;
public override void Init()
{
//点击返回按钮,返回登录界面
backBtn.onClick.AddListener(() =>
{
UIManager.Instance.DestroyPanel<ServerPanel>();
UIManager.Instance.NewPanel<LoginPanel>();
});
//点击选服按钮,进入选服面板
changeBtn.onClick.AddListener(() =>
{

});
//点击进入按钮,进入游戏
enterBtn.onClick.AddListener(() =>
{
UIManager.Instance.DestroyPanel<ServerPanel>();
//切场景
SceneManager.LoadScene("GameScene");
});
}
public override void Show()
{
base.Show();
//显示服务器信息
LoginData loginData = LoginMgr.Instance.LoginData;

}
}

在LoginData里新增服务器区服id

LoginData:

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;

public class LoginData
{
public string id;
public string password;
public bool autoLogin;
public bool rememberPW;
//服务器相关
public int serverID=-1;
}

LoginPanel在成功登录后要判断是否直接进入服务器面板,还是第一次选服进入选服面板

LoginPanel(节选):

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
confirmBtn.onClick.AddListener(() =>
{
if(LoginMgr.Instance.CheckInfo(idInput.text, passwordInput.text))
{
//登录成功,保存登录数据
LoginData loginData = LoginMgr.Instance.LoginData;
loginData.id = idInput.text;
loginData.password = passwordInput.text;
loginData.rememberPW = rememberPWToggle.isOn;
loginData.autoLogin = autoLoginToggle.isOn;
LoginMgr.Instance.SaveLoginData();

//销毁当前面板,判断进入服务器面板还是第一次选服
if (loginData.serverID == -1)
{
//第一次选服
UIManager.Instance.DestroyPanel<LoginPanel>();

}
else
{
//进入服务器面板
UIManager.Instance.DestroyPanel<LoginPanel>();
UIManager.Instance.NewPanel<ServerPanel>();
}
}
else
{
//提示账号或密码错误
TipPanel tipPanel = UIManager.Instance.NewPanel<TipPanel>();
tipPanel.SetInfo("账号或密码错误");
//清空密码输入框
idInput.text = "";
passwordInput.text = "";
}
});

8.选服面板

1.拼面板

image-20260318154522027

2.数据准备

ServerInfo.Json:

使用excel表编写,通过网页转换成Json文件,并放入StreamingAssets文件夹

image-20260318154730042

在Data文件夹下新建选服信息脚本

ServerInfo:

1
2
3
4
5
6
7
8
9
using UnityEngine;

public class ServerInfo
{
public int id;
public string name;
public int status;
public bool isNew;
}

在LoginMgr中新建List表读取Json

LoginMgr(节选):

1
2
3
4
5
6
7
8
private List<ServerInfo> serverData;
public List<ServerInfo> ServerData => serverData;
private LoginMgr()
{
loginData = JsonMgr.Instance.LoadData<LoginData>("LoginData");
registerData = JsonMgr.Instance.LoadData<RegisterData>("RegisterData");
serverData = JsonMgr.Instance.LoadData<List<ServerInfo>>("ServerInfo");
}

3.左侧按钮逻辑

ServerLeftBtn:

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
using UnityEngine;
using UnityEngine.UI;

public class ServerLeftBtn : MonoBehaviour
{
public Button leftBtn;
public Text serverRangeText;
public int beginIndex;
public int endIndex;
private void Start()
{
//点击左侧按钮显示对应服务器列表
leftBtn.onClick.AddListener(()=>
{

});
}

private void InitInfo(int begin,int end)
{
beginIndex = begin;
endIndex = end;
serverRangeText.text = beginIndex + " - " + endIndex + "区";
}

}

4.右侧按钮逻辑

试着打个图集,将四种服务器状态图放一起(放的是sprite图,因为image源图像是sprite格式的,并且代码里加载图别忘了stateImg.sprite)

ServerRightBtn:

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
using UnityEngine;
using UnityEngine.U2D;
using UnityEngine.UI;

public class NewMonoBehaviourScript : MonoBehaviour
{
public Button rightBtn;
public Text name;
public Image stateImg;
public Image newImg;
private ServerInfo serverInfo;
private void Start()
{
//点击右侧按钮
rightBtn.onClick.AddListener(() =>
{
//销毁当前面板
//显示服务器面板
UIManager.Instance.NewPanel<ServerPanel>();

});
}
//初始化按钮信息
public void InitInfo(ServerInfo info)
{
serverInfo = info;
name.text = info.id + "区 " + info.name;
newImg.gameObject.SetActive(serverInfo.isNew);
SpriteAtlas atlas = Resources.Load<SpriteAtlas>("Login");
switch (serverInfo.state)
{
case 0://流畅
stateImg.sprite = atlas.GetSprite("ui_DL_liuchang_01");
break;
case 1://无
stateImg.gameObject.SetActive(false);
break;
case 2://火爆
stateImg.sprite = atlas.GetSprite("ui_DL_huobao_01");
break;
case 3://繁忙
stateImg.sprite = atlas.GetSprite("ui_DL_fanhua_01");
break;
case 4://维护
stateImg.sprite = atlas.GetSprite("ui_DL_weihu_01");
break;
}
}

}

Resources 是一个被施了魔法的“特殊文件夹”

在 Unity 中,叫 Resources 的文件夹有着至高无上的特权。

  • 代码层面上:Resources 其实是 Unity 引擎自带的一个 C# 类(UnityEngine.Resources),Load 是这个类里面的一个静态方法。它并不是指代路径的字符串。
  • 打包层面上(最致命的区别):当你把你辛辛苦苦做的游戏打包成 .exe 或 .apk 时,Unity 会进行极其严格的“瘦身运动”。普通文件夹(比如 UI_Images、MyPrefabs):如果里面的图片、预制体没有被场景里的物体挂载(没有被引用),Unity 会认为:“哦,这些东西游戏里用不到,全部当垃圾扔掉!不打包进去!Resources 文件夹:Unity 对它绝对敬畏。只要是放在 Resources 文件夹里的东西,不管你有没有在场景里用到它,Unity 都会把它们强制压缩打包进游戏的底层核心文件里!

总结一下用到的文件夹:

  • 要动态读游戏资产(预制体、图片图集、音频) ➡️ 用 Resources 文件夹。
  • 要动态读纯文本数据(JSON 配置表、外部 MP4 视频) ➡️ 用 StreamingAssets 文件夹,不能改变其名称,Application.streamingAssetsDataPath是封装好的静态属性。
  • 要存玩家的打怪存档 ➡️ 用 Application.persistentDataPath 路径。

5.动态创建按钮

创建选服面板,主要逻辑是在创建时生成左侧按钮,随后更新右侧相关对象

ChooseServerPanel:

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
84
85
86
87
88
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.U2D;
public class ChooseServerPanel : BasePanel
{
public Button lastServerBtn;
public Text lastServerText;
public Image lastServerStateImg;
public Text serverRangeText;

public ScrollRect leftScrollRect;
public ScrollRect rightScrollRect;

public override void Init()
{
//左侧生成服务器列表
List<ServerInfo> serverData = LoginMgr.Instance.ServerData;
int count=serverData.Count/5+(serverData.Count%5==0?0:1);
for (int i = 0; i < count; i++)
{
GameObject leftBtnPrefab = Instantiate(Resources.Load<GameObject>("UI/ServerLeftBtn"));
leftBtnPrefab.transform.SetParent(leftScrollRect.content, false);
ServerLeftBtn serverLeftBtn = leftBtnPrefab.GetComponent<ServerLeftBtn>();
serverLeftBtn.InitInfo(i * 5 + 1, serverData.Count < (i + 1) * 5 ? serverData.Count : (i + 1) * 5);
}
}

public override void Show()
{
base.Show();
//显示上次登录的服务器信息
LoginData loginData = LoginMgr.Instance.LoginData;
if (loginData.serverID != -1)
{
// 如果上次选择过服务器,根据服务器ID获取服务器信息并显示
ServerInfo lastServerInfo = LoginMgr.Instance.ServerData[loginData.serverID - 1 ];
lastServerText.text = lastServerInfo.id + "区 " + lastServerInfo.name;
SpriteAtlas atlas = Resources.Load<SpriteAtlas>("Login");
switch (lastServerInfo.state)
{
case 0://流畅
lastServerStateImg.sprite = atlas.GetSprite("ui_DL_liuchang_01");
break;
case 1://无
lastServerStateImg.gameObject.SetActive(false);
break;
case 2://火爆
lastServerStateImg.sprite = atlas.GetSprite("ui_DL_huobao_01");
break;
case 3://繁忙
lastServerStateImg .sprite = atlas.GetSprite("ui_DL_fanhua_01");
break;
case 4://维护
lastServerStateImg.sprite = atlas.GetSprite("ui_DL_weihu_01");
break;
}
}
else
{
lastServerText.text = "暂无服务器记录";
lastServerStateImg.gameObject.SetActive(false);
}

UpdateList(1, 5>LoginMgr.Instance.ServerData.Count ? LoginMgr.Instance.ServerData.Count : 5);

}

//右侧生成服务器列表
public void UpdateList(int begin,int end)
{
//更新服务器范围文本
serverRangeText.text = "服务器 " + begin + "-" + end;
//清除右侧服务器列表
foreach (Transform child in rightScrollRect.content){
Destroy(child.gameObject);
}
//动态生成右侧服务器列表
for (int i = begin-1; i < end; i++)
{
List<ServerInfo> serverData = LoginMgr.Instance.ServerData;
GameObject rightBtnPrefab = Instantiate(Resources.Load<GameObject>("UI/ServerRightBtn"));
rightBtnPrefab.transform.SetParent(rightScrollRect.content, false);
ServerRightBtn serverRightBtn = rightBtnPrefab.GetComponent<ServerRightBtn>();
serverRightBtn.InitInfo(serverData[i]);
}
}
}

同时完善左侧按钮逻辑与右侧按钮逻辑

当点击左侧按钮时,右侧会更新按钮列表

ServerLeftBtn(节选):

1
2
3
4
5
6
7
8
private void Start()
{
//点击左侧按钮显示对应服务器列表
leftBtn.onClick.AddListener(()=>
{
UIManager.Instance.GetPanel<ChooseServerPanel>().UpdateList(beginIndex,endIndex);
});
}

当点击右侧按钮时,会记录当前服务器id同步至loginData,并返回服务器面板,展示所选服务器名称

ServerRightBtn(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void Start()
{
//点击右侧按钮
rightBtn.onClick.AddListener(() =>
{
//保存选择的服务器ID
LoginData loginData = LoginMgr.Instance.LoginData;
loginData.serverID = serverInfo.id;
//销毁当前面板
UIManager.Instance.DestroyPanel<ChooseServerPanel>();
//显示服务器面板
UIManager.Instance.NewPanel<ServerPanel>();

});
}

ServerPanel(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public override void Show()
{
base.Show();
//显示服务器信息
LoginData loginData = LoginMgr.Instance.LoginData;
if (loginData.serverID != -1)
{
ServerInfo serverInfo = LoginMgr.Instance.ServerData[loginData.serverID - 1];
serverText.text = serverInfo.id + "区 " + serverInfo.name;
}
else
{
serverText.text = "无服务器记录";
}
}

6.逻辑串联

一下是一些细节层面的逻辑串联

注册账号成功后,我们需要清除logindata中服务器号以及是否记住密码和自动登录的信息,因此在LoginMgr加入清除登录数据函数,并在RegisterPanel注册成功后调用

LoginMgr(节选):

1
2
3
4
5
6
7
//清除登录数据
public void ClearLoginData()
{
loginData.rememberPW = false;
loginData.autoLogin = false;
loginData.serverID = -1;
}

在选完服务器后,logindata会记下服务器id,但当玩家点击进入游戏后,才会调用savelogindata来真正保存服务器id

ServerPanel(节选):

1
2
3
4
5
6
7
8
9
10
11
//点击进入按钮,进入游戏
enterBtn.onClick.AddListener(() =>
{
//此刻才真正保存服务器id
LoginMgr.Instance.SaveLoginData();
//销毁服务器面板和背景面板
UIManager.Instance.DestroyPanel<ServerPanel>();
UIManager.Instance.DestroyPanel<BKPanel>();
//切场景
SceneManager.LoadScene("GameScene");
});

哦对了,为了登录结束后让背景图一并消失,可以创建新面板BKPanel来只放一个image背景图,其脚本也只是继承BasePanel什么也不实现

为这张背景图添加组件Aspect Ratio Fitter(宽高比适配器)

对比维度 Canvas Scaler (画布缩放) Aspect Ratio Fitter (宽高比适配)
作用目标 整个屏幕/整个UI系统 单个指定的 UI 元素 (比如一张图)
解决的问题 适配不同型号手机的屏幕分辨率 保证单张图片/容器的长宽比例不失调
触发时机 当游戏窗口大小/手机屏幕分辨率改变时 当它自己的宽度或高度发生改变时
生活中的比喻 放映机:调整整个画面的投影大小 相框:保证里面的照片不会被强行拉成畸形

场景还原:做一张全屏的加载背景图(Loading 背景)。

  1. 大环境交给大元帅:Canvas Scaler 设为 1920x1080,Match 设为 0.5(宽高中立)。这保证了所有的 UI 基础缩放是对的。
  2. 但遇到了问题:全面屏手机特别长,1920x1080 的背景图铺上去,左右两边会露出黑边。如果强行把图拉长,图里的人物就变胖了(变形了)。
  3. 单兵作战派上用场:选中这张背景 Image,给它挂上 Aspect Ratio Fitter,模式选择 Envelope Parent(包裹父节点),比例设为原始图片的比例(比如 1.77)。
  4. 完美结果:这张背景图会按等比例完美放大,直到填满整个全面屏的左右黑边!虽然图的上下部位会被屏幕裁掉一点点,但画面绝对没有变形拉伸,视觉体验极佳!

自动登录功能也在loginPanel中实现

LoginPanel(节选):

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
public override void Show()
{
base.Show();
//加载登录数据
LoginData loginData=LoginMgr.Instance.LoginData;
idInput.text = loginData.id;
autoLoginToggle.isOn = loginData.autoLogin;
rememberPWToggle.isOn = loginData.rememberPW;
if (loginData.rememberPW)
{
//如果记住密码,就把密码显示在输入框里
passwordInput.text = loginData.password;
}
if(loginData.autoLogin)
{
//自动登录,销毁当前面板
//取消渐变动画,直接销毁面板更自然
UIManager.Instance.DestroyPanel<LoginPanel>(false);
//进入服务器面板
if (loginData.serverID == -1)
{
//第一次选服
UIManager.Instance.NewPanel<ChooseServerPanel>();
TipPanel tipPanel = UIManager.Instance.NewPanel<TipPanel>();
tipPanel.SetInfo("自动登录成功");
}
else
{
//进入服务器面板
UIManager.Instance.NewPanel<ServerPanel>();
TipPanel tipPanel = UIManager.Instance.NewPanel<TipPanel>();
tipPanel.SetInfo("自动登录成功");
}
}
}

但点击服务器面板的左上返回按钮,会因为自动登录勾选上始终显示不出来登录面板,因此点击返回按钮要取消自动登录的勾选

ServerPanel(节选):

1
2
3
4
5
6
7
8
9
10
backBtn.onClick.AddListener(() =>
{
if(LoginMgr.Instance.LoginData.autoLogin)
{
//如果自动登录勾选上了,取消自动登录,否则永远回不到登录界面了
LoginMgr.Instance.LoginData.autoLogin = false;
}
UIManager.Instance.DestroyPanel<ServerPanel>();
UIManager.Instance.NewPanel<LoginPanel>();
});

9.图集整理

image-20260325174650381

可以将整个文件夹放入图集,一般工作时设计人员会按照文件夹分类好

建议工作流:

  1. 建一个 CommonAtlas(通用图集):把各个面板都会用到的按钮底板、弹窗背景、关闭X图标塞进去。
  2. 按“大系统”建立业务图集:不要细到“每个小面板”,而是按大功能分。比如:MainUI_Atlas:主界面上那十几个常驻图标和玩家头像框。Login_Atlas:只在登录、注册页面用的图。BattleUI_Atlas:战斗内用的血条、技能摇杆、伤害数字背景(战斗对性能要求高,最好单独打图集)。
  3. UI 预制体的制作规范:在做一个新面板时,它的图片要么来自 CommonAtlas,要么来自它专属的业务图集,绝对不要去跨界借用其他业务图集的图片!(比如商城面板千万不要去用背包图集里的某个特定发光框,如果要用,就把那个发光框移到 CommonAtlas 里去)。
  4. 大的背景图不要放入图集,大图极其浪费图集空间(很容易导致图集出现大量无法利用的缝隙),而且图集变大后会导致极高的内存峰值。大图应该直接设为单张 Sprite 动态加载。

🛠️ 终极除虫武器:Frame Debugger(帧调试器)

Unity 官方提供的终极性能透视镜

操作步骤:

  1. 在顶部菜单栏点击 Window -> Analysis -> Frame Debugger
  2. 运行游戏,点击面板左上角的 Enable(启用)
  3. 此时游戏画面会冻结。
  4. 奇迹出现了:这里列出了你这三四十个 Batch 到底是怎么画出来的!你可以按键盘的上下键,一步一步看 Unity 是先画了哪个图,再画了哪个字。
  5. 最神的地方:当你选中一个 Batch 时,面板右侧会有一行字,极其明确地告诉你为什么它没有和上一个合并!
    (比如提示:Different Material 材质不同、Intersecting with A different Texture 发生了重叠穿插…)

image-20260325202154161

这个DrawTransparentObjects才是ui的batches,其余的是URP管线带的特效和摄影机渲染等等带来的batches