Files
K-C-Multiplayer/Riptide/Client.cs
2025-12-14 21:08:19 +01:00

419 lines
20 KiB
C#

// 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
{
/// <summary>A client that can connect to a <see cref="Server"/>.</summary>
public class Client : Peer
{
/// <summary>Invoked when a connection to the server is established.</summary>
public event EventHandler Connected;
/// <summary>Invoked when a connection to the server fails to be established.</summary>
public event EventHandler<ConnectionFailedEventArgs> ConnectionFailed;
/// <summary>Invoked when a message is received.</summary>
public event EventHandler<MessageReceivedEventArgs> MessageReceived;
/// <summary>Invoked when disconnected from the server.</summary>
public event EventHandler<DisconnectedEventArgs> Disconnected;
/// <summary>Invoked when another <i>non-local</i> client connects.</summary>
public event EventHandler<ClientConnectedEventArgs> ClientConnected;
/// <summary>Invoked when another <i>non-local</i> client disconnects.</summary>
public event EventHandler<ClientDisconnectedEventArgs> ClientDisconnected;
/// <summary>The client's numeric ID.</summary>
public ushort Id => connection.Id;
/// <inheritdoc cref="Connection.RTT"/>
public short RTT => connection.RTT;
/// <inheritdoc cref="Connection.SmoothRTT"/>
/// <remarks>This value is slower to accurately represent lasting changes in latency than <see cref="RTT"/>, but it is less susceptible to changing drastically due to significant—but temporary—jumps in latency.</remarks>
public short SmoothRTT => connection.SmoothRTT;
/// <summary>Sets the client's <see cref="Connection.TimeoutTime"/>.</summary>
public override int TimeoutTime
{
set
{
defaultTimeout = value;
connection.TimeoutTime = defaultTimeout;
}
}
/// <summary>Whether or not the client is currently <i>not</i> trying to connect, pending, nor actively connected.</summary>
public bool IsNotConnected => connection is null || connection.IsNotConnected;
/// <summary>Whether or not the client is currently in the process of connecting.</summary>
public bool IsConnecting => !(connection is null) && connection.IsConnecting;
/// <summary>Whether or not the client's connection is currently pending (waiting to be accepted/rejected by the server).</summary>
public bool IsPending => !(connection is null) && connection.IsPending;
/// <summary>Whether or not the client is currently connected.</summary>
public bool IsConnected => !(connection is null) && connection.IsConnected;
/// <summary>The client's connection to a server.</summary>
// 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;
/// <summary>Encapsulates a method that handles a message from a server.</summary>
/// <param name="message">The message that was received.</param>
public delegate void MessageHandler(Message message);
/// <inheritdoc cref="Connection"/>
private Connection connection;
/// <summary>How many connection attempts have been made so far.</summary>
private int connectionAttempts;
/// <summary>How many connection attempts to make before giving up.</summary>
private int maxConnectionAttempts;
/// <inheritdoc cref="Server.messageHandlers"/>
private Dictionary<ushort, MessageHandler> messageHandlers;
/// <summary>The underlying transport's client that is used for sending and receiving data.</summary>
private IClient transport;
/// <summary>The message sent when connecting. May include custom data.</summary>
private Message connectMessage;
/// <summary>Handles initial setup.</summary>
/// <param name="transport">The transport to use for sending and receiving data.</param>
/// <param name="logName">The name to use when logging messages via <see cref="RiptideLogger"/>.</param>
public Client(IClient transport, string logName = "CLIENT") : base(logName)
{
this.transport = transport;
}
/// <summary>Handles initial setup using the built-in UDP transport.</summary>
/// <param name="logName">The name to use when logging messages via <see cref="RiptideLogger"/>.</param>
public Client(string logName = "CLIENT") : this(new Transports.Udp.UdpClient(), logName) { }
/// <summary>Disconnects the client if it's connected and swaps out the transport it's using.</summary>
/// <param name="newTransport">The new transport to use for sending and receiving data.</param>
/// <remarks>This method does not automatically reconnect to the server. To continue communicating with the server, <see cref="Connect(string, int, byte, Message, bool)"/> must be called again.</remarks>
public void ChangeTransport(IClient newTransport)
{
Disconnect();
transport = newTransport;
}
/// <summary>Attempts to connect to a server at the given host address.</summary>
/// <param name="hostAddress">The host address to connect to.</param>
/// <param name="maxConnectionAttempts">How many connection attempts to make before giving up.</param>
/// <param name="messageHandlerGroupId">The ID of the group of message handler methods to use when building <see cref="messageHandlers"/>.</param>
/// <param name="message">Data that should be sent to the server with the connection attempt. Use <see cref="Message.Create()"/> to get an empty message instance.</param>
/// <param name="useMessageHandlers">Whether or not the client should use the built-in message handler system.</param>
/// <remarks>
/// <para>Riptide's default transport expects the host address to consist of an IP and port, separated by a colon. For example: <c>127.0.0.1:7777</c>. If you are using a different transport, check the relevant documentation for what information it requires in the host address.</para>
/// <para>Setting <paramref name="useMessageHandlers"/> to <see langword="false"/> will disable the automatic detection and execution of methods with the <see cref="MessageHandlerAttribute"/>, which is beneficial if you prefer to handle messages via the <see cref="MessageReceived"/> event.</para>
/// </remarks>
/// <returns><see langword="true"/> if a connection attempt will be made. <see langword="false"/> if an issue occurred (such as <paramref name="hostAddress"/> being in an invalid format) and a connection attempt will <i>not</i> be made.</returns>
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;
}
/// <summary>Subscribes appropriate methods to the transport's events.</summary>
private void SubToTransportEvents()
{
transport.Connected += TransportConnected;
transport.ConnectionFailed += TransportConnectionFailed;
transport.DataReceived += HandleData;
transport.Disconnected += TransportDisconnected;
}
/// <summary>Unsubscribes methods from all of the transport's events.</summary>
private void UnsubFromTransportEvents()
{
transport.Connected -= TransportConnected;
transport.ConnectionFailed -= TransportConnectionFailed;
transport.DataReceived -= HandleData;
transport.Disconnected -= TransportDisconnected;
}
/// <inheritdoc/>
protected override void CreateMessageHandlersDictionary(byte messageHandlerGroupId)
{
MethodInfo[] methods = FindMessageHandlers();
messageHandlers = new Dictionary<ushort, MessageHandler>(methods.Length);
foreach (MethodInfo method in methods)
{
MessageHandlerAttribute attribute = method.GetCustomAttribute<MessageHandlerAttribute>();
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);
}
}
}
/// <inheritdoc/>
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));
}
/// <inheritdoc/>
public override void Update()
{
base.Update();
transport.Poll();
HandleMessages();
}
/// <inheritdoc/>
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();
}
/// <summary>Sends a message to the server.</summary>
/// <inheritdoc cref="Connection.Send(Message, bool)"/>
public ushort Send(Message message, bool shouldRelease = true) => connection.Send(message, shouldRelease);
/// <summary>Disconnects from the server.</summary>
public void Disconnect()
{
if (connection == null || IsNotConnected)
return;
Send(Message.Create(MessageHeader.Disconnect));
LocalDisconnect(DisconnectReason.Disconnected);
}
/// <inheritdoc/>
internal override void Disconnect(Connection connection, DisconnectReason reason)
{
if (connection.IsConnected && connection.CanQualityDisconnect)
LocalDisconnect(reason);
}
/// <summary>Cleans up the local side of the connection.</summary>
/// <param name="reason">The reason why the client has disconnected.</param>
/// <param name="message">The disconnection or rejection message, potentially containing extra data to be handled externally.</param>
/// <param name="rejectReason">The reason why the connection was rejected (<i>if</i> it was rejected).</param>
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);
}
/// <summary>What to do when the transport establishes a connection.</summary>
private void TransportConnected(object sender, EventArgs e) { }
/// <summary>What to do when the transport fails to connect.</summary>
private void TransportConnectionFailed(object sender, EventArgs e)
{
LocalDisconnect(DisconnectReason.NeverConnected);
}
/// <summary>What to do when the transport disconnects.</summary>
private void TransportDisconnected(object sender, Transports.DisconnectedEventArgs e)
{
if (connection == e.Connection)
LocalDisconnect(e.Reason);
}
#region Events
/// <summary>Invokes the <see cref="Connected"/> event.</summary>
protected virtual void OnConnected()
{
connectMessage.Release();
connectMessage = null;
RiptideLogger.Log(LogType.Info, LogName, "Connected successfully!");
Connected?.Invoke(this, EventArgs.Empty);
}
/// <summary>Invokes the <see cref="ConnectionFailed"/> event.</summary>
/// <param name="reason">The reason for the connection failure.</param>
/// <param name="message">Additional data related to the failed connection attempt.</param>
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));
}
/// <summary>Invokes the <see cref="MessageReceived"/> event and initiates handling of the received message.</summary>
/// <param name="message">The received message.</param>
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}!");
}
}
/// <summary>Invokes the <see cref="Disconnected"/> event.</summary>
/// <param name="reason">The reason for the disconnection.</param>
/// <param name="message">Additional data related to the disconnection.</param>
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));
}
/// <summary>Invokes the <see cref="ClientConnected"/> event.</summary>
/// <param name="clientId">The numeric ID of the client that connected.</param>
protected virtual void OnClientConnected(ushort clientId)
{
RiptideLogger.Log(LogType.Info, LogName, $"Client {clientId} connected.");
ClientConnected?.Invoke(this, new ClientConnectedEventArgs(clientId));
}
/// <summary>Invokes the <see cref="ClientDisconnected"/> event.</summary>
/// <param name="clientId">The numeric ID of the client that disconnected.</param>
protected virtual void OnClientDisconnected(ushort clientId)
{
RiptideLogger.Log(LogType.Info, LogName, $"Client {clientId} disconnected.");
ClientDisconnected?.Invoke(this, new ClientDisconnectedEventArgs(clientId));
}
#endregion
}
}