在游戏开发中,常见的经典错误如下:

  • 策划人员把表配错了,程序运行报错。
  • 美术人员制作的模型缺少规定的挂载点,或者起名不规范,程序运行后,找不到就报错了。

想要彻底解决这类问题,需要给美术或策划人员提供资源检查工具

主动检查工具

程序人员开发一套工具来检查资源的规范性,策划人员或者美术人员在需要提交资源之前,运行此工具,如果资源不符合规范,就会输出错误日志,接着修改好后再重复检查,直到没问题后再提交资源。

被动检查工具

被动检查工具就是依赖Unity监听资源导入事件、程序来动态分析资源的合理性。这种检查方法比主动检查工具更安全了,因为资源只要拖入就会自动检查,不需要使用者主动操作,这样就不会有遗漏检查的情况。

导出类检查工具

无论是主动检查还是被动检查,其实最核心的问题是,策划人员和美术人员可以跳过检查直接提交SVN。理论上,各种资源都可能被提交。所以要想彻底解决这个问题,程序员就不能直接使用他们提交的资源,而采取间接使用的方式。

程序提供一个导出工具,即根据美术资源来自动生成一个新资源(程序用的资源),导出的同时做资源检查,如果资源不符合规范,那么提示错误并且不生成新资源。因为程序使用的是导出后的资源,所以错误的资源并不能被导出新的,也就不存在上传错误的情况,此时这个问题就可以彻底解决了。数据表、UI界面Prefab、模型Prefab、场景Scene都可以导出,导出的时候不仅检查错误,还可以智能地修改一些资源,比如自动生成UI代码,自动删除场景中没有的模型等。

导出UI

在Hierarchy视图中做好UI后,可以给UI的顶层节点添加一个自定义的Tag,使用一会介绍的方法导出时会以它的父节点自动生成Prefab以及部分代码。

在这里先定义顶层节点的tag为“UIView”

需要导出的UI

然后将需要代码访问的UI组件,比如说Text、Image、Button等添加新的自定义tag,在这里定义为“UIProperty”

设定UIProperty tag

在Project窗口中,在Assets文件夹内新建UI文件夹,在UI文件夹内部再分别新建UIBase文件夹和UIPrefab文件夹,它们分别用来存储自动导出时生成的UI基类和Prefab,然后新建一个UIBase.cs.txt文件,用来作为UI基类的代码模板。

UI文件夹

UIBase.cs.txt模板

1
2
3
4
5
6
7
8
9
10
11
12
13
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class #Name# : MonoBehaviour{

#Property#
void Awake(){

#Get#
}
}

使用下面的代码,用来自动生成Prefab和对应的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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.Text;
using UnityEngine.UI;
using System.IO;

