让我们从
地毯转向严肃的事情。 我们已经讨论过ECS,Unity有哪些框架,以及它们为何编写自己的框架(您可以在文章末尾找到列表)。 现在,让我们集中讨论如何在新的移动PvP射击游戏中使用ECS以及如何实现游戏功能的特定示例。 我注意到我们仅使用此体系结构来模拟服务器上的环境和客户端上的预测系统。 使用MVP模式实现对象的可视化和渲染-但今天还不行。
ECS体系结构是面向数据的,游戏世界的所有数据都存储在所谓的GameState中,并且是实体(实体)的列表,每个实体上都有一些组件。 一组组件确定对象的行为。 组件行为的逻辑集中在系统中。
我们的ECS中的gamestate由两部分组成:RuleBook和WorldState。 RuleBook是一组在比赛期间不会更改的组件。 所有静态数据都存储在此(武器/角色特性,团队组成),并且仅在游戏服务器授权期间一次发送给客户端。
考虑一个简单的示例:生成一个角色并使用两个操纵杆在2D空间中将其移动。 首先,声明组件。
这定义了播放器,并且对于角色可视化是必需的:
[Component] public class Player { }
下一个组件是创建新角色的请求。 它包含两个字段:角色生成时间(以滴答为单位)及其ID:
[Component] public class PlayerSpawnRequest { public int SpawnTime; public unit PlayerId; }
空间中物体方向的组成部分:
[Component] public class Transform { public Vector2 Position; public float Rotation; }
存储对象当前速度的组件:
[Component] public class Movement { public Vector2 Velocity; public float RotateToAngle; }
存储玩家输入(运动操纵杆矢量和角色旋转操纵杆矢量)的组件:
[Component] public class Input { public Vector2 MoveVector; public Vector2 RotateVector; }
具有角色静态特征的组件(它将存储在RuleBook中,因为这是基本特征,在游戏过程中不会更改):
[Component] public class PlayerStats { public float MoveSpeed; }
在将功能分解为系统时,我们通常遵循单一责任的原则:每个系统必须履行一个且只有一个功能。
功能可以由多个系统组成。 让我们从定义角色生成系统开始。 系统会处理所有在游戏状态中创建角色的请求,如果当前世界时间与所需时间相匹配,则系统会创建一个新实体,并在其上附加定义玩家的组件:
Player ,
Transform和
Movement 。
public class SpawnPlayerSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.SpawnAvatarRequest); foreach (var avatarRequest in gs.WorldState.SpawnAvatarRequest) { if (avatarRequest.Value.SpawnTime == gs.Time) {
现在考虑玩家在操纵杆上的移动。 我们需要一个可以处理输入的系统。 它遍历输入的所有组成部分,计算玩家(他站立或移动)的速度,并将旋转操纵杆的矢量转换为旋转角度:
MovementControlSystem public class MovementControlSystem : ExecutableSystem { public override void Execute(GameState gs) { var playerStats = gs.RuleBook.PlayerStats[1]; foreach (var pair in gs.Input) { var movement = gs.WorldState.Movement[pair.Key]; movement.Velocity = pair.Value.MoveVector.normalized * playerStats.MoveSpeed; movement.RotateToAngle = Math.Atan2(pair.Value.RotateVector.y, pair.Value.RotateVector.x); } } }
接下来是运动系统:
public class MovementSystem : ExecutableSystem { public override void Execute(GameState gs) { foreach (var pair in gs.WorldState.Movement) { var transform = gs.WorldState.Transform[pair.Key]; transform.Position += pair.Value.Velocity * GameState.TickDurationSec; } } }
负责对象旋转的系统:
public class RotationSystem : ExecutableSystem { public override void Execute(GameState gs) { foreach (var pair in gs.WorldState.Movement) { var transform = gs.WorldState.Transform[pair.Key]; transform.Angle = pair.Value.RotateToAngle; } } }
MovementSystem和
RotationSystem仅与“
变换”和“
运动”组件一起使用。 它们独立于玩家的本质。 如果其他带有“
运动”和“
变换”组件的实体出现在我们的游戏中,则运动逻辑也将与它们一起工作。
例如,添加一个急救箱,该急救箱将沿生成物沿直线移动,并在选择时补充角色的生命值。 声明组件:
[Component] public class Health { public uint CurrentHealth; public uint MaxHealth; } [Component] public class HealthPowerUp { public uint NextChangeDirection; } [Component] public class HealthPowerUpSpawnRequest { public uint SpawnRequest; } [Component] public class HealthPowerUpStats { public float HealthRestorePercent; public float MoveSpeed; public float SecondsToChangeDirection; public float PickupRadius; public float TimeToSpawn; }
我们通过添加角色的生命上限来修改角色统计信息的组成部分:
[Component] public class PlayerStats { public float MoveSpeed; public uint MaxHealth; }
现在,我们修改角色生成系统,以使角色以最大的健康状况出现:
public class SpawnPlayerSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.SpawnAvatarRequest); var playerStats = gs.RuleBook.PlayerStats[1]; foreach (var avatarRequest in gs.WorldState.SpawnAvatarRequest) { if (avatarRequest.Value.SpawnTime <= gs.Time) {
然后,我们声明急救箱的产生系统:
public class SpawnHealthPowerUpSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.HealthPowerUpSpawnRequest); var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1]; foreach (var spawnRequest in gs.WorldState.HealthPowerUpSpawnRequest) {
以及用于改变急救箱速度的系统。 为简化起见,急救箱每隔几秒钟就会改变方向:
public class HealthPowerUpMovementSystem : ExecutableSystem { public override void Execute(GameState gs) { var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1]; foreach (var pair in gs.WorldState.HealthPowerUp) { var movement = gs.WorldState.Movement[pair.Key]; if(pair.Value.NextChangeDirection <= gs.Time) { pair.Value.NextChangeDirection = (uint) (healthPowerUpStats.SecondsToChangeDirection * GameState.Hz); movement.Velocity *= -1; } } } }
由于我们已经宣布了一个
MovementSystem来移动游戏中的对象,因此我们只需要
HealthPowerUpMovementSystem每N秒更改一次速度矢量。
现在,我们完成了急救包的选择以及该角色的HP累积。 我们将需要另一个辅助组件来存储角色在选择急救箱后将获得的生命数量。
[Component] public class HealthToAdd { public int Health; public Entity Target; }
还有一个消除我们力量的组件:
[Component] public class DeleteHealthPowerUpRequest { }
我们编写了一个系统来处理急救箱的选择:
public class HealthPowerUpPickUpSystem : ExecutableSystem { public override void Execute(GameState gs) { var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1]; foreach(var powerUpPair in gs.WorldState.HealthPowerUp) { var powerUpTransform = gs.WorldState.Transform[powerUpPair.Key]; foreach(var playerPair in gs.WorldState.Player) { var playerTransform = gs.WorldState.Transform[playerPair.Key]; var distance = Vector2.Distance(powerUpTransform.Position, playerTransform.Position) if(distance < healthPowerUpStats.PickupRadius) { var healthToAdd = gs.WorldState.Health[playerPair.Key].MaxHealth * healthPowerUpStats.HealthRestorePercent; var entity = gs.WorldState.CreateEntity(); entity.AddHealthToAdd(healthToAdd, gs.WorldState.Player[playerPair.Key]); var powerUpEnity = gs.WorldState[powerUpPair.Key]; powerUpEnity.AddDeleteHealthPowerUpRequest(); break; } } } } }
系统会经历所有活动的通电过程,并计算到播放器的距离。 如果选择范围内有任何玩家,系统将创建两个请求组件:
HealthToAdd-为角色增加生命的“请求”;
DeleteHealthPowerUpRequest- “请求”以删除急救箱。
为什么不在同一系统中添加正确的生命数? 我们从玩家不仅从急救箱中获得HP,而且还从其他来源获得HP的事实出发。 在这种情况下,更方便的方法是将急救箱选择系统和角色的生命累积系统分开。 此外,这与单一责任原则更加一致。
我们实施为角色增加生命的系统:
public class HealingSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.HealthToAdd); foreach(var healtToAddPair in gs.WorldState.HealthToAdd) { var healthToAdd = healtToAddPair.Value.Health; var health = healtToAddPair.Value.Target.Health; health.CurrentHealth += healthToAdd; health.CurrentHealth = Mathf.Clamp(health.CurrentHealth, 0, health.MaxHealth); deleter.Delete(healtToAddPair.Key); } } }
系统遍历
HealthToAdd的所有组件,计算目标
Target实体的
Health组件中所需的生命数。 该实体对源和目标一无所知,并且非常通用。 该系统不仅可以用于计算角色的生命,还可以用于涉及生命存在及其再生的任何对象。
要使用急救箱来实现该功能,仍然需要添加最后一个系统:选择急救箱后,将其删除。
public class DeleteHealthPowerUpSystem : ExecutableSystem { public override void Execute(GameState gs) { var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.DeleteHealthPowerUpReques); foreach(var healthRequest in gs.WorldState.DeleteHealthPowerUpReques) { var id = healthRequest.Key; gs.WorldState.DelHealthPowerUp(id); gs.WorldState.DelTransform(id); gs.WorldState.DelMovement(id); deleter.Delete(id); } } }
HealthPowerUpPickUpSystem创建一个删除急救箱的请求。
DeleteHealthPowerUpSystem系统会通过所有此类请求,并删除属于急救箱本质的所有组件。
做完了 我们示例中的所有系统均已实现。 使用ECS有一点意义-所有系统都是按顺序执行的,这一顺序很重要。
在我们的示例中,系统的顺序如下:
_systems = new List<ExecutableSystem> { new SpawnPlayerSystem(), new SpawnHealthPowerUpSystem(), new MovementControlSystem(), new HealthPowerUpMovementSystem(), new MovementSystem(), new RotationSystem(), new HealthPowerUpPickUpSystem(), new HealingSystem(), new DeleteHealthPowerUpSystem() };
在一般情况下,负责创建新实体和组件的系统是第一位的。 然后是处理系统,最后是清除和清洁系统。
通过适当的分解,ECS具有很大的灵活性。 是的,我们的实现并不完美,但是它可以让您在短时间内实现功能,并且在现代移动设备上也具有良好的性能。 您可以在此处阅读有关ECS的更多信息: