Cách EzyTank tạo hệ thống vũ khí trong Unity ECS
Back to ezytankTrong dự án EzyTank, hệ thống vũ khí được xây dựng hoàn toàn dựa trên Unity NetCode for Entities. Mỗi viên đạn (Shell) là một entity có thể được dự đoán (predicted) và đồng bộ hóa giữa client và server. Trong bài viết này, chúng ta sẽ tìm hiểu cách Shell prefab, ShellRenderer prefab, và các hệ thống ECS xử lý quá trình bắn, di chuyển, va chạm và gây sát thương.
Shell prefab and ShellRenderer prefab
Prefab Shell đại diện cho entity ở trong thế giới ECS, nơi các hệ thống sẽ xử lý logic vật lý, va chạm, và đồng bộ giữa client/server:

Trong khi đó, prefab ShellRenderer chỉ là phần hiển thị của viên đạn trong game, nên nó chỉ có thành phần MeshFilter và MeshRenderrer.

Trong
GhostPresentationGameObjectAuthoring ở prefab Shell, prefab ShellRenderer được gán vào trường Client Prefab, nghĩa là đối tượng tương ứng sẽ chỉ được tạo ra ở client với mục đích hiển thị, và nó không ảnh hướng gì đến mô phỏng vật lý.ShellAuthoring - Dữ liệu cho viên đạn trong ECS
Khi Bake,
ShellAuthoring tạo ra một entity có component Shell, lưu các thông số cơ bản của viên đạn như tốc độ, trọng lực, thời gian tồn tại, vận tốc và trạng thái va chạm:using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using UnityEngine; namespace Youngmonkeys.EzyTank { public class ShellAuthoring : MonoBehaviour { public float speed = 20f; public float gravity = -9f; public float maxLifetime = 5f; public class Baker : Baker<ShellAuthoring> { public override void Bake(ShellAuthoring authoring) { Entity entity = GetEntity(TransformUsageFlags.Dynamic); AddComponent( entity, new Shell { speed = authoring.speed, gravity = authoring.gravity, maxLifeTime = authoring.maxLifetime, lifetimeCounter = 0f, } ); } } } [GhostComponent] public struct Shell : IComponentData { public float speed; public float gravity; public float maxLifeTime; [GhostField] public float3 velocity; [GhostField] public float lifetimeCounter; [GhostField] public byte hasHit; public Entity hitEntity; [GhostField] public float3 hitNormal; } }
ShellShotVisualAuthoring - Dữ liệu lưu thông tin sát thương cho viên đạn trong ECS
Component ShellShotVisual lưu các giá trị về sát thương của một viên đạn:
-
directHitDamage: sát thương khi trúng trực tiếp -
maxRadiusDamage: sát thương tối đa nếu ở trong vùng nổ nhưng không trúng trực tiếp -
damageRadius: bán kính mà viên đạn tạo sát thương
using Unity.Entities; using UnityEngine; namespace Youngmonkeys.EzyTank { [RequireComponent(typeof(Shell))] public class ShellShotVisualAuthoring : MonoBehaviour { public float directHitDamage = 50; public float maxRadiusDamage = 10; public float damageRadius = 8; class Baker : Baker<ShellShotVisualAuthoring> { public override void Bake(ShellShotVisualAuthoring shotVisualAuthoring) { Entity entity = GetEntity(TransformUsageFlags.Dynamic); AddComponent( entity, new ShellShotVisual { directHitDamage = shotVisualAuthoring.directHitDamage, maxRadiusDamage = shotVisualAuthoring.maxRadiusDamage, damageRadius = shotVisualAuthoring.damageRadius, } ); } } void OnDrawGizmosSelected() { Gizmos.color = Color.red; Gizmos.DrawWireSphere(transform.position, damageRadius); } } public struct ShellShotVisual : IComponentData, IEnableableComponent { public float directHitDamage; public float maxRadiusDamage; public float damageRadius; public byte hasProcessedHitSimulation; public byte hasProcessedHitVfx; } }
ShellMovementSimulationSystem - Hệ thống xử lý mô phỏng đường bay và sự kiện chạm
Hệ thống này được thực thi ở cả Client và Server để dự đoán chuyển động của viên đạn. Nó sẽ dự đoán va chạm ngay khi raycast phát hiện hit. Tuy nhiên, chỉ Server chịu trách việc hủy entity của viên đạn khi nó hết thời gian tồn tại, để bảo đảm trạng thái nhất quán và chống hack.
using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Physics; using Unity.Transforms; namespace Youngmonkeys.EzyTank { [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation)] public partial class ShellPredictionUpdateGroup : ComponentSystemGroup { } [UpdateInGroup(typeof(ShellPredictionUpdateGroup), OrderFirst = true)] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation)] partial struct ShellMovementSimulationSystem : ISystem { public void OnCreate(ref SystemState state) { state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>(); state.RequireForUpdate<NetDebug>(); state.RequireForUpdate<PhysicsWorldSingleton>(); state.RequireForUpdate<NetworkTime>(); } // [BurstCompile] public void OnUpdate(ref SystemState state) { ShellMovementSimulationJob job = new ShellMovementSimulationJob { ecb = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>() .CreateCommandBuffer(state.WorldUnmanaged), deltaTime = SystemAPI.Time.DeltaTime, networkTime = SystemAPI.GetSingleton<NetworkTime>(), physicsWorld = SystemAPI.GetSingleton<PhysicsWorldSingleton>().PhysicsWorld, isServer = state.WorldUnmanaged.IsServer() }; state.Dependency = job.Schedule(state.Dependency); } [WithAll(typeof(Simulate))] public partial struct ShellMovementSimulationJob : IJobEntity { public EntityCommandBuffer ecb; public float deltaTime; public NetworkTime networkTime; public PhysicsWorld physicsWorld; public bool isServer; void Execute(Entity entity, ref Shell shell, ref LocalTransform localTransform) { if (shell.hasHit == 0) { // Movement. shell.velocity += math.up() * shell.gravity * deltaTime; float3 displacement = shell.velocity * deltaTime; // Hit detection. if (networkTime.IsFirstTimeFullyPredictingTick) { NativeList<RaycastHit> hits = new NativeList<RaycastHit>(128, Allocator.Temp); RaycastInput raycastInput = new RaycastInput { Start = localTransform.Position, End = localTransform.Position + displacement, Filter = CollisionFilter.Default, }; physicsWorld.CastRay(raycastInput, ref hits); if (WeaponUtilities.GetClosestValidWeaponRaycastHit(in hits, out RaycastHit closestValidHit)) { displacement *= closestValidHit.Fraction; shell.hitEntity = closestValidHit.Entity; shell.hasHit = 1; shell.hitNormal = closestValidHit.SurfaceNormal; } } // Advance position. localTransform.Position += displacement; } if (isServer) { shell.maxLifeTime -= deltaTime; if (shell.maxLifeTime <= 0f) { ecb.DestroyEntity(entity); } } } } } }
ShootSystem - Hệ thống xử lý hành động bắn
ShootSystem kiểm tra khi nào người chơi nhấn nút bắn (
shootPressed), và sau đó nó:- Tạo mới một entity Shell từ prefab
- Gán vận tốc theo hướng nòng súng
- Thiết lập
GhostOwnerđể xác định ai là người bắn
using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; namespace Youngmonkeys.EzyTank { [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ServerSimulation)] partial struct ShootSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<GameResources>(); state.RequireForUpdate<ShellSpawner>(); state.RequireForUpdate<TankCommands>(); state.RequireForUpdate<BeginSimulationEntityCommandBufferSystem.Singleton>(); } [BurstCompile] public void OnUpdate(ref SystemState state) { NetworkTime networkTime = SystemAPI.GetSingleton<NetworkTime>(); if (networkTime.IsFirstTimeFullyPredictingTick) { ShellShotSimulationJob job = new ShellShotSimulationJob { entityCommandBuffer = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>() .CreateCommandBuffer(state.WorldUnmanaged), gameResources = SystemAPI.GetSingleton<GameResources>(), shellLookup = SystemAPI.GetComponentLookup<Shell>(true), }; state.Dependency = job.Schedule(state.Dependency); } } [WithAll(typeof(Simulate))] public partial struct ShellShotSimulationJob : IJobEntity { public EntityCommandBuffer entityCommandBuffer; public GameResources gameResources; [ReadOnly] public ComponentLookup<Shell> shellLookup; void Execute( in TankCommands tankCommands, in Health health, in LocalTransform localTransform, in ShellSpawner shellSpawner, in GhostOwner ghostOwner ) { if (tankCommands.shootPressed.IsSet && !health.IsDead()) { Entity shellEntity = entityCommandBuffer.Instantiate(gameResources.shellPrefabEntity); if (shellLookup.TryGetComponent(gameResources.shellPrefabEntity, out Shell shell)) { LocalTransform transform = localTransform.TransformTransform(shellSpawner.shellSpawnLocalTransform); entityCommandBuffer.SetComponent( shellEntity, transform ); shell.velocity = transform.Forward() * shell.speed; entityCommandBuffer.SetComponent( shellEntity, shell ); entityCommandBuffer.SetComponent(shellEntity, new GhostOwner { NetworkId = ghostOwner.NetworkId }); } } } } } }
ShellHitSimulationSystem - Hệ thống xử lý va chạm và gây sát thương
Hệ thống
ShellHitSimulationSystem chỉ chạy trên Server, vì chỉ Server mới có quyền quyết định kết quả va chạm và sát thương. Việc này để đảm bảo nhất quán dữ liệu ở các Client và chống hack.Có 2 kiểu sát thương ở đây:
- Sát thương trực tiếp
if (shellShotVisual.hasProcessedHitSimulation == 0 && shell.hasHit == 1) { if (healthLookup.TryGetComponent(shell.hitEntity, out Health health)) { health.currentHealth -= shellShotVisual.directHitDamage; } }
- Sát thương lan tỏa
if (collisionWorld.OverlapSphere(localTransform.Position, shellShotVisual.damageRadius, ref hits)) { for (var i = 0; i < hits.Length; i++) { if (healthLookup.TryGetComponent(hit.Entity, out Health health2)) { float falloff = 1f - math.saturate(hit.Distance / shellShotVisual.damageRadius); health2.currentHealth -= shellShotVisual.maxRadiusDamage * falloff; } } }
Sau khi viên đạn va chạm và được xử lý xong, Server sẽ xóa entity Shell, và đồng thời client sẽ được thông báo để hiển thị hiệu ứng.
Toàn bộ code chi tiết như sau:
using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Physics; using Unity.Transforms; namespace Youngmonkeys.EzyTank { // [BurstCompile] [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] public partial struct ShellHitSimulationSystem : ISystem { // [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate<BeginSimulationEntityCommandBufferSystem.Singleton>(); state.RequireForUpdate<PhysicsWorldSingleton>(); } // [BurstCompile] public void OnUpdate(ref SystemState state) { ShellHitSimulationJob job = new ShellHitSimulationJob { ecb = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>() .CreateCommandBuffer(state.WorldUnmanaged), isServer = state.WorldUnmanaged.IsServer(), healthLookup = SystemAPI.GetComponentLookup<Health>(), collisionWorld = SystemAPI.GetSingleton<PhysicsWorldSingleton>().CollisionWorld, }; state.Dependency = job.Schedule(state.Dependency); } [WithAll(typeof(Simulate))] public partial struct ShellHitSimulationJob : IJobEntity { public EntityCommandBuffer ecb; public bool isServer; public ComponentLookup<Health> healthLookup; public CollisionWorld collisionWorld; void Execute(Entity entity, in LocalTransform localTransform, ref ShellShotVisual shellShotVisual, in Shell shell) { if (isServer) { var hits = new NativeList<DistanceHit>(64, Allocator.Temp); // Hit processing if (shellShotVisual.hasProcessedHitSimulation == 0 && shell.hasHit == 1) { // Direct hit damage if (healthLookup.TryGetComponent(shell.hitEntity, out Health health)) { health.currentHealth -= shellShotVisual.directHitDamage; healthLookup[shell.hitEntity] = health; } // Area damage hits.Clear(); if (collisionWorld.OverlapSphere(localTransform.Position, shellShotVisual.damageRadius, ref hits, CollisionFilter.Default)) { for (var i = 0; i < hits.Length; i++) { var hit = hits[i]; if (hit.Entity == shell.hitEntity) { continue; } if (healthLookup.TryGetComponent(hit.Entity, out Health health2)) { var damageWithFalloff = shellShotVisual.maxRadiusDamage * (1f - math.saturate(hit.Distance / shellShotVisual.damageRadius)); health2.currentHealth -= damageWithFalloff; healthLookup[hit.Entity] = health2; } } } if (shell.hitEntity != Entity.Null) { ecb.DestroyEntity(entity); } shellShotVisual.hasProcessedHitSimulation = 1; } } } } } [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] public partial struct ShellHitVfxRequestSystem : ISystem { public void OnCreate(ref SystemState state) { state.RequireForUpdate<BeginSimulationEntityCommandBufferSystem.Singleton>(); state.RequireForUpdate( SystemAPI.QueryBuilder() .WithAll<LocalTransform, ShellShotVisual, Shell>() .Build() ); } public void OnUpdate(ref SystemState state) { var job = new ShellHitVfxRequestJob { entityCommandBuffer = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>() .CreateCommandBuffer(state.WorldUnmanaged) }; state.Dependency = job.Schedule(state.Dependency); } public partial struct ShellHitVfxRequestJob : IJobEntity { public EntityCommandBuffer entityCommandBuffer; void Execute( Entity entity, in LocalTransform transform, ref ShellShotVisual shellShotVisual, in Shell shell ) { if (shellShotVisual.hasProcessedHitVfx == 0 && shell.hasHit == 1) { Entity spawnVfxHitShellRequestEntity = entityCommandBuffer.CreateEntity(); entityCommandBuffer.AddComponent( spawnVfxHitShellRequestEntity, new VfxHitRequest { position = transform.Position, hitRadius = shellShotVisual.damageRadius, } ); shellShotVisual.hasProcessedHitVfx = 1; } } } } }