Unity基础教程系列(九)——形状行为(Modular Functionality)

2020-10-21 10:44:58 浏览数 (1)

本文重点: 1、定义形状的抽象和具体行为 2、只在需要的时候才包含行为 3、创建通用的方法和类 4、试用条件编译 5、给枚举添加方法 6、让形状摆动起来

本教程是CatLikeCoding系列的一部分,原文地址见文章底部。

这是关于对象管理的系列教程中的第九篇。它增加了对形状的模块化行为的支持。

本教程使用Unity 2017.4.12f1编写。

(每个形状在做它们自己的事情)

1 行为组件

当前,所有形状都可以移动和旋转,但这并不是它们唯一能做的。我们可以想出一些希望形状表现出来的不同行为。要使形状做其他事情,只需将其代码添加到Shape.GameUpdate中即可。但是,如果我们定义很多行为的话,那么该方法将变得非常庞大。另外,我们可能不希望所有形状的表现都相同。

可以使用切换按钮来控制形状的功能,但这会使带有所有可能行为的切换按钮和配置选项的Shape代码膨胀。理想情况下,行为是模块化的,可以单独定义。这正是Unity的MonoBehaviour提供的功能,因此将每个行为模式实现为自己的Unity组件是有意义的。

1.1 抽象行为

像往常一样,创建一个新的ShapeBehavior组件脚本并使其继承自MonoBehaviour。这是我们行为的基类,我们将通过具体行为(例如运动)进行扩展。基本的ShapeBehavior类型不应该实例化,因为它本身不会执行任何操作,所以需要将类标记为abstract。

为什么不将其命名为ShapeBehaviour? Unity在其MonoBehaviour类中使用的是英国拼写习惯,这与美国拼写习惯的其他用法有所不同。我们正在定义自己的行为基础,因此我这里使用美国拼写。

就像Shape一样,我们不会依赖单独的Update方法,而是使用我们自己的GameUpdate方法,因此将其添加到ShapeBehavior中。但是ShapeBehavior只是定义了通用功能,而不是实际的实现。因此,我们只定义方法签名,然后定义一个分号而不是一个代码块。那定义了一个抽象方法,必须由继承自ShapeBehavior的类来实现。

抽象方法必须用Abstract关键字显式地定义。

此外,行为作用于形状,因此我们将添加一个作为参数。这样我们就不用用域来记录了。

除此之外,每个形状行为可能都有配置和状态,我们需要保存和加载它们。所以也添加抽象的保存和加载方法。

1.2 移动

我们的第一个具体的形状行为组件将是关于简单的线性运动。它的功能和我们现在的移动完全一样,但现在是在一个单独的类中实现。创建一个扩展ShapeBehavior的MovementShapeBehavior脚本。它需要一个速度矢量属性,它在GameUpdate中使用来调整形状的位置,当然也需要保存和加载它。

1.3 旋转

对旋转进行相同的操作,创建一个RotationShapeBehavior类,该类使用AngularVelocity向量属性进行旋转。

1.4 在需要的时候添加行为

在SpawnZone.SpawnShape中,将这些行为组件添加到形状中并设置其属性,而不是形状本身的属性。

在这里可以使用var吗? 对于何时使用var而不是显式变量类型,没有硬性规定,只要编译器能弄清楚就行。根据我的经验,类型应该在某个地方明确提到才能被编译器推断出来。构造函数方法调用是最好的例子,但是我也认为AddComponent;已经足够显式了。

使用组件来隔离行为的好处是,我们可以在不需要它们时将其省略。这样我们就可以避免一些不必要的工作。对于运动和旋转,只要它们的速度为非零,我们才需要添加它们的行为。

如果生成区域的速度范围是从零到某个非零值,那么它与最终速度为零是非常不同的。但是,如果将生成区域的速度范围设置为零(因为我们根本不希望任何移动或旋转),那么该行为将始终被忽略。

(形状有移动,但是没有旋转)

1.5 添加行为

现在,我们将所需的组件添加到形状中,但是它们已停止移动和旋转。那是因为我们还没有调用必需的GameUpdate方法。为此,它需要追踪它身上的其行为组件,我们为其提供一个列表字段。

接下来,我们需要一个方法来向形状添加行为实例。最直接的方法是将行为作为参数的公共AddBehavior方法,该方法将其添加到列表中。该方法必须在将组件添加到形状的游戏对象之前或之后调用。

我们可以通过在AddBehavior方法内部移动AddComponent调用,使其返回新行为,从而使此操作更加方便。为了使它起作用,我们必须将AddBehavior变成通用方法,就像AddComponent一样。这是通过在尖括号之间将类型占位符附加到方法名称来完成的。占位符名称无关紧要,但通常将其命名为T作为模板类型的简写。

