Unity中的实体组件系统—教程

在实现新功能之前,让我们满足一个可能的好奇点:将isFiring的值设置在哪里? 我们应该立即解决一个非常好的问题。 让我们跳到PlayerInputEngineAssets / Scripts / Engines / Player / PlayerInputEngine.cs ),并确保它在执行我们想要的操作,然后再继续。

PlayerInputEngine也是PlayerEVSingleEntityViewEngine 。 该引擎的目的是连续读取播放器鼠标的输入并将它们作为值存储在PlayerEVinputComponent中

(您可以查看播放器的inputComponentAssets / Scripts / Components / Player / PlayerInputComponent.cs )的定义,以了解此引擎将修改的值。)

让我们编写一个协程以进行此输入读取; 像往常一样,我们希望在其中包含一个无限循环,该循环在每次迭代结束时产生。

我们要在此处更新三个inputComponent值: aimPos (这是鼠标的屏幕空间位置), isFiring (这是一个布尔值,指示玩家是否在此帧上单击了其主鼠标按钮)和aimRay (这将是一个从玩家的鼠标位置直接指向世界空间的Unity Ray)。

对于前两个,我们将简单地采用Unity的Input.PositionInput.GetButtonDown(“ Fire1”)值并将它们原样存储。 然后,我们将使用成员变量m_Camera (只是Unity的相机实用程序函数的包装器)的函数ScreenPointToRay ,将Player的鼠标位置转换为Ray供我们存储。

最后,引擎在Add函数中启动后,我们将立即调用协程。 同样,由于我们的Engines不是MonoBehaviour ,因此我们无法访问Unity的StartCoroutine函数,但是Svelto.ECS实用程序库Svelto.Tasks允许我们通过简单地输入来做完全一样的事情(实际上,甚至更有效!)。 ReadInput()。Run()

在对Player Input进行整齐排序之后,让我们返回GunShootingEngine来完成它。 现在我们知道我们正在读取准确的输入值。

GunShootingEngineShoot功能中,我们想做三件事:检查来自玩家鼠标的光线是否碰到了Collider对象,检查该对象是否为僵尸,并且-如果是,则减少其生命值。 我们也希望将对影响点的引用存储在Vector3中 ,原因目前已经很清楚了。 随意复制下面的代码,或在我们逐步学习时逐步实施。

m_RayCaster成员对象只是Unity的RayCasting功能的另一个包装,其函数GetRayHitTarget将返回被玩家的Ray击中的第一个实体的Entity ID,如果没有击中则返回-1。

如果实际上确实击中了某些东西,我们将使用IEntityViewsDatabase属性,该属性(顾名思义)是场景中当前存在的所有EntityView的数据库。

(此属性从何处神奇地出现?请注意, GunShootingEngine实现了雄辩的IQueryingEntityViewEngine接口。在Svelto.ECS初始化时,实现此接口的所有引擎都会自动为其分配一个对该数据库的引用。这些数据库很快就会变得很明显。对于发动机正常运转至关重要。)

使用此EntityView数据库,我们使用TryQueryEntityView函数检查所命中的实体是否为GunTargetEV

