Editor/SharedPlayClient.cs

Editor-side shared-play client and a spectator window UI. SpectatorWindow is a small floating window that hosts a SpectatorView and shows who is being watched. SharedPlayClient manages play-session roles (Requested, Player, Spectator), handles incoming collaboration packets (start, stop, frame, key, refused), samples the local game for frames when this editor is the player, sends packets via a provided send callback, and opens/releases the spectator window when spectating.

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using Sandbox.Internal;

namespace TeamCreate;

/// <summary>The floating window that holds a <see cref="SpectatorView"/> and says who is being watched. Closing it only closes the view; the run goes on.</summary>
public sealed class SpectatorWindow : Widget
{
    private readonly SpectatorView view;

    private readonly Label caption;

    public event Action Closed;

    public SpectatorWindow( PlayMirror mirror ) : base( null )
    {
        WindowFlags = WindowFlags.Dialog;
        WindowTitle = "Spectating";
        DeleteOnClose = true;
        MinimumSize = new Vector2( 640, 420 );
        Layout = Layout.Column();
        Layout.Margin = 4;
        Layout.Spacing = 4;
        caption = Layout.Add( new Label( "", this ) { WordWrap = true } );
        view = Layout.Add( new SpectatorView( mirror, this ), 1 );
    }

    public SpectatorView View => view;

    public void SetCaption( string title, string text )
    {
        WindowTitle = title;
        caption.Text = text;
    }

    protected override void OnClosed()
    {
        base.OnClosed();
        view.Release();
        Closed?.Invoke();
    }
}

/// <summary>
/// The editor side of shared play for one session: the packets, the player's sampling and the spectator's window, around the pure pieces in Core. Created only
/// when this computer opted in (<see cref="SharedPlaySetting"/>) and the host advertised <see cref="PlayRelayPolicy.Feature"/>; absent otherwise, so a session
/// without it behaves as it always did. It speaks only collaboration packets on the connection the session already has. It never starts, joins or changes
/// the game's own networking, and it never starts or stops the game: it watches whether a run exists.
/// </summary>
public sealed class SharedPlayClient : IDisposable
{
    public const double SampleIntervalMs = 50;

    public enum PlayRole
    {
        None,
        Requested,
        Player,
        Spectator
    }

    private readonly Action<Packet> send;

    private readonly string me;

    private readonly Action<string> notice;

    private readonly PlayMirror mirror = new PlayMirror();

    private readonly PlayCapture capture = new PlayCapture();

    private PlaySampler sampler = new PlaySampler();

    private SpectatorWindow window;

    private double lastSampleMs;

    private double lastKeyAskMs = double.NegativeInfinity;

    private bool refusedForThisRun;

    private bool disposed;

    public PlayRole Role { get; private set; }

    public string RunId { get; private set; }

    public string PlayerName { get; private set; }

    public string PlayerPeer { get; private set; }

    /// <summary>True while another member's run is the one on screen. The session reads it to pause source synchronization.</summary>
    public bool SpectatingRun => Role == PlayRole.Spectator;

    public SharedPlayClient( Action<Packet> send, string myPeer, Action<string> notice )
    {
        this.send = send ?? throw new ArgumentNullException( nameof( send ) );
        me = myPeer ?? throw new ArgumentNullException( nameof( myPeer ) );
        this.notice = notice ?? ( _ => { } );
        send( new Packet { Kind = "play-optin" } );
    }

    /// <summary>The sentence the dock shows while this editor is held back by somebody else's run. Null otherwise.</summary>
    public string EditsPausedNotice => SpectatingRun ? "Edits are paused while " + ( PlayerName ?? "someone" ) + " is playing." : null;

    /// <summary>Handles a shared play packet; false when the packet is not one, so the session processes it as usual.</summary>
    public bool Handle( Packet p )
    {
        if ( disposed || p?.Kind is not ( "play-start" or "play-stop" or "play-frame" or "play-key" or "play-refused" ) )
        {
            return false;
        }
        switch ( p.Kind )
        {
        case "play-start":
            Begin( p );
            break;
        case "play-stop":
            if ( p.RuntimeRunId == RunId ) End( p.Peer == me ? null : ( PlayerName ?? "The player" ) + " stopped playing." );
            break;
        case "play-frame":
            if ( Role == PlayRole.Spectator && p.RuntimeRunId == RunId ) Apply( p );
            break;
        case "play-key":
            // The host asks the player for a full keyframe (a spectator joined, or one fell behind).
            if ( Role == PlayRole.Player && p.RuntimeRunId == RunId ) sampler.RequestKeyframe();
            break;
        case "play-refused":
            // A refusal answers a request of this editor's own; a spectator that asked (it pressed Play while watching) keeps watching.
            if ( Role == PlayRole.Requested ) Role = PlayRole.None;
            refusedForThisRun = true;
            if ( !string.IsNullOrEmpty( p.Notice ) ) notice( p.Notice );
            break;
        }
        return true;
    }

