Zabiju je 2. Epicky thriller od tvurce Zabiju je

This commit is contained in:
2026-04-26 14:58:39 +02:00
parent 700e6bfbfc
commit e0b808faed
10 changed files with 1114 additions and 465 deletions

View File

@@ -197,7 +197,6 @@ public class GameManager : MonoBehaviour
}
}
// ── Kill cooldown ─────────────────────────────────────────────────────────
private float _killCooldownSeconds = 0f;
private const float KillCooldownDuration = 20f;
@@ -206,10 +205,16 @@ public class GameManager : MonoBehaviour
if (_killCooldownSeconds > 0)
{
_killCooldownSeconds -= Time.deltaTime;
// Mirror into GameState so UI reads from the single source of truth
if (networkSubsystem?.State != null)
networkSubsystem.State.KillCooldownRemaining = _killCooldownSeconds;
uiSubsystem?.SetKillCooldownText($"Kill: {Mathf.CeilToInt(_killCooldownSeconds)}s");
}
else
{
_killCooldownSeconds = 0f;
if (networkSubsystem?.State != null)
networkSubsystem.State.KillCooldownRemaining = 0;
uiSubsystem?.SetKillCooldownText("");
}
}

View File

@@ -411,10 +411,14 @@ namespace Subsystems{
var go = GameObject.CreatePrimitive(PrimitiveType.Sphere);
go.name = $"Task_{task.TaskId}";
go.transform.parent = _mapCenterPoint.transform;
go.transform.position = task.Location.ToLocalVector3(_centerPosition) + Vector3.up * 0.3f;
go.transform.localScale = Vector3.one * 0.5f;
go.transform.position = task.Location.ToLocalVector3(_centerPosition) + Vector3.up * 1f; // Raised
go.transform.localScale = Vector3.one * 8f; // Bigger
var mr = go.GetComponent<MeshRenderer>();
if (mr) mr.material.color = Color.yellow;
var light = go.AddComponent<Light>();
light.color = Color.yellow;
light.intensity = 2;
light.range = 5;
_taskMarkers[task.TaskId] = go;
}
}

View File

