我又回来写第二部了!如果你还没看过编辑器编程黑科技的第一部,可以在这里查看。这篇两部分的文章将带你了解高级的编辑器使用技巧、改善你的工作流程,让你下一个项目进展更顺利。
每条技巧都建立在一个类RTS(即时战略)的游戏原型上,游戏里的每个单位都会自动攻击敌方建筑及其他单位。下方是最初版的原型:
上一篇里,我介绍了怎样为项目导入并设置好美术资产。现在我们再到游戏中使用这些资产,同时尽可能地节省时间。
首先是游戏元素的拆包。在准备游戏里的各个元素时,我们通常会遇到以下情况:
一方面,我们从美术团队那拿到预制件,它可以是FBX Importer生成的预制件,也可能是手动用材质和动画小心建立的预制件,用于给层级结构添加道具等。要在游戏里使用预制件,合理的做法是创建这个预制体的预制件变体(Prefab Variant),再把所有游戏相关的组件加进去。这样一来,当美术团队修改或更新预制件时,所有改动可以立即应用到游戏中。这种方法的确能奏效,如果对象的组件较少、设置较简单。但如果它非常复杂,每次都要从头开始配置就非常的麻烦。
另一方面,许多的组件其实会有同样的属性,比如所有的“汽车”预制件或相似的敌人。这时,用同一个基础预制件来制作所有变体是可行的。也就是说,如果预制件的美术设置起来很方便(即模型网格与材质),那这种方法就很理想。
接着来看看怎样简化游戏玩法组件的设置过程,以便快速添加并直接使用。
对于游戏里较为复杂的元素,最常见的方法是用一个“主要”组件(比如“enemy”、“pickup”或“door”)作为与对象互动的接口,用一堆可重复使用的小组件来实现各种功能,比如“selectable”、“CharacterMovement”或“UnitHealth”,以及renderer、collider这些Unity内置组件。
有些组件依赖于其他组件工作。比如,角色可能要有一个NavMeshAgent(代理)才能移动。而Unity的[RequireComponent]特性正好能写入这些依赖项。如果特定对象有一个“主要”组件,你可以用[RequireComponent]来添加对象所必须的组件。
例如,我原型的单位们有以下特性:
[AddComponentMenu("My Units/Soldier")]
[RequireComponent(typeof(Locomotion))]
[RequireComponent(typeof(AttackComponent))]
[RequireComponent(typeof(Vision))]
[RequireComponent(typeof(Armor))]
public class Soldier : Unit
{
…
}
并且,我还能在AddComponentMenu轻松找到其他需要的额外组件。这里,Locomotion负责移动,AttackComponent负责攻击其他单位。
另外,该类还会继承基础类(与建筑共享)的其他RequireComponent特性,比如Health(血量)组件。有了这些,我只需再手动加上Soldier组件,其他组件都会自动被添加。如果我再为某个组件添加一条新的RequireComponnet特性,Unity会将新组件更新到所有现存的游戏对象上,帮助扩展现有对象。
[RequireComponent]还有一个隐蔽的好处:如果“组件A”需要“组件B”,则添加A不仅能保证B会被添加,还能让B先于A被添加。如果组件A调用了Reset方法,组件B依旧会存在并且对数据的访问仍会保留。我们可以引用这个组件,记录持续性的UnityEvents,再完成对象的设置。同时使用RequireComponent特性与Reset方法,我们可以只加一个组件就完成对象的配置。
上个方法最大的缺点在于,如果我们想修改某个值,就必须一个个手动更改。如果所有的设置都用代码完成,设计师们要改起来会非常困难。
在前一篇文章里,我们讨论了怎样用AssetPostprocessor在导入期间添加依赖项、修改对象。我们同样能用它在预制件上应用数值。
为了让设计师们能轻松修改这些数值,我们需要从预制件上读取它们,以便通过修改预制件来更改整个项目中的数值。
如果是编辑器代码,你可以利用Preset类把一个组件的数值复制到另一个。
像这样用原组件创建一个预设,再应用到另一个组件:
private static void ApplyTemplate(GameObject go, GameObject template)
{
// Get all the components in the object
foreach (var comp in go.GetComponents<Component>())
{
// Try to get the corresponding component in the teplate
if (!template.TryGetComponent(comp.GetType(), out var templateComp))
continue;
// Create the preset
var preset = new Preset(templateComp);
// Apply it
preset.ApplyTo(comp);
}
}
在生效时,它会覆盖预制件的数值,不过这并不是我们的目的。我们只想复制部分数值,其他的不动。此时,我们可以用Preset.ApplyTo覆盖方法,它接收一个一定要应用的属性列表。虽然我们能硬写出一份待覆盖的属性列表,这对大部分项目来说都没问题,但如何让这个流程更为通用呢。
我先是创建了一个带有所有组件的基础预制件,然后以其作为模板创建了一个变体。接着我再从变体的覆盖列表里确定需要应用的数值。
你可以用PrefabUtility.GetPropertyModifications来获取覆盖值。该方法会抓取整个预制件上的所有覆盖值,你需要筛选出与组件相关的那几个。要注意的是我们修改的是基础预制件的组件,而非变体,所以得用GetCorrespondingObjectFromSource来引用它。
private static void ApplyTemplate(GameObject go, GameObject template)
{
// Get all the components in the object
foreach (var comp in go.GetComponents<Component>())
{
// Try to get the corresponding component in the template
if (!template.TryGetComponent(comp.GetType(), out var templateComp))
continue;
// Get all the modifications
var overrides = new List<string>();
var changes = PrefabUtility.GetPropertyModifications(templateComp);
if (changes == null || changes.Length == 0)
continue;
// Filter only the ones that are for this component
var target = PrefabUtility.GetCorrespondingObjectFromSource(templateComp);
foreach (var change in changes)
{
if (change.target == target)
overrides.Add(change.propertyPath);
}
// Create the preset
var preset = new Preset(templateComp);
// Apply only the selected ones
if (overrides.Count > 0)
preset.ApplyTo(comp, overrides.ToArray());
}
}
这一段会将模板的所有覆盖值应用到预制件上。另一个细节问题是模板可能是一个变体的变体,所以我们也得应用上一层变体的覆盖值。
为此,我们得让这段操作循环往复:
private static void ApplyTemplateRecursive(GameObject go, GameObject template)
{
// If this is a variant, apply the base prefab first
var templateSource = PrefabUtility.GetCorrespondingObjectFromSource(template);
if (templateSource)
ApplyTemplateRecursive(go, templateSource);
// Apply the overrides from this prefab
ApplyTemplate(go, template);
}
接着我们找到预制件的模板。理想情况下,不同类型的对象会有不同的模板。我们可以将模板及待修改的对象放在同一文件夹下来提高效率。
在放预制件的文件夹下查找名为Template.prefab的对象。如果没有,就在上级文件夹里重复查找:
private void OnPostprocessPrefab(GameObject gameObject)
{
// Recursive call to apply the template
SetupAllComponents(gameObject, Path.GetDirectoryName(assetPath), context);
}
private static void SetupAllComponents(GameObject go, string folder, AssetImportContext context = null)
{
// Don't apply templates to the templates!
if (go.name == "Template" || go.name.Contains("Base"))
return;
// If we reached the root, stop
if (string.IsNullOrEmpty(folder))
return;
// We add the path as a dependency so this gets reimported if the prefab changes
var templatePath = string.Join("/", folder, "Template.prefab");
if (context != null)
context.DependsOnArtifact(templatePath);
// If the file doesn't exist, check in our parent folder
if (!File.Exists(templatePath))
{
SetupAllComponents(go, Path.GetDirectoryName(folder), context);
return;
}
// Apply the template
var template = AssetDatabase.LoadAssetAtPath<GameObject>(templatePath);
if (template)
ApplyTemplateSourceRecursive(go, template);
}
到这里,只要模板预制件被修改,所有改动都能自动应用到同一文件夹内的预制件上,即便它们并非模板的变体。在例中,我修改了默认的玩家颜色(当单位未被指派给任意玩家时)。注意看所有对象都更新了:
在平衡游戏时,需要调整的数据往往散布在各个组件上,存在每个角色的预制件或ScriptableObject里。这使得细节调整非常低效。
使用表格来简化平衡过程是一种常见的做法。它可以搜集所有的数据,还能用公式来计算一些额外数据。手动将数据输入到Unity里可能会非常麻烦。
而表格此时就能发挥一定作用。表格可以被导出为CSV (.csv)或TSV (.tsv),再用ScriptedImporter导入。下方截图展示了原型单位的统计数据:
这段代码非常简单:用单位的所有统计数据创建一个ScriptableObject,再读取文件。你可以根据表的每一行创建一个ScriptableObject的实例,填入行内的数据。
最后,利用上下文将ScriptableObject添加到导入后的资产上。另外,我们还得添加一个主资产,这里我创建了一个空的TextAsset(我们不会真的用这个对象干什么)。
这个方法同时适用于建筑和单位,你只需要留心那些数据更多的单位。
[ScriptedImporter(0, "tsv")]
public class UnitStatsImporter : ScriptedImporter
{
public override void OnImportAsset(AssetImportContext ctx)
{
var file = File.OpenText(assetPath);
// Check if this is a Unit
bool isUnit = !assetPath.Contains("Buildings");
// The first line is the header, ignore
file.ReadLine();
var main = new TextAsset();
ctx.AddObjectToAsset("Main", main);
ctx.SetMainObject(main);
while (!file.EndOfStream)
{
// Read the line and divide at the tabs
var line = file.ReadLine();
var lineElements = line.Split('\t');
var name = lineElements[0].ToLower();
if (isUnit)
{
// Fill all the values
var entry = ScriptableObject.CreateInstance<SoldierStats>();
entry.name = name;
entry.HP = float.Parse(lineElements[1]);
entry.attack = float.Parse(lineElements[2]);
entry.defense = float.Parse(lineElements[3]);
entry.attackRatio = float.Parse(lineElements[4]);
entry.attackRange = float.Parse(lineElements[5]);
entry.viewRange = float.Parse(lineElements[6]);
entry.speed = float.Parse(lineElements[7]);
ctx.AddObjectToAsset(name, entry);
}
else
{
// Fill all the values
var entry = ScriptableObject.CreateInstance<BuildingStats>();
entry.name = name;
entry.HP = float.Parse(lineElements[1]);
ctx.AddObjectToAsset(name, entry);
}
}
// Close the file
file.Close();
}
}
这步完成后,你就有了包含所有表格数据的ScriptableObject。
生成的ScriptableObject可以随时被用到游戏里。你也能借助之前编写的PrefabPostprocessor来应用它们。
在OnPostprocessPrefab方法里,我们能加载该资产,并自动在组件的参数上填入输入。不仅如此,如果数据资产设有依赖项,预制件会在数据被修改时重新导入,自动更新所有内容。
在搭建关卡时,快速修改并测试、重复微调并实验非常关键。因此,快速迭代、简化测试步骤十分重要。
在讨论Unity迭代时间时,我们最先想到的可能就是Domain Reload(域重载)。Domain Reload与两种关键情形相关:代码编译完成、加载动态链接库(DLL)时,以及进入与退出Play Mode(运行模式)时。编译产生的域重载不可避免,但你可以在Project Settings > Editor > Enter Play Mode Settings里禁用Play Mode的重载。
如果你的代码编写得不合适,禁用这部分重载会产生一些问题,最常见的有静态变量在运行后不会重置。如果你的代码能适应,那就禁用它吧。在我的原型里,Domain Reload已被禁用,你可以瞬间进入Play Mode。
迭代时间的另一个问题在于重新计算运行所需的数据。我们需要选中这些组件,点击对应的按钮来触发重新计算。比如,在我的原型里,每支队伍都有一个TeamController。这个控制程序以列表列出了所有敌方建筑,并会派出单位攻击建筑。要想自动填写这些数据,我们可以用IProcessSceneWithReport接口。我在两种情形下调用这个接口:游戏打包时,在Play Mode里加载场景时。这时我有机会创建、摧毁或修改任意对象。不过,这些改动只会影响运行版和Play Mode。
这次回调会创建控制器、设定建筑列表。而我就不必再手动调整任何东西。当游戏开始时,控制器会带有一份更新后的建筑列表,任何对列表的修改也会被自动更新。
在原型里,我编写了一个方法来获取场景内某一组件的所有实例。你能用它来抓取所有的建筑:
private List<T> FindAllComponentsInScene<T> (Scene scene) where T : Component
{
var result = new List<T>();
var roots = scene.GetRootGameObjects();
foreach (var root in roots)
{
result.AddRange(root.GetComponentsInChildren<T>());
}
return result;
}
剩下的就简单了:抓取所有建筑,找到建筑的所有所属队伍,为每支队伍创建一个带有敌方建筑列表的控制器。
public void OnProcessScene(Scene scene, BuildReport report)
{
// Find all the targets
var targets = FindAllComponentsInScene<Building>(scene);
if (targets.Count == 0)
return;
// Get a list with the teams of all the buildings
var allTeams = new List<Team>();
foreach (var target in targets)
{
if (!allTeams.Contains(target.team))
allTeams.Add(target.team);
}
// Create the team controllers
foreach (var team in allTeams)
{
var obj = new GameObject(team.name + " Team", typeof(TeamController));
var controller = obj.GetComponent<TeamController>();
controller.team = team;
foreach (var target in targets)
{
if (target.team != team)
controller.allTargets.Add(target);
}
}
}
除了编辑中的场景,游戏还会加载其他场景(每个场景都包含管理程序、UI等等)。编辑这些场景会占据一定的宝贵时间。在我的原型里,展示血条的Canvas被放在了另一个称为InGameUI的场景中。
为了高效地利用好这个场景,我在场景里加了一个组件,其中以列表列出了需要一并加载的场景。如果你在Awake里同时加载这些场景,UI场景也会被加载,它所有的Awake方法都会被触发。等到调用Start方法时,所有的场景就已经完成了加载和初始化,让你能访问其中的数据,比如管理器单例。
当然,在进入Play Mode时部分场景是已经加载了的,所以有必要在加载前检查个别场景是否已加载:
private void Awake()
{
foreach (var scene in m_scenes)
{
if (!SceneManager.GetSceneByBuildIndex(scene.idx).IsValid())
{
SceneManager.LoadScene(scene.idx, LoadSceneMode.Additive);
}
}
}
在第一部和第二部两篇文章里,我已经展示了怎样利用起那些鲜为人知的Unity特色功能。这里所列出的方法只是一个个小步骤,但我希望它们能在你的下一个项目里发挥作用,或至少成为候选。
原型所用到的资产都能在资源商店上免费下载:
如果你有兴趣讨论下文章,或者分享自己的感想,请前往我们的Scripting论坛。这里我先下线了,但你可以在Twitter(@CaballolD)联系我。未来将有更多Unity开发者发布Tech from the Trenches系列技术博文,请持续关注。