但是,仅当AddBehavior与扩展ShapeBehavior的类型一起使用时,它才有效。要强制执行此约束,请在方法名称后写T:ShapeBehavior。

现在,我们可以在SpawnZone.SpawnShape中将AddComponent替换为AddBehavior。

最后,我们可以从Shape.GameUpdate中删除旧代码,而以自身作为参数调用其所有行为的GameUpdate方法。这将使形状移动并再次旋转。

1.6 移除行为

实例化新形状时,每次生成形状时添加行为的效果都很好,但是当形状被回收时,会导致行为组件重复。

(行为重复)

解决此问题的最快方法是简单地销毁所有行为并在回收形状时清除列表。这意味着即使在重用形状时我们也会重新分配内存,但是稍后我们将对其进行处理。

1.7 保存

保存形状时,我们现在还必须保存其所有行为。这意味着我们更改了保存文件格式,因此将Game.saveVersion增加到6。

就像形状列表一样,我们必须将每个行为的类型保存在列表中。和上次一样,我们可以为此使用一个标识符号。但是这次我们处理的是class类型,而不是预制数组索引。现在我们有固定数量的行为类型,目前有两种。那我们就定义一个ShapeBehaviorType枚举以标识运动和旋转,并将其放在自己的脚本文件中。

接下来,向ShapeBehavior添加一个抽象的BehaviorType getter属性,以便我们可以保留正确的枚举值。

该属性的实现很简单。MovementShapeBehavior始终返回ShapeBehaviorType.Movement。

现在我们可以在Shape.Save中编写行为列表。对于每种行为,首先编写其类型,转换为整数,然后调用其自己的Save方法。这取代了旧的运动和旋转数据的写入。

1.8 加载

当加载形状行为时,我们现在必须读取枚举值,然后向形状添加正确的行为组件。使用ShapeBehaviorType参数,为其添加一个私有的AddBehavior方法。让它使用switch语句来添加正确的行为组件。当我们未能添加正确的类型时,也让它返回null。如果我们在调用这个方法后出现了一个空引用异常,这意味着我们忘记在开关中包含一个行为类型。

将读取移动和旋转数据的旧代码替换为读取行为列表。对于每个行为,读取其标识符integer,将其转换为ShapeBehaviorType,用它调用AddBehavior,然后加载行为的其余数据。

这适用于文件版本6和更高版本,但文件版本4和5仍包含旧的移动和旋转数据。为了保持向后兼容,请在存在数据时读取该数据并添加必要的行为。即使是较旧的版本,我们也不必这样做,因为它们仅包含静止形状。

此时不再使用Shape的AngularVelocity和Velocity属性,因此应将其删除。

2 回收行为

因为我们每次生成形状时都会添加形状行为组件,然后销毁该行为组件,所以最终会一直分配内存。回收形状的最终目的是最大程度地减少内存分配,因此我们也必须找到一种回收形状行为的方法。

Unity组件无法从其游戏对象上分离,因此无法将它们放置在池中以便以后附加到其他游戏对象上。如果要继续使用Unity组件,则一旦将行为添加到形状中,就无法将其删除。可以使用该限制,例如,不破坏未使用的组件,并在以后需要时添加它们之前检查它们是否已经存在。或者通过让工厂意识到形状行为,来进行复杂的合并。这些解决方案并不理想,因为我们最终要与Unity的组件体系结构去抗争,而不是利用它。简单的解决方案是不使用Unity组件进行形状行为。

2.1 不在使用Unity组件

要让ShapeBehavior不成为Unity组件,只需让它不从MonoBehaviour继承即可。它不需要继承自任何东西。

现在我们不能在Shape.AddBehavior中使用AddComponent。相反,我们必须通过调用该类型的默认构造函数方法来创建一个常规对象实例。

尽管在未定义显式构造函数方法的情况下,类仍然具有隐式公共默认构造函数方法,但这不能保证它们的一定存在。因此,我们必须通过明确要求存在不带参数的构造函数方法来进一步限制模板类型。这是通过将new()添加到T的约束列表中来完成的。

我们也不能再销毁行为的形态。相反,我们只会清除列表。未使用的对象将在某个时候由垃圾收集器清理。但这个想法是,我们将回收行为,所以保持循环,即使它现在什么也不做。

2.2 行为池

要回收行为,我们必须将其放入池中。每个行为都有其自己的类型,因此应获取自己的池。为此,我们将创建一个通用的ShapeBehaviorPool  类。类型限制与以前相同。由于这些池按类型存在,因此我们不必费心创建它们的实例。相反,我们可以使用静态类。这意味着这些池将无法承受热重载,但也没太大影响。

