GeoSus
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user