608 lines
18 KiB
C#
608 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net.Sockets;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace GeoSus.Client
|
|
{
|
|
// Hlavní klientská třída pro připojení k serveru
|
|
public class GameClient : IDisposable
|
|
{
|
|
private TcpClient? _tcpClient;
|
|
private NetworkStream? _stream;
|
|
private ClientEncryption? _encryption;
|
|
private CancellationTokenSource? _cts;
|
|
private Task? _receiveTask;
|
|
private int _clientSeq;
|
|
private readonly object _sendLock = new object();
|
|
private bool _handshakeComplete;
|
|
|
|
public string ClientUuid { get; }
|
|
public string DisplayName { get; set; }
|
|
public bool IsConnected => _tcpClient?.Connected ?? false;
|
|
public bool IsReady => IsConnected && _handshakeComplete && (_encryption?.HasSessionKey ?? false);
|
|
public EventDispatcher Dispatcher { get; }
|
|
|
|
// Events - voláno na main thread přes dispatcher
|
|
public event Action? OnConnected;
|
|
public event Action<string>? OnDisconnected;
|
|
public event Action<string>? OnError;
|
|
public event Action<Message>? OnMessage;
|
|
public event Action<GameEvent>? OnGameEvent;
|
|
|
|
// Lobby state
|
|
public string? LobbyId { get; private set; }
|
|
public string? JoinCode { get; private set; }
|
|
public LobbyState? CurrentLobbyState { get; private set; }
|
|
public PlayerRole? MyRole { get; private set; }
|
|
public List<GameTask> MyTasks { get; } = new List<GameTask>();
|
|
public Position MyPosition { get; set; }
|
|
public Dictionary<string, PlayerPositionInfo> PlayerPositions { get; } = new Dictionary<string, PlayerPositionInfo>();
|
|
public List<Body> Bodies { get; } = new List<Body>();
|
|
public int Ping { get; private set; }
|
|
public long LastEventId { get; private set; }
|
|
|
|
/// <summary>Returns true if this client is the current lobby owner</summary>
|
|
public bool IsOwner => CurrentLobbyState?.OwnerId == ClientUuid;
|
|
|
|
public GameClient(string clientUuid, string displayName)
|
|
{
|
|
ClientUuid = clientUuid;
|
|
DisplayName = displayName;
|
|
Dispatcher = new EventDispatcher();
|
|
}
|
|
|
|
#region Connection
|
|
|
|
public async Task<bool> ConnectAsync(string host, int port)
|
|
{
|
|
try
|
|
{
|
|
_tcpClient = new TcpClient();
|
|
await _tcpClient.ConnectAsync(host, port);
|
|
_stream = _tcpClient.GetStream();
|
|
_encryption = new ClientEncryption();
|
|
_cts = new CancellationTokenSource();
|
|
|
|
// Handshake
|
|
if (!await PerformHandshakeAsync())
|
|
{
|
|
Disconnect("Handshake failed");
|
|
return false;
|
|
}
|
|
|
|
// Spustíme příjem zpráv
|
|
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
|
|
|
|
Dispatcher.Post(() => OnConnected?.Invoke());
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Dispatcher.Post(() => OnError?.Invoke(ex.Message));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task<bool> PerformHandshakeAsync()
|
|
{
|
|
if (_stream == null || _encryption == null) return false;
|
|
|
|
// 1. ClientHello
|
|
var hello = new ClientHello
|
|
{
|
|
ClientUuid = ClientUuid,
|
|
DisplayName = DisplayName
|
|
};
|
|
await SendPlainAsync(hello);
|
|
|
|
// 2. ServerHello
|
|
var serverHelloData = await ReadMessageAsync();
|
|
if (serverHelloData == null) return false;
|
|
|
|
var serverHello = MessageSerializer.Deserialize(serverHelloData) as ServerHello;
|
|
if (serverHello == null) return false;
|
|
|
|
// 3. Generujeme session key a šifrujeme RSA
|
|
_encryption.GenerateSessionKey();
|
|
var (encKey, encIv) = _encryption.EncryptSessionKeyForServer(serverHello.RsaPublicKeyPem);
|
|
|
|
var keyExchange = new KeyExchange
|
|
{
|
|
EncryptedSessionKey = encKey,
|
|
EncryptedIV = encIv
|
|
};
|
|
await SendPlainAsync(keyExchange);
|
|
|
|
// 4. KeyExchangeAck (šifrovaně)
|
|
var ackData = await ReadMessageAsync();
|
|
if (ackData == null) return false;
|
|
|
|
var decrypted = _encryption.Decrypt(ackData);
|
|
if (decrypted == null) return false;
|
|
|
|
var ack = MessageSerializer.Deserialize(decrypted) as KeyExchangeAck;
|
|
if (ack?.Status == "success")
|
|
{
|
|
_handshakeComplete = true;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public void Disconnect(string reason = "User disconnected")
|
|
{
|
|
_cts?.Cancel();
|
|
_tcpClient?.Close();
|
|
_tcpClient = null;
|
|
_stream = null;
|
|
_encryption?.Dispose();
|
|
_encryption = null;
|
|
|
|
LobbyId = null;
|
|
JoinCode = null;
|
|
CurrentLobbyState = null;
|
|
MyRole = null;
|
|
MyTasks.Clear();
|
|
PlayerPositions.Clear();
|
|
Bodies.Clear();
|
|
|
|
Dispatcher.Post(() => OnDisconnected?.Invoke(reason));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Sending
|
|
|
|
public void Send(Message message)
|
|
{
|
|
if (_stream == null || _encryption == null || !IsConnected) return;
|
|
|
|
message.ClientSeq = Interlocked.Increment(ref _clientSeq);
|
|
if (string.IsNullOrEmpty(message.ActionId))
|
|
{
|
|
message.ActionId = Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
}
|
|
|
|
var plain = MessageSerializer.Serialize(message);
|
|
var encrypted = _encryption.Encrypt(plain);
|
|
|
|
lock (_sendLock)
|
|
{
|
|
try
|
|
{
|
|
SendData(encrypted);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Dispatcher.Post(() => OnError?.Invoke($"Send error: {ex.Message}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SendPlainAsync(Message message)
|
|
{
|
|
if (_stream == null) return;
|
|
var data = MessageSerializer.Serialize(message);
|
|
await SendDataAsync(data);
|
|
}
|
|
|
|
private void SendData(byte[] data)
|
|
{
|
|
if (_stream == null) return;
|
|
|
|
var lengthBuffer = BitConverter.GetBytes(data.Length);
|
|
if (BitConverter.IsLittleEndian)
|
|
Array.Reverse(lengthBuffer);
|
|
|
|
_stream.Write(lengthBuffer, 0, 4);
|
|
_stream.Write(data, 0, data.Length);
|
|
_stream.Flush();
|
|
}
|
|
|
|
private async Task SendDataAsync(byte[] data)
|
|
{
|
|
if (_stream == null) return;
|
|
|
|
var lengthBuffer = BitConverter.GetBytes(data.Length);
|
|
if (BitConverter.IsLittleEndian)
|
|
Array.Reverse(lengthBuffer);
|
|
|
|
await _stream.WriteAsync(lengthBuffer, 0, 4);
|
|
await _stream.WriteAsync(data, 0, data.Length);
|
|
await _stream.FlushAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Receiving
|
|
|
|
private async Task ReceiveLoopAsync(CancellationToken ct)
|
|
{
|
|
int decryptFailures = 0;
|
|
|
|
try
|
|
{
|
|
while (!ct.IsCancellationRequested && IsConnected)
|
|
{
|
|
var data = await ReadMessageAsync();
|
|
if (data == null) break;
|
|
|
|
var decrypted = _encryption?.Decrypt(data);
|
|
if (decrypted == null)
|
|
{
|
|
decryptFailures++;
|
|
if (decryptFailures >= 3)
|
|
{
|
|
Disconnect("Too many decryption failures");
|
|
return;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
decryptFailures = 0;
|
|
|
|
var message = MessageSerializer.Deserialize(decrypted);
|
|
if (message != null)
|
|
{
|
|
ProcessMessage(message);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex) when (!ct.IsCancellationRequested)
|
|
{
|
|
Disconnect($"Connection error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<byte[]?> ReadMessageAsync()
|
|
{
|
|
if (_stream == null) return null;
|
|
|
|
var lengthBuffer = new byte[4];
|
|
var read = await _stream.ReadAsync(lengthBuffer, 0, 4);
|
|
if (read < 4) return null;
|
|
|
|
if (BitConverter.IsLittleEndian)
|
|
Array.Reverse(lengthBuffer);
|
|
var length = BitConverter.ToInt32(lengthBuffer, 0);
|
|
|
|
if (length <= 0 || length > 1048576) return null;
|
|
|
|
var buffer = new byte[length];
|
|
var totalRead = 0;
|
|
while (totalRead < length)
|
|
{
|
|
read = await _stream.ReadAsync(buffer, totalRead, length - totalRead);
|
|
if (read == 0) return null;
|
|
totalRead += read;
|
|
}
|
|
|
|
return buffer;
|
|
}
|
|
|
|
private void ProcessMessage(Message message)
|
|
{
|
|
// Zpracujeme speciální typy
|
|
switch (message)
|
|
{
|
|
case CreateLobbyResponse r:
|
|
if (r.Success)
|
|
{
|
|
LobbyId = r.LobbyId;
|
|
JoinCode = r.JoinCode;
|
|
CurrentLobbyState = r.LobbyState;
|
|
}
|
|
break;
|
|
|
|
case JoinLobbyResponse r:
|
|
if (r.Success)
|
|
{
|
|
LobbyId = r.LobbyId;
|
|
CurrentLobbyState = r.LobbyState;
|
|
JoinCode = r.LobbyState?.JoinCode;
|
|
}
|
|
break;
|
|
|
|
case PositionBroadcast b:
|
|
ProcessPositionBroadcast(b);
|
|
break;
|
|
|
|
case Pong p:
|
|
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
Ping = (int)(now - p.ClientTime);
|
|
break;
|
|
|
|
case GameEvent evt:
|
|
ProcessGameEvent(evt);
|
|
Dispatcher.Post(() => OnGameEvent?.Invoke(evt));
|
|
break;
|
|
}
|
|
|
|
Dispatcher.Post(() => OnMessage?.Invoke(message));
|
|
}
|
|
|
|
private void ProcessPositionBroadcast(PositionBroadcast broadcast)
|
|
{
|
|
PlayerPositions.Clear();
|
|
foreach (var player in broadcast.Players)
|
|
{
|
|
PlayerPositions[player.ClientUuid] = player;
|
|
}
|
|
}
|
|
|
|
private void ProcessGameEvent(GameEvent evt)
|
|
{
|
|
LastEventId = evt.EventId;
|
|
|
|
switch (evt.EventType)
|
|
{
|
|
case "PlayerJoined":
|
|
// Add player to lobby state
|
|
var joinedPayload = evt.GetPayload<PlayerJoinedPayload>();
|
|
if (joinedPayload != null && CurrentLobbyState?.Players != null)
|
|
{
|
|
// Check if player already exists
|
|
bool exists = CurrentLobbyState.Players.Any(p => p.ClientUuid == joinedPayload.ClientUuid);
|
|
if (!exists)
|
|
{
|
|
CurrentLobbyState.Players.Add(new PlayerInfo
|
|
{
|
|
ClientUuid = joinedPayload.ClientUuid,
|
|
DisplayName = joinedPayload.DisplayName,
|
|
IsOwner = false,
|
|
IsReady = false,
|
|
State = PlayerState.Alive
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "PlayerLeft":
|
|
// Remove player from lobby state
|
|
var leftPayload = evt.GetPayload<PlayerLeftPayload>();
|
|
if (leftPayload != null && CurrentLobbyState?.Players != null)
|
|
{
|
|
CurrentLobbyState.Players.RemoveAll(p => p.ClientUuid == leftPayload.ClientUuid);
|
|
}
|
|
break;
|
|
|
|
case "HostChanged":
|
|
// Update lobby owner
|
|
var hostPayload = evt.GetPayload<HostChangedPayload>();
|
|
if (hostPayload != null && CurrentLobbyState != null)
|
|
{
|
|
CurrentLobbyState.OwnerId = hostPayload.NewHostId;
|
|
// Update IsOwner flag on all players
|
|
foreach (var player in CurrentLobbyState.Players)
|
|
{
|
|
player.IsOwner = player.ClientUuid == hostPayload.NewHostId;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "GameStarting":
|
|
// Game is entering loading phase - update lobby state if available
|
|
if (CurrentLobbyState != null)
|
|
{
|
|
CurrentLobbyState.Phase = GamePhase.Loading;
|
|
}
|
|
break;
|
|
|
|
case "MapDataReady":
|
|
// Map data received - store it and send confirmation
|
|
var mapDataPayload = evt.GetPayload<MapDataReadyPayload>();
|
|
if (mapDataPayload != null && CurrentLobbyState != null)
|
|
{
|
|
CurrentLobbyState.MapData = mapDataPayload.MapData;
|
|
CurrentLobbyState.MapDataReady = true;
|
|
}
|
|
// Send confirmation to server
|
|
Send(new MapDataReceived());
|
|
break;
|
|
|
|
case "GameStarted":
|
|
// Game officially started - update phase
|
|
if (CurrentLobbyState != null)
|
|
{
|
|
CurrentLobbyState.Phase = GamePhase.Playing;
|
|
}
|
|
break;
|
|
|
|
case "RoleAssigned":
|
|
var rolePayload = evt.GetPayload<RoleAssignedPayload>();
|
|
if (rolePayload != null && rolePayload.ClientUuid == ClientUuid)
|
|
{
|
|
MyRole = rolePayload.Role;
|
|
MyTasks.Clear();
|
|
if (rolePayload.Tasks != null)
|
|
{
|
|
MyTasks.AddRange(rolePayload.Tasks);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "PlayerKilled":
|
|
var killPayload = evt.GetPayload<PlayerKilledPayload>();
|
|
if (killPayload != null)
|
|
{
|
|
Bodies.Add(new Body
|
|
{
|
|
BodyId = killPayload.BodyId,
|
|
VictimId = killPayload.VictimId,
|
|
Location = killPayload.Location
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "MeetingStarted":
|
|
if (CurrentLobbyState != null)
|
|
{
|
|
CurrentLobbyState.Phase = GamePhase.Meeting;
|
|
}
|
|
break;
|
|
|
|
case "VotingClosed":
|
|
Bodies.Clear(); // Bodies zmizí po meetingu
|
|
if (CurrentLobbyState != null)
|
|
{
|
|
CurrentLobbyState.Phase = GamePhase.Playing;
|
|
}
|
|
break;
|
|
|
|
case "GameEnded":
|
|
if (CurrentLobbyState != null)
|
|
{
|
|
CurrentLobbyState.Phase = GamePhase.Ended;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Game Actions
|
|
|
|
public void CreateLobby(Position? center = null, int impostorCount = 1, int taskCount = 5, string? password = null, double playAreaRadius = 500)
|
|
{
|
|
Send(new CreateLobby
|
|
{
|
|
PlayAreaCenter = center,
|
|
PlayAreaRadius = playAreaRadius,
|
|
ImpostorCount = impostorCount,
|
|
TaskCount = taskCount,
|
|
Password = password
|
|
});
|
|
}
|
|
|
|
public void JoinLobby(string joinCode, string? password = null)
|
|
{
|
|
Send(new JoinLobby
|
|
{
|
|
JoinCode = joinCode.ToUpperInvariant(),
|
|
Password = password
|
|
});
|
|
}
|
|
|
|
public void LeaveLobby()
|
|
{
|
|
Send(new LeaveLobby());
|
|
LobbyId = null;
|
|
JoinCode = null;
|
|
}
|
|
|
|
public void StartGame()
|
|
{
|
|
Send(new StartGame());
|
|
}
|
|
|
|
public void ReturnToLobby()
|
|
{
|
|
Send(new ReturnToLobby());
|
|
}
|
|
|
|
public void UpdatePosition(Position position)
|
|
{
|
|
MyPosition = position;
|
|
Send(new UpdatePosition { Position = position });
|
|
}
|
|
|
|
public void Kill(string targetUuid)
|
|
{
|
|
Send(new KillAttempt { TargetClientUuid = targetUuid });
|
|
}
|
|
|
|
public void ReportBody(string bodyId)
|
|
{
|
|
Send(new ReportBody { BodyId = bodyId });
|
|
}
|
|
|
|
public void CallEmergencyMeeting()
|
|
{
|
|
Send(new CallEmergencyMeeting());
|
|
}
|
|
|
|
public void Vote(string? targetUuid)
|
|
{
|
|
Send(new CastVote { TargetClientUuid = targetUuid });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pokus o dokončení tasku. Server ověří že hráč je na správné pozici.
|
|
/// </summary>
|
|
public void CompleteTask(string taskId)
|
|
{
|
|
Send(new TaskComplete { TaskId = taskId });
|
|
}
|
|
|
|
public void SendPing()
|
|
{
|
|
Send(new Ping { ClientTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
|
|
}
|
|
|
|
public void Reconnect(string lobbyId)
|
|
{
|
|
Send(new Reconnect { LobbyId = lobbyId, LastEventId = LastEventId });
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helpers
|
|
|
|
public Body? FindNearbyBody(double maxDistance)
|
|
{
|
|
foreach (var body in Bodies)
|
|
{
|
|
if (MyPosition.DistanceTo(body.Location) <= maxDistance)
|
|
{
|
|
return body;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public string? FindNearbyPlayer(double maxDistance, bool aliveOnly = true)
|
|
{
|
|
foreach (var (uuid, info) in PlayerPositions)
|
|
{
|
|
if (uuid == ClientUuid) continue;
|
|
if (aliveOnly && info.State != PlayerState.Alive) continue;
|
|
|
|
if (MyPosition.DistanceTo(info.Position) <= maxDistance)
|
|
{
|
|
return uuid;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public GameTask? FindNearbyTask(double maxDistance)
|
|
{
|
|
foreach (var task in MyTasks)
|
|
{
|
|
if (MyPosition.DistanceTo(task.Location) <= maxDistance)
|
|
{
|
|
return task;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Volat z Unity Update() pro zpracování callbacků
|
|
public void Update()
|
|
{
|
|
Dispatcher.ProcessPendingActions();
|
|
}
|
|
|
|
#endregion
|
|
|
|
public void Dispose()
|
|
{
|
|
Disconnect("Disposed");
|
|
_encryption?.Dispose();
|
|
}
|
|
}
|
|
}
|