NetCooldown.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
/// <summary>
/// Shared per-player cooldown system with host-authoritative timing and client-side countdown.
/// No network round-trip needed to query remaining time on the client.
/// </summary>
/// <remarks>
/// <para><b>Host:</b> <c>NetCooldown.Start( conn, "dash", 5f );</c></para>
/// <para><b>Host check:</b> <c>if ( NetCooldown.IsActive( conn, "dash" ) ) return;</c></para>
/// <para><b>Client UI:</b> <c>float remaining = NetCooldown.Remaining( "dash" );</c></para>
/// </remarks>
[Title( "NetKit - Cooldown System" )]
public sealed class NetCooldown : GameObjectSystem<NetCooldown>
{
private static readonly Dictionary<(ulong steamId, string key), (RealTimeSince started, float duration)> _host = new();
private static readonly Dictionary<string, (RealTimeSince started, float duration)> _client = new();
/// <summary>Fires on the client when a cooldown starts.</summary>
public static event Action<string, float> OnStarted;
/// <summary>Fires on the client when a cooldown expires.</summary>
public static event Action<string> OnExpired;
private RealTimeSince _lastCleanup = 0;
public NetCooldown( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, 0, Tick, "NetCooldown.Tick" );
}
/// <summary>Start a cooldown. Host-only. Automatically pushed to the owning client.</summary>
public static void Start( Connection connection, string key, float duration )
{
if ( !Networking.IsHost || connection == null ) return;
_host[(connection.SteamId, key)] = (0, duration);
using ( Rpc.FilterInclude( connection ) )
RpcReceive( key, duration );
}
/// <summary>Host: is this cooldown still active?</summary>
public static bool IsActive( Connection connection, string key )
{
if ( connection == null ) return false;
return _host.TryGetValue( (connection.SteamId, key), out var cd ) && cd.started < cd.duration;
}
/// <summary>Host: remaining seconds.</summary>
public static float HostRemaining( Connection connection, string key )
{
if ( connection == null ) return 0f;
return _host.TryGetValue( (connection.SteamId, key), out var cd ) ? MathF.Max( 0f, cd.duration - cd.started ) : 0f;
}
/// <summary>Client: remaining seconds (local countdown, no network round-trip).</summary>
public static float Remaining( string key )
{
return _client.TryGetValue( key, out var cd ) ? MathF.Max( 0f, cd.duration - cd.started ) : 0f;
}
/// <summary>Client: true if cooldown expired or never started.</summary>
public static bool IsReady( string key ) => Remaining( key ) <= 0f;
private void Tick()
{
var expired = _client.Where( kv => kv.Value.started >= kv.Value.duration ).Select( kv => kv.Key ).ToList();
foreach ( var key in expired )
{
_client.Remove( key );
OnExpired?.Invoke( key );
}
if ( Networking.IsHost && _lastCleanup > 60f )
{
_lastCleanup = 0;
var hostExpired = _host.Where( kv => kv.Value.started >= kv.Value.duration ).Select( kv => kv.Key ).ToList();
foreach ( var key in hostExpired ) _host.Remove( key );
}
}
[Rpc.Owner( NetFlags.Reliable )]
private static void RpcReceive( string key, float duration )
{
_client[key] = (0, duration);
OnStarted?.Invoke( key, duration );
}
}