(从前面的图中回想起GunTargetEV实际上只是Zombie实体的同义词。(或更准确地说, GunTargetEV是定义Zombie是什么的EntityViews之一。)

如果是的话,我们将访问GunTarget的healthComponent ,并通过PlayerEV的gunComponent中定义的DamagePerShot值减小其currentHealth值(这只是当前设置为1的简单int值,稍后我们可以对其进行自定义)。 最后,我们将在我们的PlayerEV的gunComponentlastImpactPos值中存储ImpactPoint (子弹碰到僵尸的点) 的位置

记住在分配了PlayerEV参考之后立即启动CheckForFire协程,现在我们在游戏中具有正常的枪击行为。 有了一点声音和视觉效果,我们就能看到此引擎的影响。

现在,如果您从上面勤奋地将代码行复制到Shoot中 ,您可能仍会很好奇为什么在Shoot末尾分配给我们的两个变量在末尾附加了.value属性,以及这可能有用。 没错,我们马上进行探索。

广播消息和泼血

如果看一下PlayerEV的gunComponent的定义( Assets / Scripts / Components / Player / GunComponent.cs ),您会注意到lastImpactPos不仅是常规的Vector3 ,而且实际上是“ DispatchOnSet ”

实际上,此DispatchOnSet数据类型表示Svelto.ECS中引擎相互通信的主要手段之一。

(实际上,有几种有用的方法可以在Svelto中的类之间进行通信,这些方法同样是模块化的(我鼓励您尝试一下!),但是为了简单起见,在本教程中我们将仅使用DispatchOnSet

DispatchOnSet是一个简单的value-type变量的包装器,也是一个独立的Observer-Listener对象。 任何可以访问DispatchOnSet属性的类都可以注册回调函数,只要其引用的值发生更改,便会得到通知。

因此,就我们的lastImpactPos变量而言,当我们在GunShootingEngine中更改其值时,将立即通知每个注册以侦听该更改的类。 但是,目前, lastImpactPos还没有订阅者,所以让我们做些什么。

导航到GunEffectsEngineAssets / Engines / Player / GunEffectsEngine.cs )。 该引擎也可以在单个EntityView PlayerEV上运行 。 每当玩家的枪成功射击僵尸时,在这里我们将实例化一个简单的血液飞溅粒子效果。

在Add函数中,在我们分配了对PlayerEV的引用之后 ,我们将注册以通知lastImpactPos的更改。 为此,只需访问PlayerEVgunComponent ,然后调用lastImpactPos的 NotifyOnValueSet函数,并传递一个带有标题的回调函数名称即可。

同样,您可以让Visual Studio为此自动生成适当的函数(只需选择您传入的函数名称,然后按ALT + ENTER键)。

您会从Visual Studio的自动生成的参数中注意到(一旦知道了它们的引用,便可以对其进行重命名),该函数将接受一个int ,即持有刚刚更改的值的Entity的ID,以及更改后的值本身(在本例中为Vector3 )。

如果您回想起前面提到的gunComponent ,它已经包含了gunEffectPrefab变量(已经分配了变量,我们将在稍后介绍)。 让我们将其检索并在枪的撞击位置实例化它。

我们使用成员对象m_GameObjectFactoryBuild函数实例化预制

(无论如何, GameObjectFactory都是(出于我们的目的)仅是Unity熟悉的Instantiate函数的包装,该函数通过GunEffectsEngine的构造函数手动传递 —请查看MainContext.cs以了解此对象的构造位置。)

实例化预制件后,我们将其位置分配给刚刚传递的新的子弹撞击位置。

这样,我们现在在游戏中有了子弹,可以在撞击时飞溅出血液颗粒(您可以通过检查预制件( Assets / Prefabs / Blood.prefab )来检查和调整血液效果。 当然,在我们生成僵尸之前,我们几乎没有要针对此功能进行测试。

但是,在继续开发僵尸衍生功能之前,让我们花点时间解开Svelto.ECS的最后一个关键部分,我们只是在此略过了一点: Implementers

到目前为止,我们已经访问了属于几个不同实体的Components,使用它们来读取常量数据(例如DamagePerShotbloodEffectPrefab ),并编辑数据以供其他Engine读取(例如currentHealthlastImpactPos )。

如果您检查了这些组件的定义,就会发现它们只是声明这些属性存在的接口。 这是保持Svelto.ECS代码库尽可能模块化的另一项设计决定,但这提出了直接的实际问题-它们在哪里实现? 研究Assets / Scripts / Implementors文件夹(及其子文件夹)以获得明确的答案。

这些Implementor类提供游戏中每个Component的特定类实现。 尽管其中一些(特别是PlayerInputImplementor )是非常简单的类,它们仅定义其接口中布置的属性,但其他类( GunImplementor和我们稍后要解决的几个Zombie Implementers)也是MonoBehaviour的子类,并且包含与Unity的一些交互。

实现者可以在其中进行这些传统的Unity交互(例如,接收OnTriggerEnter回调或从Unity Inspector接收SerializeField属性)。

(例如,注意,我们在GunEffectsEngine中访问的bloodEffectPrefab只是一个序列化的GameObject变量,已从Assets / Prefabs文件夹拖入到Unity场景中附加到Player Camera对象的GunImplementor MonoBehaviour脚本中。)

换句话说,实现者是Unity和Svelto.ECS之间的桥梁,从而使其余代码库(大部分)独立于Unity的工作方式。

除了这一点,让我们回到引擎开发中,带着让僵尸出现在游戏中的想法。

产生僵尸并使它们移动

如果您查看了我们的Unity场景,就会发现有一个ZombieSpawner对象,其中包含许多名为SpawnPosition的空子转换。 这些是我们希望僵尸生成的地方。

打开ZombieSpawnerEngineAssets / Scripts / Engines / Zombies / ZombieSpawnerEngine.cs ),您将获得与我们研究过的其他引擎相同的基本结构。

(您可以检查ZombieSpawnerEV及其zombieSpawnerComponent的定义,以了解我们将在此处检索和操作的数据的概念)。

该引擎的目的非常简单,每隔几秒钟就生成一个新的僵尸实体。 让我们创建另一个协程来执行此操作。

在此协程内部,我们将无限循环,每次迭代都从ZombieSpawnerEV的spawnerComponent的spawnPosition字段中选取一个随机的生成位置。 最后,我们将像以前一样构建相同组件的zombieToSpawn预制件,并将其放置在随机选择的生成位置。

(如果要查看如何获取这些生成位置,请查看ZombieSpawnerImplementor 。顺便说一句,您可能会在这里注意到,即使理想情况下,Components及其实现者只应包含数据访问器,该类的Awake函数中仍有几行行为代码这些小片段有时对于封装实现细节是必要的。这是一个很好的平衡。)

但是,我们还没有完成。 正如您将要收集的那样,僵尸实际上代表了ECS结构中的一个实体,因此,我们要确保在生成其Unity GameObject的同时构建一个新的ZombieEntity

我们将通过检索所有新产生的Zombie的Implementor类的列表来实现此目的(因为它们都是MonoBehaviours,可以通过简单的GetComponents调用对其进行访问)。 从那里开始,我们将使用EntityFactory成员对象构建一个新的Zombie实体,通过简单地获取其GameObject InstanceID为其分配一个唯一的Entity ID。

有了这个新的Zombie实体,我们将再次获取其ID,并将其存储在ZombieSpawnerEV的spawnerComponent的lastSpawnedID值中。 此lastSpawnedID变量是另一个DispatchOnSet属性,该属性使我们可以隐式警告其他有兴趣了解我们新生成的Zombie的引擎。

(在上面的代码段中,请注意,在分配lastSpawnedID之前 ,我们会屈服于下一帧。这是有意的,因为这可以确保Svelto.ECS在其他监听lastSpawnedID的引擎开始查询之前已经完全完成了新的僵尸实体的构建。它。)

最后,我们需要调整协程,使其仅每隔几秒钟(当然不是每帧)生成一个新的僵尸。 在这里,我们将利用Unity方便的WaitForSeconds 宾语。 我们将其分配为成员变量,并在添加了ZombieSpawnerEV参考(在Add函数中)后,使用ZombieSpawnerEVspawnerComponent (当前设置为3.0的简单float值)的secsBetweenSpawns属性对其进行构造

然后,我们可以在每次循环迭代开始时让我们的新WaitForSeconds对象返回,以确保每次生成之间有几秒钟的延迟。

最后,让我们在Add函数的末尾运行协程。

立即尝试在Unity播放模式下运行游戏,您确实应该看到僵尸在现场产生。 尝试用鼠标点击它们,以测试我们之前实现的机枪引擎!

但是,除了循环播放简单的步行动画外,这些僵尸也不会移动,因此一旦向这些相当无聊的敌人开火的新颖性就消失了,让我们继续执行其移动行为。

打开ZombieMovementEngineAssets / Scripts / Engines / Zombies / ZombieMovementEngine.cs )。

