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,30 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Predicted per-player ability cooldown gate. Holds the earliest server tick at which the
/// owning player may fire again, so <see cref="AbilityFireSystem"/> can throttle shots
/// deterministically across client prediction and server simulation.
/// <para>
/// Replicated as a <see cref="GhostField"/> so the cooldown survives the frame→tick→rollback
/// boundary: when the client re-predicts ticks after a snapshot, it sees the same authoritative
/// gate the server applied and converges without double-firing. Stored as a raw <c>uint</c>
/// rather than a <see cref="NetworkTick"/> for simple, quantization-free serialization; compare
/// by wrapping it back into a <see cref="NetworkTick"/> and using
/// <see cref="NetworkTick.IsNewerThan"/> (raw subtraction is unsafe across tick wraparound).
/// </para>
/// </summary>
public struct AbilityCooldown : IComponentData
{
/// <summary>
/// Raw tick value of the earliest tick the player may fire again. <c>0</c> = ready (no
/// cooldown pending). Set by <see cref="AbilityFireSystem"/> to
/// <c>serverTick + max(1, CooldownTicks)</c> on fire; treat as "still cooling down" only
/// while a valid <see cref="NetworkTick"/> built from it is newer than the current
/// <c>ServerTick</c>.
/// </summary>
[GhostField] public uint NextFireTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b7a2b67b22b2a4abaa8efd84759445c0
@@ -0,0 +1,14 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Singleton handle to the baked ability/character definition database (config, not replicated -
/// baked identically into both worlds from the gameplay subscene). The companion AbilityPrefabElement
/// buffer on the same entity carries the per-ability projectile prefab entity refs.
/// </summary>
public struct AbilityDatabase : IComponentData
{
public BlobAssetReference<AbilityDatabaseBlob> Value;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b297f7d451084342af25012ccb3a3e8
@@ -0,0 +1,74 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>One authored ability definition, baked immutable into the AbilityDatabase blob.</summary>
public struct AbilityDefBlob
{
public byte Id; // AbilityId
public float Damage;
public float ProjectileSpeed;
public float Range;
public float AutoTargetRange;
public float AutoTargetConeRadians;
public int CooldownTicks;
public FixedString64Bytes Name;
}
/// <summary>One authored character-stats definition, baked immutable into the AbilityDatabase blob.</summary>
public struct CharacterStatsBlob
{
public byte Id; // CharacterId
public float MoveSpeed;
public float TurnRateRadiansPerSec;
public float MaxHealth;
public FixedString64Bytes Name;
}
/// <summary>
/// Immutable designer-authored definition database, baked from ScriptableObjects to a blob asset and
/// shared by every entity (Burst-fast, zero per-instance cost). Looked up by stable id. Entity/prefab
/// references are NOT stored here (blobs don't remap entity refs) - see AbilityPrefabElement.
///
/// NOTE: the lookups are intentionally NOT 'readonly' methods. A readonly struct method forces a
/// defensive copy of a field when calling a non-readonly member on it; copying a BlobArray breaks its
/// relative-offset pointer, so the array would read as empty. Plain (non-readonly) methods access the
/// BlobArray in place. Always reach these through 'ref blob.Value'.
/// </summary>
public struct AbilityDatabaseBlob
{
public BlobArray<AbilityDefBlob> Abilities;
public BlobArray<CharacterStatsBlob> Characters;
/// <summary>Linear lookup by ability id (the array is tiny). Returns false if not present.</summary>
public bool TryGetAbility(byte id, out AbilityDefBlob def)
{
for (int i = 0; i < Abilities.Length; i++)
{
if (Abilities[i].Id == id)
{
def = Abilities[i];
return true;
}
}
def = default;
return false;
}
/// <summary>Linear lookup by character id (the array is tiny). Returns false if not present.</summary>
public bool TryGetCharacter(byte id, out CharacterStatsBlob def)
{
for (int i = 0; i < Characters.Length; i++)
{
if (Characters[i].Id == id)
{
def = Characters[i];
return true;
}
}
def = default;
return false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 974be1ee95bef486ea49a4d42ecc9796
@@ -0,0 +1,173 @@
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Predicted "fire" ability: on the single fully-predicting pass of each tick, spawns a
/// Projectile ghost for every player whose PlayerInput.Fire event is set this tick and whose
/// AbilityCooldown has elapsed. Runs in both worlds: the owning client predict-spawns the
/// projectile (classified into the authoritative ghost by ProjectileClassificationSystem via the
/// Projectile.SpawnId key), and the server spawns the replicated truth.
///
/// M3 data-driven: ability stats are read from the per-entity EffectiveAbilityStats (authored base
/// from the AbilityDatabase blob keyed by AbilityRef, folded with the replicated StatModifier buffer
/// by StatRecomputeSystem earlier this tick). The projectile ghost prefab is resolved per ability via
/// the AbilityPrefabElement buffer on the AbilityDatabase singleton. Effective Speed/Damage/Range are
/// snapshotted into the spawned Projectile, so the downstream move/damage systems are unchanged and
/// predicted + server projectiles match (both folded the same replicated modifiers).
///
/// Determinism / idempotency: the prediction loop re-runs this system on rollback, so all
/// non-idempotent effects (spawning, cooldown advance) are gated behind
/// NetworkTime.IsFirstTimeFullyPredictingTick so they happen exactly once per tick. The absolute
/// fire count comes from the replicated input command buffer at NetworkTime.ServerTick (not a
/// local counter) so the SpawnId matches on client and server. No wall-clock, no System.Random,
/// no UnityEngine.Time.
///
/// Auto-target is intentionally server-only: the client fires along raw aim, and the server's
/// authoritative Projectile.Direction GhostField reconciles the predicted projectile to the
/// assisted heading.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(PlayerAimSystem))]
[BurstCompile]
public partial struct AbilityFireSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<AbilityDatabase>();
state.RequireForUpdate<NetworkTime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Spawning is a one-off effect: only run on the unique fully-predicting pass of this tick
// so a rollback re-simulation does not double-spawn.
var networkTime = SystemAPI.GetSingleton<NetworkTime>();
if (!networkTime.IsFirstTimeFullyPredictingTick)
return;
var serverTick = networkTime.ServerTick;
if (!serverTick.IsValid)
return;
// Per-ability projectile ghost prefabs live on the AbilityDatabase singleton's companion buffer.
var dbEntity = SystemAPI.GetSingletonEntity<AbilityDatabase>();
var abilityPrefabs = SystemAPI.GetBuffer<AbilityPrefabElement>(dbEntity);
bool isServer = state.WorldUnmanaged.IsServer();
// Server-only auto-target candidate set: training-dummy world XZ positions, collected once.
var candidatePositions = new NativeList<float3>(Allocator.Temp);
if (isServer)
{
foreach (var dummyTransform in
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<TrainingDummyTag>())
{
candidatePositions.Add(dummyTransform.ValueRO.Position);
}
}
var candidates = candidatePositions.AsArray();
var ecb = new EntityCommandBuffer(state.WorldUpdateAllocator);
foreach (var (input, facing, xform, eff, abilityRef, cd, owner, entity) in
SystemAPI.Query<RefRO<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>,
RefRO<EffectiveAbilityStats>, RefRO<AbilityRef>, RefRW<AbilityCooldown>,
RefRO<GhostOwner>>()
.WithAll<Simulate>()
.WithEntityAccess())
{
// The InputEvent on the component carries the per-tick delta: set => fired this tick.
if (!input.ValueRO.Fire.IsSet)
continue;
// Cooldown gate. NextFireTick == 0 means "ready". Otherwise the player may fire only
// once serverTick is at-or-newer than the stored tick (i.e. the stored tick is not
// strictly newer than now).
uint nextFireRaw = cd.ValueRO.NextFireTick;
if (nextFireRaw != 0)
{
var nextTick = new NetworkTick(nextFireRaw);
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick))
continue; // still cooling down
}
// Resolve the projectile ghost prefab for this player's selected ability id.
Entity prefab = Entity.Null;
for (int i = 0; i < abilityPrefabs.Length; i++)
{
if (abilityPrefabs[i].Id == abilityRef.ValueRO.Id)
{
prefab = abilityPrefabs[i].Prefab;
break;
}
}
if (prefab == Entity.Null)
continue; // ability has no projectile prefab wired
// Absolute (monotonic) fire count from the replicated command buffer at this tick.
// This is the classification key shared by client prediction and server truth.
var inputBuffer = SystemAPI.GetBuffer<InputBufferData<PlayerInput>>(entity);
if (!inputBuffer.GetDataAtTick(serverTick, out var applied))
continue;
uint absoluteFireCount = applied.InternalInput.Fire.Count;
float2 rawAim = facing.ValueRO.Direction;
if (math.lengthsq(rawAim) < 1e-6f)
rawAim = new float2(0f, 1f);
else
rawAim = math.normalize(rawAim);
// Client fires along raw aim; only the server applies the auto-target assist cone.
float2 dir = rawAim;
if (isServer && eff.ValueRO.AutoTargetRange > 0f)
{
dir = AutoTarget.Resolve(
xform.ValueRO.Position,
rawAim,
eff.ValueRO.AutoTargetRange,
eff.ValueRO.AutoTargetConeRadians,
candidates);
}
uint spawnId = (uint)owner.ValueRO.NetworkId << 16 | absoluteFireCount;
var projectile = ecb.Instantiate(prefab);
float3 planarDir = new float3(dir.x, 0f, dir.y);
float3 spawnPos = xform.ValueRO.Position + planarDir * 0.6f;
spawnPos.y = xform.ValueRO.Position.y;
quaternion rot = quaternion.LookRotationSafe(planarDir, math.up());
ecb.SetComponent(projectile, LocalTransform.FromPositionRotation(spawnPos, rot));
ecb.SetComponent(projectile, new GhostOwner { NetworkId = owner.ValueRO.NetworkId });
// Snapshot the effective ability stats into the projectile (base + modifiers, computed
// identically on both worlds), so the move/damage systems need no modifier lookup.
ecb.SetComponent(projectile, new Projectile
{
Direction = math.normalize(dir),
SpawnId = spawnId,
Speed = eff.ValueRO.ProjectileSpeed,
Damage = eff.ValueRO.Damage,
Range = eff.ValueRO.Range,
DistanceTravelled = 0f,
});
// Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
cd.ValueRW.NextFireTick = serverTick.TickIndexForValidTick + cooldownTicks;
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
candidatePositions.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 473b7521bce4d4d1abc794bcd4e8e6fe
@@ -0,0 +1,15 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Companion buffer on the AbilityDatabase singleton mapping an ability id to its projectile ghost
/// prefab entity. Prefab/entity references are kept OUT of the blob (blob assets don't remap entity
/// references); they are baked here via GetEntity, which the entity serializer patches correctly.
/// </summary>
public struct AbilityPrefabElement : IBufferElementData
{
public byte Id; // AbilityId
public Entity Prefab;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 91844bb3b4d7843318fc0cdbfe68d43e
@@ -0,0 +1,15 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Which authored ability definition occupies this entity's primary slot - a light replicated key
/// into the AbilityDatabase blob, replacing M2's inlined AbilityStats values. Replicated so an
/// ability swap is server-authoritative and prediction-correct. <c>Id</c> stores an <see cref="AbilityId"/>.
/// </summary>
public struct AbilityRef : IComponentData
{
[GhostField] public byte Id;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d6ea08a11ef3d4afdb722b735ca3ed03
@@ -0,0 +1,92 @@
using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic auto-target assist helper for player abilities (M2 combat). Given a shooter
/// position, a raw aim direction, and a set of candidate target positions, picks the best target
/// inside an assist cone and snaps the shot toward it; otherwise returns the raw aim unchanged.
/// <para>
/// Authored as a <see langword="static"/> class (no state) so it is Burst-safe and allocation-free,
/// callable from inside predicted/jobified systems. It is intended to run server-side only (see
/// <c>AbilityFireSystem</c>) — the server's authoritative <c>Projectile.Direction</c> GhostField then
/// reconciles the client's raw-aim predicted projectile. Determinism: no wall-clock, no randomness,
/// pure math; ties are broken by smallest candidate index so identical inputs always yield identical
/// output across the prediction loop and across machines.
/// </para>
/// </summary>
public static class AutoTarget
{
/// <summary>
/// Resolves the planar (XZ) direction a shot should take. Returns the normalized direction toward
/// the nearest candidate within <paramref name="autoTargetRange"/> whose bearing from
/// <paramref name="from"/> lies within <paramref name="coneHalfAngleRadians"/> of
/// <paramref name="rawAimDir"/>; if no candidate qualifies, returns <paramref name="rawAimDir"/>
/// unchanged.
/// </summary>
/// <param name="from">Shooter world position; only the XZ plane is considered.</param>
/// <param name="rawAimDir">
/// Caller-normalized planar aim direction (world XZ mapped to <c>float2(x, y)</c>). If it is
/// effectively zero-length, it is returned unchanged (no valid heading to assist).
/// </param>
/// <param name="autoTargetRange">Max planar distance to consider a candidate; <c>0</c> (or less) disables assist.</param>
/// <param name="coneHalfAngleRadians">Half-angle of the assist cone, measured from <paramref name="rawAimDir"/>.</param>
/// <param name="candidatePositions">Candidate target world positions (XZ used). Read-only; not modified.</param>
/// <returns>
/// The normalized direction toward the chosen candidate, or <paramref name="rawAimDir"/> when no
/// candidate qualifies. Ties on distance are broken by the smallest candidate index for determinism.
/// </returns>
public static float2 Resolve(float3 from, float2 rawAimDir, float autoTargetRange, float coneHalfAngleRadians,
in NativeArray<float3> candidatePositions)
{
// No valid heading to assist along — caller guarantees normalization, but guard zero-length.
if (math.lengthsq(rawAimDir) < 1e-6f)
return rawAimDir;
// Disabled / nothing to consider.
if (autoTargetRange <= 0f || candidatePositions.Length == 0)
return rawAimDir;
float rangeSq = autoTargetRange * autoTargetRange;
float cosCone = math.cos(coneHalfAngleRadians);
int bestIndex = -1;
float bestDistSq = float.MaxValue;
float2 bestDir = rawAimDir;
for (int i = 0; i < candidatePositions.Length; i++)
{
// Planar (XZ) offset from shooter to candidate.
float3 offset = candidatePositions[i] - from;
float2 planar = new float2(offset.x, offset.z);
float distSq = math.lengthsq(planar);
// Skip self / coincident candidates (effectively zero distance → undefined bearing).
if (distSq < 1e-6f)
continue;
// Out of range.
if (distSq > rangeSq)
continue;
// Bearing test: dot of unit bearing with the (unit) raw aim vs cos(half-angle).
float2 dir = planar * math.rsqrt(distSq); // normalized planar bearing
float dot = math.dot(dir, rawAimDir);
if (dot < cosCone)
continue; // outside the assist cone
// Nearest wins; strict less-than keeps the first (smallest-index) candidate on ties.
if (distSq < bestDistSq)
{
bestDistSq = distSq;
bestIndex = i;
bestDir = dir;
}
}
return bestIndex >= 0 ? bestDir : rawAimDir;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac02264d177dd426e8f6972c7c3ceaae
@@ -0,0 +1,20 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One pending hit against a damageable entity, queued as a per-entity buffer element. The server
/// appends a DamageEvent when a projectile hits (ProjectileDamageSystem), then HealthApplyDamageSystem
/// drains the buffer once per tick to subtract from Health. Buffering decouples hit detection from
/// health resolution and lets multiple simultaneous hits accumulate before being applied. Not
/// replicated — only Health.Current is a GhostField; the buffer is server-side and cleared each tick.
/// </summary>
public struct DamageEvent : IBufferElementData
{
/// <summary>Damage to subtract from the target's Health.Current (world health units).</summary>
public float Amount;
/// <summary>NetworkId of the firing player that caused this hit (attribution / self-hit filtering upstream).</summary>
public int SourceNetworkId;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 498738282d585418893b23454a4b88a0
@@ -0,0 +1,20 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Per-entity effective ability stats: the authored base (from the AbilityDatabase blob keyed by
/// AbilityRef) folded with the entity's StatModifier buffer by StatRecomputeSystem each predicted
/// tick. Derived/local, NOT replicated - both worlds recompute it deterministically from the
/// replicated modifier buffer, so it matches under prediction without being in the snapshot.
/// </summary>
public struct EffectiveAbilityStats : IComponentData
{
public float Damage;
public float ProjectileSpeed;
public float Range;
public float AutoTargetRange;
public float AutoTargetConeRadians;
public int CooldownTicks;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a8bb3a5c343e74e7fb249e96c0c55fdc
@@ -0,0 +1,16 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Per-entity effective character stats (movement + survivability): authored base (from the
/// CharacterStats blob keyed by CharacterStatsRef) folded with the StatModifier buffer by
/// StatRecomputeSystem each predicted tick. Derived/local, NOT replicated (see EffectiveAbilityStats).
/// </summary>
public struct EffectiveCharacterStats : IComponentData
{
public float MoveSpeed;
public float TurnRateRadiansPerSec;
public float MaxHealth;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1d8413eebbeda4761b33430128e7a437
@@ -0,0 +1,20 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Hit points for any damageable ghost (players, training dummies). Server-authoritative:
/// only server systems write <see cref="Current"/>; clients receive it via the [GhostField]
/// for display and prediction reconciliation. <see cref="Max"/> is baked identically on both
/// worlds and is not replicated. Added by PlayerBaker / TrainingDummyBaker.
/// </summary>
public struct Health : IComponentData
{
/// <summary>Current hit points. Replicated for display and reconciles the predicted value against the server's authoritative state.</summary>
[GhostField] public float Current;
/// <summary>Maximum hit points. Baked identically on client and server; not replicated.</summary>
public float Max;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dbe438fe95346418f9ae1908c9288a75
@@ -0,0 +1,15 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Hit-test radius (world units) for a damageable entity. Baked identically on client and server
/// from authoring so projectile collision is deterministic; consumed server-side by
/// ProjectileDamageSystem to decide whether a projectile struck this entity. Not replicated.
/// </summary>
public struct HitRadius : IComponentData
{
/// <summary>Collision radius in world units; the projectile hit test compares planar (XZ) distance against this.</summary>
public float Value;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: df72a4dfa71044e5683932cb89ec912a
@@ -0,0 +1,39 @@
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Ghost component on the projectile prefab. Drives predicted+server movement and carries the
/// classification key used to reconcile a client's predicted-spawned projectile with the server's
/// authoritative ghost. <see cref="Direction"/> is replicated so the server's auto-targeted aim
/// reconciles the client's raw-aim prediction; <see cref="SpawnId"/> is replicated so it lives in
/// snapshot history for the predicted-spawn classifier. Speed/Damage/Range are baked on the prefab
/// (identical both worlds → deterministic) and not replicated; DistanceTravelled is integrated
/// locally each tick and not replicated.
/// </summary>
public struct Projectile : IComponentData
{
/// <summary>Planar XZ travel direction (world XZ mapped to float2 x,y), normalized.</summary>
[GhostField(Quantization = 1000)] public float2 Direction;
/// <summary>
/// Classification key: (ownerNetId << 16) | absoluteFireCount. Replicated so it is present
/// in snapshot history; the client classifier matches this against its predicted spawn.
/// </summary>
[GhostField] public uint SpawnId;
/// <summary>Travel speed in units/second. Baked on the prefab; not replicated.</summary>
public float Speed;
/// <summary>Damage applied on hit. Baked on the prefab; not replicated.</summary>
public float Damage;
/// <summary>Max travel distance before the server expires the projectile. Baked on the prefab; not replicated.</summary>
public float Range;
/// <summary>Integrated distance travelled (predicted on client + authoritative on server). Not replicated.</summary>
public float DistanceTravelled;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 33b95996c22674decbe6d858e8e37a73
@@ -0,0 +1,42 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Predicted projectile integrator: advances every live <see cref="Projectile"/> along its
/// replicated planar (XZ) <see cref="Projectile.Direction"/> at its baked <see cref="Projectile.Speed"/>,
/// accumulating <see cref="Projectile.DistanceTravelled"/>. Runs inside the prediction loop on the
/// owning client (re-simulated on rollback) and once per tick on the server, after
/// <see cref="AbilityFireSystem"/> has spawned this tick's shots; filtered to <see cref="Simulate"/>
/// so only predicted ghosts move. Deterministic by construction: uses <c>SystemAPI.Time.DeltaTime</c>
/// (the fixed tick step) only — no wall-clock, no <c>System.Random</c>. Pure motion: range expiry and
/// destruction are server-authoritative in <c>ProjectileDamageSystem</c>, so this system never
/// performs structural changes and is fully idempotent across rollback re-simulation.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(AbilityFireSystem))]
[BurstCompile]
public partial struct ProjectileMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime;
foreach (var (transform, projectile) in
SystemAPI.Query<RefRW<LocalTransform>, RefRW<Projectile>>()
.WithAll<Simulate>())
{
float step = projectile.ValueRO.Speed * dt;
float3 dir = new float3(projectile.ValueRO.Direction.x, 0f, projectile.ValueRO.Direction.y);
transform.ValueRW.Position += dir * step;
projectile.ValueRW.DistanceTravelled += step;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b7e91cb40d3af4ff2b260589a3b19b31
@@ -0,0 +1,16 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked singleton holding the entity reference to the projectile ghost prefab. Present in both
/// client and server worlds (the prefab is a predicted ghost), so AbilityFireSystem can instantiate
/// it deterministically during prediction on either side. Authored once via ProjectileSpawnerAuthoring;
/// AbilityFireSystem reads the prefab's baked Projectile component for Speed/Damage/Range. Not replicated.
/// </summary>
public struct ProjectileSpawner : IComponentData
{
/// <summary>The projectile ghost prefab to instantiate when a player fires.</summary>
public Entity Prefab;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 49212356bd330423ca66ed65e44f90f4
@@ -0,0 +1,43 @@
namespace ProjectM.Simulation
{
/// <summary>Stable, index-independent key for an authored ability definition in the AbilityDatabase blob.</summary>
public enum AbilityId : byte
{
None = 0,
Primary = 1,
FastLight = 2,
SlowHeavy = 3,
}
/// <summary>Stable key for an authored character-stats definition in the AbilityDatabase blob.</summary>
public enum CharacterId : byte
{
None = 0,
Default = 1,
}
/// <summary>
/// Which base stat a <see cref="StatModifier"/> targets. Replicated as a raw byte on the modifier
/// buffer to keep the generated ghost serializer trivial; mapped back to this enum only in StatMath.
/// </summary>
public enum StatTarget : byte
{
Damage = 0,
CooldownTicks = 1,
Range = 2,
ProjectileSpeed = 3,
AutoTargetRange = 4,
AutoTargetConeRadians = 5,
MoveSpeed = 6,
TurnRate = 7,
MaxHealth = 8,
}
/// <summary>How a <see cref="StatModifier"/> combines into the effective stat.</summary>
public enum ModOp : byte
{
Flat = 0, // additive: + Value
PercentAdd = 1, // additive percent, pooled into (1 + sum Value)
PercentMult = 2, // multiplicative percent, product of (1 + Value)
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b103cd0b266a4776bcd47c2f12b1d7d
@@ -0,0 +1,42 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic folding of a StatModifier set into an effective value for one StatTarget:
/// effective = (base + sum flat) * (1 + sum percentAdd) * product(1 + percentMult).
/// Order-independent within each op class, Burst-friendly, and unit-tested like AutoTarget.Resolve.
/// Returns the raw fold; consumers clamp domain bounds (e.g. cooldown >= 1 tick).
/// </summary>
public static class StatMath
{
public static float Apply(float baseValue, StatTarget target, in DynamicBuffer<StatModifier> mods)
{
return Apply(baseValue, target, mods.AsNativeArray());
}
public static float Apply(float baseValue, StatTarget target, in NativeArray<StatModifier> mods)
{
float flat = 0f;
float percentAdd = 0f;
float percentMult = 1f;
byte t = (byte)target;
for (int i = 0; i < mods.Length; i++)
{
var m = mods[i];
if (m.Target != t)
continue;
switch ((ModOp)m.Op)
{
case ModOp.Flat: flat += m.Value; break;
case ModOp.PercentAdd: percentAdd += m.Value; break;
case ModOp.PercentMult: percentMult *= 1f + m.Value; break;
}
}
return (baseValue + flat) * (1f + percentAdd) * percentMult;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 49228160eb5a44dfda64b623389055d5
@@ -0,0 +1,34 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// One runtime stat modifier (from an upgrade, pickup, or buff) on a modifiable entity. The
/// per-entity DynamicBuffer of these is the server-authoritative source StatRecomputeSystem folds
/// into the effective-stat components, on both the server and the predicting client.
///
/// Replication: this is a GhostField buffer, so it is part of the ghost snapshot and is restored on
/// rollback - that is what lets the predicting owner recompute identical effective stats.
/// OwnerSendType.All is explicit so the owning (predicting) client receives it; without it the
/// owner would recompute from an empty list and mispredict every tick. Target/Op replicate as raw
/// bytes (not the enums) to keep the generated serializer trivial and avoid the cross-assembly
/// enum-codegen hazard that already de-Bursted ProjectileClassificationSystem.
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
[InternalBufferCapacity(8)]
public struct StatModifier : IBufferElementData
{
/// <summary>The <see cref="StatTarget"/> this modifier applies to (stored as a byte).</summary>
[GhostField] public byte Target;
/// <summary>The <see cref="ModOp"/> combine operation (stored as a byte).</summary>
[GhostField] public byte Op;
/// <summary>Magnitude: a flat amount, or a fractional percent (0.1 = +10%).</summary>
[GhostField(Quantization = 1000)] public float Value;
/// <summary>Provenance tag (e.g. pickup SpawnId / debug sentinel). Reserved for future ClearByType / timed buffs.</summary>
[GhostField] public uint SourceId;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f44086f6603c74fe6909b731cf99ff82
@@ -0,0 +1,68 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Folds each modifiable entity's authored base stats (from the AbilityDatabase blob, keyed by
/// AbilityRef / CharacterStatsRef) with its replicated StatModifier buffer into the
/// EffectiveAbilityStats / EffectiveCharacterStats components - every predicted tick, on both worlds.
///
/// Runs at the head of the predicted group (UpdateBefore PlayerAimSystem and PlayerMoveSystem;
/// AbilityFireSystem runs after PlayerAimSystem, so it sees fresh values too). Recompute is
/// unconditional every tick: it is a pure function of (blob base + replicated buffer), both of which
/// are restored on rollback, so predicted and server results always agree. A dirty-flag / change
/// filter would be WRONG here - the Effective* components are NOT in the ghost snapshot and would go
/// stale across reprediction.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateBefore(typeof(PlayerAimSystem))]
[UpdateBefore(typeof(PlayerMoveSystem))]
[BurstCompile]
public partial struct StatRecomputeSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<AbilityDatabase>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var database = SystemAPI.GetSingleton<AbilityDatabase>();
ref var db = ref database.Value.Value;
foreach (var (abilityRef, charRef, mods, effAbility, effChar) in
SystemAPI.Query<RefRO<AbilityRef>, RefRO<CharacterStatsRef>, DynamicBuffer<StatModifier>,
RefRW<EffectiveAbilityStats>, RefRW<EffectiveCharacterStats>>()
.WithAll<Simulate>())
{
if (db.TryGetAbility(abilityRef.ValueRO.Id, out var a))
{
effAbility.ValueRW = new EffectiveAbilityStats
{
Damage = StatMath.Apply(a.Damage, StatTarget.Damage, mods),
ProjectileSpeed = StatMath.Apply(a.ProjectileSpeed, StatTarget.ProjectileSpeed, mods),
Range = StatMath.Apply(a.Range, StatTarget.Range, mods),
AutoTargetRange = StatMath.Apply(a.AutoTargetRange, StatTarget.AutoTargetRange, mods),
AutoTargetConeRadians = StatMath.Apply(a.AutoTargetConeRadians, StatTarget.AutoTargetConeRadians, mods),
CooldownTicks = (int)math.round(StatMath.Apply(a.CooldownTicks, StatTarget.CooldownTicks, mods)),
};
}
if (db.TryGetCharacter(charRef.ValueRO.Id, out var c))
{
effChar.ValueRW = new EffectiveCharacterStats
{
MoveSpeed = StatMath.Apply(c.MoveSpeed, StatTarget.MoveSpeed, mods),
TurnRateRadiansPerSec = StatMath.Apply(c.TurnRateRadiansPerSec, StatTarget.TurnRate, mods),
MaxHealth = StatMath.Apply(c.MaxHealth, StatTarget.MaxHealth, mods),
};
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4aa8d74c3871e4fdf9b9fc45a0193130
@@ -0,0 +1,26 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked singleton describing the training-dummy field to spawn at world start. Consumed once by
/// the server-only TrainingDummySpawnSystem, which instantiates <see cref="Count"/> dummies from
/// <see cref="Prefab"/> and then destroys this singleton so the spawn runs exactly once. Not
/// replicated — dummies are spawned authoritatively on the server and reach clients as ghosts.
/// </summary>
public struct TrainingDummySpawner : IComponentData
{
/// <summary>Baked entity prefab to instantiate for each dummy.</summary>
public Entity Prefab;
/// <summary>Number of dummies to spawn.</summary>
public int Count;
/// <summary>World-unit gap between consecutive dummies along the spawn line (X axis).</summary>
public float Spacing;
/// <summary>World-space position of the first dummy; subsequent dummies offset by <see cref="Spacing"/>.</summary>
public float3 Origin;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a8f27601fb20640718aaed2c48d1d016
@@ -0,0 +1,11 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Zero-size marker identifying a training-dummy enemy. Server auto-target collects
/// <see cref="TrainingDummyTag"/> entities as candidates, and the damage/death path destroys
/// a dummy when its <c>Health.Current</c> reaches zero. Added by TrainingDummyBaker.
/// </summary>
public struct TrainingDummyTag : IComponentData { }
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ebe9114e40db748669d89ef861415bac
@@ -0,0 +1,17 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// A world pickup that, on player overlap, grants one StatModifier (server-authoritative) and is then
/// destroyed. Mirrors a single StatModifier payload (Target/Op as bytes). The pickup is an
/// interpolated ghost so clients see and despawn it; the grant is applied by UpgradePickupSystem.
/// </summary>
public struct UpgradePickup : IComponentData
{
public byte Target; // StatTarget
public byte Op; // ModOp
public float Value;
public uint SourceId;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a0d34f4d0b12241c7b0f0b1d002142f3
@@ -0,0 +1,17 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Singleton describing how many upgrade pickups to spawn and where (mirrors TrainingDummySpawner).
/// Consumed once by the server's UpgradePickupSpawnSystem.
/// </summary>
public struct UpgradePickupSpawner : IComponentData
{
public Entity Prefab;
public float3 Origin;
public int Count;
public float Spacing;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 059368b62be834614ae3a110a51d42da