using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
///
/// Programmatically builds the complete in-game HUD inside the InGame canvas (Client.unity).
///
/// Call BuildNow() from GameManager.OnSceneLoaded BEFORE BindClientScene so GameManager_UI
/// can locate named children.
///
/// Named elements expected by GameManager_UI (Transform.Find / FindTMP):
/// ActionButton – proxim action button
/// SabotagePanel – top-of-screen sabotage banner
/// SabotageTimer – TMP countdown text inside SabotagePanel
/// MeetingPanel – full-screen voting overlay
/// MeetingHeader – TMP title (event type)
/// MeetingPhaseLabel – TMP sub-phase label (DISCUSSION / VOTING / RESULTS / ARRIVAL)
/// MeetingPhaseProgressBar – Image filled, drains with countdown
/// MeetingPhaseCountdown – TMP "0:24" countdown text
/// MeetingPlayerList – TMP player list (text fallback)
/// MyVoteIndicator – TMP "You voted for: X" strip
/// SkipButton – skip-vote button
/// VoteResultPanel – sub-panel shown after voting
/// VoteResult – TMP result text
/// GameEndPanel – full-screen end-of-game overlay
/// GameEndText – TMP result text
/// ReconnectOverlay – full-screen "reconnecting..." overlay
/// ReconnectMessage – TMP "Reconnecting..." headline
/// ReconnectSubtext – TMP secondary message
/// KillCooldown – TMP kill-cooldown label
/// TaskList – TMP task name list
/// TaskProgress – TMP global task progress
/// Toast – TMP toast notification
///
public class InGameHUDBuilder : MonoBehaviour
{
// ── Palette ───────────────────────────────────────────────────────────────
// Aliases to UITheme so this file's existing local references keep working
// without a wholesale rename pass. New code should call UITheme.* directly;
// the C_* names stay as a transitional crutch for in-flight edits.
static readonly Color C_BG = UITheme.Bg;
static readonly Color C_BAR = UITheme.Surface;
static readonly Color C_ACCENT = UITheme.Accent;
static readonly Color C_GREEN = UITheme.Success;
static readonly Color C_RED = UITheme.Danger;
static readonly Color C_ORANGE = UITheme.Warning;
static readonly Color C_YELLOW = UITheme.Caution;
static readonly Color C_MUTED = UITheme.TextLo;
static readonly Color C_ROW_A = UITheme.RowA;
static readonly Color C_ROW_B = UITheme.RowB;
private bool _built;
// Reference resolution constants kept here as public surface so external
// callers (other UI scripts that grew to use these) don't break. Forward
// to UITheme so there's still one source of truth.
public const float kReferenceWidth = UITheme.ReferenceWidth;
public const float kReferenceHeight = UITheme.ReferenceHeight;
public const float kMatchWidthHeight = UITheme.MatchWidthOrHeight;
public void BuildNow() { if (!_built) { _built = true; Build(); } }
void Start() { if (!_built) Build(); }
void Build()
{
var rt = GetComponent();
if (rt == null) return;
// Wire up emoji fallback before any TMP component renders. Idempotent
// and cheap on subsequent calls.
UITheme.EnsureEmojiFontFallback();
// Make sure the host canvas has a CanvasScaler with our reference
// resolution. Without this, RectTransform offsets are interpreted as
// raw pixels and the layout looks correct only on whichever device
// the project was last opened against.
ConfigureCanvasScaler(GetComponentInParent());
// Apply Screen.safeArea so iOS notches and Android punch-hole cameras
// don't eat the top/bottom bar. We anchor the host RectTransform to
// the safe rectangle so all child anchors inherit the inset.
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
ApplySafeArea(rt);
BuildTopBar(rt);
BuildTaskPanel(rt);
BuildTaskProgress(rt);
BuildBottomBar(rt);
BuildActionButton(rt);
BuildSabotagePanel(rt);
BuildMeetingPanel(rt);
BuildGameEndPanel(rt);
BuildReconnectOverlay(rt);
BuildSpectatePanel(rt);
BuildToast(rt);
}
///
/// Apply the project's standard CanvasScaler config to a Canvas. Idempotent -
/// adds the component if missing, otherwise updates settings in-place.
///
public static void ConfigureCanvasScaler(Canvas canvas)
{
if (canvas == null) return;
var scaler = canvas.GetComponent()
?? canvas.gameObject.AddComponent();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(kReferenceWidth, kReferenceHeight);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = kMatchWidthHeight;
scaler.referencePixelsPerUnit = 100f;
}
///
/// Anchors the given RectTransform to the screen's safe rectangle, so all
/// children inherit the inset. Called once at build time; the safe area
/// rarely changes after the app launches and a full HUD rebuild on rotation
/// would be the simpler way to handle that case.
///
public static void ApplySafeArea(RectTransform rt)
{
if (rt == null) return;
var safe = Screen.safeArea;
var screenSize = new Vector2(Screen.width, Screen.height);
if (screenSize.x <= 0 || screenSize.y <= 0) return;
Vector2 aMin = safe.position;
Vector2 aMax = safe.position + safe.size;
aMin.x /= screenSize.x; aMin.y /= screenSize.y;
aMax.x /= screenSize.x; aMax.y /= screenSize.y;
rt.anchorMin = aMin;
rt.anchorMax = aMax;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
// ── Top bar ───────────────────────────────────────────────────────────────
void BuildTopBar(RectTransform parent)
{
var bar = Child("_TopBar", parent);
Anchor(bar, new Vector2(0,1), new Vector2(1,1), new Vector2(0,-90f), Vector2.zero);
bar.pivot = new Vector2(0.5f,1f);
Img(bar, C_BAR);
// Kill cooldown – right half, hidden by default
var cd = Child("KillCooldown", bar);
cd.anchorMin = new Vector2(0.5f, 0); cd.anchorMax = Vector2.one;
cd.offsetMin = new Vector2(0, 6); cd.offsetMax = new Vector2(-12, -6);
var cdTmp = cd.gameObject.AddComponent();
cdTmp.text = ""; cdTmp.fontSize = 32; cdTmp.color = C_ORANGE;
cdTmp.fontStyle = FontStyles.Bold; cdTmp.alignment = TextAlignmentOptions.MidlineRight;
cd.gameObject.SetActive(false);
}
// ── Task panel (right side) ───────────────────────────────────────────────
void BuildTaskPanel(RectTransform parent)
{
var panel = Child("_TaskPanel", parent);
panel.anchorMin = new Vector2(1,0.35f); panel.anchorMax = new Vector2(1,0.88f);
panel.pivot = new Vector2(1,0.5f); panel.sizeDelta = new Vector2(280,0);
Img(panel, new Color(0.05f,0.06f,0.12f,0.85f));
var hdr = Child("_Hdr", panel);
Anchor(hdr, new Vector2(0,1), new Vector2(1,1), new Vector2(0,-44), Vector2.zero);
hdr.pivot = new Vector2(0.5f,1f);
Img(hdr, new Color(0.2f,0.6f,1f,0.5f));
TxtChild(hdr,"MY TASKS",26,Color.white,TextAlignmentOptions.Center,bold:true);
var body = Child("TaskList", panel);
body.anchorMin = Vector2.zero; body.anchorMax = Vector2.one;
body.offsetMin = new Vector2(8,8); body.offsetMax = new Vector2(-8,-48);
var t = body.gameObject.AddComponent();
t.text = ""; t.fontSize = 22; t.color = Color.white; t.alignment = TextAlignmentOptions.TopLeft;
t.enableWordWrapping = true;
}
// ── Task progress (above bottom bar) ─────────────────────────────────────
void BuildTaskProgress(RectTransform parent)
{
var prog = Child("TaskProgress", parent);
Anchor(prog, new Vector2(0,0), new Vector2(1,0), new Vector2(-20,120), new Vector2(20,160));
var t = prog.gameObject.AddComponent();
t.text = ""; t.fontSize = 28; t.color = Color.white;
t.fontStyle = FontStyles.Bold; t.alignment = TextAlignmentOptions.Center;
}
// ── Bottom bar ────────────────────────────────────────────────────────────
void BuildBottomBar(RectTransform parent)
{
var bar = Child("_BottomBar", parent);
Anchor(bar, Vector2.zero, new Vector2(1,0), Vector2.zero, new Vector2(0,110));
bar.pivot = new Vector2(0.5f,0);
Img(bar, C_BAR);
var recBtn = Child("_RecenterBtn", bar);
recBtn.anchorMin = new Vector2(0.82f,0.08f); recBtn.anchorMax = new Vector2(0.98f,0.92f);
var recBg = Img(recBtn, C_ACCENT);
var recButton = recBtn.gameObject.AddComponent();
recButton.targetGraphic = recBg;
recButton.onClick.AddListener(() => MapCameraController.Instance?.Recenter());
TxtChild(recBtn,"⊙",42,Color.white,TextAlignmentOptions.Center,bold:true);
}
// ── Action button (DIRECT child so Transform.Find works) ─────────────────
void BuildActionButton(RectTransform parent)
{
var btn = Child("ActionButton", parent);
Anchor(btn, new Vector2(0.15f,0), new Vector2(0.80f,0), new Vector2(0,12), new Vector2(0,102));
btn.pivot = new Vector2(0.5f,0);
var bg = Img(btn, C_GREEN);
var button = btn.gameObject.AddComponent();
button.targetGraphic = bg;
var txtRt = Child("Text", btn);
Stretch(txtRt);
var tmp = txtRt.gameObject.AddComponent();
tmp.text = "ACTION"; tmp.fontSize = 44; tmp.fontStyle = FontStyles.Bold;
tmp.color = Color.white; tmp.alignment = TextAlignmentOptions.Center;
btn.gameObject.SetActive(false);
}
// ── Sabotage panel (top strip) ────────────────────────────────────────────
void BuildSabotagePanel(RectTransform parent)
{
var panel = Child("SabotagePanel", parent);
panel.anchorMin = new Vector2(0,1); panel.anchorMax = new Vector2(1,1);
panel.pivot = new Vector2(0.5f,1f); panel.sizeDelta = new Vector2(0,80);
Img(panel, new Color(0.76f,0.19f,0.19f,0.92f));
var timer = Child("SabotageTimer", panel);
Stretch(timer);
var t = timer.gameObject.AddComponent();
t.text = "SABOTAGE!"; t.fontSize = 48; t.fontStyle = FontStyles.Bold;
t.color = Color.white; t.alignment = TextAlignmentOptions.Center;
panel.gameObject.SetActive(false);
}
// ── Meeting panel (full screen overlay) ───────────────────────────────────
//
// Layout (vertical, top to bottom):
// 0.90 - 1.00 MeetingHeader - "EMERGENCY MEETING" / "BODY REPORTED"
// 0.83 - 0.90 MeetingPhaseLabel - "ARRIVAL" / "DISCUSSION" / "VOTING" / "RESULTS"
// 0.79 - 0.83 MeetingPhaseProgressBar - thin fill bar that drains as countdown runs
// 0.74 - 0.79 MeetingPhaseCountdown - "0:24" countdown text
// 0.18 - 0.74 _MeetingScroll - vote rows (or VoteResultPanel when resolved)
// 0.14 - 0.18 MyVoteIndicator - "You voted for: X" or "Voting hasn't started"
// 0.04 - 0.14 SkipButton - skip vote (will move into the row list in P2.7)
void BuildMeetingPanel(RectTransform parent)
{
var panel = Child("MeetingPanel", parent);
Stretch(panel);
Img(panel, new Color(0.04f,0.05f,0.14f,0.97f));
// Title
var hdr = Child("MeetingHeader", panel);
Anchor(hdr, new Vector2(0,0.90f), new Vector2(1,1), Vector2.zero, Vector2.zero);
var hdrTmp = hdr.gameObject.AddComponent();
hdrTmp.text = "EMERGENCY MEETING"; hdrTmp.fontSize = 52;
hdrTmp.fontStyle = FontStyles.Bold; hdrTmp.color = C_ORANGE;
hdrTmp.alignment = TextAlignmentOptions.Center;
// Sub-phase label (DISCUSSION / VOTING / RESULTS / ARRIVAL)
var phaseLbl = Child("MeetingPhaseLabel", panel);
Anchor(phaseLbl, new Vector2(0,0.83f), new Vector2(1,0.90f), Vector2.zero, Vector2.zero);
var phaseLblTmp = phaseLbl.gameObject.AddComponent();
phaseLblTmp.text = ""; phaseLblTmp.fontSize = 32;
phaseLblTmp.fontStyle = FontStyles.Bold; phaseLblTmp.color = C_ACCENT;
phaseLblTmp.alignment = TextAlignmentOptions.Center;
// Phase progress bar (drains as countdown elapses)
var progBg = Child("MeetingPhaseProgressBg", panel);
Anchor(progBg, new Vector2(0.10f,0.79f), new Vector2(0.90f,0.83f), Vector2.zero, Vector2.zero);
Img(progBg, new Color(0.10f,0.13f,0.22f,1f));
var progFill = Child("MeetingPhaseProgressBar", progBg);
progFill.anchorMin = new Vector2(0,0); progFill.anchorMax = new Vector2(1,1);
progFill.offsetMin = Vector2.zero; progFill.offsetMax = Vector2.zero;
var fillImg = progFill.gameObject.AddComponent();
fillImg.color = C_ACCENT;
fillImg.type = Image.Type.Filled;
fillImg.fillMethod = Image.FillMethod.Horizontal;
fillImg.fillAmount = 0f;
// Countdown text under the bar
var cd = Child("MeetingPhaseCountdown", panel);
Anchor(cd, new Vector2(0,0.74f), new Vector2(1,0.79f), Vector2.zero, Vector2.zero);
var cdTmp = cd.gameObject.AddComponent();
cdTmp.text = ""; cdTmp.fontSize = 28;
cdTmp.color = new Color(0.8f,0.85f,0.95f);
cdTmp.alignment = TextAlignmentOptions.Center;
// Scrollable player vote list
var scrollArea = Child("_MeetingScroll", panel);
Anchor(scrollArea, new Vector2(0,0.18f), new Vector2(1,0.74f), Vector2.zero, Vector2.zero);
BuildMeetingScroll(scrollArea);
// Text fallback (hidden by default; shown if scroll build fails)
var fallback = Child("MeetingPlayerList", panel);
Anchor(fallback, new Vector2(0,0.18f), new Vector2(1,0.74f), new Vector2(8,0), new Vector2(-8,0));
var fallbackTmp = fallback.gameObject.AddComponent();
fallbackTmp.text = ""; fallbackTmp.fontSize = 28; fallbackTmp.color = Color.white;
fallbackTmp.alignment = TextAlignmentOptions.TopLeft;
fallback.gameObject.SetActive(false); // hidden - scroll list used instead
// "Your vote: X" indicator strip
var myVote = Child("MyVoteIndicator", panel);
Anchor(myVote, new Vector2(0.05f,0.14f), new Vector2(0.95f,0.18f), Vector2.zero, Vector2.zero);
var myVoteTmp = myVote.gameObject.AddComponent();
myVoteTmp.text = ""; myVoteTmp.fontSize = 26;
myVoteTmp.color = new Color(0.73f,0.8f,0.88f);
myVoteTmp.alignment = TextAlignmentOptions.Center;
// Skip button (will be merged into the vote list as a row in P2.7)
var skip = Child("SkipButton", panel);
Anchor(skip, new Vector2(0.05f,0.04f), new Vector2(0.95f,0.14f), Vector2.zero, Vector2.zero);
var skipBg = Img(skip, C_MUTED);
var skipBtn = skip.gameObject.AddComponent();
skipBtn.targetGraphic = skipBg;
skipBtn.onClick.AddListener(() => GameManager.Instance?.CastVote(null));
TxtChild(skip, "⏭ SKIP", 36, Color.white, TextAlignmentOptions.Center, bold: true);
// Vote-result sub-panel - now sized to *replace* the scroll area when
// results arrive, instead of squeezing into the bottom strip alongside
// skip/my-vote (which caused the previous overlap).
var resultPanel = Child("VoteResultPanel", panel);
Anchor(resultPanel, new Vector2(0,0.18f), new Vector2(1,0.74f), Vector2.zero, Vector2.zero);
Img(resultPanel, new Color(0.05f,0.05f,0.15f,0.95f));
var resultText = Child("VoteResult", resultPanel);
Stretch(resultText);
var rtTmp = resultText.gameObject.AddComponent();
rtTmp.text = ""; rtTmp.fontSize = 34; rtTmp.color = C_YELLOW;
rtTmp.fontStyle = FontStyles.Bold; rtTmp.alignment = TextAlignmentOptions.Center;
resultPanel.gameObject.SetActive(false);
panel.gameObject.SetActive(false);
}
void BuildMeetingScroll(RectTransform rt)
{
var sr = rt.gameObject.AddComponent();
var vp = Child("Viewport", rt);
Stretch(vp);
vp.gameObject.AddComponent();
var content = Child("MeetingContent", vp);
content.anchorMin = new Vector2(0,1); content.anchorMax = new Vector2(1,1);
content.pivot = new Vector2(0.5f,1);
var vlg = content.gameObject.AddComponent();
vlg.childControlWidth = true; vlg.childControlHeight = false;
vlg.childForceExpandWidth = true; vlg.spacing = 4;
var csf = content.gameObject.AddComponent();
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
sr.viewport = vp; sr.content = content;
sr.horizontal = false; sr.vertical = true; sr.scrollSensitivity = 60;
}
// ── Game-end panel ────────────────────────────────────────────────────────
void BuildGameEndPanel(RectTransform parent)
{
var panel = Child("GameEndPanel", parent);
Stretch(panel);
Img(panel, new Color(0,0,0,0.90f));
// Result text (upper half)
var txt = Child("GameEndText", panel);
Anchor(txt, new Vector2(0,0.4f), new Vector2(1,0.9f), Vector2.zero, Vector2.zero);
var tmp = txt.gameObject.AddComponent();
tmp.text = ""; tmp.fontSize = 72; tmp.fontStyle = FontStyles.Bold;
tmp.color = Color.white; tmp.alignment = TextAlignmentOptions.Center;
// "Return to lobby" button – owner only (GameManager_UI shows/hides it)
var retBtn = Child("ReturnToLobbyButton", panel);
Anchor(retBtn, new Vector2(0.15f,0.22f), new Vector2(0.85f,0.36f), Vector2.zero, Vector2.zero);
var retBg = Img(retBtn, C_GREEN);
var retButton = retBtn.gameObject.AddComponent();
retButton.targetGraphic = retBg;
retButton.onClick.AddListener(() => GameManager.Instance?.gameClient?.ReturnToLobby());
TxtChild(retBtn, "▶ RETURN TO LOBBY", 38, Color.white, TextAlignmentOptions.Center, bold: true);
retBtn.gameObject.SetActive(false); // shown only for host
// "Leave" button
var leaveBtn = Child("LeaveGameButton", panel);
Anchor(leaveBtn, new Vector2(0.3f,0.08f), new Vector2(0.7f,0.20f), Vector2.zero, Vector2.zero);
var leaveBg = Img(leaveBtn, C_RED);
var leaveButton = leaveBtn.gameObject.AddComponent();
leaveButton.targetGraphic = leaveBg;
leaveButton.onClick.AddListener(() => GameManager.Instance?.LeaveLobbyButton());
TxtChild(leaveBtn, "✕ LEAVE", 34, Color.white, TextAlignmentOptions.Center, bold: true);
panel.gameObject.SetActive(false);
}
// ── Reconnect overlay (visible while the socket is dropping/reconnecting) ─
void BuildReconnectOverlay(RectTransform parent)
{
var panel = Child("ReconnectOverlay", parent);
Stretch(panel);
Img(panel, new Color(0.04f,0.05f,0.14f,0.90f));
var msg = Child("ReconnectMessage", panel);
Anchor(msg, new Vector2(0,0.45f), new Vector2(1,0.65f), Vector2.zero, Vector2.zero);
var msgTmp = msg.gameObject.AddComponent();
msgTmp.text = "Reconnecting..."; msgTmp.fontSize = 56;
msgTmp.fontStyle = FontStyles.Bold; msgTmp.color = C_YELLOW;
msgTmp.alignment = TextAlignmentOptions.Center;
var sub = Child("ReconnectSubtext", panel);
Anchor(sub, new Vector2(0,0.35f), new Vector2(1,0.45f), Vector2.zero, Vector2.zero);
var subTmp = sub.gameObject.AddComponent();
subTmp.text = "Server keeps your slot for up to 60 seconds.";
subTmp.fontSize = 28; subTmp.color = new Color(0.73f,0.8f,0.88f);
subTmp.alignment = TextAlignmentOptions.Center;
panel.gameObject.SetActive(false);
}
// ── Spectate panel (visible after death; dim banner so the player still
// sees the live map but understands they're spectating) ────────────────
void BuildSpectatePanel(RectTransform parent)
{
var panel = Child("SpectatePanel", parent);
// Top strip only - we don't want to occlude the map. Just enough to
// communicate "you're dead, watching live."
panel.anchorMin = new Vector2(0, 1); panel.anchorMax = new Vector2(1, 1);
panel.pivot = new Vector2(0.5f, 1f); panel.sizeDelta = new Vector2(0, 96);
Img(panel, new Color(0f, 0f, 0f, 0.65f));
var label = Child("SpectateLabel", panel);
Stretch(label);
var t = label.gameObject.AddComponent();
UITheme.StyleText(t, UITheme.FontTitle, UITheme.TextHi,
TextAlignmentOptions.Center, bold: true);
t.text = "👻 YOU ARE DEAD - SPECTATING";
var sub = Child("SpectateSub", panel);
Anchor(sub, new Vector2(0, 0), new Vector2(1, 0.45f), Vector2.zero, Vector2.zero);
var st = sub.gameObject.AddComponent();
UITheme.StyleText(st, UITheme.FontSmall, UITheme.TextMid,
TextAlignmentOptions.Center);
st.text = "Crew can finish tasks as ghosts. Impostors cannot kill you.";
panel.gameObject.SetActive(false);
}
// ── Toast notification ────────────────────────────────────────────────────
void BuildToast(RectTransform parent)
{
var toast = Child("Toast", parent);
Anchor(toast, new Vector2(0.05f,0.88f), new Vector2(0.95f,0.94f), Vector2.zero, Vector2.zero);
Img(toast, new Color(0.1f,0.1f,0.2f,0.92f));
TxtChild(toast, "", 30, C_YELLOW, TextAlignmentOptions.Center, bold: true);
toast.gameObject.SetActive(false);
}
// ── Helpers ───────────────────────────────────────────────────────────────
RectTransform Child(string name, RectTransform parent)
{
var go = new GameObject(name);
var rt = go.AddComponent();
rt.SetParent(parent, false);
rt.localScale = Vector3.one;
return rt;
}
Image Img(RectTransform rt, Color c)
{
var img = rt.gameObject.AddComponent();
img.color = c;
return img;
}
void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
}
// min/max by anchor + absolute offset corners
void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax, Vector2 offsetMin, Vector2 offsetMax)
{
rt.anchorMin = aMin; rt.anchorMax = aMax;
rt.offsetMin = offsetMin; rt.offsetMax = offsetMax;
}
TextMeshProUGUI TxtChild(RectTransform parent, string text, float size, Color color,
TextAlignmentOptions align, bool bold = false)
{
var rt = Child("Txt", parent);
Stretch(rt);
var tmp = rt.gameObject.AddComponent();
tmp.text = text; tmp.fontSize = size; tmp.color = color; tmp.alignment = align;
if (bold) tmp.fontStyle = FontStyles.Bold;
return tmp;
}
}