在其Add函数中,一旦分配了我们的ZombieSpawnerEV参考,我们将注册对其lastSpawnedID的更改。 和以前一样,让Visual Studio生成相应的回调函数。

在此回调函数中,我们将使用EntityViewsDB检索对刚生成的Zombie的引用(使用传入的ID作为该函数的第二个参数)。

接下来,我们将访问Zombie的MovementComponent ,并将navMeshEnabled设置为true以激活其Unity NavMeshAgent行为。

我们想要将玩家的位置分配为该僵尸的NavMeshDestination ,以便僵尸将向玩家移动。 为此,我们需要检索对播放器实体的引用。

回想一下之前的图表 ,正是出于这个目的,还通过ZombieDestinationEV定义了播放器实体。 因为我们碰巧知道场景中将永远只有一个这样的EntityView,所以我们可以使用EntityViewsDB查询所有ZombieDestinationEV,并获取它返回的第一个。

完成此操作后,我们只需从ZombieDestinationEVpositionComponent中获取位置 ,并将其分配为新生成的Zombie的navMeshDestination

立即在Unity中播放游戏,您应该会看到僵尸直奔您时突然看起来更加不祥。 但是,当您看到它们直走摄像机时,这种错觉可能会丢失。 接下来让我们对其进行整理。

触发器和动画

