This commit is contained in:
Bandwidth
2026-04-26 20:49:32 +02:00
parent e0b808faed
commit d886f97e14
66 changed files with 8327 additions and 933 deletions

View File

@@ -4,6 +4,7 @@ using Subsystems;
using GeoSus.Client;
using System.Collections.Generic;
using System;
using System.Linq;
using TMPro;
namespace Subsystems
@@ -34,15 +35,22 @@ namespace Subsystems
private TMP_Text _sabotageTimerText;
private GameObject _meetingPanel;
private TMP_Text _meetingHeader;
private TMP_Text _meetingPhaseLabel;
private TMP_Text _meetingPhaseCountdown;
private Image _meetingPhaseProgressBar;
private TMP_Text _myVoteIndicator;
private GameObject _meetingScrollGO;
private Transform _meetingScrollContent;
private TMP_Text _meetingFallbackText;
private GameObject _voteResultPanel;
private TMP_Text _voteResultText;
private Button _skipButton;
private GameObject _gameEndPanel;
private TMP_Text _gameEndText;
private RectTransform _returnToLobbyBtn;
private TMP_Text _toastText;
private GameObject _toastGO;
private GameObject _reconnectOverlay;
// ── Internal state ────────────────────────────────────────────────────
private bool _isDead;
@@ -54,6 +62,7 @@ namespace Subsystems
// Meeting vote-row references rebuilt each meeting
private readonly List<GameObject> _voteRows = new List<GameObject>();
private string _pendingVoteResultDisplay; // shown after voting
private Coroutine _meetingCloseCoroutine; // tracked so phase changes can cancel it
public GameManager_UI(GameClient gameClient) { _gameClient = gameClient; }
@@ -89,9 +98,19 @@ namespace Subsystems
_gameEndText = FindTMP(t, "GameEndText");
_toastText = FindTMP(t, "Toast");
_meetingHeader = FindTMP(t, "MeetingHeader");
_meetingPhaseLabel = FindTMP(t, "MeetingPhaseLabel");
_meetingPhaseCountdown = FindTMP(t, "MeetingPhaseCountdown");
_myVoteIndicator = FindTMP(t, "MyVoteIndicator");
_meetingFallbackText = FindTMP(t, "MeetingPlayerList");
_voteResultText = FindTMP(t, "VoteResult");
_meetingScrollContent = FindTransform(t, "MeetingContent");
_meetingScrollGO = FindTransformGO(t, "_MeetingScroll");
var progressBarGO = FindTransformGO(t, "MeetingPhaseProgressBar");
if (progressBarGO != null) _meetingPhaseProgressBar = progressBarGO.GetComponent<Image>();
var skipGO = FindTransformGO(t, "SkipButton");
if (skipGO != null) _skipButton = skipGO.GetComponent<Button>();
var actionGO = t.Find("ActionButton");
if (actionGO != null)
@@ -105,6 +124,7 @@ namespace Subsystems
_gameEndPanel = t.Find("GameEndPanel")?.gameObject;
_voteResultPanel = FindTransformGO(t, "VoteResultPanel");
_toastGO = FindTransformGO(t, "Toast");
_reconnectOverlay = FindTransformGO(t, "ReconnectOverlay");
var retBtn = FindTransform(t, "ReturnToLobbyButton");
if (retBtn != null) _returnToLobbyBtn = retBtn as RectTransform;
@@ -113,6 +133,7 @@ namespace Subsystems
if (_gameEndPanel) _gameEndPanel.SetActive(false);
if (_voteResultPanel) _voteResultPanel.SetActive(false);
if (_toastGO) _toastGO.SetActive(false);
if (_reconnectOverlay) _reconnectOverlay.SetActive(false);
}
// ── Update (called every frame from GameManager.Update) ───────────────
@@ -192,19 +213,52 @@ namespace Subsystems
if (show) _killCooldownText.text = $"Kill: {Mathf.CeilToInt(s.KillCooldownRemaining)}s";
}
// Sabotage banner
// Sabotage banner - meltdown countdown plus simultaneous-repair coaching
if (_sabotageTimerActive && _sabotageTimerText != null)
{
double remaining = (_sabotageMeltdownDeadline - DateTime.UtcNow).TotalSeconds;
_sabotageTimerText.text = remaining > 0 ? $"⚠ MELTDOWN: {remaining:F0}s" : "⚠ MELTDOWN!";
string head = remaining > 0 ? $"⚠ MELTDOWN: {remaining:F0}s" : "⚠ MELTDOWN!";
// For multi-station sabotages, surface how many of the required
// simultaneous repair stations are currently active. This is
// what makes "you're alone, you need a partner" obvious.
int required = s.ActiveSabotage?.RequiredSimultaneousRepairs ?? 0;
if (required > 1)
{
int active = s.ActiveRepairs.Count;
head += $" <size=32>{active}/{required} stations active</size>";
}
_sabotageTimerText.text = head;
}
// Keep meeting voted-indicator rows fresh each frame
TickMeetingVoteIndicators();
// Keep meeting sub-phase strip, countdown, vote gating, tallies and
// my-vote indicator fresh each frame.
UpdateMeetingPhaseStrip();
}
// ── Kill cooldown helper (called from GameManager) ────────────────────
// ── Reconnect overlay ─────────────────────────────────────────────────
/// <summary>
/// Show a full-screen "Reconnecting..." overlay. Call when the socket
/// drops mid-game; the server keeps the player slot for ~60s before
/// removing them so a brief disconnect is recoverable.
/// </summary>
public void ShowReconnecting()
{
if (_reconnectOverlay) _reconnectOverlay.SetActive(true);
}
/// <summary>
/// Hide the reconnect overlay. Call from OnConnected once the socket
/// is healthy again.
/// </summary>
public void HideReconnecting()
{
if (_reconnectOverlay) _reconnectOverlay.SetActive(false);
}
public void SetKillCooldownText(string text)
{
if (_killCooldownText == null) return;
@@ -233,6 +287,96 @@ namespace Subsystems
}
}
// ── P13g: Impostor sabotage menu ──────────────────────────────────────
// The audit found that the production HUD never had an impostor
// sabotage trigger - GameManager.StartSabotage exists, the wire path
// is intact (StartSabotage -> server -> SabotageStarted broadcast +
// station markers), but no UI ever called it. So sabotages literally
// never fired in production. This menu fixes that gap with a runtime-
// built two-button overlay (no scene file change, no prefab needed).
private GameObject _sabotageMenuRoot;
private Button _sabotageBlackoutBtn;
private Button _sabotageMeltdownBtn;
private void EnsureSabotageMenu()
{
if (_sabotageMenuRoot != null || ClientGameScreen == null) return;
var canvasRT = ClientGameScreen.transform as RectTransform;
if (canvasRT == null) return;
// Root container - top-right corner, vertical stack.
_sabotageMenuRoot = new GameObject("ImpostorSabotageMenu", typeof(RectTransform), typeof(CanvasRenderer));
var rootRT = _sabotageMenuRoot.GetComponent<RectTransform>();
rootRT.SetParent(canvasRT, worldPositionStays: false);
rootRT.anchorMin = new Vector2(1, 1);
rootRT.anchorMax = new Vector2(1, 1);
rootRT.pivot = new Vector2(1, 1);
rootRT.anchoredPosition = new Vector2(-24, -180); // below the top-right safe-area
rootRT.sizeDelta = new Vector2(360, 240);
_sabotageBlackoutBtn = BuildSabotageOption(rootRT, "📡 BLACKOUT",
new Color(0.20f, 0.55f, 1.0f), 0, () => GameManager.Instance?.StartSabotage(0));
_sabotageMeltdownBtn = BuildSabotageOption(rootRT, "☢️ MELTDOWN",
new Color(1.0f, 0.30f, 0.30f), 1, () => GameManager.Instance?.StartSabotage(1));
_sabotageMenuRoot.SetActive(false);
}
private static Button BuildSabotageOption(RectTransform parent, string label, Color tint, int slot, UnityEngine.Events.UnityAction onClick)
{
// Each button: 360w x 110h, stacked vertically with 10px gap.
var go = new GameObject($"SabBtn_{slot}", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button));
var rt = go.GetComponent<RectTransform>();
rt.SetParent(parent, worldPositionStays: false);
rt.anchorMin = new Vector2(0, 1);
rt.anchorMax = new Vector2(1, 1);
rt.pivot = new Vector2(0.5f, 1);
rt.anchoredPosition = new Vector2(0, -slot * 120);
rt.sizeDelta = new Vector2(0, 110);
var img = go.GetComponent<Image>();
img.color = new Color(tint.r * 0.4f, tint.g * 0.4f, tint.b * 0.4f, 0.92f);
// Border via outline component
var outline = go.AddComponent<Outline>();
outline.effectColor = tint;
outline.effectDistance = new Vector2(2, -2);
// Text child
var txtGO = new GameObject("Label", typeof(RectTransform));
var txtRT = txtGO.GetComponent<RectTransform>();
txtRT.SetParent(rt, worldPositionStays: false);
txtRT.anchorMin = Vector2.zero;
txtRT.anchorMax = Vector2.one;
txtRT.offsetMin = Vector2.zero;
txtRT.offsetMax = Vector2.zero;
var tmp = txtGO.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.alignment = TextAlignmentOptions.Center;
tmp.fontSize = 36;
tmp.color = Color.white;
tmp.fontStyle = FontStyles.Bold;
var btn = go.GetComponent<Button>();
btn.onClick.AddListener(onClick);
return btn;
}
/// <summary>
/// P13g: show the impostor sabotage menu when the local player is
/// alive impostor in the Playing phase with no active sabotage and
/// not in a meeting. Driven from GameManager_Tasks.UpdateProximity.
/// </summary>
public void SetSabotageMenuVisible(bool visible)
{
if (visible) EnsureSabotageMenu();
if (_sabotageMenuRoot != null && _sabotageMenuRoot.activeSelf != visible)
_sabotageMenuRoot.SetActive(visible);
}
// ── Player state ──────────────────────────────────────────────────────
public void OnLocalPlayerDied()
@@ -256,9 +400,14 @@ namespace Subsystems
if (_meetingHeader != null)
_meetingHeader.text = payload.Type == MeetingType.BodyReport ? "BODY REPORTED!" : "EMERGENCY MEETING!";
// Make sure the result subpanel is hidden at start of a fresh meeting,
// and the scroll list is visible (results phase will swap them).
if (_voteResultPanel) _voteResultPanel.SetActive(false);
if (_meetingScrollGO) _meetingScrollGO.SetActive(true);
if (_myVoteIndicator) _myVoteIndicator.text = "";
BuildMeetingVoteRows(players);
UpdateMeetingPhaseStrip();
}
private void BuildMeetingVoteRows(List<PlayerInfo> players)
@@ -284,24 +433,47 @@ namespace Subsystems
string myId = _gameClient.ClientUuid;
bool canVote = !_isDead;
// Dynamic row height: spread the available scroll-area height
// across however many players we have. Clamps so rows never get
// tinier than legible (small phone, many players -> 80px) or
// ridiculously tall (tablet, two players -> 140px).
float rowH = ComputeVoteRowHeight(players.Count);
foreach (var player in players)
{
bool isMe = player.ClientUuid == myId;
bool isAlive = player.State == PlayerState.Alive;
var row = BuildVoteRow(player, isMe, isAlive, canVote && isAlive && !isMe);
var row = BuildVoteRow(player, isMe, isAlive, canVote && isAlive && !isMe, rowH);
row.transform.SetParent(_meetingScrollContent, false);
_voteRows.Add(row);
}
}
private GameObject BuildVoteRow(PlayerInfo player, bool isMe, bool isAlive, bool canVote)
/// <summary>
/// Compute a per-row height that fills the scroll viewport when there
/// are few players, and shrinks (until scrolling kicks in) when there
/// are many. Inputs are CanvasScaler reference coordinates, so the
/// values are device-independent.
/// </summary>
private float ComputeVoteRowHeight(int playerCount)
{
if (playerCount <= 0) return 110f;
// The scroll area occupies y=0.18 to y=0.74 of the canvas (per
// InGameHUDBuilder.BuildMeetingPanel) and reference height is 1920.
const float referenceHeight = 1920f;
const float scrollFraction = 0.74f - 0.18f; // 0.56
float available = referenceHeight * scrollFraction;
float h = available / playerCount;
return Mathf.Clamp(h, 80f, 140f);
}
private GameObject BuildVoteRow(PlayerInfo player, bool isMe, bool isAlive, bool canVote, float rowH)
{
const float ROW_H = 110f;
var go = new GameObject($"VoteRow_{player.ClientUuid}");
var rt = go.AddComponent<RectTransform>();
rt.sizeDelta = new Vector2(0, ROW_H);
rt.sizeDelta = new Vector2(0, rowH);
var le = go.AddComponent<LayoutElement>();
le.minHeight = le.preferredHeight = ROW_H;
le.minHeight = le.preferredHeight = rowH;
var bg = go.AddComponent<Image>();
bg.color = isMe ? new Color(0.12f,0.18f,0.30f) : new Color(0.10f,0.12f,0.20f);
@@ -312,9 +484,9 @@ namespace Subsystems
bg.color = new Color(0.08f,0.08f,0.10f,0.7f);
}
// Name label
// Name label - left 50% (was 65%, gave width back to tally + button)
var namRT = MakeChild("Name", rt);
namRT.anchorMin = new Vector2(0,0); namRT.anchorMax = new Vector2(0.65f,1);
namRT.anchorMin = new Vector2(0,0); namRT.anchorMax = new Vector2(0.50f,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 ?? "???");
@@ -323,7 +495,18 @@ namespace Subsystems
namTmp.fontStyle = isMe ? FontStyles.Bold : FontStyles.Normal;
namTmp.alignment = TextAlignmentOptions.MidlineLeft;
// Vote button
// Tally column - middle 18%, shows live vote count for this player
var tallyRT = MakeChild("Tally", rt);
tallyRT.anchorMin = new Vector2(0.50f,0); tallyRT.anchorMax = new Vector2(0.66f,1);
tallyRT.offsetMin = Vector2.zero; tallyRT.offsetMax = Vector2.zero;
var tallyTmp = tallyRT.gameObject.AddComponent<TextMeshProUGUI>();
tallyTmp.text = "";
tallyTmp.fontSize = 30;
tallyTmp.fontStyle = FontStyles.Bold;
tallyTmp.color = new Color(1f,0.72f,0.10f); // C_YELLOW-ish
tallyTmp.alignment = TextAlignmentOptions.Center;
// Vote button - right 30% (interactability is updated each frame)
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>();
@@ -342,7 +525,7 @@ namespace Subsystems
voteTmp.color = Color.white;
voteTmp.alignment = TextAlignmentOptions.Center;
// Voted indicator (hidden by default; shown by TickMeetingVoteIndicators)
// Voted-by-this-player checkmark (shown when the row's player has cast a vote)
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>();
@@ -353,51 +536,235 @@ namespace Subsystems
return go;
}
private void TickMeetingVoteIndicators()
/// <summary>
/// Per-frame meeting UI update. Computes the meeting sub-phase from the
/// timestamps in MeetingStartedPayload (server doesn't broadcast a
/// discrete discussion-end event) and uses it to drive the countdown
/// label, progress bar, vote-button interactivity, live tallies, and
/// "Your vote: X" indicator.
/// </summary>
private void UpdateMeetingPhaseStrip()
{
var s = _state;
if (s == null) return;
// Only run if we're actually in a meeting; phase Playing skips the work.
if (s.Phase != GamePhase.Meeting && s.LastVoteResult == null) return;
var sub = s.GetMeetingSubPhase();
// ── Sub-phase label + countdown text + progress bar ───────────────
string label;
switch (sub)
{
case MeetingSubPhase.Arrival: label = "ARRIVAL"; break;
case MeetingSubPhase.Discussion: label = "DISCUSSION"; break;
case MeetingSubPhase.Voting: label = "VOTING"; break;
case MeetingSubPhase.Resolved: label = "RESULTS"; break;
default: label = ""; break;
}
if (_meetingPhaseLabel != null) _meetingPhaseLabel.text = label;
if (s.ActiveMeeting != null && sub != MeetingSubPhase.Resolved)
{
var deadline = s.GetMeetingSubPhaseDeadline(sub);
var remaining = (deadline - DateTime.UtcNow).TotalSeconds;
if (remaining < 0) remaining = 0;
if (_meetingPhaseCountdown != null)
{
int mins = (int)(remaining / 60);
int secs = (int)(remaining % 60);
string verb = sub == MeetingSubPhase.Voting ? "Voting ends in"
: sub == MeetingSubPhase.Discussion ? "Voting begins in"
: "Arrival ends in";
_meetingPhaseCountdown.text = $"{verb} {mins}:{secs:D2}";
}
// Progress bar drains over the current sub-phase. The server
// doesn't tell us when the meeting started, so we can only
// compute a meaningful fill for Discussion (start = arrival
// deadline) and Voting (start = discussion end / arrival
// deadline). Arrival's start time is unknown here; show full.
if (_meetingPhaseProgressBar != null)
{
if (sub == MeetingSubPhase.Arrival)
{
_meetingPhaseProgressBar.fillAmount = 1f;
}
else
{
DateTime start = sub == MeetingSubPhase.Discussion
? s.ActiveMeeting.ArrivalDeadline
: (s.ActiveMeeting.DiscussionEndTime ?? s.ActiveMeeting.ArrivalDeadline);
var total = (deadline - start).TotalSeconds;
var elapsed = (DateTime.UtcNow - start).TotalSeconds;
float fill = total > 0.001
? Mathf.Clamp01(1f - (float)(elapsed / total))
: 0f;
_meetingPhaseProgressBar.fillAmount = fill;
}
}
}
else
{
if (_meetingPhaseCountdown != null) _meetingPhaseCountdown.text = "";
if (_meetingPhaseProgressBar != null) _meetingPhaseProgressBar.fillAmount = 0f;
}
// ── Vote button gating + per-row tally / voted-indicator ──────────
bool votingOpen = sub == MeetingSubPhase.Voting && !_isDead;
bool iAmArrived = s.ActiveMeeting == null
|| s.ArrivedPlayerIds.Contains(_gameClient.ClientUuid);
// Skip button mirrors the same gate
if (_skipButton != null) _skipButton.interactable = votingOpen && iAmArrived;
foreach (var row in _voteRows)
{
if (row == null) continue;
// Row name is "VoteRow_<uuid>"
string uuid = row.name.Replace("VoteRow_", "");
string rowUuid = row.name.Replace("VoteRow_", "");
// Voted-tick: this row's player has cast a vote
var tick = row.transform.Find("VotedTick")?.gameObject;
if (tick != null)
tick.SetActive(s.VotedPlayerIds.Contains(uuid));
if (tick != null) tick.SetActive(s.VotedPlayerIds.Contains(rowUuid));
// Tally text: how many votes is this row's player receiving?
var tally = row.transform.Find("Tally")?.GetComponent<TMP_Text>();
if (tally != null)
{
s.VoteTallies.TryGetValue(rowUuid, out var count);
tally.text = count > 0 ? count.ToString() : "";
}
// Vote button: gate by sub-phase + arrival + alive + not-self
var btnGO = row.transform.Find("VoteBtn")?.gameObject;
if (btnGO != null)
{
var btn = btnGO.GetComponent<Button>();
var btnImg = btnGO.GetComponent<Image>();
var rowPlayer = s.Players?.FirstOrDefault(p => p.ClientUuid == rowUuid);
bool isMe = rowUuid == _gameClient.ClientUuid;
bool rowAlive = rowPlayer?.State == PlayerState.Alive;
bool canPress = votingOpen && iAmArrived && rowAlive && !isMe;
if (btn != null) btn.interactable = canPress;
if (btnImg != null)
btnImg.color = canPress ? new Color(0.2f,0.6f,1f)
: new Color(0.2f,0.2f,0.2f,0.5f);
// Mark the row's button if it's the local player's chosen vote
if (s.MyVoteTarget != null && s.MyVoteTarget == rowUuid && btnImg != null)
btnImg.color = new Color(0.2f,0.75f,0.30f); // green = your vote
}
}
// ── My vote indicator strip ───────────────────────────────────────
if (_myVoteIndicator != null)
{
if (s.LastVoteResult != null) _myVoteIndicator.text = "";
else if (!iAmArrived) _myVoteIndicator.text = "Travel to the meeting point to vote";
else if (sub == MeetingSubPhase.Discussion) _myVoteIndicator.text = "Discussion - voting opens shortly";
else if (sub == MeetingSubPhase.Arrival) _myVoteIndicator.text = "Waiting for players to arrive";
else if (s.MyVoteTarget == null) _myVoteIndicator.text = "Cast your vote";
else if (s.MyVoteTarget == GameState.VoteSkip) _myVoteIndicator.text = "You voted: SKIP";
else
{
var target = s.Players?.FirstOrDefault(p => p.ClientUuid == s.MyVoteTarget);
_myVoteIndicator.text = $"You voted for: {target?.DisplayName ?? s.MyVoteTarget}";
}
}
}
public void AppendVoteInstruction()
{
// no-op vote instructions are embedded in the row buttons
// no-op - vote instructions are embedded in the row buttons
}
public void ShowVoteResult(VotingClosedPayload payload, List<PlayerInfo> players)
{
// Swap scroll list out, result subpanel in. They occupy the same
// anchor region (0.18-0.74) so the result text replaces the vote
// rows rather than overlapping them.
if (_meetingScrollGO != null) _meetingScrollGO.SetActive(false);
if (_voteResultPanel != null) _voteResultPanel.SetActive(true);
// Skip + my-vote strips are no longer relevant once voting ended.
if (_skipButton != null) _skipButton.gameObject.SetActive(false);
if (_myVoteIndicator != null) _myVoteIndicator.text = "";
if (_voteResultText != null)
{
// Build a compact tally summary alongside the headline.
var sb = new System.Text.StringBuilder();
if (payload.WasTie)
_voteResultText.text = "⚖ TIE — nobody ejected.";
sb.AppendLine("⚖ TIE — nobody ejected.");
else if (string.IsNullOrEmpty(payload.EjectedPlayerId))
_voteResultText.text = "Nobody ejected (skip).";
sb.AppendLine("Nobody ejected (skip).");
else
{
var ej = players?.Find(p => p.ClientUuid == payload.EjectedPlayerId);
_voteResultText.text = $"🚪 {ej?.DisplayName ?? payload.EjectedPlayerId} ejected!";
sb.AppendLine($"🚪 {ej?.DisplayName ?? payload.EjectedPlayerId} ejected!");
}
if (payload.VoteCounts != null && payload.VoteCounts.Count > 0)
{
sb.AppendLine();
foreach (var kv in payload.VoteCounts.OrderByDescending(p => p.Value))
{
if (kv.Value <= 0) continue;
string name = kv.Key == GameState.VoteSkip
? "(skip)"
: (players?.Find(p => p.ClientUuid == kv.Key)?.DisplayName ?? kv.Key);
sb.AppendLine($"<size=24>{name}: {kv.Value}</size>");
}
}
_voteResultText.text = sb.ToString();
}
// Auto-close meeting panel after 5 s
GameManager.Instance?.StartCoroutine(CloseMeetingAfterDelay(5f));
// Auto-close meeting panel after 5 s. Track the handle so we can
// cancel it if the game ends or returns to lobby before it fires
// (otherwise the coroutine fires mid-GameEndPanel and hides nothing
// useful while the meeting overlay sits visibly stacked on top).
CancelMeetingAutoClose();
var gm = GameManager.Instance;
if (gm != null) _meetingCloseCoroutine = gm.StartCoroutine(CloseMeetingAfterDelay(5f));
}
/// <summary>
/// Hide the meeting/vote panels immediately and cancel any pending
/// auto-close coroutine. Resets internal toggles (skip/result/scroll
/// visibility) so the next meeting starts from a clean state. Safe to
/// call from any phase transition.
/// </summary>
public void HideMeetingPanel()
{
CancelMeetingAutoClose();
if (_meetingPanel) _meetingPanel.SetActive(false);
if (_voteResultPanel) _voteResultPanel.SetActive(false);
if (_meetingScrollGO) _meetingScrollGO.SetActive(true);
if (_skipButton) _skipButton.gameObject.SetActive(true);
if (_myVoteIndicator) _myVoteIndicator.text = "";
if (_meetingPhaseLabel) _meetingPhaseLabel.text = "";
if (_meetingPhaseCountdown) _meetingPhaseCountdown.text = "";
if (_meetingPhaseProgressBar) _meetingPhaseProgressBar.fillAmount = 0f;
}
private void CancelMeetingAutoClose()
{
if (_meetingCloseCoroutine != null)
{
var gm = GameManager.Instance;
if (gm != null) gm.StopCoroutine(_meetingCloseCoroutine);
_meetingCloseCoroutine = null;
}
}
private System.Collections.IEnumerator CloseMeetingAfterDelay(float delay)
{
yield return new UnityEngine.WaitForSeconds(delay);
if (_meetingPanel) _meetingPanel.SetActive(false);
if (_voteResultPanel) _voteResultPanel.SetActive(false);
// Use HideMeetingPanel so we restore the scroll/skip/indicator
// state for the next meeting, not just hide the root panel.
HideMeetingPanel();
_meetingCloseCoroutine = null;
}
// ── Sabotage ──────────────────────────────────────────────────────────
@@ -417,7 +784,31 @@ namespace Subsystems
SetCommsBlackout(false);
}
public void SetCommsBlackout(bool active) => _commsBlackout = active;
/// <summary>
/// Set the comms-blackout flag and (when active) raise the sabotage
/// banner with a clear "comms down" message. The flag is read by
/// GameManager_Tasks.UpdateProximity to suppress the REPORT/EMERGENCY
/// action button while comms are jammed - this gives the player the
/// visible reason why those buttons disappeared.
/// </summary>
public void SetCommsBlackout(bool active)
{
_commsBlackout = active;
if (active)
{
if (_sabotagePanel) _sabotagePanel.SetActive(true);
if (_sabotageTimerText)
{
_sabotageTimerText.gameObject.SetActive(true);
_sabotageTimerText.text = "📡 COMMS DOWN — reports & meetings disabled";
}
}
else if (!_sabotageTimerActive)
{
// Only tear the banner down if no meltdown timer is using it.
if (_sabotagePanel) _sabotagePanel.SetActive(false);
}
}
// ── Game end ──────────────────────────────────────────────────────────
@@ -429,7 +820,20 @@ namespace Subsystems
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>";
// Non-owners can't actually return to lobby themselves; tell
// them who they're waiting on so the panel doesn't read as
// "tap leave or stare at the wall." If we can't find an
// owner record, fall back to a generic message.
string waitMessage = "";
if (!_gameClient.IsOwner)
{
var s = _state;
var host = s?.Players?.Find(p => p.IsOwner);
string hostName = host?.DisplayName ?? "the host";
waitMessage = $"\n\n<size=32>Waiting for {hostName} to return to lobby...</size>";
}
_gameEndText.text = $"{title}\n{faction}\n<size=38>{payload.Reason}</size>{waitMessage}";
}
// Show "Return to Lobby" only for the host