public class ExportUIPrefab
{
[MenuItem("UI/Tools/Export")]
static void ExportUI()
{
//prefab顶层目录
string prefabPathRoot = "Assets/UI/UIPrefab";
//base类顶层目录
string scriptPathRoot = "Assets/UI/UIBase";

if(Selection.activeTransform){
//如果选择了一个UI界面,那么开始导出
if(Selection.activeGameObject.tag == "UIView"){
//生成Prefab
string prefabPath = string.Format("{0}/{1}{2}",prefabPathRoot,Selection.activeGameObject.name,".prefab");
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
PrefabUtility.SaveAsPrefabAssetAndConnect(Selection.activeGameObject,prefabPath,InteractionMode.UserAction);
//生成代码
string basePath = string.Format("{0}/{1}{2}{3}",scriptPathRoot,"Base",Selection.activeGameObject.name,".cs");
//读取代码模板
string script = AssetDatabase.LoadAssetAtPath<TextAsset>("Assets/UI/UIBase.cs.txt").text;

StringBuilder property = new StringBuilder();
StringBuilder get = new StringBuilder();
//确保变量唯一
Dictionary<string,int> uniqueName = new Dictionary<string, int>();
//确保路径唯一
HashSet<string> uniquePath = new HashSet<string>();//不允许有重复元素的集合

foreach (var transform in Selection.activeGameObject.GetComponentsInChildren<Transform>(true))
{
if(transform.tag == "UIProperty")//标记了UIProperty的子对象都需要导出
{
Component component = transform.GetComponent<Button>();
if(component == null)
component = transform.GetComponent<Image>();
if(component == null)
component = transform.GetComponent<Text>();
if(component == null)
component = transform.GetComponent<RectTransform>();
if(component != null){
string propertyName = component.name.ToLower();
int uniqueValue;
if(uniqueName.TryGetValue(propertyName,out uniqueValue)){
//如果uniqueName中已经包含同名component,则uniqueValue加1,同时更新Dictionary对应的值
uniqueValue = uniqueName[propertyName] += 1;
}else{
uniqueValue = uniqueName[propertyName] = 0;//如果Dictionary中还未包含此Key,则从零开始计数
}
if(uniqueValue > 0){
propertyName = string.Format("{0}_{1}",propertyName,uniqueValue);
}
property.AppendFormat("\tprotected {0} m_{1};",component.GetType().ToString(),propertyName).AppendLine();

string findPath = AnimationUtility.CalculateTransformPath(component.transform,Selection.activeTransform);
if(!uniquePath.Contains(findPath))
uniquePath.Add(findPath);
else
Debug.LogErrorFormat("读取路径发生重复: {0}",findPath);
get.AppendFormat("\t\tm_{0}=transform.Find(\"{1}\").GetComponent<{2}>();",propertyName,findPath,component.GetType().ToString()).AppendLine();
}
}
}

script = script.Replace("#Name#",Path.GetFileNameWithoutExtension(basePath));
script = script.Replace("#Property#",property.ToString());
script = script.Replace("#Get#",get.ToString());
File.WriteAllText(basePath,script);

AssetDatabase.Refresh();
}
}
}
}

然后我们在Scene里面选中“UIMain”对象,然后在最上方导航栏中选择UI——Tools——Export

接在我们就能看见导出的Prefab和基类

UI导出结果

基类中的代码

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

public class BaseUIMain : MonoBehaviour{

protected UnityEngine.UI.Button m_button;
protected UnityEngine.RectTransform m_gameobject;
protected UnityEngine.RectTransform m_gameobject_1;

void Awake(){

m_button=transform.Find("Button").GetComponent<UnityEngine.UI.Button>();
m_gameobject=transform.Find("GameObject").GetComponent<UnityEngine.RectTransform>();
m_gameobject_1=transform.Find("GameObject").GetComponent<UnityEngine.RectTransform>();//相同的名字会导致读取出错

}
}

既然使用到了基类,那肯定是要继承的,这样导出就是为了在子类中更方便地访问需要交互的UI元素

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

public class UIMainTest : BaseUIMain
{
private void Start() {
this.m_button.onClick.AddListener(() => {
Debug.Log(gameObject.name);
});
}
}

导出模型

例如,程序员和美术人员约定,所有模型都必须要有effect_0和effect_1这两个挂载点。我们可以做工具来帮助美术人员生成Prefab,同时可以检查资源是不是没有这两个挂载点。如果美术人员没有提供挂载点或者挂载点的名字写错了,那么Prefab就不会被导出了

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
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.IO;

public class ExportModelPrefab
{
[MenuItem("UI/Tools/Export")]
static void ExportModel()
{
//Prefab模型目录
string prefabPathRoot = "Assets/Model";

//待检查的挂载点
List<string> checkPoint = new List<string>()
{
"effect_0",
"effect_1"
};

if (Selection.activeTransform)
{
//检查模型的挂载点是否合法
foreach (var transform in Selection.activeTransform.GetComponentsInChildren<Transform>(true))
{
for (int i = 0; i < checkPoint.Count; i++)
{
if (transform.name == checkPoint[i])
checkPoint.RemoveAt(i);
}
}
if (checkPoint.Count > 0)
Debug.LogErrorFormat("缺少挂载点{0} 不予导出", string.Join(",", checkPoint.ToArray()));
else
{
string prefabPath = Path.ChangeExtension(Path.Combine(prefabPathRoot, Selection.activeGameObject.name), "prefab");
PrefabUtility.SaveAsPrefabAssetAndConnect(Selection.activeGameObject, prefabPath, InteractionMode.UserAction);
}
}
}
}

错误警告

不参与保存对象

