Initial Combat Implementation

This commit is contained in:
Luis Gonzalez
2026-05-31 21:35:12 -07:00
parent 7fa77ce821
commit 1f647dd5e1
166 changed files with 93337 additions and 91 deletions
@@ -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&lt;T&gt;() 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;
}
}
}
}
}
}
}