这一次,我们将使用一个堆栈来跟踪未使用的行为,因此向类中添加一个静态堆栈 字段,并立即对其进行初始化。

什么是栈? 它就像一个列表,只不过你只能通过push和pop在顶部添加和移除。Unity没有序列化堆栈,但在这个例子中没影响。

给池一个Get和Reclaim方法。它们的工作方式与ShapeFactory的工作方式相同,但它们要简单得多。发生行为时,如果行为不为空,则将其从堆栈中弹出,否则返回一个新实例。回收时,将行为推送到堆栈上。

2.3 返回正确的池

在Shape Behavior中添加一个抽象的Recycle方法,以使回收成为可能。

对于MovementShapeBehavior,请使用正确的模板类型回收池。

对RotationShapeBehavior执行同样的操作。

2.4 密封类

与形状预制件不同,每种形状行为都有自己的类型,因此所有代码都是强类型的。无法将行为添加到错误的池中。但是,仅当每个行为仅继承自ShapeBehavior时才如此。从技术上讲,可以扩展其他行为,例如,扩展了MovementShapeBehavior的某些怪异的运动类型。然后,可以将该行为的实例添加到ShapeBehaviorPool  池中,而不是其自身类型的池中。为防止这种情况,我们可以通过添加sealed关键字来使其无法继承MovementShapeBehavior。

对RotationShapeBehavior执行同样的操作。

2.5 使用池

要使用池,调用ShapeBehaviorPool ;。形状。AddBehavior而不是总是创建一个新的对象实例。

最后,要启用行为重用,请在Shape.Recycle中回收它们。

2.6 支持热重载

不使用Unity组件的不利之处在于,我们的形状行为不再能承受热重载。重新编译完成后,所有行为都会消失。对于构建而言,这不是问题,但是在编辑器中工作时可能会很烦。

光让行为可序列化是不够的,因为Unity会尝试对每个形状的抽象ShapeBehavior实例列表进行反序列化,因为列表的类型是List  。

我们可以做的是让ShapeBehavior继承自ScriptableObject。这实际上将我们的行为实例变成了仅运行时资产,Unity可以正确地序列化这些资产。

这似乎可行,但是Unity会编译报错,说我们直接调用构造函数方法来创建新的资产实例,而不是使用ScriptableObject.CreateInstance。那就调整ShapeBehaviorPool。以正确的方式进行操作。

现在,shape使用的行为在热重新加载时仍然存在。但是池不能一起存活,并且对回收行为的引用会丢失。这不是一个大问题,但是是可以重新创建池的。

首先,向ShapeBehavior添加一个公共布尔is回收属性。

其次,将此属性在ShapeBehaviorPool.Reclaim中设置为true,在弹出后的Get中设置为false。

最后,添加一个OnEnable方法来检查ShapeBehavior是否被回收。如果是的话,让它自我循环。当通过ScriptableObject创建资产时,将调用此方法。每次热重新加载后,将重新生成池。

2.7 条件编译

但仅在编辑器中工作时才需要扩展ScriptableObject。在构建中并不需要创建运行时资产的开销。当我们的代码被编译为在编辑器中使用时,可以使用条件编译来仅使ShapeBehavior继承自ScriptableObject。这是通过将:ScriptableObject代码放在#if UNITY_EDITOR和#endif编译器指令之间的单独一行中来完成的。

if UNITY_EDITOR如何工作? if指令由编译器用来确定在编译过程中是否包括或跳过一段代码。这意味着可以通过两种方式来编译代码:ShapeBehavior继承自ScriptableObject,或者不继承。 根据是否定义了在#if之后写入的符号来做出决定。可以通过#define指令定义符号,但是也可以通过代码编辑器或其他应用程序将符号传递给编译器。这个时候,Unity确保在编译我们的代码以供在编辑器中使用时定义UNITY_EDITOR符号。同样的方法也可以用于检查Unity版本以及代码针对哪个目标平台进行编译。

同样,我们只需要编辑器中的is回收和OnEnable代码,因此也要使该位具有条件。

被ShapeBehaviorPool回收的使用也必须变成有条件的。

最后,我们只能在编辑器中使用ScriptableObject.CreateInstance。否则,我们需要使用构造方法。可以在#else指令的帮助下完成。

3 摆动

如果我们所做的只是移动和旋转形状,那么我们关于形状行为的新方法就毫无意义了。只有当我们有相当数量的行为可供选择时,它才有用。来添加第三种行为吧。我们将添加支持摆动的形状,沿着直线来回移动(相对于它的原始位置)。