当美术人员做好场景以后,可能需要放进去一些摄像机以及一些模型来作为参照物,这样他们可以检查场景做得是否正确。如果一不小心这些参照物的游戏对象也被保存在在场景上,那么导出就肯定不正确。

Unity内置EditorOnly的Tag,使用此Tag的对象不会被打包导出

EditorOnly标签

导出场景

我们可以和美术人员约定一个节点。这里导出一份新场景。其中只保留Map节点下的元素,别的节点统统删除。另外,Map节点下还有static节点,此节点下的对象导出时自动全部设置static属性,为了避免美术人员忘记勾选。

准备导出前的场景

应用下面的代码

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
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.IO;
using UnityEditor.SceneManagement;
using UnityEngine.SceneManagement;

public class ExportScenePrefab : MonoBehaviour
{
[MenuItem("Tools/Map/ExportScene")]
static void ExportSceneMap()
{
string scenePath = EditorSceneManager.GetActiveScene().path;
string sceneName = System.IO.Path.GetFileName(scenePath);
string generateScenePath = Path.Combine("Assets/ExportScenes/", sceneName);
//复制到新场景中
if (AssetDatabase.CopyAsset(scenePath, generateScenePath))
{
EditorSceneManager.SaveOpenScenes();
//打开新场景
Scene baseScene = EditorSceneManager.OpenScene(generateScenePath);
if (!GameObject.Find("Map"))
{
Debug.LogError("场景缺失Map节点,不予导出");
return;
}

//删除不是Map节点下的游戏对象
foreach (var gameObject in GetRootGameObjects())
{
if (gameObject.name != "Map")
GameObject.DestroyImmediate(gameObject);
}
//强制设置选择Static
GameObject staticGameObject = GameObject.Find("Map/static");
if (staticGameObject)
{
foreach (var transform in staticGameObject.GetComponentsInChildren<Transform>(true))
{
transform.gameObject.isStatic = true;
}
}
//保存场景
EditorSceneManager.SaveScene(baseScene);
//打开保存前的场景
EditorSceneManager.OpenScene(scenePath);
}
}
/// <summary>
/// 获取顶层游戏对象
/// </summary>
/// <returns>The root game objects.</returns>
static private List<GameObject> GetRootGameObjects()
{
GameObject[] pAllObjects = Resources.FindObjectsOfTypeAll<GameObject>();
List<GameObject> list = new List<GameObject>();
foreach (GameObject pObject in pAllObjects)
{
if (pObject.transform.parent)//如果有父对象,则跳过
continue;
if (pObject.hideFlags == HideFlags.NotEditable || pObject.hideFlags == HideFlags.HideAndDontSave)//如果是不可编辑不可保存的对象,则跳过
continue;
if (Application.isEditor)
{
string sAssetPath = AssetDatabase.GetAssetPath(pObject.transform.root.gameObject);//如果是Asset,则跳过
if (!string.IsNullOrEmpty(sAssetPath))
continue;
}
if (!pObject.transform.parent && GameObject.Find(pObject.name))//如果没有父对象且是active状态,则记录
{
if (!list.Contains(pObject))
list.Add(pObject);
}
}
return list;
}
}

导出场景后,自动删除无用节点并且保留Map节点,static节点下的游戏对象将标记Static属性。另外,复制的对象会保留原场景的烘焙以及寻路信息,无需执行复制他们的操作

导出后的场景

过滤无用场景

只要场景被添加到Build Settings面板中,打包就会带上这个场景。由于场景的制作是阶段性的,美术人员会陆陆续续地制作,有一些开发中的场景可能会添加在Build Settings中,需要注意。另外,一般情况下策划人员并不会接触所有场景,而是全部在数据表中配置场景,打包之前可以读取这个表,提取出来需要用到的场景,过滤掉哪些无用场景。

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

public class SetBuildSettingScenes : MonoBehaviour
{
[MenuItem("Tools/Map/SetScenes")]
static void SetScene()
{
//在脚本中添加关联场景
UnityEditor.EditorBuildSettings.scenes = new EditorBuildSettingsScene[]
{
new EditorBuildSettingsScene("Assets/Scene.unity",true),
new EditorBuildSettingsScene("Assets/Scene1.unity",true)//第二个参数表示是否在BuildSettings窗口中设置激活状态
};
}
}