Files
MSCO/NetSession.cs
T

550 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using UnityEngine;
namespace MSCO
{
public enum MsgType : byte
{
Hello = 1, // Client -> Host: Mod-Version + Name
Welcome = 2, // Host -> Client: eigene ID + Spielerliste
Reject = 3, // Host -> Client: Grund
Player = 4, // Spielerposition
SaveBegin = 5, // Host -> Client: Spielstand kommt
SaveFile = 6, // Host -> Client: Dateiname + Inhalt
StartGame = 7, // Host -> Client: jetzt laden
ObjStates = 8, // Positionen der Objekte, die man gerade besitzt
ObjOwn = 9, // "Ich übernehme dieses Objekt"
ObjRelease = 10, // "Objekt liegt still, wieder frei"
FsmEvent = 11, // Schraube angezogen/gelöst, Teil an-/abgebaut
PlayerJoined = 12,// Host -> alle: neuer Spieler (ID + Name)
PlayerLeft = 13, // Host -> alle: Spieler weg (ID)
VehicleFx = 14, // Lichter und Motor-/Dauergeräusche eines Fahrzeugs
Ping = 15, // Lebenszeichen (Steam hat keinen Verbindungsabbruch)
Ready = 16, // Client -> Server: Spiel geladen, Objekte erfasst
WorldSnapshot = 17,// Server -> Client: aktueller Weltzustand
Info = 18, // Server-Browser fragt Infos ab (ohne Hello)
InfoReply = 19,
WorldTime = 20, // Uhrzeit + Wetter vom "Weltuhr"-Spieler
MoneyDelta = 21, // Geld hat sich geändert (+/- Betrag, gemeinsame Kasse)
DoorState = 22, // Tür/Tor/Haube hat den Zustand gewechselt (FSM-Pfad + Zustand)
AdminStatus = 23, // Server -> Client: bool, ist Admin
AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü
AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin]
AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund
AdminResult = 27, // Server -> Client: string Meldung
Chat = 28, // Chatnachricht (string)
VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert]
Teleport = 30 // Admin holt einen Spieler zu sich: 3 float Position
}
public struct Message
{
public MsgType Type;
public byte[] Payload;
public BinaryReader Reader()
{
return new BinaryReader(new MemoryStream(Payload));
}
}
public struct PlayerState
{
public Vector3 Position;
public float RotationY;
public Vector3 CamPos; // Kopf/Kamera in Weltkoordinaten
public Quaternion CamRot; // Blickrichtung inkl. hoch/runter
public int HeldId; // ID des gehaltenen Objekts, 0 = nichts
public Vector3 HeldLocalPos; // relativ zur Kamera
public Quaternion HeldLocalRot; // relativ zur Kamera
public bool Seated; // fährt oder sitzt als Beifahrer
public float FeetY; // Bodenhöhe unter dem Spieler (für Ducken/Lehnen der Figur)
public byte[] Serialize()
{
PlayerState s = this;
return NetSession.Build(w =>
{
Write(w, s.Position);
w.Write(s.RotationY);
Write(w, s.CamPos);
Write(w, s.CamRot);
w.Write(s.Seated);
w.Write(s.FeetY);
w.Write(s.HeldId);
if (s.HeldId == 0) return;
Write(w, s.HeldLocalPos);
Write(w, s.HeldLocalRot);
});
}
public static PlayerState Deserialize(BinaryReader r)
{
var s = new PlayerState
{
Position = ReadVector(r),
RotationY = r.ReadSingle(),
CamPos = ReadVector(r),
CamRot = ReadQuaternion(r),
Seated = r.ReadBoolean(),
FeetY = r.ReadSingle(),
HeldId = r.ReadInt32(),
HeldLocalRot = Quaternion.identity
};
if (s.HeldId != 0)
{
s.HeldLocalPos = ReadVector(r);
s.HeldLocalRot = ReadQuaternion(r);
}
return s;
}
static void Write(BinaryWriter w, Vector3 v) { w.Write(v.x); w.Write(v.y); w.Write(v.z); }
static void Write(BinaryWriter w, Quaternion q) { w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w); }
static Vector3 ReadVector(BinaryReader r) { return new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); }
static Quaternion ReadQuaternion(BinaryReader r) { return new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); }
}
public interface IConnection
{
bool IsClosed { get; }
string CloseReason { get; }
List<Message> Poll();
void Send(MsgType type, byte[] payload);
void Close(string reason);
}
/// <summary>Setzt aus einem Byte-Strom Nachrichten zusammen: [int Länge][byte Typ][Payload].</summary>
public class FrameParser
{
const int MaxFrame = 32 * 1024 * 1024;
byte[] buffer = new byte[64 * 1024];
int count;
public void Append(byte[] data, int length)
{
EnsureCapacity(count + length);
Buffer.BlockCopy(data, 0, buffer, count, length);
count += length;
}
public void Parse(List<Message> messages)
{
int offset = 0;
while (count - offset >= 5)
{
int length = BitConverter.ToInt32(buffer, offset);
if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
if (count - offset - 4 < length) break;
var payload = new byte[length - 1];
Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload });
offset += 4 + length;
}
if (offset > 0)
{
Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset);
count -= offset;
}
}
public static byte[] Frame(MsgType type, byte[] payload)
{
if (payload == null) payload = new byte[0];
var frame = new byte[5 + payload.Length];
BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
frame[4] = (byte)type;
Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
return frame;
}
void EnsureCapacity(int needed)
{
if (needed <= buffer.Length) return;
var bigger = new byte[Math.Max(needed, buffer.Length * 2)];
Buffer.BlockCopy(buffer, 0, bigger, 0, count);
buffer = bigger;
}
}
/// <summary>TCP-Verbindung (LAN / IP). Lesen ist nicht-blockierend und wird über Poll() abgefragt.</summary>
public class Connection : IConnection
{
public bool IsClosed { get; private set; }
public string CloseReason { get; private set; }
readonly TcpClient client;
readonly NetworkStream stream;
readonly FrameParser parser = new FrameParser();
readonly byte[] readBuffer = new byte[64 * 1024];
public Connection(TcpClient client)
{
this.client = client;
client.NoDelay = true;
client.SendBufferSize = 4 * 1024 * 1024;
stream = client.GetStream();
}
public List<Message> Poll()
{
var messages = new List<Message>();
if (IsClosed) return messages;
try
{
// Verbindung von der Gegenseite geschlossen?
if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
{
Close("Connection closed");
return messages;
}
while (stream.DataAvailable)
{
int read = stream.Read(readBuffer, 0, readBuffer.Length);
if (read <= 0) break;
parser.Append(readBuffer, read);
}
parser.Parse(messages);
}
catch (Exception e)
{
Close("Network error: " + e.Message);
}
return messages;
}
public void Send(MsgType type, byte[] payload)
{
if (IsClosed) return;
try
{
byte[] frame = FrameParser.Frame(type, payload);
stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
}
catch (Exception e)
{
Close("Network error: " + e.Message);
}
}
public void Close(string reason)
{
if (IsClosed) return;
IsClosed = true;
CloseReason = reason;
try { client.Close(); } catch { }
}
}
/// <summary>
/// Verbindung über Steam P2P (Steam kümmert sich um NAT/Relay). Pakete sind auf 1 MB
/// begrenzt, deshalb werden Nachrichten in Stücke geteilt und beim Empfänger über den
/// FrameParser wieder zusammengesetzt (der Reliable-Kanal hält die Reihenfolge ein).
/// </summary>
public class SteamConnection : IConnection
{
const int ChunkSize = 512 * 1024;
const float Timeout = 20f; // ohne Pakete (auch Pings) gilt die Verbindung als tot
public readonly Steamworks.CSteamID Remote;
public bool IsClosed { get; private set; }
public string CloseReason { get; private set; }
readonly FrameParser parser = new FrameParser();
float lastReceive;
public SteamConnection(Steamworks.CSteamID remote)
{
Remote = remote;
lastReceive = Time.realtimeSinceStartup;
}
public void OnPacket(byte[] data, int length)
{
parser.Append(data, length);
lastReceive = Time.realtimeSinceStartup;
}
public List<Message> Poll()
{
var messages = new List<Message>();
if (IsClosed) return messages;
try { parser.Parse(messages); }
catch (Exception e) { Close("Network error: " + e.Message); }
if (Time.realtimeSinceStartup - lastReceive > Timeout) Close("Timed out");
return messages;
}
public void Send(MsgType type, byte[] payload)
{
if (IsClosed) return;
byte[] frame = FrameParser.Frame(type, payload);
for (int offset = 0; offset < frame.Length; offset += ChunkSize)
{
int length = Math.Min(ChunkSize, frame.Length - offset);
byte[] chunk = frame;
if (offset != 0 || length != frame.Length)
{
chunk = new byte[length];
Buffer.BlockCopy(frame, offset, chunk, 0, length);
}
if (!Steamworks.SteamNetworking.SendP2PPacket(Remote, chunk, (uint)length, Steamworks.EP2PSend.k_EP2PSendReliable, 0))
{
Close("Steam send failed");
return;
}
}
}
public void Close(string reason)
{
if (IsClosed) return;
IsClosed = true;
CloseReason = reason;
try { Steamworks.SteamNetworking.CloseP2PSessionWithUser(Remote); } catch { }
}
}
public enum NetEventKind { Connected, Disconnected, Message }
public struct NetEvent
{
public NetEventKind Kind;
public int PeerId; // beim Client immer NetSession.HostId
public Message Message;
public string Reason;
}
/// <summary>
/// Stern-Topologie: Der Host hält eine Verbindung zu jedem Client,
/// Clients sind nur mit dem Host verbunden. Der Host leitet Nachrichten weiter.
/// Transport entweder TCP (LAN/IP) oder Steam P2P.
/// </summary>
public class NetSession
{
public const int HostId = 0;
public bool IsHost { get; private set; }
public bool IsSteam { get; private set; }
public bool IsClosed { get; private set; }
public string CloseReason { get; private set; }
// Host
TcpListener listener;
readonly Dictionary<int, IConnection> peers = new Dictionary<int, IConnection>();
readonly Dictionary<ulong, int> steamPeerIds = new Dictionary<ulong, int>();
Func<Steamworks.CSteamID, bool> allowSteamPeer;
int nextPeerId = 1;
// Client
TcpClient connectingClient;
IAsyncResult connecting;
IConnection server;
bool pendingConnected;
byte[] steamBuffer = new byte[64 * 1024];
public static NetSession StartHost(int port)
{
var s = new NetSession { IsHost = true };
s.listener = new TcpListener(IPAddress.Any, port);
s.listener.Start();
return s;
}
public static NetSession StartClient(string ip, int port)
{
var s = new NetSession();
s.connectingClient = new TcpClient();
s.connecting = s.connectingClient.BeginConnect(ip, port, null, null);
return s;
}
/// <summary>Steam-Host: nimmt Pakete von Spielern an, die allowPeer erlaubt (Lobby-Mitglieder).</summary>
public static NetSession StartSteamHost(Func<Steamworks.CSteamID, bool> allowPeer)
{
return new NetSession { IsHost = true, IsSteam = true, allowSteamPeer = allowPeer };
}
public static NetSession StartSteamClient(Steamworks.CSteamID host)
{
return new NetSession { IsSteam = true, server = new SteamConnection(host), pendingConnected = true };
}
public List<NetEvent> Poll()
{
var events = new List<NetEvent>();
if (IsClosed) return events;
try
{
if (IsSteam) PumpSteam(events);
if (IsHost) PollHost(events);
else PollClient(events);
}
catch (Exception e)
{
Close("Network error: " + e.Message);
}
return events;
}
void PumpSteam(List<NetEvent> events)
{
uint size;
while (Steamworks.SteamNetworking.IsP2PPacketAvailable(out size, 0))
{
if (steamBuffer.Length < size) steamBuffer = new byte[size];
uint read;
Steamworks.CSteamID from;
if (!Steamworks.SteamNetworking.ReadP2PPacket(steamBuffer, size, out read, out from, 0)) break;
if (IsHost)
{
int id;
if (!steamPeerIds.TryGetValue(from.m_SteamID, out id))
{
if (allowSteamPeer == null || !allowSteamPeer(from)) continue; // nicht in der Lobby
id = nextPeerId++;
steamPeerIds[from.m_SteamID] = id;
peers[id] = new SteamConnection(from);
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = id });
}
IConnection c;
if (peers.TryGetValue(id, out c)) ((SteamConnection)c).OnPacket(steamBuffer, (int)read);
}
else
{
var host = server as SteamConnection;
if (host != null && from == host.Remote) host.OnPacket(steamBuffer, (int)read);
}
}
}
void PollHost(List<NetEvent> events)
{
if (listener != null)
{
while (listener.Pending())
{
int id = nextPeerId++;
peers[id] = new Connection(listener.AcceptTcpClient());
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = id });
}
}
List<int> closed = null;
foreach (KeyValuePair<int, IConnection> p in peers)
{
foreach (Message m in p.Value.Poll())
events.Add(new NetEvent { Kind = NetEventKind.Message, PeerId = p.Key, Message = m });
if (p.Value.IsClosed)
{
if (closed == null) closed = new List<int>();
closed.Add(p.Key);
}
}
if (closed == null) return;
foreach (int id in closed)
{
IConnection c = peers[id];
events.Add(new NetEvent { Kind = NetEventKind.Disconnected, PeerId = id, Reason = c.CloseReason });
var steamConn = c as SteamConnection;
if (steamConn != null) steamPeerIds.Remove(steamConn.Remote.m_SteamID);
peers.Remove(id);
}
}
void PollClient(List<NetEvent> events)
{
if (connecting != null)
{
if (!connecting.IsCompleted) return;
IAsyncResult result = connecting;
connecting = null;
connectingClient.EndConnect(result); // wirft, wenn nicht erreichbar
server = new Connection(connectingClient);
pendingConnected = true;
}
if (server == null) return;
if (pendingConnected)
{
pendingConnected = false;
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = HostId });
}
foreach (Message m in server.Poll())
events.Add(new NetEvent { Kind = NetEventKind.Message, PeerId = HostId, Message = m });
if (server.IsClosed) Close(server.CloseReason);
}
/// <summary>An einen bestimmten Spieler (Client: immer an den Host).</summary>
public void SendTo(int peerId, MsgType type, byte[] payload = null)
{
if (!IsHost) { if (server != null) server.Send(type, payload); return; }
IConnection c;
if (peers.TryGetValue(peerId, out c)) c.Send(type, payload);
}
/// <summary>An alle (Client: an den Host). Optional einen Spieler auslassen.</summary>
public void SendToAll(MsgType type, byte[] payload = null, int except = -1)
{
if (!IsHost) { if (server != null) server.Send(type, payload); return; }
foreach (KeyValuePair<int, IConnection> p in peers)
if (p.Key != except) p.Value.Send(type, payload);
}
/// <summary>Host: Verbindung zu einem Spieler trennen (wird beim nächsten Poll gemeldet).</summary>
public void Kick(int peerId, string reason)
{
IConnection c;
if (peers.TryGetValue(peerId, out c)) c.Close(reason);
}
/// <summary>Host: Steam-Spieler hat die Lobby verlassen.</summary>
public void DropSteamPeer(ulong steamId, string reason)
{
int id;
if (steamPeerIds.TryGetValue(steamId, out id)) Kick(id, reason);
}
public void Close(string reason = "Disconnected")
{
if (IsClosed) return;
IsClosed = true;
CloseReason = reason;
foreach (IConnection c in peers.Values) c.Close(reason);
peers.Clear();
steamPeerIds.Clear();
if (server != null) server.Close(reason);
try { if (connectingClient != null && server == null) connectingClient.Close(); } catch { }
try { if (listener != null) listener.Stop(); } catch { }
}
public static byte[] Build(Action<BinaryWriter> write)
{
using (var ms = new MemoryStream())
using (var w = new BinaryWriter(ms))
{
write(w);
w.Flush();
return ms.ToArray();
}
}
/// <summary>Stellt der Nachricht die Absender-ID voran (für weitergeleitete Nachrichten).</summary>
public static byte[] WithSender(int senderId, byte[] payload)
{
var result = new byte[4 + payload.Length];
BitConverter.GetBytes(senderId).CopyTo(result, 0);
Buffer.BlockCopy(payload, 0, result, 4, payload.Length);
return result;
}
}
}