Skip to content

Instantly share code, notes, and snippets.

@FREEZX
Last active October 21, 2024 11:00
Show Gist options
  • Select an option

  • Save FREEZX/7b4eb1f5719f4ba0992e0a3bab36d3c7 to your computer and use it in GitHub Desktop.

Select an option

Save FREEZX/7b4eb1f5719f4ba0992e0a3bab36d3c7 to your computer and use it in GitHub Desktop.
FishNet Stopwatch that tries to account for client-server drift and only moves forward
#if FISHNET_STABLE_MODE
using FishNet.Documenting;
using FishNet.Managing.Timing;
using FishNet.Object.Synchronizing.Internal;
using FishNet.Serializing;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace FishNet.Object.Synchronizing
{
public class AdvancedSyncStopwatch : SyncBase, ICustomSync
{
#region Type.
/// <summary>
/// Information about how the Stopwatch has changed.
/// </summary>
private struct ChangeData
{
public readonly SyncStopwatchOperation Operation;
public readonly float Previous;
public readonly PreciseTick TickStart;
public readonly float DriftTime;
public ChangeData(SyncStopwatchOperation operation, float previous, PreciseTick tickStart, float driftTime)
{
Operation = operation;
Previous = previous;
TickStart = tickStart;
DriftTime = driftTime;
}
}
#endregion
#region Public.
/// <summary>
/// Delegate signature for when the Stopwatch operation occurs.
/// </summary>
/// <param name="op">Operation which was performed.</param>
/// <param name="prev">Previous value of the Stopwatch. This will be -1f is the value is not available.</param>
/// <param name="asServer">True if occurring on server.</param>
public delegate void SyncTypeChanged(SyncStopwatchOperation op, float prev, PreciseTick tickStart, float driftTime, bool asServer);
/// <summary>
/// Called when a Stopwatch operation occurs.
/// </summary>
public event SyncTypeChanged OnChange;
/// <summary>
/// How much time has passed since the Stopwatch started.
/// </summary>
public float ElapsedStart { get; private set; } = -1f;
/// <summary>
/// The server tick on which the stopwatch started, to sync in case of latency
/// </summary>
public PreciseTick TickStart { get; private set; }
/// <summary>
/// The offset time that accounts for pause or changed timescale
/// </summary>
public float DriftTime { get; private set; }
/// <summary>
/// True if the SyncStopwatch is currently paused. Calls to Update(float) will be ignored when paused.
/// </summary>
public bool Paused { get; private set; }
/// <summary>
/// Gets the real elapsed time by calculating the difference on the ticks to account for latency.
/// </summary>
public double Elapsed
{
get
{
if (ElapsedStart == -1f || base.NetworkManager == null || base.NetworkManager.TimeManager == null)
return -1f;
return elapsed;
}
}
#endregion
#region Private.
protected double lastTimePassed;
protected double lastElapsed;
protected double elapsed;
/// <summary>
/// Changed data which will be sent next tick.
/// </summary>
private List<ChangeData> _changed = new List<ChangeData>();
/// <summary>
/// Server OnChange events waiting for start callbacks.
/// </summary>
private List<ChangeData> _serverOnChanges = new List<ChangeData>();
/// <summary>
/// Client OnChange events waiting for start callbacks.
/// </summary>
private List<ChangeData> _clientOnChanges = new List<ChangeData>();
#endregion
#region Constructors
public AdvancedSyncStopwatch(SyncTypeSettings settings = new SyncTypeSettings()) : base(settings) { }
#endregion
/// <summary>
/// Called when the SyncType has been registered, but not yet initialized over the network.
/// </summary>
protected override void Initialized()
{
base.Initialized();
//Initialize collections if needed. OdinInspector can cause them to become deinitialized.
#if ODIN_INSPECTOR
if (_changed == null) _changed = new();
if (_serverOnChanges == null) _serverOnChanges = new();
if (_clientOnChanges == null) _clientOnChanges = new();
#endif
}
/// <summary>
/// Starts a Stopwatch. If called when a Stopwatch is already active then StopStopwatch will automatically be sent.
/// </summary>
/// <param name="remaining">Time in which the Stopwatch should start with.</param>
/// <param name="sendElapsedOnStop">True to include remaining time when automatically sending StopStopwatch.</param>
public void StartStopwatch(bool sendElapsedOnStop = true)
{
if (!base.CanNetworkSetValues(true))
return;
if (ElapsedStart > 0f)
StopStopwatch(sendElapsedOnStop);
lastTimePassed = 0;
ElapsedStart = 0f;
TickStart = base.NetworkManager.TimeManager.GetPreciseTick(TickType.Tick);
AddOperation(SyncStopwatchOperation.Start, 0f, base.NetworkManager.TimeManager.GetPreciseTick(TickType.Tick), DriftTime);
}
/// <summary>
/// Pauses the Stopwatch. Calling while already paused will be result in no action.
/// </summary>
/// <param name="sendElapsed">True to send Remaining with this operation.</param>
public void PauseStopwatch(bool sendElapsed = false)
{
if (ElapsedStart < 0f)
return;
if (Paused)
return;
if (!base.CanNetworkSetValues(true))
return;
Paused = true;
float prev;
SyncStopwatchOperation op;
if (sendElapsed)
{
prev = ElapsedStart;
op = SyncStopwatchOperation.PauseUpdated;
}
else
{
prev = -1f;
op = SyncStopwatchOperation.Pause;
}
AddOperation(op, prev, TickStart, DriftTime);
}
/// <summary>
/// Unpauses the Stopwatch. Calling while already unpaused will be result in no action.
/// </summary>
public void UnpauseStopwatch()
{
if (ElapsedStart < 0f)
return;
if (!Paused)
return;
if (!base.CanNetworkSetValues(true))
return;
Paused = false;
AddOperation(SyncStopwatchOperation.Unpause, -1f, TickStart, DriftTime);
}
/// <summary>
/// Stops and resets the Stopwatch.
/// </summary>
public void StopStopwatch(bool sendElapsed = false)
{
if (ElapsedStart < 0f)
return;
if (!base.CanNetworkSetValues(true))
return;
float prev = (sendElapsed) ? -1f : ElapsedStart;
StopStopwatch_Internal(true);
SyncStopwatchOperation op = (sendElapsed) ? SyncStopwatchOperation.StopUpdated : SyncStopwatchOperation.Stop;
AddOperation(op, prev, new PreciseTick(0, 0), DriftTime);
}
/// <summary>
/// Adds an operation to synchronize.
/// </summary>
private void AddOperation(SyncStopwatchOperation operation, float prev, PreciseTick tickStart, float driftTime)
{
if (!base.IsInitialized)
return;
bool asServerInvoke = !base.IsNetworkInitialized || base.NetworkBehaviour.IsServerStarted;
if (asServerInvoke)
{
if (base.Dirty())
{
ChangeData change = new ChangeData(operation, prev, tickStart, driftTime);
_changed.Add(change);
}
}
OnChange?.Invoke(operation, prev, tickStart, driftTime, asServerInvoke);
}
/// <summary>
/// Writes all changed values.
/// </summary>
///<param name="resetSyncTick">True to set the next time data may sync.</param>
internal protected override void WriteDelta(PooledWriter writer, bool resetSyncTick = true)
{
base.WriteDelta(writer, resetSyncTick);
writer.WriteInt32(_changed.Count);
for (int i = 0; i < _changed.Count; i++)
{
ChangeData change = _changed[i];
writer.WriteUInt8Unpacked((byte)change.Operation);
if (change.Operation == SyncStopwatchOperation.Start)
WriteStartStopwatch(writer, 0f, TickStart, DriftTime, false);
//Pause and unpause updated need current value written.
//Updated stop also writes current value.
else if (change.Operation == SyncStopwatchOperation.Pause || change.Operation == SyncStopwatchOperation.Unpause)
{
writer.WriteSingle(DriftTime);
}
else if (change.Operation == SyncStopwatchOperation.PauseUpdated || change.Operation == SyncStopwatchOperation.StopUpdated)
{
writer.WriteSingle(change.Previous);
writer.WriteSingle(change.DriftTime);
}
}
_changed.Clear();
}
/// <summary>
/// Writes all values.
/// </summary>
internal protected override void WriteFull(PooledWriter writer)
{
//Only write full if a Stopwatch is running.
if (ElapsedStart < 0f)
return;
base.WriteDelta(writer, false);
//There will be 1 or 2 entries. If paused 2, if not 1.
int entries = (Paused) ? 2 : 1;
writer.WriteInt32(entries);
//And the operations.
WriteStartStopwatch(writer, ElapsedStart, TickStart, DriftTime, true);
if (Paused)
{
writer.WriteUInt8Unpacked((byte)SyncStopwatchOperation.Pause);
writer.WriteSingle(DriftTime);
}
}
/// <summary>
/// Writers a start with elapsed time.
/// </summary>
/// <param name="elapsed"></param>
private void WriteStartStopwatch(Writer w, float elapsed, PreciseTick tickStart, float driftTime, bool includeOperationByte)
{
if (includeOperationByte)
w.WriteUInt8Unpacked((byte)SyncStopwatchOperation.Start);
w.WriteSingle(elapsed);
w.WritePreciseTick(tickStart);
w.WriteSingle(driftTime);
}
/// <summary>
/// Reads and sets the current values for server or client.
/// </summary>
[APIExclude]
internal protected override void Read(PooledReader reader, bool asServer)
{
base.SetReadArguments(reader, asServer, out bool newChangeId, out bool asClientHost, out bool canModifyValues);
int changes = reader.ReadInt32();
for (int i = 0; i < changes; i++)
{
SyncStopwatchOperation op = (SyncStopwatchOperation)reader.ReadUInt8Unpacked();
if (op == SyncStopwatchOperation.Start)
{
float elapsed = reader.ReadSingle();
PreciseTick tickStart = reader.ReadPreciseTick();
float driftTime = reader.ReadSingle();
lastTimePassed = elapsed;
if (canModifyValues)
{
ElapsedStart = elapsed;
TickStart = tickStart;
DriftTime = driftTime;
}
if (newChangeId)
InvokeOnChange(op, elapsed, tickStart, driftTime, asServer);
}
else if (op == SyncStopwatchOperation.Pause)
{
float driftTime = reader.ReadSingle();
if (canModifyValues)
{
Paused = true;
DriftTime = driftTime;
}
if (newChangeId)
InvokeOnChange(op, -1f, TickStart, driftTime, asServer);
}
else if (op == SyncStopwatchOperation.PauseUpdated)
{
float prev = reader.ReadSingle();
float driftTime = reader.ReadSingle();
if (canModifyValues)
{
Paused = true;
DriftTime = driftTime;
}
if (newChangeId)
InvokeOnChange(op, prev, TickStart, driftTime, asServer);
}
else if (op == SyncStopwatchOperation.Unpause)
{
float driftTime = reader.ReadSingle();
if (canModifyValues)
{
Paused = false;
DriftTime = driftTime;
}
if (newChangeId)
InvokeOnChange(op, -1f, TickStart, driftTime, asServer);
}
else if (op == SyncStopwatchOperation.Stop)
{
lastTimePassed = 0;
if (canModifyValues)
StopStopwatch_Internal(asServer);
if (newChangeId)
InvokeOnChange(op, -1f, new PreciseTick(0, 0), 0f, false);
}
else if (op == SyncStopwatchOperation.StopUpdated)
{
float prev = reader.ReadSingle();
float driftTime = reader.ReadSingle();
lastTimePassed = prev;
if (canModifyValues)
{
ElapsedStart = prev;
DriftTime = driftTime;
StopStopwatch_Internal(asServer);
}
if (newChangeId)
InvokeOnChange(op, prev, TickStart, driftTime, asServer);
}
}
if (newChangeId && changes > 0)
InvokeOnChange(SyncStopwatchOperation.Complete, -1f, new PreciseTick(0, 0), 0f, asServer);
}
/// <summary>
/// Stops the Stopwatch and resets.
/// </summary>
private void StopStopwatch_Internal(bool asServer)
{
Paused = false;
ElapsedStart = -1f;
}
/// <summary>
/// Invokes OnChanged callback.
/// </summary>
private void InvokeOnChange(SyncStopwatchOperation operation, float prev, PreciseTick tickStart, float driftTime, bool asServer)
{
if (asServer)
{
if (base.NetworkBehaviour.OnStartServerCalled)
OnChange?.Invoke(operation, prev, tickStart, driftTime, asServer);
else
_serverOnChanges.Add(new ChangeData(operation, prev, tickStart, driftTime));
}
else
{
if (base.NetworkBehaviour.OnStartClientCalled)
OnChange?.Invoke(operation, prev, tickStart, driftTime, asServer);
else
_clientOnChanges.Add(new ChangeData(operation, prev, tickStart, driftTime));
}
}
/// <summary>
/// Called after OnStartXXXX has occurred.
/// </summary>
/// <param name="asServer">True if OnStartServer was called, false if OnStartClient.</param>
internal protected override void OnStartCallback(bool asServer)
{
base.OnStartCallback(asServer);
List<ChangeData> collection = (asServer) ? _serverOnChanges : _clientOnChanges;
if (OnChange != null)
{
foreach (ChangeData item in collection)
OnChange.Invoke(item.Operation, item.Previous, item.TickStart, item.DriftTime, asServer);
}
collection.Clear();
}
/// <summary>
/// Adds delta from Remaining for server and client.
/// </summary>
/// <param name="delta">Value to remove from Remaining.</param>
public void Update(float delta)
{
//Not enabled.
if (ElapsedStart == -1f)
return;
if (Paused)
{
DriftTime += delta;
return;
}
var timeManager = base.NetworkManager.TimeManager;
float roundTrip = !base.NetworkManager.IsServerStarted ? timeManager.RoundTripTime / 2000f : 0f;
double timePassed = timeManager.TimePassed(TickStart) + timeManager.GetTickPercentAsDouble() * timeManager.TickDelta;
if(timePassed < lastTimePassed) {
timePassed = lastTimePassed;
}
elapsed = ElapsedStart + timePassed - roundTrip - DriftTime;
if(elapsed < lastElapsed) {
elapsed = lastElapsed;
}
lastTimePassed = timePassed;
if (UnityEngine.Time.timeScale != 1)
{
DriftTime += UnityEngine.Time.unscaledDeltaTime - delta;
}
}
/// <summary>
/// Return the serialized type.
/// </summary>
/// <returns></returns>
public object GetSerializedType() => null;
}
}
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment