using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using Normal.Realtime.Native; using Normal.Realtime.Serialization; // Represents a room on the server. Manages the connection to the server and the data model. namespace Normal.Realtime { public class Room { public delegate void ConnectionStateChanged(Room room, ConnectionState previousConnectionState, ConnectionState connectionState); public event ConnectionStateChanged connectionStateChanged; public delegate void RPCMessageReceived(Room room, byte[] data, bool reliable); public event RPCMessageReceived rpcMessageReceived; // Connection state public enum ConnectionState { Error = -1, Disconnected = 0, ConnectingToMatcher = 1, PingingRegions = 2, FinishedMatching = 3, ConnectingToRealtime = 4, ConnectedToRealtime = 5, Ready = 6, // Datastore is ready } private ConnectionState _connectionState = ConnectionState.Disconnected; public ConnectionState connectionState { get { return _connectionState; } } public bool connecting { get { return _connectionState == ConnectionState.ConnectingToMatcher || _connectionState == ConnectionState.PingingRegions || _connectionState == ConnectionState.FinishedMatching || _connectionState == ConnectionState.ConnectingToRealtime || _connectionState == ConnectionState.ConnectedToRealtime; } } public bool connected { get { return _connectionState == ConnectionState.Ready; } } public bool disconnected { get { return _connectionState == ConnectionState.Disconnected || _connectionState == ConnectionState.Error; } } // Matcher private Matcher _matcher; private List _pingResults; private int _ipsLeftToPing = 0; private UInt64 _clientIdentifier; private byte[] _connectToken; public int clientID { get { return !sessionCapturePlayback ? _client.ClientIndex() : 255; } } private double _time; public double time { get { return _time; } } // Datastore private Datastore _datastore; public Datastore datastore { get { return _datastore; } } public double datastoreFrameDuration = 1.0/20.0; private double _deltaTime; private uint _nextUpdateID = 1; // TODO: Convert to zero when 0 isn't used to signal the initial state of the room // Native API private Client _client; // Realtime private Realtime _realtime; public Realtime realtime { get { return _realtime; } } private IModel _roomModel; // Debug private bool _debugLogging = false; public bool debugLogging { get { return _debugLogging; } set { if (value == _debugLogging) return; Plugin.logLevel = value ? Plugin.LogLevel.LogLevelInfo : Plugin.LogLevel.LogLevelError; _debugLogging = value; } } // Session capture / playback private SessionCapture _sessionCapture; private bool sessionCaptureRecord { get { return _sessionCapture != null && _sessionCapture.mode == SessionCapture.Mode.Record; } } private bool sessionCapturePlayback { get { return _sessionCapture != null && _sessionCapture.mode == SessionCapture.Mode.Playback; } } public Room() : this(null) { } public Room(SessionCapture sessionCapture) { _sessionCapture = sessionCapture; _deltaTime = 0.0; _datastore = new Datastore(); if (!sessionCapturePlayback) { _client = new Client(); _client.persistenceMessageReceived += ReceivedPersistenceMessage; _client.rpcMessageReceived += ReceivedRPCMessage; } } ~Room() { if (_client != null) { _client.persistenceMessageReceived -= ReceivedPersistenceMessage; _client.rpcMessageReceived -= ReceivedRPCMessage; _client.Dispose(); } DisposeMatcher(); } // State private void SetConnectionState(ConnectionState connectionState) { if (connectionState == _connectionState) return; ConnectionState previousConnectionState = _connectionState; _connectionState = connectionState; if (connectionStateChanged != null) { try { connectionStateChanged(this, previousConnectionState, connectionState); } catch (Exception exception) { Debug.LogException(exception); } } switch (connectionState) { case ConnectionState.Error: // Stop recording if (sessionCaptureRecord) _sessionCapture.StopRecording(); // Clear datastore _datastore.Reset(null); Debug.LogError("Realtime: Connection failed" + (!_debugLogging ? ". Turn on debug logging for more details." : "")); break; case ConnectionState.Disconnected: // Stop recording if (sessionCaptureRecord) _sessionCapture.StopRecording(); // Clear datastore _datastore.Reset(null); Debug.Log("Realtime: Disconnected"); break; case ConnectionState.ConnectingToMatcher: if (_debugLogging) Debug.Log("Realtime: Connecting to matcher."); break; case ConnectionState.FinishedMatching: // Reset datastore _datastore.Reset(_roomModel); // Set state to connecting to realtime SetConnectionState(ConnectionState.ConnectingToRealtime); _client.Connect(_clientIdentifier, _connectToken); break; case ConnectionState.Ready: Debug.Log("Realtime: Connected"); break; case ConnectionState.PingingRegions: Debug.Log("Realtime: Pinging regions to determine server with lowest latency for this client."); string[] IPsToPing = _matcher.GetIPsToPing(); PingServers(IPsToPing); break; } } // Realtime public void _SetRealtime(Realtime realtime) { _realtime = realtime; } // Network Statistics public NetworkInfo GetNetworkStatistics() { if (_client == null) return new NetworkInfo(); return _client.GetNetworkStatistics(); } // Connect / Disconnect public void Connect(string roomName, string appKey, IModel roomModel = null) { if (!sessionCapturePlayback) { ConnectToServer(roomName, appKey, roomModel); } else { Debug.Log("Realtime: Connect() called on Room with session capture override. Will begin playing back session capture now."); StartSessonCapturePlayback(roomModel); } } private void ConnectToServer(string roomName, string appKey, IModel roomModel = null) { if (connectionState != ConnectionState.Disconnected && connectionState != ConnectionState.Error) { Debug.LogError("Room asked to connect to server, but room is already connecting or connected (" + connectionState + "). Ignoring."); return; } Debug.Log("Realtime: Connecting to room \"" + roomName + "\""); SetConnectionState(ConnectionState.ConnectingToMatcher); _roomModel = roomModel; _clientIdentifier = Client.GenerateSecureClientIdentifier(); // Begin connecting to the matcher JoinRoom(roomName, appKey, _clientIdentifier); } private void StartSessonCapturePlayback(IModel roomModel = null) { // Reset datastore _datastore.Reset(roomModel); // Fire connecting / connected to Realtime events SetConnectionState(ConnectionState.ConnectingToRealtime); SetConnectionState(ConnectionState.ConnectedToRealtime); // Start session capture playback byte[] initialDatastore = _sessionCapture.StartPlayback(); // Initialize datastore _datastore.Deserialize(initialDatastore); // Reset _deltaTime _deltaTime = 0.0; // We're ready! SetConnectionState(ConnectionState.Ready); } public void Disconnect() { Disconnect(false); } private void Disconnect(bool error) { // Disconnect client if (_client != null) _client.Disconnect(); DisposeMatcher(); // Stop session capture / playback if (_sessionCapture != null) { _sessionCapture.StopRecording(); _sessionCapture.StopPlayback(); } // Set connection state if (error) SetConnectionState(ConnectionState.Error); else SetConnectionState(ConnectionState.Disconnected); } // Tick public void Tick(double deltaTime) { if (!sessionCapturePlayback) RoomTick(deltaTime); else PlaybackTick(deltaTime); } private void RoomTick(double deltaTime) { // Connect to a room on the Matcher if needed. MatcherTick(); // Info // We grab time here because we want to have the same timestamp on deserialize/serialize operations called by ClienTick()/PersistenceTick(). _time = _client.RoomTime(); // Process incoming network packets and send out anything that's been sitting around. // TODO: Given that this is going to send/receive packets all at once. Maybe we should call Tick twice? Call it once here to receive packets + dispatch messages, and again at the end to send them right away. // TODO: We can't do the above TODO until _client.Tick() does its own delta time calculation ClientTick(); // Persistence PersistenceTick(deltaTime); } private void MatcherTick() { if (_matcher == null) return; int matcherState = _matcher.Tick(); switch (matcherState) { case (int)Matcher.State.ReadyToPingRegions: SetConnectionState(ConnectionState.PingingRegions); break; case (int)Matcher.State.Disconnected: if (_debugLogging) Debug.LogError("Realtime: The matcher has disconnected."); SetConnectionState(ConnectionState.Disconnected); DisposeMatcher(); break; case (int)Matcher.State.Done: _connectToken = _matcher.GetConnectToken(); DisposeMatcher(); SetConnectionState(ConnectionState.FinishedMatching); break; case (int)Matcher.State.Error: Debug.LogError("Realtime: There was an issue connecting to a room on the matcher. (" + _matcher.GetServerError() + ")"); SetConnectionState(ConnectionState.Error); DisposeMatcher(); break; default: break; } } // Matcher /// /// Join a realtime room. If one with the given roomID is not currently running, it will ping /// servers around the world to find the closest region to start up a new room. /// The UUID of a room to join. /// Normal Developer App Key. /// Secure random UInt64 used to identify this client /// private void JoinRoom(string roomID, string appKey, UInt64 clientIdentifier) { if (_matcher != null) { DisposeMatcher(); } SetConnectionState(ConnectionState.ConnectingToMatcher); _matcher = new Matcher(); _matcher.Connect(roomID, appKey, clientIdentifier); } /// /// Ping an array of IPs and return the IP with the lowest ping time. /// An array of IP strings. /// private void PingServers(string[] servers) { SetConnectionState(ConnectionState.PingingRegions); _pingResults = new List(); _ipsLeftToPing = servers.Length; foreach (string ip in servers) { _realtime.StartCoroutine(StartPing(ip)); } } /// /// Starts a new ping operation for a given IP. /// IEnumerator StartPing(string address) { Ping ping = new Ping(address); float startTime = Time.realtimeSinceStartup; // Wait for up to 8 seconds for the ping to finish. while (!ping.isDone && (Time.realtimeSinceStartup - startTime) < 8.0f) { yield return null; } PingFinished(ping); } /// /// Each ping will call this when it's done or has failed. When we have all the pings /// we call the completion handler with the ping with the lowest time. /// The ping that just completed. /// private void PingFinished(Ping p) { PingResult pr = new PingResult(p.ip, p.time); _pingResults.Add(pr); // Log an error if a ping failed, but we need to respond to the matcher with the time regardless, which will be -1. if (!p.isDone && _debugLogging) { Debug.LogError(string.Format("Ping failed for address: {0} with ping time: {1}", p.ip, p.time)); } _ipsLeftToPing--; if (_ipsLeftToPing <= 0) { foreach (PingResult sPing in _pingResults) { if (_debugLogging) Debug.Log(string.Format("Realtime: IP: {0} Ping Time: {1}", sPing.address, sPing.time)); } if (_debugLogging && (_connectionState != ConnectionState.PingingRegions || _matcher == null)) { Debug.LogWarning("Realtime: Yo, Jeff here. If you see this log, lmk, you can safely ignore it, but I want to know what's happening. The client (" + _clientIdentifier + ") sent back ping results in the state (" + connectionState + ") which was not PingingRegions."); } else { _matcher.ContinueConnectionWithPingTimes(_pingResults.ToArray()); } } } private void ClientTick() { if (connectionState != ConnectionState.ConnectingToRealtime && connectionState != ConnectionState.ConnectedToRealtime && connectionState != ConnectionState.Ready) return; int clientConnectionState = _client.Tick(); // Update connection state using native client switch (clientConnectionState) { case -1: // Error SetConnectionState(ConnectionState.Error); break; case 0: // Disconnected SetConnectionState(ConnectionState.Disconnected); break; case 1: // Connecting SetConnectionState(ConnectionState.ConnectingToRealtime); break; case 2: // Connected if (connectionState == ConnectionState.ConnectingToRealtime) SetConnectionState(ConnectionState.ConnectedToRealtime); break; default: Debug.LogError("NativeClient returned unknown client state: " + clientConnectionState + ". This is a bug."); break; } } // Persistence private void PersistenceTick(double deltaTime) { // Wait until we're in sync with the server if (_connectionState != ConnectionState.Ready) return; _deltaTime += deltaTime; if (_deltaTime >= datastoreFrameDuration) { _deltaTime -= datastoreFrameDuration; try { WriteBuffer writeBuffer = _datastore.writeBuffer; // Unreliable _datastore.SerializeDeltaUpdates(false); if (writeBuffer.bytesWritten > 0) { _client.SendPersistenceMessage(writeBuffer.GetBuffer(), writeBuffer.bytesWritten, false); // Record delta update if local session capture is enabled and set to record if (sessionCaptureRecord) _sessionCapture.WriteDeltaUpdate(_time, clientID, writeBuffer.GetBuffer(), writeBuffer.bytesWritten, false, 0, false); } // Reliable _datastore.SerializeDeltaUpdates(true, _nextUpdateID); if (writeBuffer.bytesWritten > 0) { _client.SendPersistenceMessage(writeBuffer.GetBuffer(), writeBuffer.bytesWritten, true); // Record delta update if local session capture is enabled and set to record if (sessionCaptureRecord) _sessionCapture.WriteDeltaUpdate(_time, clientID, writeBuffer.GetBuffer(), writeBuffer.bytesWritten, true, _nextUpdateID, false); // Only increment if data was written _nextUpdateID++; } } catch (Exception exception) { Debug.LogException(exception); Debug.LogError("Failed to serialize datastore message. Disconnecting."); Disconnect(true); } } } void ReceivedPersistenceMessage(Client client, int sender, byte[] data, bool reliable) { if (connectionState != ConnectionState.ConnectingToRealtime && connectionState != ConnectionState.ConnectedToRealtime && connectionState != ConnectionState.Ready) { Debug.LogError("Room received persistence message while not in a connection state where it would be expecting one (" + connectionState + "). This is a bug! Ignoring."); return; } bool updateIsFromUs = sender == clientID; // TODO: We need to add try catches around all native -> managed callbacks (ideally in Plugin.cs or Client.cs if we have to). If C# throws an exception, it skips the native code that runs too which can lead to leaks. bool isInitialRoomMessage = connectionState != ConnectionState.Ready; try { // If we're not in a ready state, try to initialize the datastore with this message. if (connectionState != ConnectionState.Ready) { // Make sure we didn't accidentally receive an unreliable update that arrived out of order from the original room setup message... if (reliable) { // Retrieve the room time now that the client has calculated it _time = _client.RoomTime(); // Initialize datastore _datastore.Deserialize(data); // Reset _deltaTime _deltaTime = 0.0; // We're ready! SetConnectionState(ConnectionState.Ready); // Start session capture if local session capture is enabled and set to record if (sessionCaptureRecord) _sessionCapture.StartRecording(clientID, time, data); } } else { // Apply delta update to the datastore uint updateID = _datastore.DeserializeDeltaUpdates(data, reliable, updateIsFromUs); // Record delta update if local session capture is enabled and set to record if (sessionCaptureRecord) _sessionCapture.WriteDeltaUpdate(_time, sender, data, data.Length, reliable, updateID, true); } } catch (Exception e) { bool readUpdateID = reliable; if (isInitialRoomMessage) readUpdateID = false; Debug.LogException(e); BufferAnalyzer bufferAnalyzer = new BufferAnalyzer(); Debug.LogError("Failed to deserialize incoming datastore message. Disconnecting. (" + isInitialRoomMessage + ") (" + reliable + "): " + bufferAnalyzer.AnalyzeBuffer(new ReadBuffer(data), readUpdateID)); Disconnect(true); } } private void PlaybackTick(double deltaTime) { if (!_sessionCapture.playing) return; // Tick _sessionCapture.PlaybackTick(deltaTime); // Read updates until we've read up to the current playback time SessionCapture.DeltaUpdate deltaUpdate = _sessionCapture.ReadDeltaUpdate(); while (deltaUpdate != null) { // Update room time _time = deltaUpdate.timestamp; // Apply delta update if (deltaUpdate.data.Length > 0) _datastore.DeserializeDeltaUpdates(deltaUpdate.data, deltaUpdate.reliable, false); // Read next update deltaUpdate = _sessionCapture.ReadDeltaUpdate(); } if (!_sessionCapture.playing) SetConnectionState(ConnectionState.Disconnected); } private void DisposeMatcher() { if (_matcher != null) { if (_debugLogging) Debug.Log("Disposing Matcher."); _matcher.Dispose(); _matcher = null; } } // RPC public bool SendRPCMessage(byte[] data, bool reliable) { return _client.SendRPCMessage(data, data.Length, reliable); } public bool SendRPCMessage(byte[] data, int dataLength, bool reliable) { return _client.SendRPCMessage(data, dataLength, reliable); } private void ReceivedRPCMessage(Client client, byte[] data, bool reliable) { try { if (rpcMessageReceived != null) rpcMessageReceived(this, data, reliable); } catch (Exception exception) { Debug.LogException(exception); } } // Audio public AudioInputStream CreateAudioInputStream(bool voice, int sampleRate, int channels) { if (sessionCapturePlayback) return null; // TODO: Implement audio stream support return _client.CreateAudioInputStream(voice, sampleRate, channels); } public AudioOutputStream GetAudioOutputStream(int clientID, int streamID) { if (sessionCapturePlayback) return null; // TODO: Implement audio stream support return _client.GetAudioOutputStream(clientID, streamID); } } }