Initial Combat Implementation
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using Unity.NetCode.LowLevel;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only predicted-spawn classifier for projectiles. When a predicted client fires, it
|
||||
/// locally spawns a predicted projectile ghost; later the server's authoritative spawn arrives in
|
||||
/// the <see cref="GhostSpawnQueue"/>. This system pairs the incoming server ghost with the matching
|
||||
/// locally predicted entity so netcode reconciles them instead of double-spawning. The match key is
|
||||
/// <see cref="Projectile.SpawnId"/> — a deterministic <c>(ownerNetId << 16) | absoluteFireCount</c>
|
||||
/// value computed identically on client and server, replicated as a <c>[GhostField]</c> so it is
|
||||
/// present in snapshot history and readable here via <see cref="SnapshotDataBufferComponentLookup"/>.
|
||||
/// Mirrors the official Netcode HelloNetcode 02_PredictedSpawning GrenadeClassificationSystem,
|
||||
/// with GrenadeData→<see cref="Projectile"/> and GrenadeSpawner→<see cref="ProjectileSpawner"/>.
|
||||
/// Runs after the built-in <see cref="GhostSpawnClassificationSystem"/> (so any owner-predicted
|
||||
/// default classification has already had a pass) and before the OrderLast
|
||||
/// <c>DefaultGhostSpawnClassificationSystem</c> (so entries this system does NOT match still fall
|
||||
/// through to the spawn-tick-window fallback).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(GhostSpawnClassificationSystemGroup))]
|
||||
[UpdateAfter(typeof(GhostSpawnClassificationSystem))]
|
||||
[CreateAfter(typeof(GhostCollectionSystem))]
|
||||
[CreateAfter(typeof(GhostReceiveSystem))]
|
||||
// NOTE: intentionally NOT [BurstCompile]d. The cross-assembly generic
|
||||
// SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>() trips a Burst
|
||||
// internal compiler error (type-hash resolution) on Netcode 1.13.2. Classification only runs when
|
||||
// ghost spawns are received (a cold path, not the prediction loop), so a managed job is fine here.
|
||||
public partial struct ProjectileClassificationSystem : ISystem
|
||||
{
|
||||
SnapshotDataLookupHelper m_SnapshotDataLookupHelper;
|
||||
BufferLookup<PredictedGhostSpawn> m_PredictedGhostSpawnLookup;
|
||||
ComponentLookup<Projectile> m_ProjectileLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved once in <see cref="OnUpdate"/>: the ghost-collection index of our projectile prefab.
|
||||
/// -1 until the <see cref="GhostCollectionPrefab"/> buffer has been populated and scanned.
|
||||
/// </summary>
|
||||
int m_GhostType;
|
||||
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
// Match the built-in GhostSpawnClassificationSystem / DefaultGhostSpawnClassificationSystem:
|
||||
// in a single-world host (a world flagged GameClient AND GameServer) there is no real client
|
||||
// snapshot history to classify against, so the package's classification systems disable
|
||||
// themselves. We do the same so we never run alone in that scenario after the system we
|
||||
// UpdateAfter has switched itself off. (For Project M's standard separate ClientWorld +
|
||||
// ServerWorld over IPC, IsHost() is false and this guard is a no-op.)
|
||||
if (state.WorldUnmanaged.IsHost())
|
||||
{
|
||||
state.Enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the snapshot lookup helper from the two collection singletons. CreateAfter on
|
||||
// GhostCollectionSystem + GhostReceiveSystem guarantees both singletons exist by now.
|
||||
m_SnapshotDataLookupHelper = new SnapshotDataLookupHelper(
|
||||
ref state,
|
||||
SystemAPI.GetSingletonEntity<GhostCollection>(),
|
||||
SystemAPI.GetSingletonEntity<SpawnedGhostEntityMap>());
|
||||
|
||||
m_PredictedGhostSpawnLookup = state.GetBufferLookup<PredictedGhostSpawn>(true);
|
||||
m_ProjectileLookup = state.GetComponentLookup<Projectile>(true);
|
||||
|
||||
state.RequireForUpdate<GhostSpawnQueue>();
|
||||
state.RequireForUpdate<PredictedGhostSpawnList>();
|
||||
state.RequireForUpdate<NetworkId>();
|
||||
state.RequireForUpdate<ProjectileSpawner>();
|
||||
|
||||
m_GhostType = -1;
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
// Resolve our projectile ghost-type index once by scanning the ghost-collection prefab
|
||||
// buffer for the spawner's prefab entity. The collection is populated only after the ghost
|
||||
// prefabs have loaded, so retry each tick until found.
|
||||
if (m_GhostType == -1)
|
||||
{
|
||||
var projectilePrefab = SystemAPI.GetSingleton<ProjectileSpawner>().Prefab;
|
||||
var ghostCollection = SystemAPI.GetSingletonEntity<GhostCollection>();
|
||||
var prefabs = SystemAPI.GetBuffer<GhostCollectionPrefab>(ghostCollection);
|
||||
for (int i = 0; i < prefabs.Length; ++i)
|
||||
{
|
||||
if (prefabs[i].GhostPrefab == projectilePrefab)
|
||||
{
|
||||
m_GhostType = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (m_GhostType == -1)
|
||||
return;
|
||||
}
|
||||
|
||||
m_SnapshotDataLookupHelper.Update(ref state);
|
||||
m_PredictedGhostSpawnLookup.Update(ref state);
|
||||
m_ProjectileLookup.Update(ref state);
|
||||
|
||||
// SystemAPI is a system-context-only facade and cannot be used inside the IJobEntity; resolve
|
||||
// the predicted-spawn-list singleton entity here and pass it into the job (this mirrors the
|
||||
// built-in DefaultGhostSpawnClassificationJob.spawnListEntity pattern).
|
||||
var classificationJob = new ProjectileClassificationJob
|
||||
{
|
||||
GhostType = m_GhostType,
|
||||
SnapshotDataLookup = m_SnapshotDataLookupHelper.CreateSnapshotBufferLookup(),
|
||||
PredictedSpawnListEntity = SystemAPI.GetSingletonEntity<PredictedGhostSpawnList>(),
|
||||
PredictedGhostSpawnLookup = m_PredictedGhostSpawnLookup,
|
||||
ProjectileLookup = m_ProjectileLookup,
|
||||
};
|
||||
state.Dependency = classificationJob.Schedule(state.Dependency);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each newly received server spawn in a <see cref="GhostSpawnQueue"/>, attempts to find a
|
||||
/// locally predicted projectile with the same <see cref="Projectile.SpawnId"/> read out of
|
||||
/// snapshot history. On a match it points the queued spawn at the predicted entity (so netcode
|
||||
/// adopts it instead of instantiating a duplicate), marks the entry classified, and removes that
|
||||
/// predicted entry from the list. Entries this system does NOT match are left untouched so the
|
||||
/// OrderLast <c>DefaultGhostSpawnClassificationSystem</c> fallback can still try the spawn-tick
|
||||
/// window match.
|
||||
/// </summary>
|
||||
[WithAll(typeof(GhostSpawnQueue))]
|
||||
partial struct ProjectileClassificationJob : IJobEntity
|
||||
{
|
||||
public int GhostType;
|
||||
public SnapshotDataBufferComponentLookup SnapshotDataLookup;
|
||||
|
||||
// Resolved in OnUpdate (SystemAPI is unavailable inside a job). A single Entity field, so it
|
||||
// is NOT marked [ReadOnly].
|
||||
public Entity PredictedSpawnListEntity;
|
||||
|
||||
[ReadOnly] public BufferLookup<PredictedGhostSpawn> PredictedGhostSpawnLookup;
|
||||
[ReadOnly] public ComponentLookup<Projectile> ProjectileLookup;
|
||||
|
||||
// 'data' is taken by value (NOT 'in') because TryGetComponentDataFromSnapshotHistory needs a
|
||||
// mutable 'ref DynamicBuffer<SnapshotDataBuffer>'. The built-in GhostSpawnClassification uses
|
||||
// 'in' only because it never calls that ref overload — do not copy that here.
|
||||
public void Execute(DynamicBuffer<GhostSpawnBuffer> newSpawns, DynamicBuffer<SnapshotDataBuffer> data)
|
||||
{
|
||||
var predictedSpawnList = PredictedGhostSpawnLookup[PredictedSpawnListEntity];
|
||||
|
||||
for (int i = 0; i < newSpawns.Length; ++i)
|
||||
{
|
||||
ref var newSpawn = ref newSpawns.ElementAt(i);
|
||||
|
||||
// Only classify our own ghost type, and only predicted spawns that have not already
|
||||
// been matched/claimed (PredictedSpawnEntity == Null && !HasClassifiedPredictedSpawn)
|
||||
// — leave everything else to the defaults.
|
||||
if (newSpawn.GhostType != GhostType)
|
||||
continue;
|
||||
if (newSpawn.SpawnType != GhostSpawnBuffer.Type.Predicted ||
|
||||
newSpawn.HasClassifiedPredictedSpawn ||
|
||||
newSpawn.PredictedSpawnEntity != Entity.Null)
|
||||
continue;
|
||||
|
||||
if (!SnapshotDataLookup.TryGetComponentDataFromSnapshotHistory(
|
||||
newSpawn.GhostType, data, out Projectile incoming, i))
|
||||
continue;
|
||||
|
||||
for (int j = 0; j < predictedSpawnList.Length; ++j)
|
||||
{
|
||||
if (predictedSpawnList[j].ghostType != GhostType)
|
||||
continue;
|
||||
|
||||
var predictedEntity = predictedSpawnList[j].entity;
|
||||
if (incoming.SpawnId == ProjectileLookup[predictedEntity].SpawnId)
|
||||
{
|
||||
// Claim the decision ONLY on a real match, so non-matches still fall through
|
||||
// to the OrderLast default classifier (matches the official sample).
|
||||
newSpawn.PredictedSpawnEntity = predictedEntity;
|
||||
newSpawn.HasClassifiedPredictedSpawn = true;
|
||||
predictedSpawnList.RemoveAtSwapBack(j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user