- 为形状定义抽象行为与具体行为
- 根据需要加入行为
- 创建泛型类与泛型方法
- 使用预处理指令进行条件编译
- 为枚举类型添加扩展方法(extension method)
- 让形状发生震荡
这是对象管理章节的第九篇教程. 将在上个教程的代码基础上制作. 本篇教程将使形状支持模块化的行为特性
本教程源码使用Unity2017.4.12f1编写
形状们燥起来了
行为脚本组件
目前, 所有形状都可以移动和旋转, 但是也仅仅如此. 我们还能再想出一些形状可表现的其他行为, 想好之后, 只要按照思路向Shape.GameUpdate方法中添加具体的功能代码即可. 不过如果我们设计了很多种行为, 该方法里的代码数量会变得非常庞大. 此外, 如果我们还希望形状之间的行为能够有所区别, 那就还需要在Shape脚本中添加多个与行为相关的配置选项字段, 这样又会进一步使得脚本的代码变得臃肿不堪.
合理的做法是, 每个行为都被模块化, 可以独立的定义和使用. 而这也是MonoBehavior这个内置类的理念与作用, 跟着教程, 让我们一起Unity脚本组件的形式实现各个行为
抽象行为
新建组件脚本ShapeBehavior(注意, 此处Behavior是美式写法, 跟MonoBehaviour的英式写法单词不一样, 没有字母u 别写错了), 它默认继承MonoBehavior类. ShapeBehavior将作为所有行为类的父类, 它不会用来实例化, 因为它本身并不实现任何具体的行为功能, 而只是给出与行为有关的方法声明. 为了确保这一点, 需要使用abstract关键字将它声明为一个抽象类
为什么不命名为ShapeBehaviour而是少了一个字母u?
Unity使用英式写法为MonoBehaviour类命名, 这并不符合美式拼写的惯例. 我们正在定义自己的behavior类基础, 所以我坚持使用美式写法
(大哥, 你不是荷兰人吗? 还有, 写法跟功能有关系吗, 你是在吐槽自己吗?)
using UnityEngine;
//所有形状行为脚本组件的抽象基类
public abstract class ShapeBehavior : MonoBehaviour {
}
就像是前面教程中, 在Shape脚本中做的一样, 我们不需要依赖Update方法更新行为状态, 而是在ShapeBehavior中创建一个自定义的更新方法GameUpdate. 另外, 就像上面提到的, ShapeBehavior脚本只对行为功能做出定义, 而不需实现任何可执行的代码, 因此GameUpdate需要被声明为一个抽象方法, 它的功能代码应该由派生类实现 :
//定义主要用于更新形状行为状态的抽象方法, abstract, 前面教程提到过, 声明"抽象"的关键字
public abstract void GameUpdate ();
另外, 行为是依托于形状而存在的, 所以要为方法增加一个Shape类型的参数, 用来指定要做出该行为的形状 :
//public abstract void GameUpdate ();
//shape参数用来指定要做出该行为的形状
public abstract void GameUpdate (Shape shape);
除此了上述方法之外, 每个形状都拥有各自的配置选项与运行状态信息, 这些数据应该支持被游戏保存和加载. 因此还要添加Save 和 Load 方法 :
//定义保存行为配置与状态数据的抽象方法
public abstract void Save (GameDataWriter writer);
//定义加载行为配置与状态数据的抽象方法
public abstract void Load (GameDataReader reader);
移动
首先要实现的是让形状进行简单线性移动的行为组件. 它的功能与当前生成形状时的移动效果非常相像, 区别只是用了一个单独的类书写功能代码. 新建脚本MovementShapeBehavior, 并让其继承ShapeBehavior类. 它需要一个代表速度的属性, 并在自身的GameUpdate方法中根据该属性调整形状的位置, 并还要能对速度的值进行保存和加载 :
using UnityEngine;
//实现移动行为的脚本组件, 继承抽象类ShapeBehavior
public class MovementShapeBehavior : ShapeBehavior {
//代表移动速度
public Vector3 Velocity { get; set; }
//更新行为状态
public override void GameUpdate (Shape shape) {
//每次执行根据速度值更新形状的位置
shape.transform.localPosition += Velocity * Time.deltaTime;
}
//保存方法
public override void Save (GameDataWriter writer) {
//写入速度值
writer.Write(Velocity);
}
//加载方法
public override void Load (GameDataReader reader) {
//读取速度值
Velocity = reader.ReadVector3();
}
}
旋转
以几乎一样的做法来实现旋转行为, 新建脚本RotationShapeBehavior, 与移动行为的区别是, 旋转行为使用角速度控制形状进行旋转运动 :
using UnityEngine;
//实现旋转行为的脚本组件, 继承抽象类ShapeBehavior
public class RotationShapeBehavior : ShapeBehavior {
//代表旋转角速度
public Vector3 AngularVelocity { get; set; }
//更新行为状态
public override void GameUpdate (Shape shape) {
//每次执行根据角速度值设置形状的旋转角度
shape.transform.Rotate(AngularVelocity * Time.deltaTime);
}
//保存方法
public override void Save (GameDataWriter writer) {
//写入角速度值
writer.Write(AngularVelocity);
}
//加载方法
public override void Load (GameDataReader reader) {
//读取角速度值
AngularVelocity = reader.ReadVector3();
}
}
根据需要添加行为
在SpawnZone.SpawnShape方法中, 将上面创建的行为脚本组件添加给形状并设置对应的速度属性, 取代为形状自身的速度属性赋值的代码 :
public virtual Shape SpawnShape () {
…
//AddComponent<脚本类名>()方法会为指定的游戏对象添加指定类型的脚本组件, 并返回该脚本的实例
var rotation = shape.gameObject.AddComponent<RotationShapeBehavior>();
//shape.AngularVelocity = Random.onUnitSphere * spawnConfig.angularSpeed.RandomValueInRange;
//不再使用上面这句代码设置形状自身的角速度, 而是使用行为脚本的角速度控制旋转
rotation.AngularVelocity = Random.onUnitSphere * spawnConfig.angularSpeed.RandomValueInRange;
Vector3 direction;
switch (spawnConfig.movementDirection) {
…
}
//为形状添加移动行为脚本组件
var movement = shape.gameObject.AddComponent<MovementShapeBehavior>();
//shape.Velocity = direction * spawnConfig.speed.RandomValueInRange;
//不再使用上面这句代码设置形状自身的移动速度, 而是使用行为脚本的移动速度控制移动
movement.Velocity = direction * spawnConfig.speed.RandomValueInRange;
return shape;
}
可以在这里使用var声明变量类型?
只要编译不会出现错误, 并没有什么严格的规定要求什么时候使用var, 什么时候使用精确的类型名称. 依我的经验来说, 你的代码只要能够明确的传达出类型即可使用var, 比如说调用构造方法的代码就是一个例子. 同样, 此处的AddComponent
将行为代码组件化的一个好处是, 在不需要某个行为的时候, 我们可以直接移除与它对应的脚本组件, 从而避免不必要的工作. 比如说, 只有当形状对应的速度时才有必要为其添加对应的行为组件 :
public virtual Shape SpawnShape () {
…
//获取本次形状生成时随机得到的角速度值
float angularSpeed = spawnConfig.angularSpeed.RandomValueInRange;
//只有角速度值不为0时才有必要添加旋转行为组件
if (angularSpeed != 0f) {
var rotation = shape.gameObject.AddComponent<RotationShapeBehavior>();
//rotation.AngularVelocity = Random.onUnitSphere * spawnConfig.angularSpeed.RandomValueInRange;
//使用变量angularSpeed代替spawnConfig.angularSpeed.RandomValueInRange
rotation.AngularVelocity = Random.onUnitSphere * angularSpeed;
}
//获取本次形状生成时随机得到的移动速度值
float speed = spawnConfig.speed.RandomValueInRange;
//只有移动速度不为0时才有必要添加移动行为组件
if (speed != 0f) {
Vector3 direction;
switch (spawnConfig.movementDirection) {
…
}
var movement = shape.gameObject.AddComponent<MovementShapeBehavior>();
//movement.Velocity = direction * spawnConfig.speed.RandomValueInRange;
//使用变量speed代替spawnConfig.speed.RandomValueInRange
movement.Velocity = direction * speed;
}
通过以上代码, 当我们为生成区配置的速度范围最终确定的速度出现了0这个结果时, 表示形状不需要旋转或移动, 也就不会为其添加对应的行为脚本组件了
带有移动行为脚本而没有旋转行为脚本的形状
更新行为状态
为形状添加了对应的行为脚本后, 形状却不会移动也不会旋转, 因为我们还没有调用过行为脚本用来更新状态的GameUpdate方法. 调用该方法的任务要交给Shape脚本, 因此需要为其添加一个存储行为脚本引用的列表, 使其可以追踪行为脚本组件 :
using UnityEngine;
//List类位于该命名空间下
using System.Collections.Generic;
public class Shape : PersistableObject {
//用于存储该形状的行为组件引用
List<ShapeBehavior> behaviorList = new List<ShapeBehavior>();
下一步要做的是在Shape脚本中创建一个为该列表增加行为组件的方法. 最直接的方式就是创建一个使用行为组件类型参数的方法AddBehavior, 它将向列表中添加该参数代表的行为组件 :
//完成与添加行为组件相关的工作
public void AddBehavior (ShapeBehavior behavior) {
//将参数代表的行为组件引用加入自身的行为组件列表
behaviorList.Add(behavior);
}
如果把为形状添加行为组件的AddComponent方法也移动到该方法内, 由该方法来返回添加的行为组件引用, 这样就可以在方法内部获得要添加到列表中的行为组件引用, 从而也就不需要参数了, 方法使用起来更加方便. 不过这样做之后, 需要将AddBehavior变成像AddComponent方法一样的泛型方法. 通过向方法名称后面增加类型占位符就可以完成这个转变, 类型占位符使用一对尖括号包围. 占位符的名字可以随意设置, 但是惯例是是用字母T, 它的含义是”模板类型(template type)” :
//public void AddBehavior (ShapeBehavior behavior) {
//AddComponent方法加入方法后, 就不需要使用参数传递行为脚本的引用了
//但是同时, 由于AddComponent需要的类型并不只有一种, 因此需要将方法变成泛型方法, 调用时指定脚本组件的类型
public T AddBehavior<T> () {
//向形状添加类型为T的行为脚本
T behavior = gameObject.AddComponent<T>();
behaviorList.Add(behavior);
//返回添加的行为脚本引用
return behavior;
}
然而, 只有泛型T是一种继承了ShapeBehavior的类型时, 才能让上述代码正常工作, 编译器需要我们明确的声明这一点, 否则会报错, 因此需要我们在方法圆括号后面书写where T : ShapeBehavior语句来完成这种声明 :
//public T AddBehavior<T> () {
//AddComponent方法的定义约束了泛型必须继承自Component,
//ShapeBehavior继承自MonoBehaviour继承自Behaviour继承自Component
public T AddBehavior<T> () where T : ShapeBehavior {
(此处需要where语句的根本原因是 : AddComponent方法的定义约束了必须使用继承Component的泛型, 在AddBehavior方法中使用泛型T调用AddComponent方法时, 如果不声明T的继承条件, 编译器就认为此处可能出错, 不予编译, 并报错. 之所以可以使用ShapeBehavior约束解决这个问题是因为, 它继承自MonoBehaviour => 继承自Behaviour => 继承自Component)
现在可以去SpawnZone.SpawnShape方法中将AddComponent方法替换为AddBehavior方法了 :
//var rotation = shape.gameObject.AddComponent<RotationShapeBehavior>();
//使用AddBehavior代替AddComponent
var rotation = shape.AddBehavior<RotationShapeBehavior>();
…
//var movement = shape.gameObject.AddComponent<MovementShapeBehavior>();
//使用AddBehavior代替AddComponent
var movement = shape.AddBehavior<MovementShapeBehavior>();
最后, 我们可以在Shape.GameUpdate方法中移除旧代码, 并调用每个行为组件自身的GameUpdate方法, 并将当前的Shape脚本自身作为参数传递进去, 这样, 形状可以按照行为脚本的控制键移动或旋转了 :
public void GameUpdate () {
//transform.Rotate(AngularVelocity * Time.deltaTime);
//transform.localPosition += Velocity * Time.deltaTime;
//遍历形状的行为脚本数组, 依次调用每一个的GameUpdate方法
for (int i = 0; i < behaviorList.Count; i++) {
behaviorList[i].GameUpdate(this);
}
}
移除行为组件
如果产生的是新实例化的形状, 那么添加行为组件的过程工作正常, 但是如果产生的是一个回收再利用的形状, 会导致重复添加相同的行为脚本组件, 如下图所示 :
重复的行为组件
修复这个问题的最快捷方法是在回收一个形状时, 移除它所包含的所有行为脚本, 这也会带来一个问题, 就是即便是使用回收利用的形状, 却依然要为了添加行为组件而进行内存分配的过程, 不过稍后的教程中我们会处理这个问题. 修改Shape.Recycle方法的代码 :
public void Recycle () {
//在回收一个形状之前, 先遍历其行为组件脚本列表
for (int i = 0; i < behaviorList.Count; i++) {
//将列表中每一个行为组件都销毁
Destroy(behaviorList[i]);
}
//清空行为组件列表内的元素
behaviorList.Clear();
OriginFactory.Reclaim(this);
}
保存
现在保存形状数据的时候, 也必须保存其行为脚本组件的情况, 这表示保存文件中又会增加新的数据种类, 版本号需要进行设计, Game.saveVersion增加到6 :
//const int saveVersion = 5;
//版本号6实现了保存形状所带有的行为脚本的状态数据
const int saveVersion = 6;
就像之前的教程保存形状种类一样, 对于行为数据, 也需要将所有类型的行为存放到一个列表中, 并用它们在列表中的索引作为行为代号. 只不过区别是, 行为组件是一种类型, 而不是预制体. 此时只有两种行为类型, 让我们新建一个脚本ShapeBehaviorType, 在该脚本内定义一个枚举类型, 使用两个枚举值分别代表移动行为组件和旋转行为组件 :
//代表每一种行为组件类型的枚举
public enum ShapeBehaviorType {
//代表移动行为代号的枚举
Movement,
//代表旋转行为代号的枚举
Rotation
}
接下来, 向ShapeBehavior脚本中增加一个抽象属性BehaviorType, 用它来得到代表行为组件类型的枚举值 :
//定义用于得到代表行为组件代号枚举值的抽象属性
public abstract ShapeBehaviorType BehaviorType { get; }
在MovementShapeBehavior脚本中, 实现该属性的代码需要返回ShapeBehaviorType.Movement :
//获得代表当前行为组件代号的枚举值
public override ShapeBehaviorType BehaviorType {
get {
return ShapeBehaviorType.Movement;
}
}
在RotationShapeBehavior脚本中, 实现该属性的代码需要返回ShapeBehaviorType.Rotation :
//获得代表当前行为组件代号的枚举值
public override ShapeBehaviorType BehaviorType {
get {
return ShapeBehaviorType.Rotation;
}
}
有了该属性后, 就能在Shape.Save方法中写入每个形状的行为组件代号列表了. 保存行为数据的过程是, 先写入总共有多少个行为组件, 然后遍历行为组件数组, 以整数类型保存每一个组件的代号枚举, 并调用每个行为组件自身的Save方法. 它们将代替原来写入移动和旋转数据的代码 :
public override void Save (GameDataWriter writer) {
…
//writer.Write(AngularVelocity);
//writer.Write(Velocity);
//写入该形状总共有多少个行为组件
writer.Write(behaviorList.Count);
//遍历形状的组件列表
for (int i = 0; i < behaviorList.Count; i++) {
//以int类型保存每个行为组件的代号枚举
writer.Write((int)behaviorList[i].BehaviorType);
//调用每个行为组件自己的Save方法
behaviorList[i].Save(writer);
}
}
加载
从保存文件中加载形状的行为数据时, 需要根据读取到的行为代号来为形状添加对应的行为脚本组件. 在Shape脚本中增加一个私有方法AddBehavior用来完成这个任务, 该方法接受一个ShapeBehaviorType类型的参数. 在方法内部, 通过switch语句来添加正确的行为组件. 当没有找到与参数对应的行为脚本时, 则返回null, 并输出操作失败提示信息, 出现这种情况意味着我们忘记将某个行为类型添加到switch的case条件分支中 :
//根据行为类型枚举, 为形状添加对应的行为脚本组件
ShapeBehavior AddBehavior (ShapeBehaviorType type) {
switch (type) {
//添加移动行为组件
case ShapeBehaviorType.Movement:
//return语句执行后会直接结束方法, 不会继续执行后面的代码
//添加与参数类型对应的行为组件, 并返回添加的行为组件引用
return AddBehavior<MovementShapeBehavior>();
//添加旋转行为组件
case ShapeBehaviorType.Rotation:
//添加与参数类型对应的行为组件, 并返回添加的行为组件引用
return AddBehavior<RotationShapeBehavior>();
}
//如过代码能执行到这里, 说明没有添加与参数对应类型的行为组件, 输出提示文字
Debug.LogError("该行为脚本组件未进行处理, 请检查Shape.AddBehavior代码 : " + type);
//返回null, 表示组件添加失败
return null;
}
然后在Shape.Load方法中, 使用读取行为脚本组件数据的代码代替之前用来读取移动和旋转数据的代码. 循环读取以整数类型保存的行为代号, 并将其转换为ShapeBehaviorType枚举类型, 将其作为参数传递给AddBehavior方法 :
public override void Load (GameDataReader reader) {
…
//AngularVelocity = reader.Version >= 4 ? reader.ReadVector3() : Vector3.zero;
//Velocity = reader.Version >= 4 ? reader.ReadVector3() : Vector3.zero;
//只有版本号大于等于6才代表保存文件包含行为脚本的数据
if (reader.Version >= 6) {
//读取该形状总共保存了几个行为脚本
int behaviorCount = reader.ReadInt();
//按照保存的行为脚本数量, 取对应数量的行为代号, 并根据代号为形状添加行为脚本
for (int i = 0; i < behaviorCount; i++) {
AddBehavior((ShapeBehaviorType)reader.ReadInt()).Load(reader);
}
}
}
对于版本号为6的保存文件, 上面的代码工作正常, 但是如果保存文件的版本号小于6, 就会出现问题. 版本4和版本5文件都包含旧的移动与旋转数据. 为了向后兼容, 需要根据读取到的旋转速度与移动速度来为形状添加必要的行为脚本组件, 版本号小于4的保存文件无需特别处理, 因为它们还没有添加让形状发生运动的功能 :
if (reader.Version >= 6) {
…
}
//如果版本号小于6但是大于等于4, 表示虽然没有行为脚本数据, 却存在旋转和移动速度数据, 需要特别处理
else if (reader.Version >= 4) {
//根据读取到的旋转速度为形状添加旋转行为脚本
AddBehavior<RotationShapeBehavior>().AngularVelocity = reader.ReadVector3();
//根据读取到的移动速度为形状添加移动行为脚本
AddBehavior<MovementShapeBehavior>().Velocity = reader.ReadVector3();
}
此时, 对于Shape脚本来说, 已经不再需要AngularVelocity和Velocity这两个属性, 它们的功能已经被行为脚本组件代替, 因此删除它们 :
//public Vector3 AngularVelocity { get; set; }
//public Vector3 Velocity { get; set; }
回收行为
由于每次产生形状时要为其添加行为组件, 而回收形状时又要将其带有的行为组件销毁, 导致这个过程始终都在进行内存的分配工作. 形状回收机制的最大意义是为了最小化内存分配的次数, 而上述情况有悖于这个目标, 因此必须找到一种也能回收行为组件的方法
Unity的组件不能被独立的从其所属游戏对象上剥离出来, 因此无法将它们放置在一种回收池中以供稍后使用. 如果我们希望持续使用某个Unity的组件, 那么该组件被添加给游戏对象后就不可以被移除.
在上述组件机制限制下, 也有办法可以实现我们的目的 : 比如为生成的形状添加行为组件之前检查是否已经存在同样的组件, 如果存在, 则不重复添加; 或是为形状工厂添加复杂的回收池, 由工厂来跟踪和管理每一个形状的行为组件等等. 这些办法并不理想, 因为我们做的这一切都是为了向Unity的组件系统机制妥协, 而不是在利用它的优势.
其实还可以尝试另一个完全不同的思路 : 不通过组件的方式为形状添加行为
不再使用Unity组件
只需要让ShapeBehavior类不再继承MonoBehaviour, 行为脚本便不再是一种组件了, 另外ShapeBehavior也不需要继承别的什么类 :
//public abstract class ShapeBehavior : MonoBehaviour {
//不再继承MonoBehaviour类, 不再是一种Unity组件类
public abstract class ShapeBehavior {
这样一来, 在Shape.AddBehavior
public T AddBehavior<T> () where T : ShapeBehavior {
//T behavior = gameObject.AddComponent<T>();
//T不再是一种组件类, 将添加它作为组件的代码用它的构造方法取代
T behavior = new T();
behaviorList.Add(behavior);
return behavior;
}
尽管没有为类明确的提供任何构造方法时, C#会为类提供一个默认的无参数构造方法, 但是编译器无法确定T是否一定有无参数的构造方法, 因此它认为new T()是有可能出错的, 所以编译不通过, 并报错提示我们. 要修复这个报错, 需要为泛型T增加一个”new()”约束, 用逗号与已有的约束分隔开来, 该约束明确的要求了T必须包含无参数构造方法 :
//public T AddBehavior<T> () where T : ShapeBehavior
//新增约束new(), 在别处调用该泛型方法时, 如果传入的类型不包含无参数构造方法, 则会报错
public T AddBehavior<T> () where T : ShapeBehavior, new() {
(如果一个类没有任何自定义构造方法, 则C#自动提供无参数构造方法; 否则, 类只使用自定义的构造方法, 也就是说, 一个类, 不一定包含无参数的构造方法, 所以编译器在发现泛型方法内调用了泛型的无参数构造方法后, 要求必须对传入的泛型做出对应的约束)
在Shape.Recycle方法中也不再需要销毁行为脚本, 只需要保留清空列表的方法, 不过遍历行为列表的for循环先留着, 后面有用 :
public void Recycle () {
for (int i = 0; i < behaviorList.Count; i++) {
//行为脚本已经不再是一种组件, 不需要销毁它们了
//Destroy(behaviorList[i]);
}
behaviorList.Clear();
OriginFactory.Reclaim(this);
}
行为回收池
要回收利用行为, 需要将其放入回收池中. 不同行为之间的类型并不相同, 所以应该为每种类型行为准备各自的回收池. 我们新建单独的脚本ShapeBehaviorPool, 在其中创建泛型类ShapeBehaviorPool
using System.Collections.Generic;
using UnityEngine;
//代表行为回收池的静态类
public static class ShapeBehaviorPool<T> where T : ShapeBehavior, new() {
}
(个人补充知识点, 静态类在程序运行过程中只会存在一个实例, 该实例在初次访问静态类的任何成员时自动创建, 之后对静态类的访问其实都是在访问这个实例; 对于泛型静态类型, 有一点不同, 就是每一种具体的模板类型的实例, 均会存在一个, 比如某个静态泛型类 StaticClass
这次将使用一个”栈(Stack)”来追踪被回收的行为. 因此向上面的类中添加静态Stack
//用于存储被回收的行为的"堆"
static Stack<T> stack = new Stack<T>();
“栈”是什么?
它就像是一个列表, 区别是你只能在首位添加或移除其中的元素, 向栈添加元素的操作叫做”入栈”(push), 从栈中移除元素的操作叫做”出栈”(pop). Untiy不会序列化栈, 不过对于本教程来说, 这不是问题.
为ShapeBehaviorPool类添加一个Get方法和一个Reclaim方法. 它们的功能类似ShapeFactory脚本中的同名方法, 区别是它们的代码非常简单. 需要从回收池获得一个行为时, 如果栈不是空的, 执行出栈操作, 否则返回一个新的行为实例. 需要回收一个行为时, 执行入栈操作 :
//获取一个行为
public static T Get () {
if (stack.Count > 0) {
return stack.Pop();
}
return new T();
}
//回收一个行为
public static void Reclaim (T behavior) {
stack.Push(behavior);
}
放入行为回收池
在ShapeBehavior脚本中添加抽象方法Recycle, 它将用来行为放入回收池 :
//定义用来回收行为的抽象方法
public abstract void Recycle ();
在MovementShapeBehavior脚本中, 使用的自身的类型作为模板类型来调用行为回收池的Reclaim方法 :
//将自身放入行为回收池
public override void Recycle () {
//回收该行为到与自身类型对应的回收池
ShapeBehaviorPool<MovementShapeBehavior>.Reclaim(this);
}
对RotationShapeBehavior脚本进行同样修改 :
//将自身放入行为回收池
public override void Recycle () {
//回收该行为到与自身类型对应的回收池
ShapeBehaviorPool<RotationShapeBehavior>.Reclaim(this);
}
将类密封
由于每个形状的行为都对应着一种具体的行为类型, 因此前面与处理行为有关的代码都是强类型的(strongly-typed, 进而保障了一个行为不会被放入错误的回收池中. 然而, 只有每一种行为都只继承自ShapeBehavior类时上面这句话才成立. 理论上来说, 行为是有可能继承自别的类的, 比如一些特殊的运动行为可以在MovementShapeBehavior的基础上扩展. 然后它们同样可以被回收到MovementShapeBehavior回收池中, 因为它们的父类符合该回收池对行为类型的要求, 如果真的发生了这种情况, 虽然代码不会报错, 但是实际上却产生了错误的行为回收, 没有将这些扩展的行为放到它们自己类型的回收池中.
为了预防这个潜在的问题, 需要让已经继承了ShapeBehavior类的行为类型不可以被其他行为继承, 这可以通过在类的声明处添加sealed关键字来做到 :
//public class MovementShapeBehavior : ShapeBehavior {
//sealed关键字, 顾名思义, 这个类被"密封"住了, 其他类不可以继承它
public sealed class MovementShapeBehavior : ShapeBehavior {
RotationShapeBehavior类进行同样的操作 :
//public class RotationShapeBehavior : ShapeBehavior {
public sealed class RotationShapeBehavior : ShapeBehavior {
===================译者补充开始===================
不知道有没有人学到这里, 会产生这种疑问 :
“既然都已经想到了继承这些类可能会出错, 不去这样写代码不就好了吗? 有必要专门去加个sealed关键字?“
我在这里说一下个人的看法, 首先对于本教程来说, 这个sealed, 不加没有任何问题, 就像你想的那样, 既然想到了这样做可能有问题, 不去这么做就好了.
换个角度来看, 首先看到的是这个系列教程的好处, 能感觉原作者是个经验老道功底扎实的开发者, 每一篇教程都围着中心主题引入了非常多的知识点, 这个sealed关键字也不例外, 又让我们学到一个知识点.
其次, 再谈谈, 加这个sealed关键字的意义, 这需要超出教程本身, 看的更远. 软件开发, 无论是个人单兵作战, 还是团队协同配合, 都离不开代码管理这件事, 你的代码越易于维护, 越不容易出错, 你就可以节省下更多的精力去进行创造性的事务. 编程语言中的很多特性, 都在服务这一点. 试想一下, 如果你的一个项目, 有成百的代码文件, 文件中存在上万乃至十万量级的代码, 你还能像教程中提到的这种情况, 靠你的记忆力去维持每一种代码规则吗? 这一定很难, 甚至不可能, 那么就需要在设计某一处代码时, 尽可能的将当时想到的规则, 以代码的形式记录下来, 这样, 就算有一天我们已经忘记了某处代码不能以某种形式使用, 这些写在代码中的规则也能严格的约束我们. sealed关键字就是这样的一种写在代码中的规则, 以后即便你忘了不能继承某个类, 不小心去继承它了, 编译器也会提示你, 这个类已经被密封了, 不能继承. 像是public, private, abstract等等关键字, 以及刚刚接触的泛型中的where约束关键字, 其实都在对代码做出使用规则的管理, 减少出错的可能.
===================译者补充结束===================
使用回收池
要使用行为回收池, 应该在Shape.AddBehavior
public T AddBehavior<T> () where T : ShapeBehavior, new() {
//T behavior = new T();
//通过回收池获得行为实例, 而不是每次都新建
T behavior = ShapeBehaviorPool<T>.Get();
behaviorList.Add(behavior);
return behavior;
}
最后, 在Shape.Recycle方法中, 在形状回收之前, 其拥有的每一个行为都进行回收 :
public void Recycle () {
for (int i = 0; i < behaviorList.Count; i++) {
//遍历形状的行为列表, 对每一个行为进行回收
behaviorList[i].Recycle();
}
behaviorList.Clear();
OriginFactory.Reclaim(this);
}
回收池热重载
行为不用Unity组件的形式实现, 存在一个短板, 那就是如果进行了热重载(Hot Reload, 指的在运行时修改脚本, 返回Untiy编辑器后的重编译过程), 行为实例都会消失, 在游戏中的表现就是, 所有的形状不再移动和转转了. 这个问题不会发生在Build之后的程序中, 不过依然影响在编辑器阶段的调试工作.
只把行为的类型变为可序列化的还不够, 因为Unity会尝试反序列化将序列化数据反序列化为ShapeBehavior类型, 这是因为形状的行为列表的类型是List
想要让热重载后行为不消失, 需要做的事让ShapeBehavior继承ScriptableObject类. 这样做之后, 行为的实例就相当于一种仅在运行期间存在的资产(asset), 从而可以对它们进行序列化 :
//public abstract class ShapeBehavior {
//为了在运行时重编译后可以保持行为的实例, 让它们继承ScriptableObject
public abstract class ShapeBehavior : ScriptableObject {
上述代码可以解决行为实例在热重载后消失的问题, 不过在控制台会看到Unity对我们发出了警告信息, 意思是说我们不应该使用构造方法创建新的资产实例, 而是应该通过ScriptableObject.CreateInstance方法 :
public static T Get () {
if (stack.Count > 0) {
return stack.Pop();
}
//return new T();
//Unity不赞成我们调用任何继承自Object类的构造方法, 手动调用可能带来问题
//所以使用Unity推荐的实例化方法来对类实例化
return ScriptableObject.CreateInstance<T>();
}
(关于为什么Unity要警告我们不要调用ScriptableObject类的构造方法, 我在官网论坛找到了一个帖子, 其中11楼说的值得一看, 如果你懒得看英文, 可以简单记住 : Unity不赞成我们调用任何继承自Object类的构造方法, 因为Unity会根据需要来管理何时调用它们, 我们手动调用很可能带来问题)
现在热重载后行为不会消失了. 可是回收池的实例还是会在热重载后消失, 虽然这并不会导致游戏功能出现问题, 但热重载前回收到池中的行为无法被继续追踪, 进而无法被重新利用, 却又占据着内存, 因此合理的做法是在热重载后再将它们放入重建的回收池内
首先, 向ShapeBehavior脚本添加一个公开的布尔属性IsReclaimed :
//标记一个行为是否应该在回收池内的属性, true表示该行为应该在回收池内
public bool IsReclaimed { get; set; }
然后, 在ShapeBehaviorPool.Reclaim方法中将入栈行为的IsReclaimed设置为true, 在Get方法中将出栈行为的IsReclaimed设置为false :
public static T Get () {
if (stack.Count > 0) {
//return stack.Pop();
//使用变量存储出栈的行为, 以便设置了IsReclaimed后再返回它
T behavior = stack.Pop();
//行为本身通过IsReclaimed决定热重载后是否重新加入回收池, false表示不加入
behavior.IsReclaimed = false;
return behavior;
}
return ScriptableObject.CreateInstance<T>();
}
public static void Reclaim (T behavior) {
//行为本身通过IsReclaimed决定热重载后是否重新加入回收池, true表示加入
behavior.IsReclaimed = true;
stack.Push(behavior);
}
**
最后, 在ShapeBehavior脚本的OnEnable方法中判断行为自身是否是被回收状态, 如果是, 则需要将自己放入回收池. 热重载后, 所有游戏对象都会先被禁用再被启用, 所以热重载后会触发行为的OnEnable方法 :
//主要目的是在热重载后, 可以将引用丢失的行为再加入到重建的回收池中
void OnEnable () {
//如果行为发现自身是被回收状态, 则将自己放入回收池
if (IsReclaimed) {
Recycle();
}
}
条件编译
让行为继承ScriptableObject类只是为了方便在编辑器下调试(Build后的程序不存在”热重载”这种情况, 也就不用考虑热重载时怎么序列化行为), Build后的程序中行为并不需要继承ScriptableObject类. 我们可以使用”条件编译”这一机制, 让ShapeBehavior只在编辑器环境下才继承ScriptableObject类. 要做到这一点, 需要将”: ScriptableObject”代码放到#if UNITY_EDITOR 和 #endif 之间 :
public abstract class ShapeBehavior
//带有#的#if语句, 叫做条件编译指令, 顾名思义, 在满足指定条件后才会编译被它包围的代码
// UNITY_EDITOR 就代表了在编辑器环境下这个前提条件
//仅编辑器环境下才需要以下代码解决热重载后的回收行为的引用丢失问题
#if UNITY_EDITOR
: ScriptableObject
#endif
{
…
}
(参考阅读, C#编译指令参考)
**
#if UNITY_EDITOR是如何工作的?
编译器用#if指令来判断是否要编译它所包围的代码.
对于我们的代码来说, 加入#if后, 有两种可能的编译方式 : ShapeBehavior继承ScriptableObject, 或是 不继承.
具体的编译方式由#if后面所书写的条件标识符决定, 标识符可以通过#define指令自定义, 也可以使用通过其他应用程序传递过来的标识符, 比如说UNITY_EDITOR就是Unity直接定义的, 编译器通过这个标识符检查是否是在Unity编辑器环境下编译代码.
条件编译还有很多用途, 比如检查Unity版本或是检查代码运行的目标平台类型, 本教程不展开赘述
同样也只需要在编辑器环境下使用IsReclaimed属性和OnEnable方法的代码, 所以对它们也进行条件编译处理 :
//仅编辑器环境下才需要以下代码解决热重载后的回收行为的引用丢失问题
#if UNITY_EDITOR
public bool IsReclaimed { get; set; }
void OnEnable () {
if (IsReclaimed) {
Recycle();
}
}
#endif
在ShapeBehaviorPool脚本中设置IsReclaimed属性的代码也要加上编译条件, 此外还必须只在编辑器环境下使用ScriptableObject.CreateInstance代码, 如果不是编辑器环境, 我们要直接调用行为类的构造方法, 这就需要用到条件编译指令#else :
public static T Get () {
if (stack.Count > 0) {
T behavior = stack.Pop();
//仅编辑器环境下才需要以下代码解决热重载后的回收行为的引用丢失问题
#if UNITY_EDITOR
behavior.IsReclaimed = false;
#endif
return behavior;
}
#if UNITY_EDITOR
return ScriptableObject.CreateInstance<T>();
#else
return new T();
#endif
}
public static void Reclaim (T behavior) {
//仅编辑器环境下才需要以下代码解决热重载后的回收行为的引用丢失问题
#if UNITY_EDITOR
behavior.IsReclaimed = true;
#endif
stack.Push(behavior);
}
震荡
如果只是要控制形状进行移动或是旋转这种简单的行为, 根本不必使用本篇教程中的制作方式, 这种制作方式的好处是方便我们添加扩展多种其他行为, 为了展示这一点, 接下来要为形状增加第三种类型的行为, 该行为使得形状相对于它的产生位置, 沿着直线作反复的震荡运动
基础行为
要支持另一种行为类型, 首先需要向ShapeBehaviorType枚举中增加一个新的行为代号枚举. 记住, 必须保障之前类型枚举的顺序不变(顺序变了, 它们对应的枚举整数值也就变了, 与保存文件中的数据就不能正确对应了), 因此将新的枚举加在末尾 :
public enum ShapeBehaviorType {
Movement,
Rotation, //←这里别忘了加个逗号
//代表震荡行为代号的枚举
Oscillation
}
然后就可以创建新类型的行为脚本OscillationShapeBehavior, 先只书写行为必须具备的基础方法和属性. 与震荡行为有关的代码稍后添加 :
using UnityEngine;
//实现震荡行为的类
public sealed class OscillationShapeBehavior : ShapeBehavior {
public override ShapeBehaviorType BehaviorType {
get {
return ShapeBehaviorType.Oscillation;
}
}
public override void GameUpdate (Shape shape) {}
public override void Save (GameDataWriter writer) {}
public override void Load (GameDataReader reader) {}
public override void Recycle () {
ShapeBehaviorPool<OscillationShapeBehavior>.Reclaim(this);
}
}
从枚举到实例
想要对新类型的行为进行加载, 需要在非泛型方法Shape.AddBehavior方法中的Switch语句里加入新行为类型的case分支. 如果加入新的行为类型后不需要修改Shap脚本会方便很多, 为此我们可以让ShapeBehaviorType脚本来完成根据枚举值获取行为实例的工作, 从而让Shape脚本不需要关心是否新增了行为类型
由于枚举类型内部不能加入方法, 我们要借助扩展方法(extension method)间接的这样做. 扩展方法可以在任何类中定义. 在ShapeBehaviorType脚本的枚举代码下方, 创建一个静态类ShapeBehaviorTypeMethods, 该类可以与枚举写在同一个脚本文件中 :
public enum ShapeBehaviorType {
Movement,
Rotation,
Oscillation
}
//为行为代号的枚举定义扩展方法的类
public static class ShapeBehaviorTypeMethods {
}
什么是扩展方法?
扩展方法指的是在一个静态类中, 表现的像是某种类型的实例方法一样的静态方法. 此处提到的”某种类型”, 可以是类, 或结构, 或基本类型, 或是枚举. 扩展方法的第一个参数代表了要进行操作的类型实例.
这种手段是不是表示可以任何对象添加方法? 是的, 只需要为扩展方法定义对应类型的参数即可.
使用扩张方法是一个非常好的方式吗? 不一定, 凡事有度. 适度使用扩展方法, 有助于解决一些特殊问题; 过渡滥用扩展方法, 你的代码结构会变得混乱不堪, 难以理解.
为该类增加公开的静态方法GetInstance, 它接收一个ShapeBehaviorType类型的参数. 接着将Shape.AddShapeBehavior方法中的switch语句移动到该方法里, 并略加修改, 使用回收池的Get方法获得行为的实例, 然后再将震荡行为的case加入到末尾 :
public static class ShapeBehaviorTypeMethods {
public static ShapeBehavior GetInstance (ShapeBehaviorType type) {
//Shape.AddBehavior中的代码全部复制过来, 略加修改
switch (type) {
case ShapeBehaviorType.Movement:
//return AddBehavior<MovementShapeBehavior>();
//使用回收池的Get方法获得指定类型的行为实例
return ShapeBehaviorPool<MovementShapeBehavior>.Get();
case ShapeBehaviorType.Rotation:
//return AddBehavior<RotationShapeBehavior>();
//使用回收池的Get方法获得指定类型的行为实例
return ShapeBehaviorPool<RotationShapeBehavior>.Get();
//添加震荡行为
case ShapeBehaviorType.Oscillation:
return ShapeBehaviorPool<OscillationShapeBehavior>.Get();
}
//Debug.LogError("该行为脚本组件未进行处理, 请检查Shape.AddBehavior代码 : " + type);
//在不引入命名空间UnityEngine的情况下, 调用Debug类时要把命名空间也写上
UnityEngine.Debug.Log("该行为脚本组件未进行处理, 请检查Shape.AddBehavior代码" + type);
return null;
}
}
要让上述方法称为能够被ShapeBehaviorType类型直接调用的扩展方法, 需要在ShapeBehaviorType方法第一个参数的类型前加上this关键字 :
//public static ShapeBehavior GetInstance (ShapeBehaviorType type) {
//this关键字表示这是一个ShapeBehaviorType类型的扩展方法
public static ShapeBehavior GetInstance (this ShapeBehaviorType type) {
这样我们就可以书写类似于ShapeBehaviorType.Movement.GetInstance()这样的代码了, 在Shape.Load方法中使用这种方式的代码代替之前调用形状的AddBehavior方法的代码, 并将得到的行为实例加入形状的行为列表, 然后调用行为的Load方法加载行为的保存数据 :
if (reader.Version >= 6) {
int behaviorCount = reader.ReadInt();
for (int i = 0; i < behaviorCount; i++) {
//AddBehavior((ShapeBehaviorType)reader.ReadInt()).Load(reader);
//通过ShapeBehaviorType的扩展方法GetInstance获得行为的实例
ShapeBehavior behavior = ((ShapeBehaviorType)reader.ReadInt()).GetInstance();
//将得到的行为实例加入形状的行为列表
behaviorList.Add(behavior);
//调用行为的Load方法加载行为的保存数据
behavior.Load(reader);
}
}
删除不再有任何作用的非泛型Shape.AddBehavior方法 :
//删除不再有任何作用的非泛型Shape.AddBehavior方法 :
//ShapeBehavior AddBehavior (ShapeBehaviorType type)
//{
// …
//}
实现震荡行为
震荡行为可以通过让形状做正弦波来实现, 并加入一个向量参数来修正震荡的幅度. 另外需要通过频率来控制震荡的速度, 频率即每秒震荡多少次, 在OscillationShapeBehavior脚本中加入这两个属性 :
//代表震荡的幅度
public Vector3 Offset { get; set; }
//代表震荡频率 次/秒
public float Frequency { get; set; }
震荡曲线就是 2π乘以频率再乘以当前时间 的正弦曲线, 并将得到的正弦值使用Offset属性修正后, 将结果作为形状的新位置 :
public override void GameUpdate (Shape shape) {
//根据频率与当前时间, 计算特定的正弦值
float oscillation = Mathf.Sin(2f * Mathf.PI * Frequency * Time.time);
//将正弦结果与偏移向量相乘, 得到形状的新位置
shape.transform.localPosition = oscillation * Offset;
}
但是上述代码会导致所有形状在生成区的位置点周围震荡, 而是不是形状的生成位置, 更糟的是, 还会导致移动行为失效, 因为不断地用震荡行为设置形状的位置, 覆盖了移动行为的位置设置. 所以我们不应该直接为形状的位置赋值, 而是在形状当前的位置基础之上, 加上震荡行为的位置变化 :
//shape.transform.localPosition = oscillation * Offset;
//将震荡行为的位置变化作用在形状当前位置上
shape.transform.localPosition += oscillation * Offset;
然而, 如果在每次行为状态更新时都将震荡结果添加到到形状当前位置上, 最终会不断的累加每一次的震荡偏移, 而不是用新的偏移替代旧的偏移. 为了解决这个问题, 就需要添加一个字段记录上一次震荡的偏移情况, 在添加新的震荡偏移时减去上一次的震荡偏移, 并且在使用Recycle方法回收行为时, 应该将上一次偏移重置为0 :
//记录上一次震荡偏移情况
float previousOscillation;
public override void GameUpdate (Shape shape) {
float oscillation = Mathf.Sin(2f * Mathf.PI * Frequency * Time.time);
//shape.transform.localPosition += oscillation * Offset;
//添加新的震荡偏移时减去上一次的震荡偏移
shape.transform.localPosition += (oscillation - previousOscillation) * Offset;
//记录本次震荡偏移的值作为后续的"上一次偏移情况"
previousOscillation = oscillation;
}
//…
public override void Recycle () {
//行为回收后与之前的状态无关了, 应该将记录的上一次震荡偏移重置为0
previousOscillation = 0f;
ShapeBehaviorPool<OscillationShapeBehavior>.Reclaim(this);
}
至此, 也已经理清了, 震荡行为的那些数据需要写入保存文件 : 震荡幅度, 震荡频率, 和上一次震荡偏移, 修改OscillationShapeBehavior脚本的Save和Load方法 :
public override void Save (GameDataWriter writer) {
//在保存震荡行为数据时, 依次写入震荡幅度, 震荡频率和上次震荡偏移
writer.Write(Offset);
writer.Write(Frequency);
writer.Write(previousOscillation);
}
public override void Load (GameDataReader reader) {
//在加载震荡行为数据时, 依次读取震荡幅度, 震荡频率和上次震荡偏移
Offset = reader.ReadVector3();
Frequency = reader.ReadFloat();
previousOscillation = reader.ReadFloat();
}
配置震荡数据
像移动和旋转行为一样, 每个生成区也需要与震荡行为有关的配置数据, 因此在SpawnZone脚本的SpawnConfiguration结构中增加一个MovementDirection类型的字段代表震荡方向, 增加两个FloatRange类型的字段代表震荡频率与幅度的随机范围 :
public struct SpawnConfiguration {
//配置该生成区产生形状的震荡方向
public MovementDirection oscillationDirection;
//配置该生成区产生形状的震荡幅度随机范围
public FloatRange oscillationAmplitude;
//配置该生成区产生形状的震荡频率随机范围
public FloatRange oscillationFrequency;
生成区Inspector中的震荡行为配置
现在移动和震荡行为都需要使用switch语句将MovementDirection类型的枚举值转换为一个代表方向的向量, 为了减少重复代码, 将与此过程相关的switch代码都移动到一个新增的专用方法中 :
public virtual Shape SpawnShape () {
…
float speed = spawnConfig.speed.RandomValueInRange;
if (speed != 0f) {
//把方向选项转换为具体方向向量的switch语句代码移动到单独的方法GetDirectionVector中
//Vector3 direction;
//switch (spawnConfig.movementDirection) {
// …
//}
var movement = shape.AddBehavior<MovementShapeBehavior>();
//movement.Velocity = direction * speed;
//通过GetDirectionVector方法获得运动方向
movement.Velocity = GetDirectionVector(spawnConfig.movementDirection, t) * speed;
}
return shape;
}
//该方法专门用来将MovementDirection类型的枚举值转换为与形状本地坐标系有关的一个向量
//参数一代表生成区的枚举配置, 参数二代表目标形状
Vector3 GetDirectionVector (SpawnConfiguration.MovementDirection direction, Transform t) {
switch (direction) {
case SpawnConfiguration.MovementDirection.Upward:
return transform.up;
case SpawnConfiguration.MovementDirection.Outward:
return (t.localPosition - transform.position).normalized;
case SpawnConfiguration.MovementDirection.Random:
return Random.onUnitSphere;
default:
return transform.forward;
}
}
还可以将配置形状震荡行为的代码也放到一个专用的方法SetupOscillation中. 还要注意的是, 如果随机得到的震荡频率与幅度有一个是0, 那么就不需要为形状增加震荡行为. 在SpawnShape方法返回形状的实例之前调用它 :
public virtual Shape SpawnShape () {
…
//对当前产生的形状配置震荡行为
SetupOscillation(shape);
return shape;
}
//专门为指定形状配置震荡行为的方法
void SetupOscillation (Shape shape) {
//根据配置随机计算震荡幅度
float amplitude = spawnConfig.oscillationAmplitude.RandomValueInRange;
//根据配置随机计算震荡频率
float frequency = spawnConfig.oscillationFrequency.RandomValueInRange;
//幅度与频率只要有一个为0, 震荡行为便不能正常配置, 终止方法
if (amplitude == 0f || frequency == 0f) {
return;
}
//在形状的行为列表中增加一个震荡行为
var oscillation = shape.AddBehavior<OscillationShapeBehavior>();
//为增加的震荡行为配置方向与幅度, 一行太长了, 分行写方便阅读
oscillation.Offset = GetDirectionVector(
spawnConfig.oscillationDirection,
shape.transform) * amplitude;
//为增加的震荡行为配置频率
oscillation.Frequency = frequency;
}
根据形状”年龄”震荡
由于目前的震荡行为是基于当前游戏时间来计算的, 所以每一个形状如果频率相同, 其震荡行为是完全同步的, 无论振幅是多少. 还有一个问题就是, 保存文件中并没有写入游戏时间, 因此震荡行为的状态数据保存的也并不完成. 为了解决这两个问题, 需要基于形状总共的存活时间——就像是形状的年龄——来计算震荡的正弦波动, 并且保存游戏时将形状的年龄写入文件
首先向Shape脚本添加年龄属性Age. 它可以公开访问, 但是只有形状自身可以设置它 :
//可公开访问, 不可公开设置的形状年龄属性, 代表了形状被回收前存在的总时间
public float Age { get; private set; }
在GameUpdate方法中, 随着每帧时间增加年龄. 另外在回收形状时将年龄设置为0 :
public void GameUpdate () {
//随着每帧时间增加形状年龄
Age += Time.deltaTime;
for (int i = 0; i < behaviorList.Count; i++) {
behaviorList[i].GameUpdate(this);
}
}
public void Recycle () {
//回收形状时将年龄设置为0
Age = 0f;
…
}
**
形状年龄需要被保存和加载, 在写入行为总数之前写入它, 读取的顺序与此相同 :
public override void Save (GameDataWriter writer) {
…
//写入形状年龄
writer.Write(Age);
writer.Write(behaviorList.Count);
…
}
public override void Load (GameDataReader reader) {
…
if (reader.Version >= 6) {
//读取形状年龄
Age = reader.ReadFloat();
int behaviorCount = reader.ReadInt();
…
}
…
}
最后, 调整OscillationShapeBehavior中计算正弦结果的代码, 使用形状年龄代替游戏当前时间 :
public override void GameUpdate (Shape shape) {
//float oscillation = Mathf.Sin(2f * Mathf.PI * Frequency * Time.time);
//使用形状年龄代替游戏当前时间计算正弦结果
float oscillation = Mathf.Sin(2f * Mathf.PI * Frequency * shape.Age);
…
}
至此, 我们已经完成了为形状添加行为的基本功能框架. 本教程中的三种行为类型只是牛刀小试, 在下一票教程形造卫星中我们将大展宏图.