Initial Combat Implementation
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative projectile resolution: applies hits to damageable entities and expires
|
||||
/// projectiles past their range. Runs in the server world only
|
||||
/// (<see cref="WorldSystemFilterFlags.ServerSimulation"/>) inside the
|
||||
/// <see cref="PredictedSimulationSystemGroup"/>, ordered <see cref="ProjectileMoveSystem"/> so the
|
||||
/// projectile's <see cref="LocalTransform"/> is the post-move position for this tick.
|
||||
///
|
||||
/// Hit detection is a <b>swept</b> planar (XZ) test: rather than checking the projectile's point
|
||||
/// position (which tunnels straight through a target when the per-tick step exceeds the target's
|
||||
/// radius — e.g. a fast projectile, or any projectile while the server is tick-batching under load),
|
||||
/// it reconstructs the segment the projectile traversed this tick
|
||||
/// (<c>[curPos - dir*speed*dt, curPos]</c>) and tests each target's hit radius against the closest
|
||||
/// point on that segment. The target hit earliest along the path (smallest segment parameter) wins.
|
||||
/// A target whose <see cref="GhostOwner"/> matches the projectile's owner is skipped (no self-hits);
|
||||
/// dummies carry no <see cref="GhostOwner"/> and are therefore always valid targets.
|
||||
///
|
||||
/// On a hit the system appends a <see cref="DamageEvent"/> to the target (consumed by
|
||||
/// <c>HealthApplyDamageSystem</c>) and destroys the projectile. Deferring damage to a buffer lets a
|
||||
/// single tick stack hits from multiple projectiles. All structural changes go through an
|
||||
/// <see cref="EntityCommandBuffer"/> that plays back immediately to the
|
||||
/// <see cref="EntityManager"/> (Temp allocator) — keeping this server-only, once-per-tick system
|
||||
/// self-contained and plain-world testable without a separate ECB system.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(ProjectileMoveSystem))]
|
||||
public partial struct ProjectileDamageSystem : ISystem
|
||||
{
|
||||
/// <summary>Lookup used to read a target's owner so a projectile never hits its own caster.</summary>
|
||||
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
|
||||
|
||||
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
|
||||
const float k_ProjectileRadius = 0.2f;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
|
||||
|
||||
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
|
||||
state.RequireForUpdate<Projectile>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
m_GhostOwnerLookup.Update(ref state);
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// Snapshot all damageable targets once for this tick. Stable iteration order (query order).
|
||||
var targetEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var targetPositions = new NativeList<float3>(Allocator.Temp);
|
||||
var targetRadii = new NativeList<float>(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, hitRadius, targetEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
|
||||
.WithAll<Health>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
targetEntities.Add(targetEntity);
|
||||
targetPositions.Add(xform.ValueRO.Position);
|
||||
targetRadii.Add(hitRadius.ValueRO.Value);
|
||||
}
|
||||
|
||||
foreach (var (xform, proj, owner, projectileEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Projectile>, RefRO<GhostOwner>>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
int projOwnerId = owner.ValueRO.NetworkId;
|
||||
|
||||
// This tick's planar travel segment: [segStart -> segEnd]. Sweeping the segment (rather
|
||||
// than testing only segEnd) is what prevents fast projectiles from tunnelling targets.
|
||||
float3 cur = xform.ValueRO.Position;
|
||||
float2 segEnd = new float2(cur.x, cur.z);
|
||||
float2 segStart = segEnd - proj.ValueRO.Direction * (proj.ValueRO.Speed * dt);
|
||||
float2 seg = segEnd - segStart;
|
||||
float segLenSq = math.lengthsq(seg);
|
||||
|
||||
int bestIdx = -1;
|
||||
float bestT = float.MaxValue;
|
||||
for (int i = 0; i < targetEntities.Length; i++)
|
||||
{
|
||||
var target = targetEntities[i];
|
||||
|
||||
// Skip the caster: a target whose GhostOwner matches the projectile owner is the
|
||||
// shooter (or another ghost they own). Dummies have no GhostOwner, so never skipped.
|
||||
if (m_GhostOwnerLookup.HasComponent(target) &&
|
||||
m_GhostOwnerLookup[target].NetworkId == projOwnerId)
|
||||
continue;
|
||||
|
||||
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
||||
|
||||
// Closest point on the travel segment to the target centre.
|
||||
float t = segLenSq > 1e-8f
|
||||
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
|
||||
: 0f;
|
||||
float2 closest = segStart + t * seg;
|
||||
|
||||
float hitDist = targetRadii[i] + k_ProjectileRadius;
|
||||
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx >= 0)
|
||||
{
|
||||
// Earliest target along the travel path: deal damage and consume the projectile.
|
||||
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
|
||||
{
|
||||
Amount = proj.ValueRO.Damage,
|
||||
SourceNetworkId = projOwnerId,
|
||||
});
|
||||
ecb.DestroyEntity(projectileEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nothing hit this tick: expire the projectile once it has travelled its full range.
|
||||
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range)
|
||||
ecb.DestroyEntity(projectileEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
|
||||
ecb.Dispose();
|
||||
targetEntities.Dispose();
|
||||
targetPositions.Dispose();
|
||||
targetRadii.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user