// This file is provided under The MIT License as part of RiptideNetworking. // Copyright (c) Tom Weiland // For additional information please see the included LICENSE.md file or view it on GitHub: // https://github.com/RiptideNetworking/Riptide/blob/main/LICENSE.md using Riptide.Transports; using Riptide.Utils; using System; using System.Collections.Generic; using System.Reflection; namespace Riptide { /// A client that can connect to a . public class Client : Peer { /// Invoked when a connection to the server is established. public event EventHandler Connected; /// Invoked when a connection to the server fails to be established. public event EventHandler ConnectionFailed; /// Invoked when a message is received. public event EventHandler MessageReceived; /// Invoked when disconnected from the server. public event EventHandler Disconnected; /// Invoked when another non-local client connects. public event EventHandler ClientConnected; /// Invoked when another non-local client disconnects. public event EventHandler ClientDisconnected; /// The client's numeric ID. public ushort Id => connection.Id; /// public short RTT => connection.RTT; /// /// This value is slower to accurately represent lasting changes in latency than , but it is less susceptible to changing drastically due to significant—but temporary—jumps in latency. public short SmoothRTT => connection.SmoothRTT; /// Sets the client's . public override int TimeoutTime { set { defaultTimeout = value; connection.TimeoutTime = defaultTimeout; } } /// Whether or not the client is currently not trying to connect, pending, nor actively connected. public bool IsNotConnected => connection is null || connection.IsNotConnected; /// Whether or not the client is currently in the process of connecting. public bool IsConnecting => !(connection is null) && connection.IsConnecting; /// Whether or not the client's connection is currently pending (waiting to be accepted/rejected by the server). public bool IsPending => !(connection is null) && connection.IsPending; /// Whether or not the client is currently connected. public bool IsConnected => !(connection is null) && connection.IsConnected; /// The client's connection to a server. // Not an auto property because properties can't be passed as ref/out parameters. Could // use a local variable in the Connect method, but that's arguably not any cleaner. This // property will also probably only be used rarely from outside the class/library. public Connection Connection => connection; /// Encapsulates a method that handles a message from a server. /// The message that was received. public delegate void MessageHandler(Message message); /// private Connection connection; /// How many connection attempts have been made so far. private int connectionAttempts; /// How many connection attempts to make before giving up. private int maxConnectionAttempts; /// private Dictionary messageHandlers; /// The underlying transport's client that is used for sending and receiving data. private IClient transport; /// The message sent when connecting. May include custom data. private Message connectMessage; /// Handles initial setup. /// The transport to use for sending and receiving data. /// The name to use when logging messages via . public Client(IClient transport, string logName = "CLIENT") : base(logName) { this.transport = transport; } /// Handles initial setup using the built-in UDP transport. /// The name to use when logging messages via . public Client(string logName = "CLIENT") : this(new Transports.Udp.UdpClient(), logName) { } /// Disconnects the client if it's connected and swaps out the transport it's using. /// The new transport to use for sending and receiving data. /// This method does not automatically reconnect to the server. To continue communicating with the server, must be called again. public void ChangeTransport(IClient newTransport) { Disconnect(); transport = newTransport; } /// Attempts to connect to a server at the given host address. /// The host address to connect to. /// How many connection attempts to make before giving up. /// The ID of the group of message handler methods to use when building . /// Data that should be sent to the server with the connection attempt. Use to get an empty message instance. /// Whether or not the client should use the built-in message handler system. /// /// Riptide's default transport expects the host address to consist of an IP and port, separated by a colon. For example: 127.0.0.1:7777. If you are using a different transport, check the relevant documentation for what information it requires in the host address. /// Setting to will disable the automatic detection and execution of methods with the , which is beneficial if you prefer to handle messages via the event. /// /// if a connection attempt will be made. if an issue occurred (such as being in an invalid format) and a connection attempt will not be made. public bool Connect(string hostAddress, int maxConnectionAttempts = 5, byte messageHandlerGroupId = 0, Message message = null, bool useMessageHandlers = true) { Disconnect(); SubToTransportEvents(); if (!transport.Connect(hostAddress, out connection, out string connectError)) { RiptideLogger.Log(LogType.Error, LogName, connectError); UnsubFromTransportEvents(); return false; } this.maxConnectionAttempts = maxConnectionAttempts; connectionAttempts = 0; connection.Initialize(this, defaultTimeout); IncreaseActiveCount(); this.useMessageHandlers = useMessageHandlers; if (useMessageHandlers) CreateMessageHandlersDictionary(messageHandlerGroupId); connectMessage = Message.Create(MessageHeader.Connect); if (message != null) { if (message.ReadBits != 0) RiptideLogger.Log(LogType.Error, LogName, $"Use the parameterless 'Message.Create()' overload when setting connection attempt data!"); connectMessage.AddMessage(message); message.Release(); } StartTime(); Heartbeat(); RiptideLogger.Log(LogType.Info, LogName, $"Connecting to {connection}..."); return true; } /// Subscribes appropriate methods to the transport's events. private void SubToTransportEvents() { transport.Connected += TransportConnected; transport.ConnectionFailed += TransportConnectionFailed; transport.DataReceived += HandleData; transport.Disconnected += TransportDisconnected; } /// Unsubscribes methods from all of the transport's events. private void UnsubFromTransportEvents() { transport.Connected -= TransportConnected; transport.ConnectionFailed -= TransportConnectionFailed; transport.DataReceived -= HandleData; transport.Disconnected -= TransportDisconnected; } /// protected override void CreateMessageHandlersDictionary(byte messageHandlerGroupId) { MethodInfo[] methods = FindMessageHandlers(); messageHandlers = new Dictionary(methods.Length); foreach (MethodInfo method in methods) { MessageHandlerAttribute attribute = method.GetCustomAttribute(); if (attribute.GroupId != messageHandlerGroupId) continue; if (!method.IsStatic) throw new NonStaticHandlerException(method.DeclaringType, method.Name); Delegate clientMessageHandler = Delegate.CreateDelegate(typeof(MessageHandler), method, false); if (clientMessageHandler != null) { // It's a message handler for Client instances if (messageHandlers.ContainsKey(attribute.MessageId)) { MethodInfo otherMethodWithId = messageHandlers[attribute.MessageId].GetMethodInfo(); throw new DuplicateHandlerException(attribute.MessageId, method, otherMethodWithId); } else messageHandlers.Add(attribute.MessageId, (MessageHandler)clientMessageHandler); } else { // It's not a message handler for Client instances, but it might be one for Server instances if (Delegate.CreateDelegate(typeof(Server.MessageHandler), method, false) == null) throw new InvalidHandlerSignatureException(method.DeclaringType, method.Name); } } } /// internal override void Heartbeat() { if (IsConnecting) { // If still trying to connect, send connect messages instead of heartbeats if (connectionAttempts < maxConnectionAttempts) { Send(connectMessage, false); connectionAttempts++; } else LocalDisconnect(DisconnectReason.NeverConnected); } else if (IsPending) { // If waiting for the server to accept/reject the connection attempt if (connection.HasConnectAttemptTimedOut) { LocalDisconnect(DisconnectReason.TimedOut); return; } } else if (IsConnected) { // If connected and not timed out, send heartbeats if (connection.HasTimedOut) { LocalDisconnect(DisconnectReason.TimedOut); return; } connection.SendHeartbeat(); } ExecuteLater(HeartbeatInterval, new HeartbeatEvent(this)); } /// public override void Update() { base.Update(); transport.Poll(); HandleMessages(); } /// protected override void Handle(Message message, MessageHeader header, Connection connection) { switch (header) { // User messages case MessageHeader.Unreliable: case MessageHeader.Reliable: OnMessageReceived(message); break; // Internal messages case MessageHeader.Ack: connection.HandleAck(message); break; case MessageHeader.Connect: connection.SetPending(); break; case MessageHeader.Reject: if (!IsConnected) // Don't disconnect if we are connected LocalDisconnect(DisconnectReason.ConnectionRejected, message, (RejectReason)message.GetByte()); break; case MessageHeader.Heartbeat: connection.HandleHeartbeatResponse(message); break; case MessageHeader.Disconnect: LocalDisconnect((DisconnectReason)message.GetByte(), message); break; case MessageHeader.Welcome: if (IsConnecting || IsPending) { connection.HandleWelcome(message); OnConnected(); } break; case MessageHeader.ClientConnected: OnClientConnected(message.GetUShort()); break; case MessageHeader.ClientDisconnected: OnClientDisconnected(message.GetUShort()); break; default: RiptideLogger.Log(LogType.Warning, LogName, $"Unexpected message header '{header}'! Discarding {message.BytesInUse} bytes."); break; } message.Release(); } /// Sends a message to the server. /// public ushort Send(Message message, bool shouldRelease = true) => connection.Send(message, shouldRelease); /// Disconnects from the server. public void Disconnect() { if (connection == null || IsNotConnected) return; Send(Message.Create(MessageHeader.Disconnect)); LocalDisconnect(DisconnectReason.Disconnected); } /// internal override void Disconnect(Connection connection, DisconnectReason reason) { if (connection.IsConnected && connection.CanQualityDisconnect) LocalDisconnect(reason); } /// Cleans up the local side of the connection. /// The reason why the client has disconnected. /// The disconnection or rejection message, potentially containing extra data to be handled externally. /// The reason why the connection was rejected (if it was rejected). private void LocalDisconnect(DisconnectReason reason, Message message = null, RejectReason rejectReason = RejectReason.NoConnection) { if (IsNotConnected) return; UnsubFromTransportEvents(); DecreaseActiveCount(); StopTime(); transport.Disconnect(); connection.LocalDisconnect(); if (reason == DisconnectReason.NeverConnected) OnConnectionFailed(RejectReason.NoConnection); else if (reason == DisconnectReason.ConnectionRejected) OnConnectionFailed(rejectReason, message); else OnDisconnected(reason, message); } /// What to do when the transport establishes a connection. private void TransportConnected(object sender, EventArgs e) { } /// What to do when the transport fails to connect. private void TransportConnectionFailed(object sender, EventArgs e) { LocalDisconnect(DisconnectReason.NeverConnected); } /// What to do when the transport disconnects. private void TransportDisconnected(object sender, Transports.DisconnectedEventArgs e) { if (connection == e.Connection) LocalDisconnect(e.Reason); } #region Events /// Invokes the event. protected virtual void OnConnected() { connectMessage.Release(); connectMessage = null; RiptideLogger.Log(LogType.Info, LogName, "Connected successfully!"); Connected?.Invoke(this, EventArgs.Empty); } /// Invokes the event. /// The reason for the connection failure. /// Additional data related to the failed connection attempt. protected virtual void OnConnectionFailed(RejectReason reason, Message message = null) { connectMessage.Release(); connectMessage = null; RiptideLogger.Log(LogType.Info, LogName, $"Connection to server failed: {Helper.GetReasonString(reason)}."); ConnectionFailed?.Invoke(this, new ConnectionFailedEventArgs(reason, message)); } /// Invokes the event and initiates handling of the received message. /// The received message. protected virtual void OnMessageReceived(Message message) { ushort messageId = (ushort)message.GetVarULong(); MessageReceived?.Invoke(this, new MessageReceivedEventArgs(connection, messageId, message)); if (useMessageHandlers) { if (messageHandlers.TryGetValue(messageId, out MessageHandler messageHandler)) messageHandler(message); else RiptideLogger.Log(LogType.Warning, LogName, $"No message handler method found for message ID {messageId}!"); } } /// Invokes the event. /// The reason for the disconnection. /// Additional data related to the disconnection. protected virtual void OnDisconnected(DisconnectReason reason, Message message) { RiptideLogger.Log(LogType.Info, LogName, $"Disconnected from server: {Helper.GetReasonString(reason)}."); Disconnected?.Invoke(this, new DisconnectedEventArgs(reason, message)); } /// Invokes the event. /// The numeric ID of the client that connected. protected virtual void OnClientConnected(ushort clientId) { RiptideLogger.Log(LogType.Info, LogName, $"Client {clientId} connected."); ClientConnected?.Invoke(this, new ClientConnectedEventArgs(clientId)); } /// Invokes the event. /// The numeric ID of the client that disconnected. protected virtual void OnClientDisconnected(ushort clientId) { RiptideLogger.Log(LogType.Info, LogName, $"Client {clientId} disconnected."); ClientDisconnected?.Invoke(this, new ClientDisconnectedEventArgs(clientId)); } #endregion } }