I did not found that approach in a sample, but it seems quite appropriate when trying to figure out a solution for firing multiple bullets predictively. I can show how I implemented the approach while still other variations may be possible. Also there are some pitfalls to be handled here which mades the logic overall a bit more complicated, but I mention them all.
First the stated field has to be part of the ICommandData struct which is transferred from client to server. It should be sufficient to be a byte field except when extremly high fire rates are required like firing one or two hundred bullets per second. After reaching a value of 255, it overflows and resets to zero again, so it reuses bullet IDs. But because bullets are only short-lived, this should not be an issue but have to be kept in mind.
IssueingProjectileId
public struct UnitInput : ICommandData
{
// ...
/// <summary>
/// Gets or sets the projectile ID which are issued up to.
/// An issued projectile is directed to be fired imminently or may have been fired already.
/// </summary>
/// <remarks>
/// Examples:
/// When the <see cref="UnitAttackShooting.IssuedProjectileId" /> is 12 and the <see cref="IssuingProjectileId" /> is also
/// 12 then no shots should be fired.
/// When the <see cref="UnitAttackShooting.IssuedProjectileId" /> is 17 and the <see cref="IssuingProjectileId" /> is also
/// 22 then 5 shots should be fired.
/// The first first fired projectile has the ID 18 then, the second ID is 19 and so on.
/// </remarks>
public byte IssuingProjectileId;
/// <summary>
/// Gets or sets the currently used weapon firing mode.
/// </summary>
public WeaponFiringMode CurrentFiringMode;
}
When required to really have a short-lived unique ID of the bullet the bullet ID/projectile ID has to be combined with another ID corresponding to the shooter. In my project I combine the ID of the character/unit with it to get that short-lived unique ID.
Then an ECS component is required which will have two fields based on the stated field for counting already fired bullets and keep track of the issued ones. This component data is transferred from server to client, at least to clients which are predicting shots.
UnitAttacking and UnitPredictedAttacking
/// <summary>
/// Contains attacking relevant data.
/// </summary>
[GhostComponent]
public struct UnitAttacking : IComponentData
{
/// <summary>
/// Gets or sets the shooting part of the attacking.
/// </summary>
[GhostField(Composite = true)]
public UnitAttackShooting Shooting;
}
/// <summary>
/// Contains predicted attacking relevant data.
/// </summary>
[GhostComponent(PrefabType = GhostPrefabType.PredictedClient, OwnerSendType = SendToOwnerType.None)]
public struct UnitPredictedAttacking : IComponentData
{
/// <summary>
/// Gets or sets the shooting part of the attacking.
/// </summary>
public UnitAttackShooting Shooting;
}
FiredProjectileId and IssuedProjectileId (including calculation methods)
/// <summary>
/// Shooting relevant data about an attack.
/// </summary>
/// <remarks>The struct is used as part of an ECS component.</remarks>
public struct UnitAttackShooting
{
// ...
/// <summary>
/// Gets or sets the the projectile which has effectively has been fired last.
/// </summary>
[GhostField]
public byte FiredProjectileId;
/// <summary>
/// Gets or sets the ID of the last issued projectile.
/// An issued projectile is directed to be fired imminently or may have been fired already.
/// </summary>
[GhostField]
public byte IssuedProjectileId;
/// <summary>
/// Gets or sets the tick index at which the next projectile can be shot.
/// </summary>
[GhostField]
public uint NextProjectileReadyTick;
/// <summary>
/// Gets or sets the firing mode used for the ongoing attack.
/// When no attack is ongoing the mode has no effect and can freely be changed.
/// </summary>
[GhostField]
public WeaponFiringMode FiringMode;
/// <summary>
/// Gets whether a shooting is currently executed.
/// </summary>
public bool IsShooting
{
get => this.FiredProjectileId != this.IssuedProjectileId;
}
/// <summary>
/// Calculates the difference between the current <see cref="FiredProjectileId"/> and the current <see cref="IssuedProjectileId"/>.
/// </summary>
/// <returns>A value between -127 and 128 showing whether projectiles are added or removed.</returns>
/// <remarks>
/// Normally the value is zero or negative, because more projectiles are issued than fired.
/// </remarks>
public int CalculateDifferenceFiredToIssuedProjectileId()
{
return this.CalculateDifferenceToIssuedProjectileId(this.FiredProjectileId);
}
/// <summary>
/// Calculates the difference between the specified projectile ID and the current <see cref="IssuedProjectileId"/>.
/// </summary>
/// <param name="projectileId">The projectile ID to calculate the difference to.</param>
/// <returns>A value between -127 and 128 showing whether projectiles are added or removed.</returns>
public int CalculateDifferenceToIssuedProjectileId(int projectileId)
{
// The byte value range is split in half to determine either increase or decrement of the projectile count
int lowerThreshold;
int upperThreshold;
unchecked
{
lowerThreshold = this.IssuedProjectileId - 128;
upperThreshold = this.IssuedProjectileId + 128;
}
if (projectileId > upperThreshold)
{
return -math.abs((this.IssuedProjectileId + 256) - projectileId);
}
else if (projectileId <= lowerThreshold) // <= to ensure that the range is always -127 to 128 (same behavior as > upperThreshold)
{
return math.abs((projectileId + 256) - this.IssuedProjectileId);
}
else
{
return projectileId - this.IssuedProjectileId;
}
}
/// <summary>
/// Returns whether a new projectile can be fired.
/// </summary>
/// <param name="serverTickIndex">Current server tick index.</param>
/// <returns>True when a new projectile can be fired.</returns>
public bool IsProjectileReady(uint serverTickIndex)
{
return this.FiredProjectileId == this.IssuedProjectileId
&& serverTickIndex >= this.NextProjectileReadyTick;
}
/// <summary>
/// Increments the fired projectile ID.
/// </summary>
/// <returns>The new fired projectile ID.</returns>
public byte IncrementFiredProjectileId()
{
unchecked
{
this.FiredProjectileId = (byte)(this.FiredProjectileId + 1);
return this.FiredProjectileId;
}
}
/// <summary>
/// Increments the issued projectile ID.
/// </summary>
/// <param name="increment">Value to increment.</param>
public void IncrementIssuedProjectileId(int increment = 1)
{
this.IssuedProjectileId = this.DetermineIncrementedIssuedProjectileId(increment);
}
/// <summary>
/// Determines the incremented projectile ID using the specified increment value.
/// </summary>
/// <param name="increment">Value to increment.</param>
/// <returns>
/// Calculated projectile ID.
/// </returns>
public byte DetermineIncrementedIssuedProjectileId(int increment = 1)
{
unchecked
{
return (byte)(this.IssuedProjectileId + increment);
}
}
}
So, that was the data definition part. Now the data has to be read and write accordingly. Somewhere in the input system, the “IssuingProjectileId” has to be increased. There may be some ready to fire conditions before, but when shooting the issuing bullet count has to be increased. It may still overflow, but the determination/calculation methods above take care about this.
Issuing projectiles in the Input System
byte newIssuingProjectileId;
UnitAttackShooting currentUnitAttackShooting = ...;
// ...
newIssuingProjectileId = currentUnitAttackShooting.DetermineIncrementedIssuedProjectileId(effectiveAddedProjectileCount);
It is possible to add multiple bullets at ones OR add single bullet after single bullet, depending on player inputs and game logic. It is is important that the “newIssuingProjectileId” which is send as ICommandData is consistently tracked and send. This means when the ID is increased it has to send the same increased value frame after frame even when no more bullets are fired. I hold the “newIssuingProjectileId” in a separate field. But because of this it may in theory happen that the value is getting out of sync with the “IssuedProjectileId” coming from the server. Some logic for adjustment of the “newIssuingProjectileId” on the client-side may be required then.
Some important note about the “currentUnitAttackShooting” variable and where the value has to came from. Because the GhostInputSystemGroup run before the Prediction Loop, the value may have been reset on the client due client-side rollback when new snapshot from server arrived. Then the “IssuedProjectileId” is NOT the predicted one which will cause wrong incrementings to the “IssuingProjectileId”. I circumvented that problem with having two variations of the shooting component “UnitAttacking” and “UnitPredictedAttacking” like already shown above.
The field “Shooting” of the “UnitPredictedAttacking” is copied from the “UnitAttacking” in a specific ECS system on the client-side running right after the Prediction Loop, so the client can access this predicted values in the Input System even when data is rollbacked.
So now the bullet ID/projectile ID data can be processed in the Prediction Loop in a ECS system for handling the spawn of the bullet entities. This is straight forward and looks like this:
Shooting System
/// <summary>
/// System for spawning and processing shots.
/// </summary>
[BurstCompile]
[UpdateInGroup(typeof(PredictedFixedStepSimulationSystemGroup))]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ClientSimulation)]
public unsafe partial struct ShootingProcessorSystem : ISystem
{
// ...
/// <inheritdoc />
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!NetcodeUtilities.TryGetNetworkTime(SystemAPI.GetSingleton<NetworkTime>(), out var networkTime))
{
return;
}
// ...
this._processShotsJob.NetworkTime = networkTime;
state.Dependency = this._processShotsJob.ScheduleByRef(this._processShotsQuery, state.Dependency);
}
[BurstCompile]
private partial struct ProcessShotsJob : IJobEntity
{
// ...
[ReadOnly]
public NetworkTime NetworkTime;
[BurstCompile]
public void Execute(ShootingAspect shootingAspect)
{
// ...
ref var weaponDefinition = ref this.WeaponDefinitionPoolReference.Reference.Value.Definitions[shootingAspect.UnitWieldedWeaponRef.ValueRO.Fixed.WeaponClassId];
this.InitiateAndContinueAttacks(ref shootingAspect, ref weaponDefinition);
this.ExecuteAttacks(ref shootingAspect, ref weaponDefinition);
}
[BurstCompile]
private void InitiateAndContinueAttacks(
ref ShootingAspect shootingAspect, ref WeaponDefinition weaponDefinition)
{
var serverTickIndex = this.NetworkTime.ServerTick.TickIndexForValidTick;
ref UnitAttackShooting unitAttackShooting = ref shootingAspect.UnitAttackingRef.ValueRW.Shooting;
ref var unitWieldedWeapon = ref shootingAspect.UnitWieldedWeaponRef.ValueRW;
ref UnitInput weaponInput = ref shootingAspect.UnitWieldedWeaponRef.ValueRW.Input;
if (unitAttackShooting.IssuedProjectileId != weaponInput.IssuingProjectileId)
{
if (weaponDefinition.AvailableFiringModes.Has(weaponInput.CurrentFiringMode))
{
// Validate data before applying
if (unitAttackShooting.IsProjectileReady(serverTickIndex)
&& unitWieldedWeapon.IsAttackReady(serverTickIndex)
&& shootingAspect.UnitMotionRef.ValueRO.CanAttack)
{
var minimumBurstProjectileCount = weaponInput.CurrentFiringMode.DetermineMinimumBurstProjectileCount();
var maximumRemainingProjectileCount = (int)unitWieldedWeapon.Dynamic.LoadedRemainingProjectileCount;
var projectileDifferenceCount = unitAttackShooting.CalculateDifferenceToIssuedProjectileId(weaponInput.IssuingProjectileId);
if (maximumRemainingProjectileCount != projectileDifferenceCount
&& minimumBurstProjectileCount != projectileDifferenceCount)
{
if (this.NetworkTime.IsFirstTimeFullyPredictingTick)
{
Log.To(this.JobLogger).Warning(
"[T={ServerTickValue}/{WorldName}] Invalid projectile count to fire weapon '{WeaponName}' ({WeaponClassId}) for unit with ID {UnitId} with mode '{FiringMode}', because {IssuedProjectileCount} projectiles are issued while {RequiredProjectileCount} projectiles are required for the mode. Projectile count adjusted and continued.",
serverTickIndex, this.WorldName, weaponDefinition.Name, weaponDefinition.WeaponClassId, shootingAspect.PureUnitRef.ValueRO.UnitId,
WeaponFiringModeBurst.Texts[(int)weaponInput.CurrentFiringMode], projectileDifferenceCount, minimumBurstProjectileCount);
}
projectileDifferenceCount = minimumBurstProjectileCount;
}
var effectiveBurstProjectileCount = (byte)math.min(projectileDifferenceCount, maximumRemainingProjectileCount);
unitAttackShooting.FiringMode = weaponInput.CurrentFiringMode;
unitAttackShooting.IncrementIssuedProjectileId(effectiveBurstProjectileCount);
}
else if (unitAttackShooting.IsShooting && unitAttackShooting.FiringMode == WeaponFiringMode.Automatic)
{
var projectileDifferenceCount = unitAttackShooting.CalculateDifferenceToIssuedProjectileId(weaponInput.IssuingProjectileId);
// Only on automatic fire the issued projectile count may be increased during attack
if (projectileDifferenceCount > 0)
{
unitAttackShooting.IssuedProjectileId = weaponInput.IssuingProjectileId;
}
}
}
else
{
if (this.NetworkTime.IsFirstTimeFullyPredictingTick)
{
Log.To(this.JobLogger).Warning(
"[T={ServerTickValue}/{WorldName}] Cannot fire weapon '{WeaponName}' ({WeaponClassId}) for unit with ID {UnitId} with firing mode '{FiringMode}', because mode is not available.",
serverTickIndex, this.WorldName, weaponDefinition.Name, weaponDefinition.WeaponClassId, shootingAspect.PureUnitRef.ValueRO.UnitId,
WeaponFiringModeBurst.Texts[(int)weaponInput.CurrentFiringMode]);
}
}
}
}
[BurstCompile]
private void ExecuteAttacks(
ref ShootingAspect shootingAspect, ref WeaponDefinition weaponDefinition)
{
ref UnitAttackShooting unitAttackShooting = ref shootingAspect.UnitAttackingRef.ValueRW.Shooting;
ref var wieldedWeaponDynamicBlock = ref shootingAspect.UnitWieldedWeaponRef.ValueRW.Dynamic;
if (unitAttackShooting.IsShooting)
{
var serverTickIndex = this.NetworkTime.ServerTick.TickIndexForValidTick;
// Check for out of ammunition
if (wieldedWeaponDynamicBlock.LoadedRemainingProjectileCount == 0)
{
// Reset the issued projectiles to the fired once, this ends the burst
unitAttackShooting.IssuedProjectileId = unitAttackShooting.FiredProjectileId;
// Also activate cooldown
wieldedWeaponDynamicBlock.NextAttackReadyTick = serverTickIndex + weaponDefinition.ImmediateGunDefinition.BurstCooldownTicks;
}
else if (shootingAspect.UnitMotionRef.ValueRO.CanAttack && serverTickIndex >= unitAttackShooting.NextProjectileReadyTick)
{
var projectileId = unitAttackShooting.IncrementFiredProjectileId();
// Checks whether this predicted server tick is predicted for the very first time (on server always true, because every tick is processed only once)
// Spawning attack ghost entities and calculating result is only required to be executed once per unique server tick (spawning new ghost entities!)
if (this.NetworkTime.IsFirstTimeFullyPredictingTick)
{
// Spawn the shot entity
var shotEntity = this.SpawnShot(ref shootingAspect, serverTickIndex, projectileId, out var pureImmediateShot);
// Do additional handling of the shot like processing impact and so on
// ...
}
wieldedWeaponDynamicBlock.LoadedRemainingProjectileCount--;
unitAttackShooting.NextProjectileReadyTick = serverTickIndex + weaponDefinition.ImmediateGunDefinition.ProjectileCooldownTicks;
// When the last shot has been fired activate cooldown
if (!unitAttackShooting.IsShooting)
{
// This appoints that the attack is on cooldown (but also used for weapon switch and reload)
wieldedWeaponDynamicBlock.NextAttackReadyTick = serverTickIndex + weaponDefinition.ImmediateGunDefinition.BurstCooldownTicks;
}
}
}
}
}
}