我们希望僵尸在玩家抓取距离之内停止移动。

如果检查Unity场景,将会看到Player Camera对象已附加了Sphere Collider 。 同时,每个ZombieEV的triggerComponent都具有DispatchOnSet 属性,只要Zombie接触Trigger Collider,该属性就会设置为true(请查看ZombieTriggerImplementor来确切了解其与Unity碰撞系统的接口)。

然后,回到ZombieMovementEngine内部,让我们在设置了该触发标志的那一刻后停止僵尸的移动。

我们将在回调函数中开始一个刚编写的新Zombie派生类(这里称为OnZombieSpawn ),并进行注册以通知我们Zombie的triggerAgainstTarget属性(在其triggerComponent上 )发生更改。

在我们刚刚分配的回调函数中,我们将简单地检索对有问题的僵尸的引用并禁用其navMeshAgent属性。

具备这些基本的游戏功能后,我们将通过响应游戏中发生的事情触发不同的动画,继续为僵尸游戏增加活力。

(如果您在Unity中选择一个Zombie GameObject并查看Animator窗口,则会看到其动画系统已设置为响应两个触发器,我们将在ZombieAnimationEngine对其进行设置。您还可以检查ZombieAnimationImplementor来了解如何Svelto.ECS与Unity动画之间发生交互。)

打开ZombieAnimationEngineAssets / Scripts / Engines / Zombies / ZombieAnimationEngine.cs )。

在这里,我们将通过针对ZombieSpawnerEVlastSpawnedID属性签名回调,来完成与ZombieMovementEngine中相同操作

在此回调中,我们将注册两个附加的回调函数-一个用于更改Zombie的运行状况(其healthComponentcurrentHealth属性),另一个用于针对Player的Zombie触发(其triggerComponent的triggerAgainstTarget属性)。

在第一个新的回调函数中,我们将检查Zombie的当前运行状况(作为参数传递)是否小于或等于零。 如果是这样,我们将检索到所涉及的僵尸的引用,并将其animationComponenttrigger属性设置为“ deathTrigger”,以激活其死亡动画。

同样,在第二个回调函数中,我们将相同的触发值设置为“ attackTrigger”,以在Zombie接触到Player时激活其攻击动画。

我们需要在完成之前添加到此处的最后一个操作(应该通过一点游戏测试就显而易见)是禁用每个僵尸的移动并在它们的生命值降至零时触发行为。 跳回到ZombieMovementEngine并进行最终调整。