@@ -14,9 +14,14 @@ namespace Subsystems
private const string _serverAddress = "geosus.honzuvkod.dev";
private const int _serverPort = 7777;
private GameClient _gameClient;
private GameManager _manager; // may be null for test clients
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;
@@ -45,49 +50,40 @@ namespace Subsystems
}
Debug.Log($"Failed to connect (attempt {retries}). Retrying in {delayMs / 1000}s...");
await Task.Delay(delayMs);
delayMs = Mathf.Min(delayMs * 2, 30000); // exponential backoff, cap 30s
delayMs = Mathf.Min(delayMs * 2, 30000);
}
}
public void RegisterEventHandlers()
{
_gameClient.OnConnected += OnConnected;
_gameClient.OnConnected += OnConnected;
_gameClient.OnDisconnected += OnDisconnected;
_gameClient.OnError += OnError;
_gameClient.OnMessage += OnMessage;
_gameClient.OnGameEvent += OnGameEvent;
_gameClient.OnError += OnError;
_gameClient.OnMessage += OnMessage;
_gameClient.OnGameEvent += OnGameEvent;
}
private void OnConnected()
{
Debug.Log("Successfully connected to the server.");
}
private void OnConnected() => Debug.Log("Successfully connected to the server.");
private void OnError(string e) => Debug.LogError($"Network error: {e}");
private void OnDisconnected(string reason)
{
Debug.Log($"Disconnected: {reason}");
// Auto-reconnect unless the app is quitting
if (reason != "Disposed" && _manager != null)
_manager.StartCoroutine(ReconnectAfterDelay(3f));
}
private System.Collections.IEnumerator ReconnectAfterDelay(float seconds)
private IEnumerator ReconnectAfterDelay(float seconds)
{
yield return new UnityEngine.WaitForSeconds(seconds);
Debug.Log("Attempting to reconnect...");
OpenConnection();
}
private void OnError(string error)
{
Debug.LogError($"Network error: {error}");
}
private void OnMessage(Message message)
{
switch (message.Type)
{
case "GameEvent":
// handled via OnGameEvent
break;
case "CreateLobbyResponse":
HandleCreateLobbyResponse(message as CreateLobbyResponse);
break;
@@ -98,6 +94,7 @@ namespace Subsystems
HandlePositionBroadcast(message as PositionBroadcast);
break;
case "Ack":
case "GameEvent":
break;
default:
Debug.Log("Received message of type: " + message.Type);
@@ -107,16 +104,19 @@ namespace Subsystems
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":
// SDK already updates CurrentLobbyState; just refresh UI
_manager?.uiSubsystem?.NotifyLobbyChanged();
break;
case "GameStarting":
State.Phase = GamePhase.Loading;
HandleGameStarting();
break;
@@ -125,7 +125,7 @@ namespace Subsystems
break;
case "GameStarted":
HandleGameStarted();
State.Phase = GamePhase.Playing;
break;
case "RoleAssigned":
@@ -142,13 +142,17 @@ namespace Subsystems
case "BodyReported":
case "EmergencyMeetingCalled":
HandleMeetingCalled(gameEvent);
Toast("Meeting called! Head to the meeting point.");
break;
case "MeetingStarted":
HandleMeetingStarted(gameEvent);
break;
case "PlayerVoted":
HandlePlayerVoted(gameEvent);
break;
case "VotingClosed":
HandleVotingClosed(gameEvent);
break;
@@ -167,6 +171,7 @@ namespace Subsystems
case "SabotageRepaired":
case "SabotageMeltdown":
State.ActiveSabotage = null;
_manager?.uiSubsystem?.HideSabotageTimer();
_manager?.mapSubsystem?.ClearSabotageMarkers();
break;
@@ -188,10 +193,8 @@ namespace Subsystems
if (message == null) return;
if (message.Success)
{
Debug.Log($"Lobby created. Code: {message.JoinCode}, ID: {message.LobbyId}");
// Navigate to the create/waiting scene
Debug.Log($"Lobby created. Code: {message.JoinCode}");
SceneManager.LoadScene("create", LoadSceneMode.Single);
// Mark lobby UI dirty so LobbyDisplayUI refreshes once the scene is loaded
_manager?.uiSubsystem?.NotifyLobbyChanged();
}
else
@@ -207,7 +210,6 @@ namespace Subsystems
{
Debug.Log($"Joined lobby: {message.LobbyId}");
SceneManager.LoadScene("join loading", LoadSceneMode.Single);
// Mark lobby UI dirty so LobbyDisplayUI refreshes once the scene is loaded
_manager?.uiSubsystem?.NotifyLobbyChanged();
}
else
@@ -216,12 +218,24 @@ namespace Subsystems
}
}
// ── Game flow events ──────────────────────────────────────────────────
// ── Game flow ─────────────────────────────────────────────────────────
private void HandleGameStarting()
{
_pendingMapBuild = false;
// SDK sets Phase = Loading; load Client.unity
// 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);
}
@@ -231,10 +245,6 @@ namespace Subsystems
TryBuildMapAndMarkers();
}
/// <summary>
/// Called from GameManager.OnSceneLoaded("Client") after scene objects are bound.
/// Ensures map construction still happens even if MapDataReady arrived earlier.
/// </summary>
public void OnClientSceneReady()
{
TryBuildMapAndMarkers();
@@ -243,34 +253,40 @@ namespace Subsystems
private void TryBuildMapAndMarkers()
{
if (!_pendingMapBuild) return;
if (_manager?.mapSubsystem == null) return;
if (!_manager.mapSubsystem.IsSceneReady) 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 and task markers refreshed.");
}
private void HandleGameStarted()
{
Debug.Log("Game started");
// Phase is now Playing; GPS loop will start sending positions
Debug.Log("[Network] Map built.");
}
private void HandleRoleAssigned(GameEvent evt)
{
var payload = evt.GetPayload<RoleAssignedPayload>();
if (payload == null || payload.ClientUuid != _gameClient.ClientUuid) return;
Debug.Log($"Role: {payload.Role}, Tasks: {payload.Tasks?.Count ?? 0}");
_manager?.taskSubsystem?.Initialize(_gameClient.MyTasks);
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);
}
@@ -279,28 +295,58 @@ namespace Subsystems
{
var payload = evt.GetPayload<PlayerKilledPayload>();
if (payload == null) return;
_manager?.mapSubsystem?.CreateBodyMarker(payload.BodyId, payload.Location);
if (payload.VictimId == _gameClient.ClientUuid)
_manager?.uiSubsystem?.OnLocalPlayerDied();
}
private void HandleMeetingCalled(GameEvent evt)
{
_manager?.uiSubsystem?.ShowMeetingAlert();
_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;
_manager?.uiSubsystem?.ShowMeetingPanel(_gameClient.CurrentLobbyState?.Players, payload);
State.Phase = GamePhase.Meeting;
State.ActiveMeeting = payload;
State.VotedPlayerIds = new HashSet<string>();
State.LastVoteResult = null;
SyncPlayersFromLobby();
_manager?.uiSubsystem?.ShowMeetingPanel(State.Players, payload);
}
private void HandlePlayerVoted(GameEvent evt)
{
var payload = evt.GetPayload<PlayerVotedPayload>();
if (payload == null) return;
State.VotedPlayerIds.Add(payload.VoterId);
}
private void HandleVotingClosed(GameEvent evt)
{
var payload = evt.GetPayload<VotingClosedPayload>();
if (payload == null) return;
_manager?.uiSubsystem?.ShowVoteResult(payload, _gameClient.CurrentLobbyState?.Players);
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();
}
@@ -308,11 +354,16 @@ namespace Subsystems
{
var payload = evt.GetPayload<GameEndedPayload>();
if (payload == null) return;
State.Phase = GamePhase.Ended;
State.GameEndData = payload;
_manager?.uiSubsystem?.ShowGameEndPanel(payload, _gameClient.ClientUuid);
}
private void HandleReturnedToLobby()
{
State.Phase = GamePhase.Lobby;
if (_gameClient.IsOwner)
SceneManager.LoadScene("create", LoadSceneMode.Single);
else
@@ -323,6 +374,9 @@ namespace Subsystems
{
var payload = evt.GetPayload<SabotageStartedPayload>();
if (payload == null) return;
State.ActiveSabotage = payload;
_manager?.mapSubsystem?.CreateSabotageMarkers(payload.RepairStations);
if (payload.Type == SabotageType.CriticalMeltdown && payload.Deadline.HasValue)
_manager?.uiSubsystem?.ShowSabotageTimer(payload.Deadline.Value);
@@ -336,6 +390,21 @@ namespace Subsystems
_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)
@@ -352,6 +421,7 @@ namespace Subsystems
public void LeaveLobby()
{
_gameClient.LeaveLobby();
State.Phase = GamePhase.Lobby;
SceneManager.LoadScene(_manager?.firstMenuScene ?? "main menu asi idk lol", LoadSceneMode.Single);
}

View File

@@ -176,12 +176,10 @@ namespace Subsystems
taskComponent.TaskLocation = (entry.ServerTask.Location.Lat, entry.ServerTask.Location.Lon);
bool done = false;
bool exited = false;
taskComponent.Initialize(t => { done = true; });
taskComponent.ExitTask(t => { exited = true; });
// Wait for completion or exit
yield return new WaitUntil(() => done || exited);
yield return new WaitUntil(() => done);
yield return FinishMinigame(entry, done);
}