    private void Begin( Packet p )
    {
        ReleaseWindow();
        RunId = p.RuntimeRunId;
        PlayerPeer = p.Peer;
        PlayerName = string.IsNullOrWhiteSpace( p.Name ) ? "A collaborator" : p.Name;
        mirror.Reset();
        if ( p.Peer == me )
        {
            Role = PlayRole.Player;
            sampler = new PlaySampler();
            lastSampleMs = double.NegativeInfinity;
            return;
        }
        Role = PlayRole.Spectator;
        window = new SpectatorWindow( mirror );
        window.SetCaption( "Spectating " + PlayerName, PlayerName + " is playing. Right-drag to look, W A S D to move, Q and E down and up, Shift faster, Ctrl slower, mouse wheel changes speed. You see a copy of the scene; no game code runs here." );
        window.Closed += OnWindowClosed;
        window.Show();
        notice( PlayerName + " started playing. Spectator view opened." );
    }

    private void OnWindowClosed()
    {
        window = null;
    }

    private void Apply( Packet p )
    {
        if ( !PlayFrame.TryParse( p.Play, out PlayFrame frame, out string error ) )
        {
            // A frame the receiver refuses whole is treated like a lost one: ask for a keyframe, keep what is shown.
            AskForKeyframe();
            return;
        }
        if ( mirror.Apply( frame ) == PlayApplyResult.NeedsKeyframe )
        {
            AskForKeyframe();
        }
    }

    private void AskForKeyframe()
    {
        double now = Environment.TickCount64;
        if ( now - lastKeyAskMs < PlayRelayPolicy.KeyframeAskIntervalMs || RunId == null )
        {
            return;
        }
        lastKeyAskMs = now;
        send( new Packet { Kind = "play-key", RuntimeRunId = RunId } );
    }

    private void End( string message )
    {
        ReleaseWindow();
        mirror.Reset();
        Role = PlayRole.None;
        RunId = null;
        PlayerName = null;
        PlayerPeer = null;
        if ( message != null ) notice( message );
    }

    private void ReleaseWindow()
    {
        if ( window != null )
        {
            window.Closed -= OnWindowClosed;
            var w = window;
            window = null;
            if ( w.IsValid() )
            {
                w.View.Release();
                w.Close();
            }
        }
    }

    /// <summary>
    /// Called every editor frame while connected. Watches the editor's own game session: when one appears this person asks to be the player, when it
    /// ends the run ends; while they are the player it samples the runtime scene and sends frames.
    /// </summary>
    public void Tick()
    {
        if ( disposed )
        {
            return;
        }
        GameEditorSession game = SceneEditorSession.All.OfType<GameEditorSession>().FirstOrDefault();
        if ( game == null )
        {
            refusedForThisRun = false;
            if ( Role == PlayRole.Player || Role == PlayRole.Requested )
            {
                if ( RunId != null ) send( new Packet { Kind = "play-stop", RuntimeRunId = RunId } );
                End( null );
            }
            return;
        }
        if ( ( Role == PlayRole.None || Role == PlayRole.Spectator ) && !refusedForThisRun )
        {
            string scene = SceneAdapter.PathOf( game.Parent );
            if ( PlayRoster.IsPlayableScene( scene ) )
            {
                if ( Role == PlayRole.None ) Role = PlayRole.Requested;
                send( new Packet { Kind = "play-start", Scene = scene } );
            }
            else
            {
                refusedForThisRun = true;
                notice( "This scene is not saved under Assets/scenes, so it cannot be shared. Your run is local and is not shown to the others." );
            }
            return;
        }
        if ( Role != PlayRole.Player || RunId == null )
        {
            return;
        }
        double now = Environment.TickCount64;
        if ( now - lastSampleMs < SampleIntervalMs )
        {
            return;
        }
        lastSampleMs = now;
        try
        {
            foreach ( PlayFrame frame in sampler.Sample( capture.Capture( game ), now ) )
            {
                var packet = new Packet { Kind = "play-frame", RuntimeRunId = RunId, RuntimeSequence = frame.Seq, Play = frame.ToJson() };
                send( packet );
            }
        }
        catch ( Exception error )
        {
            // A sample that fails (a scene mid-teardown) skips this tick and asks for a keyframe so the next one is whole; it never ends the session.
            sampler.RequestKeyframe();
            GlobalGameNamespace.Log.Warning( "Shared play sample skipped: " + CollaborationDiagnostics.Bounded( error.Message ) );
        }
    }

    public void Dispose()
    {
        if ( disposed )
        {
            return;
        }
        disposed = true;
        ReleaseWindow();
        mirror.Reset();
        Role = PlayRole.None;
    }
}