Core/NetworkStorageOutdatedUI.cs
using System;
using Sandbox.UI;
namespace Sandbox;
/// <summary>
/// Built-in simple in-game UI for outdated revision warnings.
/// Supports two enforcement modes:
/// - Force Upgrade: Warning with grace countdown, Dismiss button
/// - Allow Continue: Informational with Continue Playing primary
///
/// Uses <see cref="NetworkStorage.RevisionSettings"/> for configuration.
/// Subscribes to <see cref="NetworkStorage.OnRevisionOutdated"/>
/// when <see cref="RevisionSettings.AutoOpenOnOutdated"/> is true.
///
/// The panel is hidden via CSS class toggling (SetClass("hidden", ...)).
/// A parent panel MUST be provided — the UI does not auto-attach.
/// </summary>
public class NetworkStorageOutdatedUI : Panel
{
private static NetworkStorageOutdatedUI _instance;
private static bool _hasShownOnce;
private static long? _shownForRevision; // Track which revision we've shown for
private static GameObject _autoCreatedScreenPanelObject;
private static ScreenPanel _autoCreatedScreenPanel;
/// <summary>
/// Reset the popup state. Call this when starting a new game session.
/// This allows the popup to show again even if showPopupOnce is enabled.
/// </summary>
public static void ResetState()
{
_hasShownOnce = false;
_shownForRevision = null;
}
/// <summary>
/// The parent panel to use when auto-opening the UI.
/// If not set, the library will auto-create a ScreenPanel.
/// </summary>
public static Panel RootPanel { get; set; }
private Panel _container;
private Label _titleLabel;
private Label _infoLabel;
private Label _countdownLabel;
private Panel _buttonsPanel;
// Mode-specific buttons
private Button _continueButton;
private Panel _dividerPanel;
private Button _createNewButton;
private Button _joinLobbyButton;
private Button _dismissButton;
// Mouse state tracking
private MouseVisibility _savedMouseVisibility;
private bool _isShowingCursor;
/// <summary>True when the panel instance exists and is not hidden.</summary>
public static bool IsOpen => _instance != null && !_instance.HasClass( "hidden" );
/// <summary>Fired when the user clicks "Create New Game".</summary>
public static event Action OnCreateNewGame;
/// <summary>Fired when the user clicks "Join New Lobby".</summary>
public static event Action OnJoinNewLobby;
/// <summary>Fired when the user clicks "Continue Playing" (Allow Continue mode).</summary>
public static event Action OnContinuePlaying;
/// <summary>Fired when the user dismisses the popup.</summary>
public static event Action OnDismiss;
/// <summary>
/// Open the outdated revision window.
/// Uses <paramref name="parent"/> if provided, otherwise falls back to <see cref="RootPanel"/>.
/// If no parent is available, logs a warning and returns without creating UI.
/// </summary>
public static void Open( Panel parent = null )
{
if ( !NetworkStorage.RevisionSettings.Enabled )
return;
if ( _instance != null )
{
// Validate the instance is still valid (might be stale from hot reload)
if ( !_instance.IsValid || _instance.Parent == null || !_instance.Parent.IsValid )
{
_instance = null;
}
else
{
_instance.ShowPanel();
return;
}
}
// Clear stale RootPanel
if ( RootPanel != null && !RootPanel.IsValid )
RootPanel = null;
// Use provided parent, registered RootPanel, or auto-create a ScreenPanel
var targetParent = parent ?? RootPanel ?? GetOrCreateAutoScreenPanel();
if ( targetParent == null )
{
Log.Warning( "[NetworkStorage] Cannot show outdated revision UI: failed to create screen panel." );
return;
}
try
{
_instance = new NetworkStorageOutdatedUI();
_instance.Parent = targetParent;
}
catch ( Exception ex )
{
Log.Warning( $"[NetworkStorage] Failed to create outdated revision UI: {ex.Message}" );
_instance = null;
}
}
/// <summary>
/// Get or create an auto-managed ScreenPanel for the revision UI.
/// </summary>
private static Panel GetOrCreateAutoScreenPanel()
{
// Clear stale references
if ( _autoCreatedScreenPanel != null && !_autoCreatedScreenPanel.IsValid )
{
_autoCreatedScreenPanel = null;
_autoCreatedScreenPanelObject = null;
}
// Return existing if valid
if ( _autoCreatedScreenPanel != null && _autoCreatedScreenPanel.IsValid )
return _autoCreatedScreenPanel.GetPanel();
try
{
// Create a new GameObject with a ScreenPanel
_autoCreatedScreenPanelObject = new GameObject( true, "NetworkStorageRevisionUI" );
_autoCreatedScreenPanelObject.Flags = GameObjectFlags.DontDestroyOnLoad | GameObjectFlags.Hidden;
_autoCreatedScreenPanel = _autoCreatedScreenPanelObject.Components.Create<ScreenPanel>();
_autoCreatedScreenPanel.ZIndex = 9999; // On top of everything
return _autoCreatedScreenPanel.GetPanel();
}
catch ( Exception ex )
{
Log.Warning( $"[NetworkStorage] Failed to create auto screen panel: {ex.Message}" );
return null;
}
}
/// <summary>
/// Force-open the panel regardless of ShowOnlyOnce.
/// Used by <see cref="NetworkStorage.TestShowRevisionOutdatedMessage"/>.
/// </summary>
internal static void ForceOpen( Panel parent = null )
{
_hasShownOnce = false;
Open( parent );
}
/// <summary>
/// Dismiss the panel if it is visible.
/// </summary>
public static void Close()
{
if ( _instance != null )
{
_instance.HidePanel();
}
}
public override void OnDeleted()
{
NetworkStorage.OnRevisionOutdated -= OnRevisionOutdatedEvent;
// Restore cursor if we were showing it
if ( _isShowingCursor )
{
Mouse.Visibility = _savedMouseVisibility;
_isShowingCursor = false;
}
if ( _instance == this )
_instance = null;
base.OnDeleted();
}
public override void Tick()
{
base.Tick();
// Aggressively ensure cursor stays visible while panel is shown
if ( _isShowingCursor && Style.Display == DisplayMode.Flex )
{
// Force cursor visible every frame
if ( Mouse.Visibility != MouseVisibility.Visible )
{
Mouse.Visibility = MouseVisibility.Visible;
}
// Also ensure we accept input
AcceptsFocus = true;
Focus();
}
}
// ── Panel construction ──
private NetworkStorageOutdatedUI()
{
// Apply overlay styles (full screen dark backdrop)
Style.Position = PositionMode.Absolute;
Style.Top = 0;
Style.Left = 0;
Style.Right = 0;
Style.Bottom = 0;
Style.PointerEvents = PointerEvents.All;
Style.BackgroundColor = new Color( 0, 0, 0, 0.7f );
Style.ZIndex = 9999;
Style.Display = DisplayMode.None; // Start hidden
Style.AlignItems = Align.Center;
Style.JustifyContent = Justify.Center;
Style.BackdropFilterBlur = 4;
AddClass( "ns-outdated-overlay" );
_container = new Panel();
_container.Parent = this;
_container.AddClass( "ns-outdated-container" );
ApplyContainerStyles( _container );
_titleLabel = new Label();
_titleLabel.Parent = _container;
_titleLabel.AddClass( "ns-outdated-title" );
_titleLabel.Text = "OUTDATED REVISION";
ApplyTitleStyles( _titleLabel );
_infoLabel = new Label();
_infoLabel.Parent = _container;
_infoLabel.AddClass( "ns-outdated-info" );
ApplyInfoStyles( _infoLabel );
_countdownLabel = new Label();
_countdownLabel.Parent = _container;
_countdownLabel.AddClass( "ns-outdated-countdown" );
ApplyCountdownStyles( _countdownLabel );
_buttonsPanel = new Panel();
_buttonsPanel.Parent = _container;
_buttonsPanel.AddClass( "ns-outdated-buttons" );
ApplyButtonsPanelStyles( _buttonsPanel );
// Continue Playing button (Allow Continue mode)
_continueButton = new Button();
_continueButton.Parent = _buttonsPanel;
_continueButton.AddClass( "ns-outdated-continue-btn" );
_continueButton.Text = "▶ Continue Playing";
_continueButton.AddEventListener( "onclick", OnContinueClick );
ApplyContinueButtonStyles( _continueButton );
// Divider (Allow Continue mode)
_dividerPanel = new Panel();
_dividerPanel.Parent = _buttonsPanel;
_dividerPanel.AddClass( "ns-outdated-divider" );
ApplyDividerStyles( _dividerPanel );
var dividerLabel = new Label();
dividerLabel.Parent = _dividerPanel;
dividerLabel.Text = "Or update to latest";
ApplyDividerLabelStyles( dividerLabel );
// Create New Game button
_createNewButton = new Button();
_createNewButton.Parent = _buttonsPanel;
_createNewButton.AddClass( "ns-outdated-create-btn" );
_createNewButton.Text = "Create New Session";
_createNewButton.AddEventListener( "onclick", OnCreateNewClick );
ApplySecondaryButtonStyles( _createNewButton );
// Join New Lobby button
_joinLobbyButton = new Button();
_joinLobbyButton.Parent = _buttonsPanel;
_joinLobbyButton.AddClass( "ns-outdated-join-btn" );
_joinLobbyButton.Text = "Join New Lobby";
_joinLobbyButton.AddEventListener( "onclick", OnJoinLobbyClick );
ApplySecondaryButtonStyles( _joinLobbyButton );
// Dismiss button (Force Upgrade mode only)
_dismissButton = new Button();
_dismissButton.Parent = _buttonsPanel;
_dismissButton.AddClass( "ns-outdated-dismiss-btn" );
_dismissButton.Text = "Dismiss for now";
_dismissButton.AddEventListener( "onclick", OnDismissClick );
ApplyDismissButtonStyles( _dismissButton );
// Wire events
NetworkStorage.OnRevisionOutdated += OnRevisionOutdatedEvent;
// Show immediately if already outdated
if ( NetworkStoragePackageInfo.IsOutdatedRevision )
{
UpdateForMode( NetworkStoragePackageInfo.EnforcementMode );
UpdateDisplay(
NetworkStoragePackageInfo.RevisionMessage,
NetworkStoragePackageInfo.GraceRemainingMinutes,
NetworkStoragePackageInfo.GraceExpired
);
ShowPanel();
}
}
// ── Click handlers ──
private void OnContinueClick()
{
OnContinuePlaying?.Invoke();
Close();
}
private void OnCreateNewClick()
{
OnCreateNewGame?.Invoke();
Close();
}
private void OnJoinLobbyClick()
{
OnJoinNewLobby?.Invoke();
Close();
}
private void OnDismissClick()
{
OnDismiss?.Invoke();
Close();
}
// ── Internal ──
private void OnRevisionOutdatedEvent( RevisionOutdatedData data )
{
if ( !NetworkStorage.RevisionSettings.ShowDefaultMessage || !NetworkStoragePackageInfo.PolicyShowDefaultMessage )
{
Close();
return;
}
if ( !NetworkStorage.RevisionSettings.AutoOpenOnOutdated )
return;
// Reset _hasShownOnce if the server revision changed (new update published)
var currentServerRevision = NetworkStoragePackageInfo.ServerCurrentRevision;
if ( currentServerRevision != null && _shownForRevision != currentServerRevision )
{
_hasShownOnce = false;
_shownForRevision = currentServerRevision;
}
// ShowPopupOnce from server policy takes precedence
var showOnce = NetworkStoragePackageInfo.PolicyShowPopupOnce;
if ( showOnce && _hasShownOnce )
return;
_hasShownOnce = true;
UpdateForMode( NetworkStoragePackageInfo.EnforcementMode );
UpdateDisplay(
data.Message ?? NetworkStoragePackageInfo.RevisionMessage,
NetworkStoragePackageInfo.GraceRemainingMinutes,
NetworkStoragePackageInfo.GraceExpired
);
ShowPanel();
}
/// <summary>
/// Update the UI layout for the given enforcement mode.
/// </summary>
private void UpdateForMode( RevisionEnforcementMode mode )
{
var isForceUpgrade = mode == RevisionEnforcementMode.ForceUpgrade;
var showUpdateOptions = NetworkStoragePackageInfo.PolicyShowUpdateOptions;
// Apply mode-specific container styling
if ( isForceUpgrade )
{
// Force Upgrade: amber/warning style
_container.Style.Set( "border", "2px solid #f59e0b" );
_container.Style.Set( "box-shadow", "0 8px 32px rgba(245, 158, 11, 0.25)" );
_titleLabel.Style.FontColor = new Color( 0.98f, 0.75f, 0.14f, 1f ); // #fbbf24
}
else
{
// Allow Continue: blue/info style
_container.Style.Set( "border", "2px solid #3b82f6" );
_container.Style.Set( "box-shadow", "0 8px 32px rgba(59, 130, 246, 0.25)" );
_titleLabel.Style.FontColor = new Color( 0.376f, 0.647f, 0.976f, 1f ); // #60a5fa
}
// Update title
_titleLabel.Text = isForceUpgrade ? "UPDATE REQUIRED" : "NEW VERSION AVAILABLE";
// Continue Playing: visible only in AllowContinue mode
_continueButton.Style.Display = isForceUpgrade ? DisplayMode.None : DisplayMode.Flex;
// Divider: visible only in AllowContinue mode when update options are shown
_dividerPanel.Style.Display = ( isForceUpgrade || !showUpdateOptions ) ? DisplayMode.None : DisplayMode.Flex;
// Create/Join buttons: controlled by showUpdateOptions
_createNewButton.Style.Display = showUpdateOptions ? DisplayMode.Flex : DisplayMode.None;
_joinLobbyButton.Style.Display = showUpdateOptions ? DisplayMode.Flex : DisplayMode.None;
// Dismiss: visible only in ForceUpgrade mode
_dismissButton.Style.Display = isForceUpgrade ? DisplayMode.Flex : DisplayMode.None;
}
private void UpdateDisplay( string message, int? graceRemainingMinutes, bool graceExpired )
{
if ( _infoLabel == null ) return;
_infoLabel.Text = message ?? "A new version is available.";
if ( _countdownLabel != null )
{
var isForceUpgrade = NetworkStoragePackageInfo.EnforcementMode == RevisionEnforcementMode.ForceUpgrade;
if ( isForceUpgrade && graceExpired )
{
_countdownLabel.Text = "Grace period expired";
_countdownLabel.Style.Display = DisplayMode.Flex;
// Expired red styling
_countdownLabel.Style.BackgroundColor = new Color( 0.94f, 0.27f, 0.27f, 0.15f );
_countdownLabel.Style.Set( "border", "1px solid rgba(239, 68, 68, 0.3)" );
_countdownLabel.Style.FontColor = new Color( 0.99f, 0.65f, 0.65f, 1f ); // #fca5a5
}
else if ( isForceUpgrade && graceRemainingMinutes.HasValue && graceRemainingMinutes.Value > 0 )
{
var consequence = NetworkStoragePackageInfo.PolicyPostGraceAction == "block_all"
? "this session will expire"
: "saving will be disabled";
_countdownLabel.Text = $"Grace period: {graceRemainingMinutes.Value} minute(s) remaining\n(After that, {consequence})";
_countdownLabel.Style.Display = DisplayMode.Flex;
// Normal amber styling
_countdownLabel.Style.BackgroundColor = new Color( 0.96f, 0.62f, 0.04f, 0.15f );
_countdownLabel.Style.Set( "border", "1px solid rgba(245, 158, 11, 0.3)" );
_countdownLabel.Style.FontColor = new Color( 0.98f, 0.75f, 0.14f, 1f ); // #fbbf24
}
else
{
_countdownLabel.Style.Display = DisplayMode.None;
}
}
}
private void ShowPanel()
{
Style.Display = DisplayMode.Flex;
// Save current mouse state and show cursor
if ( !_isShowingCursor )
{
_savedMouseVisibility = Mouse.Visibility;
_isShowingCursor = true;
}
Mouse.Visibility = MouseVisibility.Visible;
}
private void HidePanel()
{
Style.Display = DisplayMode.None;
// Restore original mouse state
if ( _isShowingCursor )
{
Mouse.Visibility = _savedMouseVisibility;
_isShowingCursor = false;
}
}
// ── Style Helpers (inline styles for library portability) ──
private static void ApplyContainerStyles( Panel p )
{
p.Style.BackgroundColor = new Color( 0.08f, 0.09f, 0.14f, 0.98f ); // Darker, slightly transparent
p.Style.Set( "border-radius", "16px" );
p.Style.Set( "padding", "32px 40px" );
p.Style.MaxWidth = 480;
p.Style.MinWidth = 380;
p.Style.Width = Length.Percent( 90 );
p.Style.Display = DisplayMode.Flex;
p.Style.FlexDirection = FlexDirection.Column;
p.Style.Set( "gap", "20px" );
p.Style.Set( "box-shadow", "0 25px 50px -12px rgba(0, 0, 0, 0.5)" );
}
private static void ApplyTitleStyles( Label l )
{
l.Style.FontSize = 22;
l.Style.FontWeight = 800;
l.Style.TextAlign = TextAlign.Center;
l.Style.TextTransform = TextTransform.Uppercase;
l.Style.Set( "letter-spacing", "2px" );
l.Style.FontColor = new Color( 0.98f, 0.75f, 0.14f, 1f ); // #fbbf24 (amber default)
l.Style.Set( "margin-bottom", "8px" );
}
private static void ApplyInfoStyles( Label l )
{
l.Style.FontSize = 15;
l.Style.FontColor = new Color( 0.82f, 0.86f, 0.92f, 1f ); // Softer white
l.Style.TextAlign = TextAlign.Center;
l.Style.Set( "line-height", "2" );
l.Style.Set( "padding", "4px 12px" );
}
private static void ApplyCountdownStyles( Label l )
{
l.Style.FontSize = 14;
l.Style.TextAlign = TextAlign.Center;
l.Style.Set( "padding", "16px 20px" );
l.Style.Set( "border-radius", "10px" );
l.Style.Set( "line-height", "1.9" );
l.Style.Set( "white-space", "pre-line" );
// Default amber style (force upgrade mode)
l.Style.BackgroundColor = new Color( 0.96f, 0.62f, 0.04f, 0.12f );
l.Style.Set( "border", "1px solid rgba(245, 158, 11, 0.25)" );
l.Style.FontColor = new Color( 0.98f, 0.78f, 0.2f, 1f );
}
private static void ApplyButtonsPanelStyles( Panel p )
{
p.Style.Display = DisplayMode.Flex;
p.Style.FlexDirection = FlexDirection.Column;
p.Style.Set( "gap", "12px" );
p.Style.Set( "margin-top", "8px" );
}
private static void ApplyContinueButtonStyles( Button b )
{
b.Style.Set( "background", "linear-gradient(135deg, #3b82f6, #1d4ed8)" );
b.Style.FontColor = Color.White;
b.Style.Set( "border", "none" );
b.Style.Set( "border-radius", "10px" );
b.Style.Set( "padding", "16px 28px" );
b.Style.FontSize = 16;
b.Style.FontWeight = 700;
b.Style.Set( "cursor", "pointer" );
b.Style.TextAlign = TextAlign.Center;
b.Style.Set( "text-shadow", "0 1px 2px rgba(0,0,0,0.2)" );
}
private static void ApplyDividerStyles( Panel p )
{
p.Style.Display = DisplayMode.Flex;
p.Style.AlignItems = Align.Center;
p.Style.Set( "gap", "16px" );
p.Style.Set( "margin", "8px 0" );
}
private static void ApplyDividerLabelStyles( Label l )
{
l.Style.FontColor = new Color( 0.45f, 0.52f, 0.62f, 1f ); // Slightly brighter
l.Style.FontSize = 13;
l.Style.Set( "white-space", "nowrap" );
l.Style.Set( "line-height", "1.8" );
}
private static void ApplySecondaryButtonStyles( Button b )
{
b.Style.BackgroundColor = new Color( 0.45f, 0.52f, 0.62f, 0.12f );
b.Style.FontColor = new Color( 0.85f, 0.88f, 0.92f, 1f );
b.Style.Set( "border", "1px solid rgba(148, 163, 184, 0.2)" );
b.Style.Set( "border-radius", "10px" );
b.Style.Set( "padding", "14px 24px" );
b.Style.FontSize = 14;
b.Style.FontWeight = 600;
b.Style.Set( "cursor", "pointer" );
b.Style.TextAlign = TextAlign.Center;
}
private static void ApplyDismissButtonStyles( Button b )
{
b.Style.BackgroundColor = Color.Transparent;
b.Style.FontColor = new Color( 0.52f, 0.58f, 0.68f, 1f );
b.Style.Set( "border", "none" );
b.Style.Set( "padding", "12px" );
b.Style.FontSize = 13;
b.Style.Set( "cursor", "pointer" );
b.Style.TextAlign = TextAlign.Center;
b.Style.Set( "line-height", "1.8" );
}
}