View File

@@ -9,168 +9,208 @@ using TMPro;
namespace Subsystems
{
/// <summary>
/// Manages UI for the GameManager. Canvas references are only valid in Client.unity;
/// Art-menu scenes use their own lightweight UI scripts that read from GameManager.Instance.
/// Reads from GameManager_Network.State (the authoritative GameState) and drives
/// all in-game canvas panels. No business logic lives here.
/// </summary>
public class GameManager_UI
{
private GameClient _gameClient;
private GameState _state => GameManager.Instance?.networkSubsystem?.State;
// Set by GameManager after Client.unity loads (called from GameManager.OnSceneLoaded)
public Canvas ClientCreateJoinLobby; // fallback join-code canvas in Client.unity
public Canvas ClientInLobby; // InLobby canvas in Client.unity (unused now, kept compat)
// ── Canvas refs (wired by BindClientScene from Client.unity) ──────────
public Canvas ClientCreateJoinLobby;
public Canvas ClientInLobby;
public Canvas ClientLoadingScreen;
public Canvas ClientGameScreen; // parent of all HUD elements
public Canvas ClientGameScreen;
// HUD elements (children of ClientGameScreen, resolved at runtime)
private TMP_Text _roleText;
private TMP_Text _taskListText;
private TMP_Text _taskProgressText;
private Button _actionButton;
private TMP_Text _actionButtonText;
private TMP_Text _killCooldownText;
// ── HUD element refs (resolved once in BindClientScene) ───────────────
private TMP_Text _roleText;
private TMP_Text _taskListText;
private TMP_Text _taskProgressText;
private Button _actionButton;
private TMP_Text _actionButtonText;
private TMP_Text _killCooldownText;
private GameObject _sabotagePanel;
private TMP_Text _sabotageTimerText;
private TMP_Text _sabotageTimerText;
private GameObject _meetingPanel;
private TMP_Text _meetingHeader;
private Transform _meetingScrollContent;
private TMP_Text _meetingFallbackText;
private GameObject _voteResultPanel;
private TMP_Text _voteResultText;
private GameObject _gameEndPanel;
private TMP_Text _gameEndText;
private TMP_Text _gameEndText;
private RectTransform _returnToLobbyBtn;
private TMP_Text _toastText;
private GameObject _toastGO;
// Runtime state
// ── Internal state ────────────────────────────────────────────────────
private bool _isDead;
private bool _commsBlackout;
private DateTime _sabotageMeltdownDeadline;
private bool _sabotageTimerActive;
// Lobby-changed flag — set from network thread, consumed in Update
private volatile bool _lobbyDirty;
public GameManager_UI(GameClient gameClient)
{
_gameClient = gameClient;
}
// Meeting vote-row references rebuilt each meeting
private readonly List<GameObject> _voteRows = new List<GameObject>();
private string _pendingVoteResultDisplay; // shown after voting
public GameManager_UI(GameClient gameClient) { _gameClient = gameClient; }
/// <summary>Called by Network subsystem when lobby player list changes.</summary>
public void NotifyLobbyChanged() => _lobbyDirty = true;
public bool IsCommsBlackout => _commsBlackout;
public bool IsPlayerDead => _isDead;
// ── Called from GameManager after Client.unity loads ──────────────────
// ── Scene binding ─────────────────────────────────────────────────────
public void BindClientScene(Canvas createJoin, Canvas inLobby, Canvas loading, Canvas game)
{
ClientCreateJoinLobby = createJoin;
ClientInLobby = inLobby;
ClientLoadingScreen = loading;
ClientGameScreen = game;
ClientInLobby = inLobby;
ClientLoadingScreen = loading;
ClientGameScreen = game;
EnsureCanvasReady(createJoin);
EnsureCanvasReady(inLobby);
EnsureCanvasReady(loading);
EnsureCanvasReady(game);
foreach (var c in new[] { createJoin, inLobby, loading, game })
EnsureCanvasReady(c);
if (createJoin) createJoin.gameObject.SetActive(false);
if (inLobby) inLobby.gameObject.SetActive(false);
if (loading) loading.gameObject.SetActive(false);
if (game) game.gameObject.SetActive(false);
if (inLobby) inLobby.gameObject.SetActive(false);
if (loading) loading.gameObject.SetActive(false);
if (game) game.gameObject.SetActive(false);
if (game != null)
if (game == null) return;
var t = game.transform;
_roleText = FindTMP(t, "Role");
_taskListText = FindTMP(t, "TaskList");
_taskProgressText = FindTMP(t, "TaskProgress");
_killCooldownText = FindTMP(t, "KillCooldown");
_sabotageTimerText = FindTMP(t, "SabotageTimer");
_gameEndText = FindTMP(t, "GameEndText");
_toastText = FindTMP(t, "Toast");
_meetingHeader = FindTMP(t, "MeetingHeader");
_meetingFallbackText = FindTMP(t, "MeetingPlayerList");
_voteResultText = FindTMP(t, "VoteResult");
_meetingScrollContent = FindTransform(t, "MeetingContent");
var actionGO = t.Find("ActionButton");
if (actionGO != null)
{
_roleText = FindTMP(game.transform, "Role");
_taskListText = FindTMP(game.transform, "TaskList");
_taskProgressText = FindTMP(game.transform, "TaskProgress");
_killCooldownText = FindTMP(game.transform, "KillCooldown");
_sabotageTimerText = FindTMP(game.transform, "SabotageTimer");
_gameEndText = FindTMP(game.transform, "GameEndText");
var actionGO = game.transform.Find("ActionButton");
if (actionGO != null)
{
_actionButton = actionGO.GetComponent<Button>();
_actionButtonText = actionGO.GetComponentInChildren<TMP_Text>();
}
var sabGO = game.transform.Find("SabotagePanel");
_sabotagePanel = sabGO?.gameObject;
var meetGO = game.transform.Find("MeetingPanel");
_meetingPanel = meetGO?.gameObject;
if (_meetingPanel) _meetingPanel.SetActive(false);
var endGO = game.transform.Find("GameEndPanel");
_gameEndPanel = endGO?.gameObject;
if (_gameEndPanel) _gameEndPanel.SetActive(false);
_actionButton = actionGO.GetComponent<Button>();
_actionButtonText = actionGO.GetComponentInChildren<TMP_Text>();
}
_sabotagePanel = t.Find("SabotagePanel")?.gameObject;
_meetingPanel = t.Find("MeetingPanel")?.gameObject;
_gameEndPanel = t.Find("GameEndPanel")?.gameObject;
_voteResultPanel = FindTransformGO(t, "VoteResultPanel");
_toastGO = FindTransformGO(t, "Toast");
var retBtn = FindTransform(t, "ReturnToLobbyButton");
if (retBtn != null) _returnToLobbyBtn = retBtn as RectTransform;
if (_meetingPanel) _meetingPanel.SetActive(false);
if (_gameEndPanel) _gameEndPanel.SetActive(false);
if (_voteResultPanel) _voteResultPanel.SetActive(false);
if (_toastGO) _toastGO.SetActive(false);
}
// ── Main update (called every frame from GameManager.Update) ──────────
// ── Update (called every frame from GameManager.Update) ───────────────
public void UpdateLobbyUI()
{
var state = _gameClient.CurrentLobbyState;
if (state == null) return;
var lobbyState = _gameClient.CurrentLobbyState;
if (lobbyState == null) return;
// Update any LobbyDisplayUI listeners in the current scene
if (_lobbyDirty)
{
_lobbyDirty = false;
LobbyDisplayUI.RefreshAll(state);
LobbyDisplayUI.RefreshAll(lobbyState);
}
// Only do canvas switches if we are in Client.unity (canvases assigned)
if (ClientGameScreen == null) return;
switch (state.Phase)
switch (lobbyState.Phase)
{
case GamePhase.Loading:
SetCanvases(false, false, true, false);
break;
case GamePhase.Lobby:
SetCanvases(false, true, false, false);
break;
case GamePhase.Playing:
case GamePhase.Meeting:
case GamePhase.Voting:
SetCanvases(false, false, false, true);
UpdateGameHUD();
break;
case GamePhase.Ended:
// GameEndPanel shown by HandleGameEnded
SetCanvases(false, false, false, true);
break;
}
TickToast();
}
// ── Game HUD tick ─────────────────────────────────────────────────────
private void UpdateGameHUD()
{
if (_roleText != null) _roleText.text = _gameClient.MyRole?.ToString() ?? "";
var s = _state;
if (s == null) return;
// Task list
// Role
if (_roleText != null)
{
string ghostSuffix = s.IsDead ? " (GHOST)" : "";
_roleText.text = $"{s.MyRole?.ToString() ?? "?"}{ghostSuffix}";
_roleText.color = s.MyRole == PlayerRole.Impostor ? new Color(0.9f,0.2f,0.2f) : new Color(0.2f,0.8f,1f);
}
// Task list with checkmarks
if (_taskListText != null)
{
var sb = new System.Text.StringBuilder();
foreach (var t in _gameClient.MyTasks)
sb.AppendLine(t.Name);
foreach (var task in s.MyTasks)
{
bool done = s.MyCompletedTaskIds.Contains(task.TaskId);
string mark = done ? "<color=#2DB84B>✓</color>" : "○";
sb.AppendLine($"{mark} {task.Name}");
}
_taskListText.text = sb.ToString();
}
// Kill cooldown (managed by GameManager_Tasks via Update)
// Sabotage timer
// Global task progress
if (_taskProgressText != null && s.TotalRequired > 0)
_taskProgressText.text = $"Tasks: {s.TotalCompleted}/{s.TotalRequired}";
// Kill cooldown
if (_killCooldownText != null)
{
bool show = s.KillCooldownRemaining > 0;
_killCooldownText.gameObject.SetActive(show);
if (show) _killCooldownText.text = $"Kill: {Mathf.CeilToInt(s.KillCooldownRemaining)}s";
}
// Sabotage banner
if (_sabotageTimerActive && _sabotageTimerText != null)
{
double remaining = (_sabotageMeltdownDeadline - DateTime.UtcNow).TotalSeconds;
_sabotageTimerText.text = remaining > 0 ? $"MELTDOWN: {remaining:F0}s" : "MELTDOWN!";
_sabotageTimerText.text = remaining > 0 ? $"MELTDOWN: {remaining:F0}s" : "MELTDOWN!";
}
// Keep meeting voted-indicator rows fresh each frame
TickMeetingVoteIndicators();
}
// ── Helpers called by Network handlers ────────────────────────────────
// ── Kill cooldown helper (called from GameManager) ────────────────────
public void SetKillCooldownText(string text)
{
if (_killCooldownText != null)
{
_killCooldownText.text = text;
_killCooldownText.gameObject.SetActive(!string.IsNullOrEmpty(text));
}
if (_killCooldownText == null) return;
bool show = !string.IsNullOrEmpty(text);
_killCooldownText.gameObject.SetActive(show);
if (show) _killCooldownText.text = text;
}
public void UpdateTaskProgress(int completed, int total)
@@ -179,6 +219,8 @@ namespace Subsystems
_taskProgressText.text = $"Tasks: {completed}/{total}";
}
// ── Action button ─────────────────────────────────────────────────────
public void SetActionButton(string label, bool visible, UnityEngine.Events.UnityAction onClick = null)
{
if (_actionButton == null) return;
@@ -191,15 +233,19 @@ namespace Subsystems
}
}
// ── Player state ──────────────────────────────────────────────────────
public void OnLocalPlayerDied()
{
_isDead = true;
if (_roleText != null) _roleText.text = "GHOST";
if (_state != null) _state.IsDead = true;
}
// ── Meeting ───────────────────────────────────────────────────────────
public void ShowMeetingAlert()
{
Debug.Log("Meeting called! Run to meeting point.");
ShowToast("Meeting called! Head to the meeting point.");
}
public void ShowMeetingPanel(List<PlayerInfo> players, MeetingStartedPayload payload)
@@ -207,99 +253,217 @@ namespace Subsystems
if (_meetingPanel == null) return;
_meetingPanel.SetActive(true);
var header = FindTMP(_meetingPanel.transform, "MeetingHeader");
if (header != null)
header.text = payload.Type == MeetingType.BodyReport ? "BODY REPORTED!" : "EMERGENCY MEETING!";
if (_meetingHeader != null)
_meetingHeader.text = payload.Type == MeetingType.BodyReport ? "BODY REPORTED!" : "EMERGENCY MEETING!";
// Build simple text list of players — full vote buttons need prefabs in Unity Editor
var playerList = FindTMP(_meetingPanel.transform, "MeetingPlayerList");
if (playerList != null && players != null)
if (_voteResultPanel) _voteResultPanel.SetActive(false);
BuildMeetingVoteRows(players);
}
private void BuildMeetingVoteRows(List<PlayerInfo> players)
{
// Clear old rows
foreach (var r in _voteRows) if (r) UnityEngine.Object.Destroy(r);
_voteRows.Clear();
if (_meetingScrollContent == null || players == null)
{
var sb = new System.Text.StringBuilder();
foreach (var p in players)
sb.AppendLine($"{p.DisplayName} [{p.State}]");
playerList.text = sb.ToString();
// Fall back to text list
if (_meetingFallbackText != null)
{
_meetingFallbackText.gameObject.SetActive(true);
var sb = new System.Text.StringBuilder();
foreach (var p in players ?? new List<PlayerInfo>())
sb.AppendLine($"{p.DisplayName} [{p.State}]");
_meetingFallbackText.text = sb.ToString();
}
return;
}
// Wire skip button if it exists
var skipBtn = _meetingPanel.transform.Find("SkipButton")?.GetComponent<Button>();
if (skipBtn != null)
string myId = _gameClient.ClientUuid;
bool canVote = !_isDead;
foreach (var player in players)
{
skipBtn.onClick.RemoveAllListeners();
skipBtn.onClick.AddListener(() => GameManager.Instance?.CastVote(null));
bool isMe = player.ClientUuid == myId;
bool isAlive = player.State == PlayerState.Alive;
var row = BuildVoteRow(player, isMe, isAlive, canVote && isAlive && !isMe);
row.transform.SetParent(_meetingScrollContent, false);
_voteRows.Add(row);
}
}
private GameObject BuildVoteRow(PlayerInfo player, bool isMe, bool isAlive, bool canVote)
{
const float ROW_H = 110f;
var go = new GameObject($"VoteRow_{player.ClientUuid}");
var rt = go.AddComponent<RectTransform>();
rt.sizeDelta = new Vector2(0, ROW_H);
var le = go.AddComponent<LayoutElement>();
le.minHeight = le.preferredHeight = ROW_H;
var bg = go.AddComponent<Image>();
bg.color = isMe ? new Color(0.12f,0.18f,0.30f) : new Color(0.10f,0.12f,0.20f);
// Dead overlay
if (!isAlive)
{
bg.color = new Color(0.08f,0.08f,0.10f,0.7f);
}
// Name label
var namRT = MakeChild("Name", rt);
namRT.anchorMin = new Vector2(0,0); namRT.anchorMax = new Vector2(0.65f,1);
namRT.offsetMin = new Vector2(16,6); namRT.offsetMax = new Vector2(0,-6);
var namTmp = namRT.gameObject.AddComponent<TextMeshProUGUI>();
namTmp.text = (player.IsOwner ? "👑 " : "") + (player.DisplayName ?? "???");
namTmp.fontSize = 36;
namTmp.color = !isAlive ? Color.gray : (isMe ? Color.white : new Color(0.73f,0.8f,0.88f));
namTmp.fontStyle = isMe ? FontStyles.Bold : FontStyles.Normal;
namTmp.alignment = TextAlignmentOptions.MidlineLeft;
// Vote button
var voteBtnRT = MakeChild("VoteBtn", rt);
voteBtnRT.anchorMin = new Vector2(0.68f,0.10f); voteBtnRT.anchorMax = new Vector2(0.95f,0.90f);
var voteBg = voteBtnRT.gameObject.AddComponent<Image>();
voteBg.color = canVote ? new Color(0.2f,0.6f,1f) : new Color(0.2f,0.2f,0.2f,0.5f);
var voteBtn = voteBtnRT.gameObject.AddComponent<Button>();
voteBtn.targetGraphic = voteBg;
voteBtn.interactable = canVote;
string capturedId = player.ClientUuid;
voteBtn.onClick.AddListener(() => GameManager.Instance?.CastVote(capturedId));
var voteTxtRT = MakeChild("Txt", voteBtnRT);
Stretch(voteTxtRT);
var voteTmp = voteTxtRT.gameObject.AddComponent<TextMeshProUGUI>();
voteTmp.text = isAlive ? "VOTE" : "DEAD";
voteTmp.fontSize = 28;
voteTmp.fontStyle = FontStyles.Bold;
voteTmp.color = Color.white;
voteTmp.alignment = TextAlignmentOptions.Center;
// Voted indicator (hidden by default; shown by TickMeetingVoteIndicators)
var votedRT = MakeChild("VotedTick", rt);
votedRT.anchorMin = new Vector2(0.95f,0.20f); votedRT.anchorMax = new Vector2(1f,0.80f);
var vtTmp = votedRT.gameObject.AddComponent<TextMeshProUGUI>();
vtTmp.text = "✓"; vtTmp.fontSize = 34;
vtTmp.color = new Color(0.18f,0.75f,0.30f); vtTmp.alignment = TextAlignmentOptions.Center;
votedRT.gameObject.SetActive(false);
return go;
}
private void TickMeetingVoteIndicators()
{
var s = _state;
if (s == null) return;
foreach (var row in _voteRows)
{
if (row == null) continue;
// Row name is "VoteRow_<uuid>"
string uuid = row.name.Replace("VoteRow_", "");
var tick = row.transform.Find("VotedTick")?.gameObject;
if (tick != null)
tick.SetActive(s.VotedPlayerIds.Contains(uuid));
}
}
public void AppendVoteInstruction()
{
var playerList = FindTMP(_meetingPanel?.transform, "MeetingPlayerList");
if (playerList != null)
playerList.text += "\n[Tap a name] then press VOTE — or press SKIP";
// no-op vote instructions are embedded in the row buttons
}
public void ShowVoteResult(VotingClosedPayload payload, List<PlayerInfo> players)
{
if (_meetingPanel == null) return;
var resultText = FindTMP(_meetingPanel.transform, "VoteResult");
if (resultText != null)
if (_voteResultPanel != null) _voteResultPanel.SetActive(true);
if (_voteResultText != null)
{
if (payload.WasTie)
{
resultText.text = "TIE — nobody ejected.";
}
_voteResultText.text = "⚖ TIE — nobody ejected.";
else if (string.IsNullOrEmpty(payload.EjectedPlayerId))
{
resultText.text = "Skip — nobody ejected.";
}
_voteResultText.text = "Nobody ejected (skip).";
else
{
var ejected = players?.Find(p => p.ClientUuid == payload.EjectedPlayerId);
resultText.text = $"{ejected?.DisplayName ?? payload.EjectedPlayerId} ejected!";
var ej = players?.Find(p => p.ClientUuid == payload.EjectedPlayerId);
_voteResultText.text = $"🚪 {ej?.DisplayName ?? payload.EjectedPlayerId} ejected!";
}
}
// Close panel after 5 seconds — use coroutine via GameManager
GameManager.Instance?.StartCoroutine(CloseMeetingPanelAfterDelay(5f));
// Auto-close meeting panel after 5 s
GameManager.Instance?.StartCoroutine(CloseMeetingAfterDelay(5f));
}
private System.Collections.IEnumerator CloseMeetingPanelAfterDelay(float delay)
private System.Collections.IEnumerator CloseMeetingAfterDelay(float delay)
{
yield return new UnityEngine.WaitForSeconds(delay);
if (_meetingPanel != null) _meetingPanel.SetActive(false);
if (_meetingPanel) _meetingPanel.SetActive(false);
if (_voteResultPanel) _voteResultPanel.SetActive(false);
}
public void ShowGameEndPanel(GameEndedPayload payload, string myUuid)
{
if (_gameEndPanel != null) _gameEndPanel.SetActive(true);
bool won = payload.Winners != null && payload.Winners.Contains(myUuid);
if (_gameEndText != null)
_gameEndText.text = $"{(won ? "VICTORY" : "DEFEAT")}\n{payload.WinningFaction} wins!\n{payload.Reason}";
}
// ── Sabotage ──────────────────────────────────────────────────────────
public void ShowSabotageTimer(DateTime deadline)
{
_sabotageMeltdownDeadline = deadline;
_sabotageTimerActive = true;
if (_sabotageTimerText != null) _sabotageTimerText.gameObject.SetActive(true);
_sabotageTimerActive = true;
if (_sabotagePanel) _sabotagePanel.SetActive(true);
if (_sabotageTimerText) _sabotageTimerText.gameObject.SetActive(true);
}
public void HideSabotageTimer()
{
_sabotageTimerActive = false;
if (_sabotageTimerText != null) _sabotageTimerText.gameObject.SetActive(false);
if (_sabotagePanel) _sabotagePanel.SetActive(false);
SetCommsBlackout(false);
}
public void SetCommsBlackout(bool active)
public void SetCommsBlackout(bool active) => _commsBlackout = active;
// ── Game end ──────────────────────────────────────────────────────────
public void ShowGameEndPanel(GameEndedPayload payload, string myUuid)
{
_commsBlackout = active;
if (_gameEndPanel) _gameEndPanel.SetActive(true);
if (_gameEndText != null)
{
bool won = payload.Winners?.Contains(myUuid) ?? false;
string title = won ? "<color=#FFB800>🏆 VICTORY</color>" : "<color=#C43232>💔 DEFEAT</color>";
string faction = payload.WinningFaction == "Impostor" ? "Impostors win!" : "Crew wins!";
_gameEndText.text = $"{title}\n{faction}\n<size=38>{payload.Reason}</size>";
}
// Show "Return to Lobby" only for the host
if (_returnToLobbyBtn != null)
_returnToLobbyBtn.gameObject.SetActive(_gameClient.IsOwner);
}
public bool IsCommsBlackout => _commsBlackout;
public bool IsPlayerDead => _isDead;
// ── Toast ─────────────────────────────────────────────────────────────
// ── Utilities ─────────────────────────────────────────────────────────
public void ShowToast(string message)
{
if (_state != null) { _state.ToastMessage = message; _state.ToastExpiry = UnityEngine.Time.time + 4f; }
if (_toastGO == null) return;
_toastGO.SetActive(true);
if (_toastText != null) _toastText.text = message;
}
private void TickToast()
{
var s = _state;
if (_toastGO == null) return;
if (s != null && !string.IsNullOrEmpty(s.ToastMessage) && UnityEngine.Time.time < s.ToastExpiry)
{
_toastGO.SetActive(true);
if (_toastText != null) _toastText.text = s.ToastMessage;
}
else
{
_toastGO.SetActive(false);
}
}
// ── Canvas switching ──────────────────────────────────────────────────
private void SetCanvases(bool createJoin, bool inLobby, bool loading, bool game)
{
@@ -314,14 +478,12 @@ namespace Subsystems
if (ClientGameScreen) ClientGameScreen.gameObject.SetActive(game);
}
// ── Utilities ─────────────────────────────────────────────────────────
private static void EnsureCanvasReady(Canvas canvas)
{
if (canvas == null) return;
if (!canvas.enabled)
canvas.enabled = true;
// Some scene canvases are saved with zero scale, which makes UI invisible.
if (!canvas.enabled) canvas.enabled = true;
var t = canvas.transform;
if (t != null)
{
@@ -335,12 +497,34 @@ namespace Subsystems
{
if (root == null) return null;
foreach (var tmp in root.GetComponentsInChildren<TMP_Text>(true))
{
if (tmp != null && tmp.name == name)
return tmp;
}
if (tmp != null && tmp.name == name) return tmp;
return null;
}
private static Transform FindTransform(Transform root, string name)
{
if (root == null) return null;
foreach (Transform t in root.GetComponentsInChildren<Transform>(true))
if (t.name == name) return t;
return null;
}
private static GameObject FindTransformGO(Transform root, string name)
=> FindTransform(root, name)?.gameObject;
private static RectTransform MakeChild(string name, RectTransform parent)
{
var go = new GameObject(name);
var rt = go.AddComponent<RectTransform>();
rt.SetParent(parent, false);
rt.localScale = Vector3.one;
return rt;
}
private static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
}
}
}