3.1 最小行为类

为了支持其他行为类型,我们首先必须为其添加一个元素到Shape Behavior Type枚举中。一定不能更改现有元素的顺序,因此请将其添加到列表后。

然后,我们可以创建一个最小行为类,这里的话为OscillationShapeBehavior,其中包含所有必需方法和属性的最小实现。稍后我们将添加负责摆动的代码。

3.2 从枚举到实例

为了支持加载,我们还必须给非泛型的Shape.AddBehavior方法增加一个摆动的case。但是,如果我们不必在每次添加行为类型时都编辑Shape,会更加方便。让我们把从枚举到行为实例的转换转移到ShapeBehaviorType。

虽然我们不能直接将方法放在枚举类型中,但可以使用扩展方法间接地进行操作。扩展方法可以在任何类或结构中定义,因此我们将使用专用的静态ShapeBehaviorTypeMethods类,可以将其与枚举放在同一文件中。

什么是扩展方法? 扩展方法是静态类中的静态方法,其行为类似于某种类型的实例方法。该类型可以是任何东西,类、接口、结构、原始值或枚举。扩展方法的第一个参数定义了该方法将要操作的类型和实例值。

这是否允许我们向所有内容中添加方法? 是的,就像你可以编写任何类型为参数的静态方法一样。

这是一个好主意吗? 当适度使用时,没问题。它是一种有其用途的工具,但是如果过渡使用它会产生混乱非的结构。

给这个类一个带有ShapeBehaviorType参数的公共静态GetInstance方法。然后把代码从Shape.AddShapeBehavior移出并放入其中,调整它使用池,并为摆动添加一个新case。

要将其转换为ShapeBehaviorType的扩展方法,请在ShapeBehaviorType参数之前添加this关键字。

现在可以编写像shapebehaviortype . move . getinstance()这样的代码,并从中获得一个MovementShapeBehavior实例。在形状上使用这种方法。要获取行为实例,请将其添加到列表中,然后加载它。

删除非通用的AddBehavior方法,因为我们不再需要它。

3.3 摆动实现

我们将通过使用正弦波沿着偏移矢量移动形状来实现摆动行为。该向量定义了正方向上的最大偏移量。我们还需要一个频率来控制摆动速度,以每秒摆动数来定义。将两者的属性添加到OscillationShapeBehavior。

摆动曲线仅是2π的正弦乘以频率和电流时间。这用于缩放配置的偏移量,然后用于设置形状的位置。

但这会使所有形状围绕原点摆动,而不是其生成位置摆动。更糟糕的是,它不能与移动行为结合使用。因此,我们必须将摆动添加到该位置,而不是替换它。

但是,如果我们在每次Update时都将摆动偏移量加到位置上,那么我们最终会积累偏移量,而不是在每次更新时使用一个新的偏移量。为了补偿之前的摆动值,我们需要记住它,并在确定最终偏移量之前减去它,在回收时也需要将其设为零。

现在我们知道需要保存和加载什么状态:属性和先前的摆动值。

3.4 摆动配置

像运动和旋转一样,我们将通过在SpawnConfiguration中添加字段来配置每个生成区域的摆动。使用MovementDirection作为方向,并使用FloatRange控制摆动的幅度和频率。

(摆动的生成区域)

现在在SpawnZone中有两种情况,我们需要将MovementDirection转换为向量,因此将相关代码移至其自己的方法。

因为SpawnShape方法越来越大,所以也可以在它自己的方法中添加一个摆动行为。在这种情况下,如果振幅或频率最终为零,我们可以跳过添加行为。

(锁帧下的摆动)

3.5 基于形状的生存周期摆动

由于我们是根据当前游戏时间进行摆动的,因此所有形状都以同步的方式摆动。更糟糕的是,由于我们不保存游戏时间,因此也无法正确保存摆动状态。我们可以通过根据形状的生存周期进行摆动并保存为Age来解决这两个问题。

首先,将Age属性添加到Shape中。它是公开可用的,但是形状控制着自己的年龄,因此它的Setter应该是私有的。

在GameUpdate中,将Age随着时间增量增加。并在回收时将使用期限重置为零。

Age也应保存和加载。将其直接写在行为列表之前。

最后,调整OscillationShapeBehavior,使其使用形状的年龄而不是当前时间。

(基于Shape Age的摆动)

现在,我们有了一个向形状添加模块化行为的框架。当前的方法对于三种简单的行为类型来说是“矫枉过正”,我们将在下一教程“卫星”中添加更复杂的行为。

(多样的行为展现)

0 人点赞