目录
这次我们来使用Unity ECS系统制作一个俯视角度的射击游戏。虽然现在网上有不少ECS的资料和项目,但是制作时又和实际游戏需求有较大差距。在制作这个小游戏的过程中我遇到了很多ECS特有的问题,也给出了还可以的解决方案,相信能通过实例让大家了解到ECS的优缺点是什么。
(文章不会再解释Unity DOTS的一些基本概念,感兴趣的朋友可以查阅文档了解)。
本游戏具体玩法如下:
1:完全使用键盘控制,WASD键控制角色方向移动,j 键控制射击。(这样做主要为了简化游戏输入逻辑)
2:玩家有手枪和霰弹枪两种武器形态,按Q切换。
3:当敌人低于一定量,会在玩家一定距离周围生成敌人。敌人会朝玩家移动并射击玩家。
4:玩家和敌人都有生命值,中弹后生命减少,减为0的时候死亡
这里放下Unity和相关Package版本,以免误导后来者:
Unity 版本:Unity2020.3.3f1,Universal Render Pipeline
Hybrid Renderer: Version 0.11.0-preview.44
Unity Physics: Version 0.6.0-preview.3
Jobs: Version 0.8.0-preview.23
Entities: 0.17.0-preview.41
先简单搭建场景,再创建主角。
首先建一个平面,扔上贴图,再建个圆圆胖胖的主角,添加物理组件Physics Shape 和Physics Body:
Physic Shape的碰撞框同样可以在场景中进行编辑,你也可以点击Fit to Enabled Meshs来直接适配:
以及实体转换组件,Convert To Entity:
我们需要其中主角能被敌人的子弹打中并获取碰撞事件,所以点击Collision Response,选择Raise Trigger Event ( 开启触发器事件),并点击PhysicBody的Motion Type,选择Kinematic :
首先为主角创建一个Component,包含初始速度:
using Unity.Entities;
[GenerateAuthoringComponent]
public struct Character :IComponentData
{
public float speed;
}
将组件挂到主角身上,speed设为10,再单独创建一个System,控制主角移动:
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;
using Unity.Mathematics;
using UnityEngine;
public class CharacterSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime;
float3 input;
string h = "Horizontal";
string v = "Vertical";
Entities.
WithoutBurst().
WithName("Player").
ForEach((ref Translation translation, ref Rotation rotation, in Character character) =>
{
input.x = Input.GetAxis(h);
input.y = 0;
input.z = Input.GetAxis(v);
var dir = character.speed * deltaTime * input;
dir.y = 0;
//令角色前方和移动方向一致
if (math.length(input) > 0.1f)
{
//Debug.Log("Dir " + dir);
rotation.Value = quaternion.LookRotation(math.normalize(dir), math.up());
}
translation.Value += dir;
}).Run();
}
}
相机不支持转换为Entity,所以我们还是用老办法做一个跟随脚本,通过查找包含CharacterComponent的Entity,获取其Translation,得到主角位置,进行跟随,代码如下:
using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using Unity.Collections;
public class CameraController : MonoBehaviour
{
[SerializeField] private Vector3 offset;//相机相对于玩家的位置
private Vector3 pos;
public float speed;
private EntityManager _manager;
private float3 tempPos;
public Entity targetEntity;
void Start()
{
_manager = World.DefaultGameObjectInjectionWorld.EntityManager;
//定义一个查询 :查询实体必须包含Character组件和Translation组件
var queryDescription = new EntityQueryDesc
{
None = new ComponentType[] { },
All = new ComponentType[] { ComponentType.ReadOnly
};
EntityQuery players = _manager.CreateEntityQuery(queryDescription);
//场景中只有主角有Character组件,所以直接获取引用
if (players.CalculateEntityCount() != 0)
{
NativeArray
temp = players.ToEntityArray(Allocator.Temp);
targetEntity = temp[0];
temp.Dispose();
}
players.Dispose();
}
void Update()
{
if (targetEntity != Entity.Null)
{
if (_manager.HasComponent
{
tempPos = _manager.GetComponentData
}
}
transform.position = Vector3.Lerp(transform.position, (Vector3)tempPos + offset, speed * Time.deltaTime);//调整相机与玩家之间的距离
}
}
最后给主角手里整把枪,OK,现在主角已经能跑了:
敌人造型和玩家基本一致,由于玩家需要随时找到并攻击玩家角色,所以需要在定义它的Componnet 中存一个玩家Entity的引用:
using Unity.Entities;
[GenerateAuthoringComponent]
public struct Enemy : IComponentData
{
public float speed;
//追踪目标
public Entity targetEntity;
}
首先我们需要在主角身旁一定范围外生成这些这些敌人,方便起见,我们可以在场景中创建一个管理类,存一个已经转换成实体的的敌人预制体,每次生成的时候直接按照这个模版生成即可,代码如下:
using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
public class FPSGameManager : MonoBehaviour
{
public static FPSGameManager instance;
public GameObject enemyprefab;
private EntityManager _manager;
//blobAssetStore是一个提供缓存的类,缓存能让你对象创建时更快。
private BlobAssetStore _blobAssetStore;
private GameObjectConversionSettings _settings;
public Entity enemyEntity;
void Start()
{
instance = this;
_manager = World.DefaultGameObjectInjectionWorld.EntityManager;
_blobAssetStore = new BlobAssetStore();
_settings = GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, _blobAssetStore);
enemyEntity = GameObjectConversionUtility.ConvertGameObjectHierarchy(enemyprefab, _settings);
Translation translation = new Translation
{
Value = float3.zero
};
_manager.SetComponentData(test, translation);
}
private void OnDestroy()
{
_blobAssetStore.Dispose();
}
}
EnemySystem负责控制敌人追踪主角,并在敌人数量少于一定量时生成新的敌人:
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;
using Unity.Mathematics;
using UnityEngine;
public class EnemySystem : SystemBase
{
EndSimulationEntityCommandBufferSystem endSimulationEcbSystem;
//保存筛选出来的敌人的对象
private EntityQuery query;
private uint seed = 1;
protected override void OnCreate()
{
base.OnCreate();
endSimulationEcbSystem = World.GetOrCreateSystem
}
protected override void OnUpdate()
{
Unity.Mathematics.Random random = new Unity.Mathematics.Random(seed++);
float deltaTime = Time.DeltaTime;
EntityCommandBuffer ecb = endSimulationEcbSystem.CreateCommandBuffer();
Entity template = FPSGameManager.instance.enemyEntity;
//对所有敌人操作
Entities.
WithStoreEntityQueryInField(ref query).
ForEach((Entity entity, ref Translation translation, ref Rotation rotation, ref Enemy enemy) =>
{
if (HasComponent
{
//追踪主角
LocalToWorld targetl2w = GetComponent
float3 targetPos = targetl2w.Position;
translation.Value = Vector3.MoveTowards(translation.Value, targetPos, enemy.speed * deltaTime);
var targetDir = targetPos - translation.Value;
quaternion temp1 = quaternion.LookRotation(targetDir, math.up());
rotation.Value = temp1;
}
}).Run();
//敌人数量少于6,在主角周围新生成6个敌人
if (query.CalculateEntityCount() < 6)
{
Entity characterEntity = GetSingletonEntity
float3 characterPos=float3.zero;
if (characterEntity!=Entity.Null)
{
if (HasComponent
{
Translation translation = GetComponent
characterPos = translation.Value;
}
}
for (int i = 0; i < 6; i++)
{
Entity temp = ecb.Instantiate(template);
#region 随机位置生成敌人
//略。。。详见工程
#endregion
Translation translation = new Translation
{
Value=new float3(x,characterPos.y,z)
};
//这里可能有疑问为何预制体组件已经有enemy的数据了,这里为何要重新赋值?
//这是因为场景中的主角预制体要在场景运行后才能转换为Entity,并且转换时间不确定,所以等待其生成后重新赋值
Enemy enemy = new Enemy
{
speed=5f ,
targetEntity=characterEntity
};
ecb.SetComponent(temp, translation);
ecb.SetComponent(temp, enemy);
}
}
}
}
点击运行,敌人也生成出来并开始工作了:
接下来我们要定义武器和子弹。虽然Convert to Entity会把面板的物体的子物体也转换为Entity,并在Entity Debugger中可以看到,但目前GameObject 方便的父子关系还不能在Unity ECS中使用,所以我们需要先记录枪口的位置。
首先定义武器:
using Unity.Entities;
//手枪,霰弹枪,自动模式
public enum WeaponType
{
gun,
shotgun,
gunAutoshot
}
[GenerateAuthoringComponent]
public struct Weapon : IComponentData
{
//枪口位置
public Entity gunPoint;
//武器类型
public WeaponType weaponType;
//是否允许切换武器
public bool canSwitch;
//开枪间隔
public float firingInterval;
//用来记录每次开枪的时间
public float shotTime;
}
接着定义子弹组件,制作子弹预制体的流程和上文一样,这里就不赘述了:
using Unity.Entities;
[GenerateAuthoringComponent]
public struct Bullet: IComponentData
{
public float flySpeed;
}
再定义一个作删除标签功能的组件:DeleteTag,为了尽量避免频繁的结构性变化(增删组件等),我们需要在可以被删除的物体的预制件上添加这个组件,并将其lifeTime设置为1 :
using Unity.Entities;
[GenerateAuthoringComponent]
public struct DeleteTag :IComponentData
{
public float lifeTime;
}
这样的话,我们就可以定下规则,当物体身上DeleteTag组件的lifeTime<=0时,系统会将其删除:
using Unity.Entities;
using Unity.Jobs;
public class DeleteSystem : SystemBase
{
EndSimulationEntityCommandBufferSystem endSimulationEcbSystem;
protected override void OnCreate()
{
base.OnCreate();
endSimulationEcbSystem = World.GetOrCreateSystem
}
protected override void OnUpdate()
{
// 请求一个ECS并且转换成可并行的
var ecb = endSimulationEcbSystem.CreateCommandBuffer().AsParallelWriter();
Entities
.ForEach((Entity entity, int entityInQueryIndex, in DeleteTag deleteTag) =>
{
if (deleteTag.lifeTime <=0f)
{
ecb.DestroyEntity(entityInQueryIndex, entity);
}
}).ScheduleParallel();
// 保证ECB system依赖当前这个Job
endSimulationEcbSystem.AddJobHandleForProducer(this.Dependency);
}
}
子弹的生命会不断减少,所以BulletSystem中需要自行对lifeTime 做减法:
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;
using Unity.Mathematics;
public class BulletSystem : SystemBase
{
EndSimulationEntityCommandBufferSystem endSimulationEcbSystem;
protected override void OnCreate()
{
base.OnCreate();
endSimulationEcbSystem = World.GetOrCreateSystem
}
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime;
var ecb = endSimulationEcbSystem.CreateCommandBuffer();
Entities.
ForEach(( ref Translation translation, ref DeleteTag deleteTag, in Rotation rot, in Bullet bullet) =>
{
//子弹向前飞行
translation.Value += bullet.flySpeed * deltaTime * math.forward(rot.Value);
//生命不断减少
deleteTag.lifeTime-= deltaTime;
}).Run();
}
}
WeaponSystem,不同枪械的子弹生命周期也不同,手枪子弹为1s,霰弹枪0.5f:
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;
using Unity.Mathematics;
using UnityEngine;
public class WeaponSystem : SystemBase
{
EndSimulationEntityCommandBufferSystem endSimulationEcbSystem;
protected override void OnCreate()
{
base.OnCreate();
endSimulationEcbSystem = World.GetOrCreateSystem
}
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime;
float time = UnityEngine.Time.time;
EntityCommandBuffer ecb = endSimulationEcbSystem.CreateCommandBuffer();
Entities.
WithoutBurst().
ForEach((ref Weapon weapon, in Rotation rotation) =>
{
if (weapon.weaponType == WeaponType.gunAutoshot)
{
if (weapon.shotTime == -1f)
{
weapon.shotTime = time;
}
// Debug.Log("当前时间" + time);
if (time - weapon.shotTime >= weapon.firingInterval)
{
weapon.shotTime = time;
float3 pos = new float3();
LocalToWorld gunPointL2w = new LocalToWorld();
if (HasComponent
{
gunPointL2w = GetComponent
Entity tempBullet = ecb.Instantiate(FPSGameManager.instance.bulletEntity);
Translation translation = new Translation
{
Value = gunPointL2w.Position
};
Rotation rot = new Rotation
{
Value = rotation.Value
};
Bullet bullet = new Bullet
{
lifetime = 2,
flySpeed = 20,
};
ecb.SetComponent(tempBullet, translation);
ecb.SetComponent(tempBullet, rot);
ecb.SetComponent(tempBullet, bullet);
FPSGameManager.instance.PlayShoot();
}
}
return;
}
if (weapon.canSwitch)
{
//武器切换逻辑,按Q修改武器类型,详见工程
}
#region 开枪
if (Input.GetKeyDown(KeyCode.J))
{
float3 pos = new float3();
LocalToWorld gunPointL2w = new LocalToWorld();
if (HasComponent
{
gunPointL2w = GetComponent
pos = gunPointL2w.Position;
}
switch (weapon.weaponType)
{
case WeaponType.gun:
#region 手枪
Entity tempBullet = ecb.Instantiate(FPSGameManager.instance.bulletEntity);
//=====初始化手枪组件并赋给Entity,和上文初始化子弹逻辑相同,略========
//播放射击音效
FPSGameManager.instance.PlayShoot();
#endregion
break;
case WeaponType.shotgun:
#region 霰弹枪
// ====初始化子弹的translation2和bullet组件,略======
for (int i = -5; i < 5; i++)
{
Entity tempBullet2 = ecb.Instantiate(FPSGameManager.instance.bulletEntity);
#region 传统写法
//Quaternion q = rotation.Value;
//Quaternion tempRot = Quaternion.Euler(0, q.eulerAngles.y + i * 7, 0);
#endregion
//使用Unity.Mathematics库写法,这里默认按照弧度旋转
quaternion temp = math.mul( quaternion.EulerXYZ(0, i *0.1f, 0), rotation.Value) ;
Rotation rotation2 = new Rotation
{
Value = temp
};
ecb.SetComponent(tempBullet2, translation2);
ecb.SetComponent(tempBullet2, rotation2);
ecb.SetComponent(tempBullet2, bullet1);
}
FPSGameManager.instance.PlayShoot();
#endregion
break;
default:
break;
}
}
#endregion
}).Run();
}
}
在主角和敌人身上分别挂上Weapon组件,主角便可以使用两种武器了,敌人也能自动发射子弹了:
接下来就要用到ECS中新版的物理组件了,我们先在组件中设置子弹和敌人的碰撞层级,保证同类物体不会触发碰撞事件,只有子弹和敌人碰撞会触发事件:
这里搜索资料后发现比较简单的做法是去定义一个Job继承ITriggerEventsJob接口,去接收事件,但由于Job中是并行处理数据,遇到了新的问题,由于代码比较长,上部分伪代码来说明:
[BurstCompile]
private struct TriggerJob :ITriggerEventsJob
{
#region 传递进来的各类group数据
#endregion
public void Execute(TriggerEvent triggerEvent)
{
//triggerEvent包含两个碰撞实体,需要我们自行判断他们属于哪个ComponentGroup
if (EnemyGroup.HasComponent(triggerEvent.EntityA))
{
//敌人与主角碰撞效果
if (!BulletGroup.HasComponent(triggerEvent.EntityB) && BeatBackGroup.HasComponent(triggerEvent.EntityB))
{
#region 击退
#endregion
return;
}
isbehit[0] = true;
#region 删除子弹
#endregion
#region 子弹击退敌人效果
#endregion
#region 扣血并生成爆炸粒子实体
#endregion
}
if (EnemyGroup.HasComponent(triggerEvent.EntityB)){}
}
}
}
图中代码的意思大概是这样:当接收到世界中发生的碰撞事件后,首先Job会判断碰撞物属于哪个ComponentGroup,如果Enemy,扣一滴血;包含Bullet,则直接销毁子弹实体,但实际上写完运行确遇到了这样的问题:
删除子弹实体的操作并非立即执行,同时删除子弹实体的操作和TriggerJob也是并行的(不在同一线程,两者先后顺序不确定),所以可能会出现图中的状况(箭头长度代表时间长度):
为了解决这个问题,我首先的思路是为子弹增加一个bool值记录它的状态,如果接触到敌人,再次触发碰撞事件时会直接返回,代码如下:
if (EnemyGroup.HasComponent(triggerEvent.EntityA))
{
//A是敌人,自然EntityB是子弹
if (BulletGroup[triggerEvent.EntityB].isDestory)
{
Debug.Log("子弹已被删除");
return;
}
Bullet a = BulletGroup[triggerEvent.EntityB];
a.isDestory = true;
BulletGroup[triggerEvent.EntityB] = a;
}
结果连续触发碰撞事件时,直接报错The entity does not exist,bullet Group 中并不包含这个引发碰撞的子弹:
造成这个的原因也比较好猜,当我们执行删除子弹实体的代码时,子弹实体并不会立即删除,而是要等到EntityCommandBufferSystem回放命令时统一调度,所以已经子弹可能已经被系统标记为空,自然不在BulletGroup中了,自然也找不到该实体。
解决问题思路还有很多,我们当然可以在代码中修改Collision Filter,或是关闭子弹的碰撞事件来达成效果。。但实际上这两种操作都非常麻烦,目前Dots还没有这么的自由。
在尝试过上述做法后,我所想到的一个简单的思路:在发生碰撞时,将子弹挪到一个看不见位置去,这样就不会造成多次触发碰撞事件;
同时每个子弹都有自己的生命周期,所以也可能发生子弹生命到了,被标记删除,但又刚好触发碰撞的情况。为了避免这样的冲突,我们需要在每个Group中都对子弹进行HasComponent判定,子弹删除代码如下:
if (EnemyGroup.HasComponent(triggerEvent.EntityA))
{
//A是敌人,自然EntityB是子弹
if (TranslationGroup.HasComponent(triggerEvent.EntityB))
{
Translation temp = TranslationGroup[triggerEvent.EntityB];
//将子弹移到天上去
temp.Value = new float3(0, 100, 0);
TranslationGroup[triggerEvent.EntityB] = temp;
if (DeleteGroup.HasComponent(triggerEvent.EntityB))
{
DeleteTag temp1 = DeleteGroup[triggerEvent.EntityB];
temp1.lifeTime = 0f;
DeleteGroup[triggerEvent.EntityB] = temp1;
}
}
}
最后再做个敌人被击退的效果,给敌人添加BeatBack组件,每次被子弹击中时,敌人都会获得一个持续衰减的速度,被连续击中时,获得的加速度也会逐渐衰减:
using Unity.Entities;
using Unity.Transforms;
[GenerateAuthoringComponent]
public struct BeatBack : IComponentData
{
public float velocity;
public float curVelocity;
public Rotation rotation;
public float timer;
}
BeatBackSystem :
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;
using Unity.Mathematics;
public class BeatBackSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime;
Entities.
ForEach((ref BeatBack beatBack,ref Translation translation ) =>
{
if (beatBack.velocity <0.1f)
{
beatBack.velocity = 0;
beatBack.timer = 0;
beatBack.curVelocity = 0;
return;
}
float temp = beatBack.velocity;
beatBack.timer += 2*deltaTime;
temp = math.lerp(beatBack.velocity, 0,beatBack.timer);
if (temp < 0.1f)
{
beatBack.velocity = 0;
}
beatBack.curVelocity = temp;
translation.Value += beatBack.velocity * deltaTime * math.forward(beatBack.rotation.Value);
}).Run();
}
}
完整TriggerEventSystem代码如下:
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;
using Unity.Mathematics;
using Unity.Collections;
using Unity.Physics.Systems;
using Unity.Physics;
using Unity.Burst;
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
public class TriggerEventSystem : SystemBase
{
private BuildPhysicsWorld buildPhysicsWorld;
private StepPhysicsWorld stepPhysicsWorld;
EndSimulationEntityCommandBufferSystem endSimulationEcbSystem;
protected override void OnCreate()
{
buildPhysicsWorld = World.GetOrCreateSystem
stepPhysicsWorld = World.GetOrCreateSystem
endSimulationEcbSystem = World.GetOrCreateSystem
}
protected override void OnUpdate()
{
var ecb = endSimulationEcbSystem.CreateCommandBuffer();
//传入两个bool值,用来判断是否播放被击中或者被击杀的音效
NativeArray
TriggerJob triggerJob = new TriggerJob
{
PhysicVelocityGroup = GetComponentDataFromEntity
EnemyGroup = GetComponentDataFromEntity
BeatBackGroup = GetComponentDataFromEntity
RotationGroup = GetComponentDataFromEntity
HpGroup = GetComponentDataFromEntity
BulletGroup = GetComponentDataFromEntity
DeleteGroup = GetComponentDataFromEntity
TranslationGroup = GetComponentDataFromEntity
ecb = ecb,
PhysicsColliderGroup = GetComponentDataFromEntity
CharacterGroup = GetComponentDataFromEntity
boom = FPSGameManager.instance.boomEntity,
isbehit = isbehit,
};
Dependency = triggerJob.Schedule(stepPhysicsWorld.Simulation, ref buildPhysicsWorld.PhysicsWorld,this.Dependency );
Dependency.Complete();
if (isbehit[0])
{
isbehit[0] = false;
FPSGameManager.instance.PlayBehit();
}
if (isbehit[1])
{
isbehit[1] = false;
FPSGameManager.instance.PlayBoom();
}
isbehit.Dispose();
}
[BurstCompile]
private struct TriggerJob :ITriggerEventsJob
{
public ComponentDataFromEntity
//初始化数据略
public void Execute(TriggerEvent triggerEvent)
{
if (EnemyGroup.HasComponent(triggerEvent.EntityA))
{
//敌人与主角碰撞效果
if (!BulletGroup.HasComponent(triggerEvent.EntityB) && BeatBackGroup.HasComponent(triggerEvent.EntityB))
{
#region 击退
BeatBack beatBack1 = BeatBackGroup[triggerEvent.EntityB];
if (beatBack1.curVelocity > 0.1f)
{
beatBack1.velocity += (5f - beatBack1.curVelocity) * 0.1f;
}
else
{
beatBack1.velocity = 5f;
}
if (RotationGroup.HasComponent(triggerEvent.EntityB))
{
Rotation rotation = RotationGroup[triggerEvent.EntityB];
beatBack1.rotation = rotation;
}
BeatBackGroup[triggerEvent.EntityB] = beatBack1;
#endregion
return;
}
isbehit[0] = true;
#region 删除子弹
float3 boomPos = float3.zero;
if (TranslationGroup.HasComponent(triggerEvent.EntityB))
{
Translation temp = TranslationGroup[triggerEvent.EntityB];
boomPos = temp.Value;
temp.Value = new float3(0, 100, 0);
TranslationGroup[triggerEvent.EntityB] = temp;
if (DeleteGroup.HasComponent(triggerEvent.EntityB))
{
DeleteTag temp1 = DeleteGroup[triggerEvent.EntityB];
temp1.lifeTime = 0f;
DeleteGroup[triggerEvent.EntityB] = temp1;
}
}
#endregion
#region 子弹击退敌人效果
//略
#endregion
#region 扣血并生成爆炸粒子实体
if (HpGroup.HasComponent(triggerEvent.EntityA))
{
Hp hp = HpGroup[triggerEvent.EntityA];
hp.HpValue--;
HpGroup[triggerEvent.EntityA] = hp;
if (hp.HpValue == 0)
{
//播放死亡音效
isbehit[1] = true;
Entity boomEntity = ecb.Instantiate(boom);
Translation translation = new Translation
{
Value = boomPos
};
ecb.SetComponent(boomEntity, translation);
}
}
#endregion
}
if (EnemyGroup.HasComponent(triggerEvent.EntityB))
{
//与A逻辑相同,略
}
}
}
}
目前Particle System 也能正常的转换为Entity ,但和physic shape等组件一样,它们还并没有那么方便使用,所以这里采用了和子弹组件一样的策略,写了一个粒子生命周期的组件,在单独的系统去处理,也不过多赘述了。
至于声音,没必要转换为实体,正常使用就好了~
工程地址:
https://github.com/ydwj/Unity-ECS-FpsGame
ps:工程里面下的商店的免费素材有点大~