594 lines
23 KiB
C#
594 lines
23 KiB
C#
using GeoSus.Client;
|
|
using System.Collections;
|
|
using System.Threading.Tasks;
|
|
using UnityEngine;
|
|
using System.Collections.Generic;
|
|
using Subsystems;
|
|
using System.Linq;
|
|
using UnityEngine.SceneManagement;
|
|
|
|
namespace Subsystems
|
|
{
|
|
public class GameManager_Network
|
|
{
|
|
private const string _serverAddress = "geosus.honzuvkod.dev";
|
|
private const int _serverPort = 7777;
|
|
private GameClient _gameClient;
|
|
private GameManager _manager;
|
|
private bool _pendingMapBuild;
|
|
|
|
/// <summary>
|
|
/// Authoritative game state; written here, read by GameManager_UI.
|
|
/// </summary>
|
|
public GameState State { get; } = new GameState();
|
|
|
|
public GameManager_Network(GameClient gameClient, GameManager manager)
|
|
{
|
|
_gameClient = gameClient;
|
|
_manager = manager;
|
|
RegisterEventHandlers();
|
|
}
|
|
|
|
public async void OpenConnection()
|
|
{
|
|
// Snapshot the lobby we believed we were in BEFORE the new connect
|
|
// attempt. If the client SDK preserved it across a transient drop
|
|
// (P9 fix), this is non-null and we'll send a Reconnect message
|
|
// post-handshake to re-associate with the lobby on the server side.
|
|
// Without it, the next CastVote / TaskComplete / etc. would arrive
|
|
// on a fresh connection the server doesn't recognize and bounce
|
|
// with NOT_IN_LOBBY.
|
|
var rejoinLobbyId = _gameClient.LobbyId;
|
|
|
|
int retries = 0;
|
|
int delayMs = 5000;
|
|
while (true)
|
|
{
|
|
Task<bool> state = _gameClient.ConnectAsync(_serverAddress, _serverPort);
|
|
await state;
|
|
if (state.Result)
|
|
{
|
|
Debug.Log("Connected to server.");
|
|
|
|
// Re-attach to the prior lobby if we had one. Server-side
|
|
// HandleReconnectAsync will replay missed events and ack
|
|
// with a ReconnectResponse carrying the snapshot.
|
|
if (!string.IsNullOrEmpty(rejoinLobbyId))
|
|
{
|
|
Debug.Log($"Re-associating with lobby {rejoinLobbyId} after reconnect.");
|
|
_gameClient.Reconnect(rejoinLobbyId);
|
|
}
|
|
break;
|
|
}
|
|
retries++;
|
|
if (retries >= 10)
|
|
{
|
|
Debug.LogError("Failed to connect after 10 attempts. Giving up.");
|
|
break;
|
|
}
|
|
Debug.Log($"Failed to connect (attempt {retries}). Retrying in {delayMs / 1000}s...");
|
|
await Task.Delay(delayMs);
|
|
delayMs = Mathf.Min(delayMs * 2, 30000);
|
|
}
|
|
}
|
|
|
|
public void RegisterEventHandlers()
|
|
{
|
|
_gameClient.OnConnected += OnConnected;
|
|
_gameClient.OnDisconnected += OnDisconnected;
|
|
_gameClient.OnError += OnError;
|
|
_gameClient.OnMessage += OnMessage;
|
|
_gameClient.OnGameEvent += OnGameEvent;
|
|
}
|
|
|
|
private void OnConnected()
|
|
{
|
|
Debug.Log("Successfully connected to the server.");
|
|
// Tear the reconnect overlay down once the socket is healthy.
|
|
// No-op if it wasn't shown.
|
|
_manager?.uiSubsystem?.HideReconnecting();
|
|
}
|
|
|
|
private void OnError(string e) => Debug.LogError($"Network error: {e}");
|
|
|
|
private void OnDisconnected(string reason)
|
|
{
|
|
Debug.Log($"Disconnected: {reason}");
|
|
// Show the reconnect overlay only if the user is mid-game; we
|
|
// don't want it flashing during a clean shutdown ("Disposed") or
|
|
// before a real game has started.
|
|
if (reason != "Disposed" && State.Phase != GamePhase.Lobby)
|
|
_manager?.uiSubsystem?.ShowReconnecting();
|
|
|
|
if (reason != "Disposed" && _manager != null)
|
|
_manager.StartCoroutine(ReconnectAfterDelay(3f));
|
|
}
|
|
|
|
private IEnumerator ReconnectAfterDelay(float seconds)
|
|
{
|
|
yield return new UnityEngine.WaitForSeconds(seconds);
|
|
Debug.Log("Attempting to reconnect...");
|
|
OpenConnection();
|
|
}
|
|
|
|
private void OnMessage(Message message)
|
|
{
|
|
switch (message.Type)
|
|
{
|
|
case "CreateLobbyResponse":
|
|
HandleCreateLobbyResponse(message as CreateLobbyResponse);
|
|
break;
|
|
case "JoinLobbyResponse":
|
|
HandleJoinLobbyResponse(message as JoinLobbyResponse);
|
|
break;
|
|
case "PositionBroadcast":
|
|
HandlePositionBroadcast(message as PositionBroadcast);
|
|
break;
|
|
case "Error":
|
|
HandleErrorMessage(message as ErrorMessage);
|
|
break;
|
|
case "Ack":
|
|
case "GameEvent":
|
|
break;
|
|
default:
|
|
Debug.Log("Received message of type: " + message.Type);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// P9 defensive path: if the server tells us NOT_IN_LOBBY but we still
|
|
/// believe we have a lobby (LobbyId preserved across the transient
|
|
/// disconnect), the lobby association on the server's side of the new
|
|
/// connection is missing - typically a race between OpenConnection's
|
|
/// Reconnect call and an in-flight action message that beat it. Retry
|
|
/// the Reconnect; if the second attempt also bounces, the lobby really
|
|
/// is gone and we'll surface the error to the user.
|
|
/// </summary>
|
|
private void HandleErrorMessage(ErrorMessage err)
|
|
{
|
|
if (err == null) return;
|
|
Debug.Log($"Server error: code={err.ErrorCode} text={err.ErrorText}");
|
|
|
|
if (err.ErrorCode == "NOT_IN_LOBBY" && !string.IsNullOrEmpty(_gameClient.LobbyId))
|
|
{
|
|
Debug.Log($"NOT_IN_LOBBY but we still have LobbyId={_gameClient.LobbyId}; resending Reconnect.");
|
|
_gameClient.Reconnect(_gameClient.LobbyId);
|
|
}
|
|
}
|
|
|
|
private void OnGameEvent(GameEvent gameEvent)
|
|
{
|
|
// Always sync player list from lobby state after any event
|
|
SyncPlayersFromLobby();
|
|
|
|
switch (gameEvent.EventType)
|
|
{
|
|
case "PlayerJoined":
|
|
case "PlayerLeft":
|
|
case "HostChanged":
|
|
_manager?.uiSubsystem?.NotifyLobbyChanged();
|
|
break;
|
|
|
|
case "GameStarting":
|
|
State.Phase = GamePhase.Loading;
|
|
HandleGameStarting();
|
|
break;
|
|
|
|
case "MapDataReady":
|
|
HandleMapDataReady();
|
|
break;
|
|
|
|
case "GameStarted":
|
|
State.Phase = GamePhase.Playing;
|
|
break;
|
|
|
|
case "RoleAssigned":
|
|
HandleRoleAssigned(gameEvent);
|
|
break;
|
|
|
|
case "TaskCompleted":
|
|
HandleTaskCompleted(gameEvent);
|
|
break;
|
|
|
|
case "PlayerKilled":
|
|
HandlePlayerKilled(gameEvent);
|
|
break;
|
|
|
|
case "BodyReported":
|
|
case "EmergencyMeetingCalled":
|
|
Toast("Meeting called! Head to the meeting point.");
|
|
break;
|
|
|
|
case "MeetingStarted":
|
|
HandleMeetingStarted(gameEvent);
|
|
break;
|
|
|
|
case "PlayerArrivedAtMeeting":
|
|
HandlePlayerArrivedAtMeeting(gameEvent);
|
|
break;
|
|
|
|
case "PlayerVoted":
|
|
HandlePlayerVoted(gameEvent);
|
|
break;
|
|
|
|
case "VotingClosed":
|
|
HandleVotingClosed(gameEvent);
|
|
break;
|
|
|
|
case "GameEnded":
|
|
HandleGameEnded(gameEvent);
|
|
break;
|
|
|
|
case "ReturnedToLobby":
|
|
HandleReturnedToLobby();
|
|
break;
|
|
|
|
case "SabotageStarted":
|
|
HandleSabotageStarted(gameEvent);
|
|
break;
|
|
|
|
case "RepairStarted":
|
|
HandleRepairStarted(gameEvent);
|
|
break;
|
|
|
|
case "RepairStopped":
|
|
HandleRepairStopped(gameEvent);
|
|
break;
|
|
|
|
case "SabotageRepaired":
|
|
case "SabotageMeltdown":
|
|
case "SabotageExpired":
|
|
State.ActiveSabotage = null;
|
|
State.ActiveRepairs.Clear();
|
|
_manager?.uiSubsystem?.HideSabotageTimer();
|
|
_manager?.mapSubsystem?.ClearSabotageMarkers();
|
|
break;
|
|
|
|
case "TaskStarted":
|
|
// Server now broadcasts when a player begins a task. Phase 1
|
|
// only acks; Phase 2/3 will surface this to other players.
|
|
break;
|
|
|
|
case "MapDataError":
|
|
HandleMapDataError(gameEvent);
|
|
break;
|
|
|
|
default:
|
|
Debug.Log("GameEvent: " + gameEvent.EventType);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── Lobby responses ───────────────────────────────────────────────────
|
|
|
|
private void HandleCreateLobbyResponse(CreateLobbyResponse message)
|
|
{
|
|
if (message == null) return;
|
|
if (message.Success)
|
|
{
|
|
Debug.Log($"Lobby created. Code: {message.JoinCode}");
|
|
// P13b: snapshot the server's authoritative settings into
|
|
// GameState so HUD / proximity code can read distances and
|
|
// cooldowns from a single source of truth instead of hardcodes.
|
|
State.Settings = _gameClient.CurrentLobbyState?.Settings;
|
|
SceneManager.LoadScene("create", LoadSceneMode.Single);
|
|
_manager?.uiSubsystem?.NotifyLobbyChanged();
|
|
}
|
|
else
|
|
{
|
|
Debug.LogError("Failed to create lobby: " + message.Error);
|
|
}
|
|
}
|
|
|
|
private void HandleJoinLobbyResponse(JoinLobbyResponse message)
|
|
{
|
|
if (message == null) return;
|
|
if (message.Success)
|
|
{
|
|
Debug.Log($"Joined lobby: {message.LobbyId}");
|
|
// P13b: same settings snapshot path as host. Joiners read the
|
|
// server's snapshot taken at lobby creation; they cannot edit.
|
|
State.Settings = _gameClient.CurrentLobbyState?.Settings;
|
|
// Unified lobby: both host and joiners land on create.unity.
|
|
// LobbyDisplayUI handles the role split internally (start
|
|
// button for host, waiting text for joiners).
|
|
SceneManager.LoadScene("create", LoadSceneMode.Single);
|
|
_manager?.uiSubsystem?.NotifyLobbyChanged();
|
|
}
|
|
else
|
|
{
|
|
Debug.LogError("Failed to join lobby: " + message.Error);
|
|
}
|
|
}
|
|
|
|
// ── Game flow ─────────────────────────────────────────────────────────
|
|
|
|
private void HandleGameStarting()
|
|
{
|
|
_pendingMapBuild = false;
|
|
// Reset per-game state
|
|
State.MyRole = null;
|
|
State.IsDead = false;
|
|
State.MyTasks = new List<GameTask>();
|
|
State.MyCompletedTaskIds = new HashSet<string>();
|
|
State.TotalCompleted = 0;
|
|
State.TotalRequired = 0;
|
|
State.ActiveMeeting = null;
|
|
State.LastVoteResult = null;
|
|
State.VotedPlayerIds = new HashSet<string>();
|
|
State.ActiveSabotage = null;
|
|
State.GameEndData = null;
|
|
State.KillCooldownRemaining = 0;
|
|
SceneManager.LoadScene("Client", LoadSceneMode.Single);
|
|
}
|
|
|
|
private void HandleMapDataReady()
|
|
{
|
|
_pendingMapBuild = true;
|
|
TryBuildMapAndMarkers();
|
|
}
|
|
|
|
public void OnClientSceneReady()
|
|
{
|
|
TryBuildMapAndMarkers();
|
|
}
|
|
|
|
private void TryBuildMapAndMarkers()
|
|
{
|
|
if (!_pendingMapBuild) return;
|
|
if (_manager?.mapSubsystem == null || !_manager.mapSubsystem.IsSceneReady) return;
|
|
if (_gameClient?.CurrentLobbyState?.MapData == null) return;
|
|
|
|
_manager.mapSubsystem.BuildMap();
|
|
_manager.mapSubsystem.CreateTaskMarkers(_gameClient.MyTasks);
|
|
_pendingMapBuild = false;
|
|
Debug.Log("[Network] Map built.");
|
|
}
|
|
|
|
private void HandleRoleAssigned(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<RoleAssignedPayload>();
|
|
if (payload == null || payload.ClientUuid != _gameClient.ClientUuid) return;
|
|
|
|
State.MyRole = payload.Role;
|
|
State.MyTasks = payload.Tasks ?? new List<GameTask>();
|
|
State.MyCompletedTaskIds.Clear();
|
|
|
|
Debug.Log($"Role: {payload.Role}, Tasks: {State.MyTasks.Count}");
|
|
_manager?.taskSubsystem?.Initialize(State.MyTasks);
|
|
}
|
|
|
|
private void HandleTaskCompleted(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<TaskCompletedPayload>();
|
|
if (payload == null) return;
|
|
|
|
// Track if it's our task
|
|
if (payload.ClientUuid == _gameClient.ClientUuid)
|
|
State.MyCompletedTaskIds.Add(payload.TaskId);
|
|
|
|
State.TotalCompleted = payload.TotalCompleted;
|
|
State.TotalRequired = payload.TotalTasks;
|
|
|
|
_manager?.uiSubsystem?.UpdateTaskProgress(payload.TotalCompleted, payload.TotalTasks);
|
|
_manager?.mapSubsystem?.RemoveTaskMarker(payload.TaskId);
|
|
}
|
|
|
|
private void HandlePlayerKilled(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<PlayerKilledPayload>();
|
|
if (payload == null) return;
|
|
|
|
_manager?.mapSubsystem?.CreateBodyMarker(payload.BodyId, payload.Location);
|
|
|
|
if (payload.VictimId == _gameClient.ClientUuid)
|
|
{
|
|
State.IsDead = true;
|
|
_manager?.uiSubsystem?.OnLocalPlayerDied();
|
|
}
|
|
|
|
// Update player state in our list
|
|
var p = State.Players.Find(x => x.ClientUuid == payload.VictimId);
|
|
if (p != null) p.State = PlayerState.Dead;
|
|
}
|
|
|
|
private void HandleMeetingStarted(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<MeetingStartedPayload>();
|
|
if (payload == null) return;
|
|
|
|
State.Phase = GamePhase.Meeting;
|
|
State.ActiveMeeting = payload;
|
|
State.VotedPlayerIds = new HashSet<string>();
|
|
State.ArrivedPlayerIds = new HashSet<string>();
|
|
State.VoterTargets = new Dictionary<string, string>();
|
|
State.VoteTallies = new Dictionary<string, int>();
|
|
State.MyVoteTarget = null;
|
|
State.LastVoteResult = null;
|
|
|
|
SyncPlayersFromLobby();
|
|
_manager?.uiSubsystem?.ShowMeetingPanel(State.Players, payload);
|
|
}
|
|
|
|
private void HandlePlayerArrivedAtMeeting(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<PlayerArrivedAtMeetingPayload>();
|
|
if (payload == null) return;
|
|
State.ArrivedPlayerIds.Add(payload.ClientUuid);
|
|
}
|
|
|
|
private void HandlePlayerVoted(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<PlayerVotedPayload>();
|
|
if (payload == null) return;
|
|
|
|
// Server allows vote changes within a 2s rate limit, so we always
|
|
// overwrite the voter's previous target rather than appending.
|
|
string target = payload.TargetId ?? GameState.VoteSkip;
|
|
|
|
State.VotedPlayerIds.Add(payload.VoterId);
|
|
State.VoterTargets[payload.VoterId] = target;
|
|
RecomputeVoteTallies();
|
|
|
|
if (payload.VoterId == _gameClient.ClientUuid)
|
|
State.MyVoteTarget = target;
|
|
}
|
|
|
|
private void RecomputeVoteTallies()
|
|
{
|
|
State.VoteTallies.Clear();
|
|
foreach (var t in State.VoterTargets.Values)
|
|
{
|
|
if (string.IsNullOrEmpty(t)) continue;
|
|
State.VoteTallies.TryGetValue(t, out var count);
|
|
State.VoteTallies[t] = count + 1;
|
|
}
|
|
}
|
|
|
|
private void HandleVotingClosed(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<VotingClosedPayload>();
|
|
if (payload == null) return;
|
|
|
|
State.Phase = GamePhase.Playing;
|
|
State.ActiveMeeting = null;
|
|
State.LastVoteResult = payload;
|
|
|
|
// Mark ejected player dead in our list
|
|
if (!string.IsNullOrEmpty(payload.EjectedPlayerId))
|
|
{
|
|
var p = State.Players.Find(x => x.ClientUuid == payload.EjectedPlayerId);
|
|
if (p != null) p.State = PlayerState.Dead;
|
|
}
|
|
|
|
_manager?.uiSubsystem?.ShowVoteResult(payload, State.Players);
|
|
_manager?.mapSubsystem?.ClearBodyMarkers();
|
|
}
|
|
|
|
private void HandleGameEnded(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<GameEndedPayload>();
|
|
if (payload == null) return;
|
|
|
|
State.Phase = GamePhase.Ended;
|
|
State.GameEndData = payload;
|
|
|
|
// If the round ended while the meeting/vote-result overlay was
|
|
// still up (e.g. ejection won the game outright), the auto-close
|
|
// coroutine would otherwise fire 5s later and tear down the
|
|
// meeting panel while the GameEndPanel sits on top - leaving a
|
|
// glimpse of the dead overlay during the transition.
|
|
_manager?.uiSubsystem?.HideMeetingPanel();
|
|
_manager?.uiSubsystem?.ShowGameEndPanel(payload, _gameClient.ClientUuid);
|
|
}
|
|
|
|
private void HandleReturnedToLobby()
|
|
{
|
|
State.Phase = GamePhase.Lobby;
|
|
_manager?.uiSubsystem?.HideMeetingPanel();
|
|
// Bodies survive the scene reload because the marker GameObjects are
|
|
// parented under MapCenterPoint (which lives in the persistent
|
|
// Client.unity scene). Without this clear, returning to lobby and
|
|
// starting a new round leaves stale corpses on the map of the new
|
|
// round. Server already cleared its `_bodies` set in
|
|
// ProcessReturnToLobby; this is the client-side mirror that was
|
|
// missing in HandleVotingClosed's symmetry.
|
|
_manager?.mapSubsystem?.ClearBodyMarkers();
|
|
_manager?.mapSubsystem?.ClearSabotageMarkers();
|
|
// Unified lobby: regardless of role, return to create.unity.
|
|
SceneManager.LoadScene("create", LoadSceneMode.Single);
|
|
}
|
|
|
|
private void HandleSabotageStarted(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<SabotageStartedPayload>();
|
|
if (payload == null) return;
|
|
|
|
State.ActiveSabotage = payload;
|
|
State.ActiveRepairs.Clear();
|
|
|
|
_manager?.mapSubsystem?.CreateSabotageMarkers(payload.RepairStations);
|
|
if (payload.Type == SabotageType.CriticalMeltdown && payload.Deadline.HasValue)
|
|
_manager?.uiSubsystem?.ShowSabotageTimer(payload.Deadline.Value);
|
|
if (payload.Type == SabotageType.CommsBlackout)
|
|
_manager?.uiSubsystem?.SetCommsBlackout(true);
|
|
}
|
|
|
|
private void HandleRepairStarted(GameEvent evt)
|
|
{
|
|
var payload = evt.GetPayload<RepairStartedPayload>();
|
|
if (payload == null || string.IsNullOrEmpty(payload.StationId)) return;
|
|
State.ActiveRepairs.Add(payload.StationId);
|
|
}
|
|
|
|
private void HandleRepairStopped(GameEvent evt)
|
|
{
|
|
// A player abandoned a repair station mid-fix. The station is no
|
|
// longer counted as active for the simultaneous-repair coaching;
|
|
// the marker stays on the map until the sabotage resolves.
|
|
var payload = evt.GetPayload<RepairStoppedPayload>();
|
|
if (payload != null && !string.IsNullOrEmpty(payload.StationId))
|
|
State.ActiveRepairs.Remove(payload.StationId);
|
|
}
|
|
|
|
private void HandleMapDataError(GameEvent evt)
|
|
{
|
|
// Server failed to fetch Overpass data. Without this the loading
|
|
// screen would hang forever. Drop back to lobby and surface the
|
|
// failure so the player can re-host or try a different center.
|
|
Debug.LogError("[Network] Server could not generate map data.");
|
|
State.Phase = GamePhase.Lobby;
|
|
_manager?.uiSubsystem?.ShowToast("Map fetch failed. Returning to lobby.");
|
|
LeaveLobby();
|
|
}
|
|
|
|
private void HandlePositionBroadcast(PositionBroadcast broadcast)
|
|
{
|
|
if (broadcast == null) return;
|
|
_manager?.mapSubsystem?.UpdatePlayerAvatars(_gameClient.PlayerPositions, _gameClient.ClientUuid);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
private void SyncPlayersFromLobby()
|
|
{
|
|
var lobby = _gameClient.CurrentLobbyState;
|
|
if (lobby?.Players != null)
|
|
State.Players = lobby.Players;
|
|
}
|
|
|
|
private void Toast(string message)
|
|
{
|
|
State.ToastMessage = message;
|
|
State.ToastExpiry = UnityEngine.Time.time + 4f;
|
|
}
|
|
|
|
// ── Send helpers ──────────────────────────────────────────────────────
|
|
|
|
public void CreateLobby(double lat, double lon, double radius = 500, int impostorCount = 1, int taskCount = 5, GameSettingsOverrides settings = null)
|
|
{
|
|
_gameClient.CreateLobby(new Position(lat, lon), impostorCount, taskCount, null, radius, settings);
|
|
}
|
|
|
|
public void JoinLobby(string joinCode)
|
|
{
|
|
try { _gameClient.JoinLobby(joinCode); }
|
|
catch (System.Exception ex) { Debug.LogError("JoinLobby error: " + ex.Message); }
|
|
}
|
|
|
|
public void LeaveLobby()
|
|
{
|
|
_gameClient.LeaveLobby();
|
|
State.Phase = GamePhase.Lobby;
|
|
SceneManager.LoadScene(_manager?.firstMenuScene ?? "main menu asi idk lol", LoadSceneMode.Single);
|
|
}
|
|
|
|
public void StartGame()
|
|
{
|
|
_gameClient.StartGame();
|
|
}
|
|
}
|
|
}
|
|
|