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

@@ -1,6 +1,22 @@
using GeoSus.Client;
using System;
using System.Collections.Generic;
/// <summary>
/// Sub-phase derived client-side from the timestamps in MeetingStartedPayload.
/// Server doesn't broadcast a discrete "discussion ended" event - it embeds
/// DiscussionEndTime and VotingEndTime in the meeting-start event and gates
/// vote acceptance on those timestamps. We compute the matching client view
/// by comparing UtcNow to those values every frame.
/// </summary>
public enum MeetingSubPhase
{
Arrival, // before ArrivalDeadline; players are still en route to meeting point
Discussion, // arrival deadline passed; talk only, votes server-rejected
Voting, // discussion ended; votes accepted until VotingEndTime
Resolved // VotingClosed received OR votingEndTime in the past
}
/// <summary>
/// Single source of truth for all in-game state on the client.
/// Updated exclusively by GameManager_Network; read by GameManager_UI.
@@ -12,6 +28,15 @@ public class GameState
public PlayerRole? MyRole { get; set; }
public bool IsDead { get; set; }
// ── Settings (P13b) ───────────────────────────────────────────────────────
/// <summary>
/// Per-lobby settings snapshot from the server. Populated on
/// LobbyJoined / LobbyCreated; immutable for the lifetime of the lobby.
/// Null on old server builds - callers must use null-coalescing fallbacks
/// to whatever default they previously hardcoded.
/// </summary>
public GameSettings Settings { get; set; }
// ── Tasks ─────────────────────────────────────────────────────────────────
public List<GameTask> MyTasks { get; set; } = new List<GameTask>();
public HashSet<string> MyCompletedTaskIds { get; set; } = new HashSet<string>();
@@ -22,13 +47,69 @@ public class GameState
public List<PlayerInfo> Players { get; set; } = new List<PlayerInfo>();
// ── Meeting ───────────────────────────────────────────────────────────────
public MeetingStartedPayload ActiveMeeting { get; set; }
public VotingClosedPayload LastVoteResult { get; set; }
public HashSet<string> VotedPlayerIds { get; set; } = new HashSet<string>();
public MeetingStartedPayload ActiveMeeting { get; set; }
public VotingClosedPayload LastVoteResult { get; set; }
public HashSet<string> VotedPlayerIds { get; set; } = new HashSet<string>();
public HashSet<string> ArrivedPlayerIds { get; set; } = new HashSet<string>();
/// <summary>Per-voter latest vote target. Voter ClientUuid -> target ClientUuid, or VoteSkip for skip.</summary>
public Dictionary<string, string> VoterTargets { get; set; } = new Dictionary<string, string>();
/// <summary>Live vote tallies, keyed by target ClientUuid or VoteSkip. Derived from VoterTargets.</summary>
public Dictionary<string, int> VoteTallies { get; set; } = new Dictionary<string, int>();
/// <summary>Local player's latest vote target. Null = haven't voted; VoteSkip = skip; otherwise target ClientUuid.</summary>
public string MyVoteTarget { get; set; }
/// <summary>Sentinel for "skip" votes in VoterTargets / VoteTallies / MyVoteTarget.</summary>
public const string VoteSkip = "__SKIP__";
/// <summary>
/// Derive the current meeting sub-phase from ActiveMeeting + LastVoteResult.
/// Returns Arrival when no meeting is active (caller should also gate on Phase).
/// </summary>
public MeetingSubPhase GetMeetingSubPhase()
{
if (LastVoteResult != null) return MeetingSubPhase.Resolved;
var m = ActiveMeeting;
if (m == null) return MeetingSubPhase.Arrival;
var now = DateTime.UtcNow;
if (now >= m.VotingEndTime) return MeetingSubPhase.Resolved;
if (m.DiscussionEndTime.HasValue && now < m.DiscussionEndTime.Value)
{
// Server enforces: arrival deadline AND discussion-end gate voting.
// While arrival is still open we surface "Arrival" so players know
// others may still be travelling; once arrival deadline passes we
// surface "Discussion" until the voting window opens.
return now < m.ArrivalDeadline ? MeetingSubPhase.Arrival : MeetingSubPhase.Discussion;
}
return MeetingSubPhase.Voting;
}
/// <summary>End-of-current-sub-phase boundary as a UTC DateTime, used for countdown rendering.</summary>
public DateTime GetMeetingSubPhaseDeadline(MeetingSubPhase sub)
{
var m = ActiveMeeting;
if (m == null) return DateTime.UtcNow;
switch (sub)
{
case MeetingSubPhase.Arrival:
return m.ArrivalDeadline;
case MeetingSubPhase.Discussion:
return m.DiscussionEndTime ?? m.VotingEndTime;
case MeetingSubPhase.Voting:
return m.VotingEndTime;
default:
return m.VotingEndTime;
}
}
// ── Sabotage ──────────────────────────────────────────────────────────────
public SabotageStartedPayload ActiveSabotage { get; set; }
/// <summary>StationIds currently being repaired (server broadcasts RepairStarted/RepairStopped).</summary>
public HashSet<string> ActiveRepairs { get; set; } = new HashSet<string>();
// ── End game ──────────────────────────────────────────────────────────────
public GameEndedPayload GameEndData { get; set; }

View File

@@ -1,366 +1,537 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using TMPro;
using GeoSus.Client;
/// <summary>
/// Attach to any manager GameObject in the create-lobby scene.
/// On Start it nukes all Art placeholder children from the Canvas and builds
/// a complete mobile-portrait lobby-configuration screen entirely in code.
/// Lives on host lobby.unity - the *settings* screen reached when the host
/// chooses "Host" from the main menu. Originally three fixed-position
/// controls (radius slider + impostor/task steppers). P13c expanded this to
/// a full scrollable settings panel covering every per-lobby setting the
/// server accepts: round shape, distances (kill / report / task / meeting /
/// emergency / repair), cooldowns (kill / emergency / sabotage), meeting
/// phase timings (arrival / late / discussion / voting), and sabotage
/// timings (comms / meltdown / repair hold). All values get accumulated
/// into GameManager.pendingSettings (a GameSettingsOverrides) and shipped
/// with the CreateLobby request so the server can stamp them into the
/// per-lobby snapshot at lobby creation time.
///
/// The scene already contains the art team's named UI:
/// Canvas
/// ├── Panel - full-screen background
/// ├── RawImage - decorative logo
/// ├── radius - TMP text "Game radius:\n" (HIDDEN, P13c)
/// ├── idk - TMP text container (HIDDEN, P13c)
/// ├── back - back button
/// ├── Zmekole_geosusv2 - 3D globe object
/// └── stvořit - "Create Lobby" button
///
/// We DO NOT destroy any of these. Instead we resolve them by name, wire
/// the buttons, hide the now-unused labels, and inject a ScrollRect with
/// the full settings panel anchored above the "stvořit" button. Anchor
/// offsets are in the canvas's portrait reference units (1080x1920).
/// </summary>
public class HostLobbyUI : MonoBehaviour
{
// ── Colours ───────────────────────────────────────────────────────────────
static Color H(string hex) { ColorUtility.TryParseHtmlString(hex, out var c); return c; }
static readonly Color C_BG = H("#0D0F1A");
static readonly Color C_HDR = H("#141927");
static readonly Color C_CARD = H("#111525");
static readonly Color C_BORDER = H("#1E2540");
static readonly Color C_ACCENT = H("#3399FF");
static readonly Color C_GREEN = H("#2DB84B");
static readonly Color C_MUTED = new Color(0.47f, 0.53f, 0.67f);
static readonly Color C_WHITE = Color.white;
static readonly Color C_INPUT = H("#0A0D1A");
// ── Colour palette (forwarded from UITheme) ──────────────────────────────
static readonly Color C_TRACK = UITheme.SurfaceDim;
static readonly Color C_FILL = UITheme.Accent;
static readonly Color C_HANDLE = UITheme.TextHi;
static readonly Color C_BTN_BG = UITheme.Surface;
static readonly Color C_BTN_HI = UITheme.Accent;
static readonly Color C_TEXT = UITheme.TextHi;
static readonly Color C_HEADER = UITheme.Accent;
// ── Live values ───────────────────────────────────────────────────────────
private float _radius = 500f;
private int _impostors = 1;
private int _tasks = 5;
private string _playerName = "";
// ── Live values (mirrored into GameManager.pending* + pendingSettings) ──
private float _radius = 500f;
private int _impostors = 1;
private int _tasks = 5;
private int _maxPlayers = 10;
private int _killDist = 5;
private int _reportDist = 5;
private int _taskDist = 5;
private int _meetingArrival = 10;
private int _emergencyCallRadius = 5;
private int _repairDist = 5;
private int _killCooldownS = 20;
private int _emergencyCooldownS = 60;
private int _maxEmergencyMeetings = 1;
private int _arrivalBaseS = 60;
private int _allowedLateS = 10;
private int _discussionS = 60;
private int _votingS = 60;
private int _sabotageCooldownS = 60;
private int _commsDurationS = 60;
private int _meltdownDeadlineS = 90;
private int _repairHoldS = 10;
// ── UI refs ───────────────────────────────────────────────────────────────
private TMP_Text _radiusValueLabel;
private TMP_Text _impostorValueLabel;
private TMP_Text _taskValueLabel;
private TMP_Text _statusText;
// ── Layout constants ────────────────────────────────────────────────────
const float ROW_HEIGHT = 130f;
const float HEADER_HEIGHT = 90f;
const float SLIDER_HEIGHT = 160f;
void Start()
{
// Pre-populate from GameManager defaults
// Wire emoji fallback before any TMP component renders, so labels
// with emoji glyphs display properly on the very first frame rather
// than as tofu boxes that get fixed on a later refresh.
UITheme.EnsureEmojiFontFallback();
var gm = GameManager.Instance;
if (gm != null)
{
// Carry over whatever the host had previously selected (if they
// back out and come back in, settings survive).
_radius = (float)gm.pendingRadius;
_impostors = gm.pendingImpostorCount;
_tasks = gm.pendingTaskCount;
_playerName = gm.displayName ?? "";
// Kick off GPS init NOW so by the time the host taps "Create
// Lobby" we have a real position fix to seed the play area with.
gm.inputSubsystem?.EnsureGPSStarted();
}
var canvasGO = GameObject.Find("Canvas");
if (canvasGO == null)
{
Debug.LogError("[HostLobbyUI] No Canvas found!");
Debug.LogError("[HostLobbyUI] No Canvas found in host lobby scene.");
return;
}
var canvas = canvasGO.transform;
// Nuke all Art placeholder children
var kill = new System.Collections.Generic.List<GameObject>();
foreach (Transform child in canvasGO.transform)
kill.Add(child.gameObject);
foreach (var go in kill)
DestroyImmediate(go);
// ── Bind existing scene buttons ─────────────────────────────────────
var backBtn = FindButton(canvas, "back");
if (backBtn != null)
{
backBtn.onClick.RemoveAllListeners();
backBtn.onClick.AddListener(() => SceneManager.LoadScene("main menu asi idk lol"));
}
else Debug.LogWarning("[HostLobbyUI] 'back' button not found.");
// Disable scene-changer components that bypass our logic
foreach (var sc in canvasGO.GetComponentsInChildren<CudlikZmenaSceny>(true))
sc.enabled = false;
var createBtn = FindButton(canvas, "stvořit");
if (createBtn != null)
{
createBtn.onClick.RemoveAllListeners();
createBtn.onClick.AddListener(OnCreateClicked);
}
else Debug.LogWarning("[HostLobbyUI] 'stvořit' button not found.");
Build(canvasGO.GetComponent<RectTransform>() ?? canvasGO.AddComponent<RectTransform>());
// ── Hide the art team's "radius" / "idk" labels ─────────────────────
// They were placeholders for the small set of settings we previously
// exposed; the scrollable panel below now owns all of that real estate.
var radiusLabel = FindTMP(canvas, "radius");
if (radiusLabel != null) radiusLabel.gameObject.SetActive(false);
var settingsLabel = FindTMP(canvas, "idk");
if (settingsLabel != null) settingsLabel.gameObject.SetActive(false);
// ── Build the scrollable settings panel ────────────────────────────
// Pass in the actual back/create button transforms so the scroll
// bounds can be measured against them at runtime, instead of
// hardcoded against canvas reference units that may or may not
// match where the art team actually placed the buttons.
BuildSettingsScroll(canvas,
backBtn != null ? backBtn.transform : null,
createBtn != null ? createBtn.transform : null);
}
// ── Builder ───────────────────────────────────────────────────────────────
void Build(RectTransform root)
{
// Full-screen background
var bg = MakeRT("BG", root);
Stretch(bg);
bg.gameObject.AddComponent<CanvasRenderer>();
Img(bg, C_BG);
// ── Action handlers ──────────────────────────────────────────────────────
// ── Header bar ───────────────────────────────────────────────────────
var hdr = MakeRT("Header", root);
Anchor(hdr, new Vector2(0,1), new Vector2(1,1), new Vector2(0,-80), new Vector2(0,0));
Img(hdr, C_HDR);
var hdrTxt = Txt("Create Lobby", hdr, 28, C_WHITE, TextAlignmentOptions.Center, bold: true);
Stretch(hdrTxt.rectTransform);
// ── Scroll body ───────────────────────────────────────────────────────
var scroll = MakeRT("Scroll", root);
Anchor(scroll, new Vector2(0,0), new Vector2(1,1), new Vector2(0,80), new Vector2(0,-80));
var sf = scroll.gameObject.AddComponent<ScrollRect>();
sf.horizontal = false;
Img(scroll, new Color(0,0,0,0));
var content = MakeRT("Content", scroll);
content.anchorMin = new Vector2(0,1);
content.anchorMax = new Vector2(1,1);
content.pivot = new Vector2(0.5f, 1);
content.offsetMin = Vector2.zero;
content.offsetMax = Vector2.zero;
var vlg = content.gameObject.AddComponent<VerticalLayoutGroup>();
vlg.padding = new RectOffset(16, 16, 12, 12);
vlg.spacing = 12;
vlg.childForceExpandWidth = true;
vlg.childForceExpandHeight = false;
var csf = content.gameObject.AddComponent<ContentSizeFitter>();
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
sf.content = content;
sf.viewport = scroll;
// ── Player name card ─────────────────────────────────────────────────
AddSectionLabel("PLAYER NAME", content);
var nameCard = AddCard(content, 70);
var nameInput = MakeInputField("Your name", nameCard, _playerName);
Stretch(nameInput.GetComponent<RectTransform>());
nameInput.onEndEdit.AddListener(v =>
{
_playerName = v.Trim();
var gm2 = GameManager.Instance;
if (gm2 != null) gm2.displayName = _playerName;
PlayerPrefs.SetString("PlayerName", _playerName);
});
// ── Radius card ───────────────────────────────────────────────────────
AddSectionLabel("PLAY AREA RADIUS", content);
var radCard = AddCard(content, 110);
var radRT = radCard.GetComponent<RectTransform>();
var radLbl = MakeRT("RadLbl", radRT);
Anchor(radLbl, new Vector2(0,1), new Vector2(1,1), new Vector2(12,-40), new Vector2(-12,-4));
var rt = radLbl.gameObject;
var radTmp = rt.AddComponent<TextMeshProUGUI>();
radTmp.text = RadiusLabel(_radius);
radTmp.fontSize = 20; radTmp.color = C_ACCENT;
radTmp.alignment = TextAlignmentOptions.Center;
_radiusValueLabel = radTmp;
var slider = MakeRT("RadSlider", radRT).gameObject.AddComponent<Slider>();
var srt = slider.GetComponent<RectTransform>();
Anchor(srt, new Vector2(0,0), new Vector2(1,0), new Vector2(16, 14), new Vector2(-16, 44));
slider.minValue = 100; slider.maxValue = 2000; slider.value = _radius;
BuildSliderVisuals(slider, C_ACCENT);
slider.onValueChanged.AddListener(v =>
{
_radius = v;
_radiusValueLabel.text = RadiusLabel(v);
var gm2 = GameManager.Instance;
if (gm2 != null) gm2.pendingRadius = v;
});
// ── Impostor count card ───────────────────────────────────────────────
AddSectionLabel("IMPOSTORS", content);
var impCard = AddCard(content, 80);
BuildStepper(impCard.GetComponent<RectTransform>(), ref _impostors, 1, 4,
v => { var gm2 = GameManager.Instance; if (gm2 != null) gm2.pendingImpostorCount = v; },
out _impostorValueLabel);
// ── Task count card ───────────────────────────────────────────────────
AddSectionLabel("TASKS PER PLAYER", content);
var taskCard = AddCard(content, 80);
BuildStepper(taskCard.GetComponent<RectTransform>(), ref _tasks, 1, 15,
v => { var gm2 = GameManager.Instance; if (gm2 != null) gm2.pendingTaskCount = v; },
out _taskValueLabel);
// ── Status text ───────────────────────────────────────────────────────
var statusCard = AddCard(content, 40);
_statusText = Txt("", statusCard.GetComponent<RectTransform>(), 16, C_MUTED, TextAlignmentOptions.Center);
Stretch(_statusText.rectTransform);
// ── Footer create button ──────────────────────────────────────────────
var footer = MakeRT("Footer", root);
Anchor(footer, new Vector2(0,0), new Vector2(1,0), new Vector2(0,0), new Vector2(0,80));
Img(footer, C_HDR);
var btnRT = MakeRT("CreateBtn", footer);
Anchor(btnRT, new Vector2(0.1f,0.15f), new Vector2(0.9f,0.85f), Vector2.zero, Vector2.zero);
var btn = btnRT.gameObject.AddComponent<Button>();
var btnImg = btnRT.gameObject.AddComponent<Image>();
btnImg.color = C_GREEN;
btn.targetGraphic = btnImg;
var cb = btn.colors; cb.pressedColor = C_GREEN * 0.7f; btn.colors = cb;
var btnTxt = Txt("CREATE LOBBY", btnRT, 22, C_WHITE, TextAlignmentOptions.Center, bold: true);
Stretch(btnTxt.rectTransform);
btn.onClick.AddListener(OnCreateClicked);
// ── Back button ───────────────────────────────────────────────────────
var backRT = MakeRT("BackBtn", root);
Anchor(backRT, new Vector2(0,1), new Vector2(0,1), new Vector2(8,-72), new Vector2(72,-8));
var backBtn = backRT.gameObject.AddComponent<Button>();
var backImg = backRT.gameObject.AddComponent<Image>();
backImg.color = new Color(1,1,1,0);
backBtn.targetGraphic = backImg;
var backTxt = Txt("←", backRT, 28, C_MUTED, TextAlignmentOptions.Center);
Stretch(backTxt.rectTransform);
backBtn.onClick.AddListener(() => UnityEngine.SceneManagement.SceneManager.LoadScene("main menu asi idk lol"));
}
// ── Actions ───────────────────────────────────────────────────────────────
void OnCreateClicked()
{
var gm = GameManager.Instance;
if (gm == null) { SetStatus("GameManager not found!", Color.red); return; }
if (string.IsNullOrWhiteSpace(_playerName))
{
SetStatus("Enter a player name first.", Color.yellow);
return;
}
if (gm == null) return;
// Keep the legacy flat fields populated for any caller that still
// reads them directly, and ALSO populate pendingSettings with the
// full override snapshot so the server can stamp it into the lobby.
gm.pendingRadius = _radius;
gm.pendingImpostorCount = _impostors;
gm.pendingTaskCount = _tasks;
gm.displayName = _playerName;
SetStatus("Connecting…", C_MUTED);
gm.pendingSettings = BuildOverrides();
gm.CreateLobbyButton();
}
void SetStatus(string msg, Color col)
/// <summary>
/// Pack the current control values into a wire-shape GameSettingsOverrides.
/// Every field is non-null - the server treats null as "use my default",
/// but since this UI exposes every field we always have a concrete value
/// to ship. Time fields are exposed in seconds and converted to ms here.
/// </summary>
GameSettingsOverrides BuildOverrides()
{
if (_statusText == null) return;
_statusText.text = msg;
_statusText.color = col;
return new GameSettingsOverrides
{
// Round shape
MaxPlayers = _maxPlayers,
ImpostorCount = _impostors,
TaskCount = _tasks,
// Distances (m)
KillDistanceM = _killDist,
ReportDistanceM = _reportDist,
TaskStartDistanceM = _taskDist,
MeetingArrivalRadiusM = _meetingArrival,
EmergencyMeetingCallRadiusM = _emergencyCallRadius,
RepairStationDistanceM = _repairDist,
// Cooldowns / counts
KillCooldownMs = _killCooldownS * 1000,
EmergencyMeetingCooldownMs = _emergencyCooldownS * 1000,
MaxEmergencyMeetingsPerPlayer = _maxEmergencyMeetings,
// Meeting phases (ms)
ArrivalBaseMs = _arrivalBaseS * 1000,
AllowedLateMs = _allowedLateS * 1000,
DiscussionPhaseMs = _discussionS * 1000,
VotingPhaseMs = _votingS * 1000,
// Sabotage
SabotageCooldownMs = _sabotageCooldownS * 1000,
CommsBlackoutDurationMs = _commsDurationS * 1000,
CriticalMeltdownDeadlineMs = _meltdownDeadlineS * 1000,
RepairStationHoldMs = _repairHoldS * 1000,
};
}
// ── UI helpers ────────────────────────────────────────────────────────────
static string RadiusLabel(float v) => $"{Mathf.RoundToInt(v)} m";
// ── Settings ScrollRect ─────────────────────────────────────────────────
void BuildStepper(RectTransform parent, ref int val, int min, int max,
System.Action<int> onChange, out TMP_Text label)
/// <summary>
/// Build the scrollable panel: ScrollRect → Viewport (RectMask2D) →
/// Content (VerticalLayoutGroup + ContentSizeFitter). Each setting goes
/// in as a row with a LayoutElement.preferredHeight so the VLG can stack
/// them and the ContentSizeFitter can compute the scroll height.
/// Pattern mirrors InGameHUDBuilder.BuildMeetingScroll.
///
/// Bounds are MEASURED against the actual back/stvořit RectTransforms
/// at runtime - not hardcoded against canvas reference units. The
/// previous version pinned the scroll to 280..1700 in 1080x1920 ref
/// space, which overlapped the create button on real layouts and made
/// it un-tappable. We now place the scroll's bottom edge a fixed margin
/// above stvořit's top edge, and its top edge a fixed margin below
/// back's bottom edge, falling back to the old constants only if either
/// button is missing.
/// </summary>
void BuildSettingsScroll(Transform canvas, Transform backT, Transform stvoritT)
{
int captured = val; // local copy for closures
// Force the canvas's layout to settle so GetWorldCorners returns the
// real positioned rects rather than authored-time placeholders.
Canvas.ForceUpdateCanvases();
// minus
const float MARGIN = 40f; // px (in canvas reference units) above/below buttons
var canvasRT = canvas as RectTransform;
// Pivot offset so we can express Y as "from canvas bottom" rather
// than the pivot-relative form InverseTransformPoint hands back.
float pivotYOffset = canvasRT != null
? canvasRT.pivot.y * canvasRT.rect.height
: 0f;
float scrollBottomY = 280f; // safe fallback if stvořit is missing
float scrollTopY = 1700f; // safe fallback if back is missing
if (canvasRT != null && stvoritT is RectTransform stvoritRT)
{
var corners = new Vector3[4];
stvoritRT.GetWorldCorners(corners); // [0]=BL, [1]=TL, [2]=TR, [3]=BR
var topLocal = canvasRT.InverseTransformPoint(corners[1]);
scrollBottomY = topLocal.y + pivotYOffset + MARGIN;
}
if (canvasRT != null && backT is RectTransform backRT)
{
var corners = new Vector3[4];
backRT.GetWorldCorners(corners);
var bottomLocal = canvasRT.InverseTransformPoint(corners[0]);
scrollTopY = bottomLocal.y + pivotYOffset - MARGIN;
}
// Sanity-clamp: if either button is positioned weirdly (e.g. back
// is below the create button, or they overlap), the computed
// bounds would be a negative-height rect that draws as a 1px
// sliver. Detect and fall back to the portrait-reference defaults.
if (scrollTopY <= scrollBottomY + 200f)
{
Debug.LogWarning(
$"[HostLobbyUI] Scroll bounds collapsed ({scrollBottomY:F0}..{scrollTopY:F0}); " +
"falling back to 280..1700 portrait defaults.");
scrollBottomY = 280f;
scrollTopY = 1700f;
}
var scrollRT = MakeRT("SettingsScroll", canvas);
Anchor(scrollRT, new Vector2(0.05f, 0), new Vector2(0.95f, 0),
new Vector2(0, scrollBottomY), new Vector2(0, scrollTopY));
// Subtle dark backdrop so the scroll area visually separates from
// the canvas's full-screen Panel underneath.
var bgImg = scrollRT.gameObject.AddComponent<Image>();
bgImg.color = new Color(0f, 0f, 0f, 0.55f);
var sr = scrollRT.gameObject.AddComponent<ScrollRect>();
var vp = MakeRT("Viewport", scrollRT);
Stretch(vp);
vp.gameObject.AddComponent<RectMask2D>();
var content = MakeRT("Content", 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<VerticalLayoutGroup>();
vlg.childControlWidth = true;
vlg.childControlHeight = false;
vlg.childForceExpandWidth = true;
vlg.padding = new RectOffset(20, 20, 20, 20);
vlg.spacing = 12;
var csf = content.gameObject.AddComponent<ContentSizeFitter>();
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
sr.viewport = vp;
sr.content = content;
sr.horizontal = false;
sr.vertical = true;
sr.scrollSensitivity = 80;
sr.movementType = ScrollRect.MovementType.Clamped;
// ── Round shape ────────────────────────────────────────────────────
AddHeader(content, "Round");
AddSlider(content, "Game radius", 100, 2000, _radius,
v => _radius = v,
v => Mathf.RoundToInt(v) + " m");
AddStepper(content, "Max players", _maxPlayers, 4, 15, v => _maxPlayers = v);
AddStepper(content, "Impostors", _impostors, 1, 4, v => _impostors = v);
AddStepper(content, "Tasks per crew", _tasks, 1, 15, v => _tasks = v);
// ── Distances ──────────────────────────────────────────────────────
AddHeader(content, "Distances (m)");
AddStepper(content, "Kill", _killDist, 1, 30, v => _killDist = v);
AddStepper(content, "Report", _reportDist, 1, 30, v => _reportDist = v);
AddStepper(content, "Task start", _taskDist, 1, 30, v => _taskDist = v);
AddStepper(content, "Meeting arrival", _meetingArrival, 5, 50, v => _meetingArrival = v);
AddStepper(content, "Emergency call", _emergencyCallRadius, 1, 30, v => _emergencyCallRadius = v);
AddStepper(content, "Repair station", _repairDist, 1, 30, v => _repairDist = v);
// ── Cooldowns ──────────────────────────────────────────────────────
AddHeader(content, "Cooldowns (s)");
AddStepper(content, "Kill cooldown", _killCooldownS, 5, 120, v => _killCooldownS = v);
AddStepper(content, "Emergency cd", _emergencyCooldownS, 10, 600, v => _emergencyCooldownS = v);
AddStepper(content, "Max emergencies", _maxEmergencyMeetings, 0, 10, v => _maxEmergencyMeetings = v);
AddStepper(content, "Sabotage cd", _sabotageCooldownS, 10, 600, v => _sabotageCooldownS = v);
// ── Meeting phases ─────────────────────────────────────────────────
AddHeader(content, "Meeting phases (s)");
AddStepper(content, "Arrival time", _arrivalBaseS, 30, 300, v => _arrivalBaseS = v);
AddStepper(content, "Allowed late", _allowedLateS, 0, 120, v => _allowedLateS = v);
AddStepper(content, "Discussion", _discussionS, 10, 300, v => _discussionS = v);
AddStepper(content, "Voting", _votingS, 10, 300, v => _votingS = v);
// ── Sabotage ───────────────────────────────────────────────────────
AddHeader(content, "Sabotage (s)");
AddStepper(content, "Comms duration", _commsDurationS, 10, 600, v => _commsDurationS = v);
AddStepper(content, "Meltdown deadline", _meltdownDeadlineS, 30, 600, v => _meltdownDeadlineS = v);
AddStepper(content, "Repair hold", _repairHoldS, 1, 60, v => _repairHoldS = v);
}
void AddHeader(RectTransform parent, string text)
{
var rt = MakeRT("Header_" + text, parent);
var le = rt.gameObject.AddComponent<LayoutElement>();
le.preferredHeight = HEADER_HEIGHT;
le.minHeight = HEADER_HEIGHT;
var bg = rt.gameObject.AddComponent<Image>();
bg.color = new Color(C_HEADER.r, C_HEADER.g, C_HEADER.b, 0.45f);
var txtRT = MakeRT("Txt", rt);
Stretch(txtRT);
var tmp = txtRT.gameObject.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = 44;
tmp.color = C_TEXT;
tmp.alignment = TextAlignmentOptions.MidlineLeft;
tmp.fontStyle = FontStyles.Bold;
tmp.margin = new Vector4(20, 0, 20, 0);
}
void AddStepper(RectTransform parent, string label, int initial, int min, int max, System.Action<int> onChange)
{
var rt = MakeRT("Row_" + label, parent);
var le = rt.gameObject.AddComponent<LayoutElement>();
le.preferredHeight = ROW_HEIGHT;
le.minHeight = ROW_HEIGHT;
BuildStepperRow(rt, label, initial, min, max, onChange);
}
void AddSlider(RectTransform parent, string label, float min, float max, float initial,
System.Action<float> onChange, System.Func<float, string> formatter)
{
var rt = MakeRT("Row_" + label, parent);
var le = rt.gameObject.AddComponent<LayoutElement>();
le.preferredHeight = SLIDER_HEIGHT;
le.minHeight = SLIDER_HEIGHT;
// Top half: label (left) + value readout (right)
var labelRT = MakeRT("Label", rt);
Anchor(labelRT, new Vector2(0, 0.55f), new Vector2(0.7f, 1f), Vector2.zero, Vector2.zero);
var lblTmp = labelRT.gameObject.AddComponent<TextMeshProUGUI>();
lblTmp.text = label;
lblTmp.fontSize = 36;
lblTmp.color = C_TEXT;
lblTmp.alignment = TextAlignmentOptions.MidlineLeft;
lblTmp.fontStyle = FontStyles.Bold;
var valRT = MakeRT("Val", rt);
Anchor(valRT, new Vector2(0.7f, 0.55f), new Vector2(1f, 1f), Vector2.zero, Vector2.zero);
var valTmp = valRT.gameObject.AddComponent<TextMeshProUGUI>();
valTmp.text = formatter(initial);
valTmp.fontSize = 36;
valTmp.color = C_TEXT;
valTmp.alignment = TextAlignmentOptions.MidlineRight;
valTmp.fontStyle = FontStyles.Bold;
// Bottom half: slider
var sliderRT = MakeRT("Slider", rt);
Anchor(sliderRT, new Vector2(0, 0.05f), new Vector2(1, 0.45f),
Vector2.zero, Vector2.zero);
var slider = sliderRT.gameObject.AddComponent<Slider>();
slider.minValue = min;
slider.maxValue = max;
slider.value = initial;
BuildSliderVisuals(slider);
slider.onValueChanged.AddListener(v =>
{
onChange?.Invoke(v);
valTmp.text = formatter(v);
});
}
// ── Stepper row: [Label] [-] [value] [+] ─────────────────────────────
void BuildStepperRow(RectTransform parent, string label, int initial,
int min, int max, System.Action<int> onChange)
{
int captured = Mathf.Clamp(initial, min, max);
// Left half: label
var lblRT = MakeRT("Label", parent);
Anchor(lblRT, new Vector2(0, 0), new Vector2(0.55f, 1), Vector2.zero, Vector2.zero);
var lblTmp = lblRT.gameObject.AddComponent<TextMeshProUGUI>();
lblTmp.text = label;
lblTmp.fontSize = 32;
lblTmp.color = C_TEXT;
lblTmp.alignment = TextAlignmentOptions.MidlineLeft;
lblTmp.fontStyle = FontStyles.Bold;
// Minus button
var minusRT = MakeRT("Minus", parent);
Anchor(minusRT, new Vector2(0,0), new Vector2(0,1), new Vector2(8,8), new Vector2(72,-8));
var minusBtn = minusRT.gameObject.AddComponent<Button>();
var minusImg = minusRT.gameObject.AddComponent<Image>(); minusImg.color = C_BORDER;
minusBtn.targetGraphic = minusImg;
var minusTxt = Txt("", minusRT, 28, C_WHITE, TextAlignmentOptions.Center);
Stretch(minusTxt.rectTransform);
Anchor(minusRT, new Vector2(0.55f, 0.10f), new Vector2(0.69f, 0.90f), Vector2.zero, Vector2.zero);
var minusBtn = MakeButton(minusRT, "-", C_BTN_BG);
// label
var lblRT = MakeRT("Val", parent);
Stretch(lblRT);
var lbl = Txt(captured.ToString(), lblRT, 26, C_WHITE, TextAlignmentOptions.Center, bold: true);
label = lbl;
// Value label
var valRT = MakeRT("Val", parent);
Anchor(valRT, new Vector2(0.69f, 0), new Vector2(0.83f, 1), Vector2.zero, Vector2.zero);
var valTmp = valRT.gameObject.AddComponent<TextMeshProUGUI>();
valTmp.text = captured.ToString();
valTmp.fontSize = 40;
valTmp.color = C_TEXT;
valTmp.alignment = TextAlignmentOptions.Center;
valTmp.fontStyle = FontStyles.Bold;
// plus
// Plus button
var plusRT = MakeRT("Plus", parent);
Anchor(plusRT, new Vector2(1,0), new Vector2(1,1), new Vector2(-72,8), new Vector2(-8,-8));
var plusBtn = plusRT.gameObject.AddComponent<Button>();
var plusImg = plusRT.gameObject.AddComponent<Image>(); plusImg.color = C_ACCENT;
plusBtn.targetGraphic = plusImg;
var plusTxt = Txt("+", plusRT, 28, C_WHITE, TextAlignmentOptions.Center);
Stretch(plusTxt.rectTransform);
Anchor(plusRT, new Vector2(0.83f, 0.10f), new Vector2(0.97f, 0.90f), Vector2.zero, Vector2.zero);
var plusBtn = MakeButton(plusRT, "+", C_BTN_HI);
minusBtn.onClick.AddListener(() =>
{
captured = Mathf.Max(min, captured - 1);
lbl.text = captured.ToString();
valTmp.text = captured.ToString();
onChange?.Invoke(captured);
});
plusBtn.onClick.AddListener(() =>
{
captured = Mathf.Min(max, captured + 1);
lbl.text = captured.ToString();
valTmp.text = captured.ToString();
onChange?.Invoke(captured);
});
// write back initial
val = captured;
}
static void BuildSliderVisuals(Slider s, Color fillColor)
Button MakeButton(RectTransform rt, string label, Color bg)
{
// Background track
var bgRT = MakeRT("Background", s.GetComponent<RectTransform>());
Stretch(bgRT);
bgRT.gameObject.AddComponent<CanvasRenderer>();
var bgImg = bgRT.gameObject.AddComponent<Image>(); bgImg.color = new Color(0.15f,0.18f,0.28f);
var img = rt.gameObject.AddComponent<Image>();
img.color = bg;
var btn = rt.gameObject.AddComponent<Button>();
btn.targetGraphic = img;
// Fill area
var fillArea = MakeRT("Fill Area", s.GetComponent<RectTransform>());
Anchor(fillArea, new Vector2(0,0.25f), new Vector2(1,0.75f), new Vector2(5,0), new Vector2(-5,0));
var txtRT = MakeRT("Txt", rt);
Stretch(txtRT);
var txtTmp = txtRT.gameObject.AddComponent<TextMeshProUGUI>();
txtTmp.text = label;
txtTmp.fontSize = 52;
txtTmp.color = C_TEXT;
txtTmp.alignment = TextAlignmentOptions.Center;
txtTmp.fontStyle = FontStyles.Bold;
return btn;
}
// ── Slider visual fill-in (Unity gives you no default if you AddComponent) ─
void BuildSliderVisuals(Slider s)
{
var sRT = s.GetComponent<RectTransform>();
var bgRT = MakeRT("Background", sRT);
Stretch(bgRT);
var bgImg = bgRT.gameObject.AddComponent<Image>();
bgImg.color = C_TRACK;
var fillArea = MakeRT("Fill Area", sRT);
Anchor(fillArea, new Vector2(0, 0.30f), new Vector2(1, 0.70f),
new Vector2(8, 0), new Vector2(-8, 0));
var fillRT = MakeRT("Fill", fillArea);
fillRT.anchorMin = Vector2.zero; fillRT.anchorMax = Vector2.one;
fillRT.offsetMin = Vector2.zero; fillRT.offsetMax = Vector2.zero;
fillRT.gameObject.AddComponent<CanvasRenderer>();
var fillImg = fillRT.gameObject.AddComponent<Image>(); fillImg.color = fillColor;
fillRT.anchorMin = Vector2.zero;
fillRT.anchorMax = Vector2.one;
fillRT.offsetMin = Vector2.zero;
fillRT.offsetMax = Vector2.zero;
var fillImg = fillRT.gameObject.AddComponent<Image>();
fillImg.color = C_FILL;
s.fillRect = fillRT;
// Handle area
var handleArea = MakeRT("Handle Slide Area", s.GetComponent<RectTransform>());
var handleArea = MakeRT("Handle Slide Area", sRT);
Stretch(handleArea);
var handleRT = MakeRT("Handle", handleArea);
handleRT.sizeDelta = new Vector2(28, 28);
handleRT.anchorMin = new Vector2(0, 0.5f); handleRT.anchorMax = new Vector2(0, 0.5f);
handleRT.sizeDelta = new Vector2(40, 40);
handleRT.anchorMin = new Vector2(0, 0.5f);
handleRT.anchorMax = new Vector2(0, 0.5f);
handleRT.anchoredPosition = Vector2.zero;
handleRT.gameObject.AddComponent<CanvasRenderer>();
var hImg = handleRT.gameObject.AddComponent<Image>(); hImg.color = Color.white;
s.handleRect = handleRT;
var hImg = handleRT.gameObject.AddComponent<Image>();
hImg.color = C_HANDLE;
s.handleRect = handleRT;
s.targetGraphic = hImg;
}
static void AddSectionLabel(string text, RectTransform parent)
// ── Scene-binding helpers ────────────────────────────────────────────────
static Button FindButton(Transform root, string name)
{
var rt = MakeRT("Lbl_" + text, parent);
var le = rt.gameObject.AddComponent<LayoutElement>(); le.preferredHeight = 28;
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
tmp.text = text; tmp.fontSize = 13; tmp.color = new Color(0.47f,0.53f,0.67f);
tmp.fontStyle = FontStyles.Bold; tmp.alignment = TextAlignmentOptions.Left;
var t = FindByName(root, name);
return t != null ? t.GetComponent<Button>() : null;
}
static RectTransform AddCard(RectTransform parent, float height)
static TMP_Text FindTMP(Transform root, string name)
{
var rt = MakeRT("Card", parent);
var le = rt.gameObject.AddComponent<LayoutElement>(); le.preferredHeight = height;
var img = rt.gameObject.AddComponent<Image>(); img.color = H("#111525");
// border via outline
var outline = rt.gameObject.AddComponent<Outline>();
outline.effectColor = H("#1E2540");
outline.effectDistance = new Vector2(1, -1);
return rt;
var t = FindByName(root, name);
return t != null ? t.GetComponent<TMP_Text>() : null;
}
static TMP_InputField MakeInputField(string placeholder, RectTransform parent, string initialValue)
static Transform FindByName(Transform root, string name)
{
var go = new GameObject("InputField");
var rt = go.AddComponent<RectTransform>();
rt.SetParent(parent, false);
rt.localScale = Vector3.one;
var img = go.AddComponent<Image>(); img.color = H("#0A0D1A");
var field = go.AddComponent<TMP_InputField>();
var textArea = MakeRT("Text Area", rt);
Stretch(textArea);
textArea.offsetMin = new Vector2(10, 4);
textArea.offsetMax = new Vector2(-10, -4);
var areaImg = textArea.gameObject.AddComponent<Image>(); areaImg.color = new Color(0,0,0,0);
var mask = textArea.gameObject.AddComponent<RectMask2D>();
var phRT = MakeRT("Placeholder", textArea);
Stretch(phRT);
var phTmp = phRT.gameObject.AddComponent<TextMeshProUGUI>();
phTmp.text = placeholder; phTmp.fontSize = 20;
phTmp.color = new Color(0.47f, 0.53f, 0.67f);
phTmp.alignment = TextAlignmentOptions.MidlineLeft;
var txtRT = MakeRT("Text", textArea);
Stretch(txtRT);
var txtTmp = txtRT.gameObject.AddComponent<TextMeshProUGUI>();
txtTmp.fontSize = 20; txtTmp.color = Color.white;
txtTmp.alignment = TextAlignmentOptions.MidlineLeft;
field.textViewport = textArea;
field.textComponent = txtTmp;
field.placeholder = phTmp;
field.text = initialValue;
field.targetGraphic = img;
return field;
if (root == null) return null;
if (root.name == name) return root;
foreach (Transform child in root)
{
var found = FindByName(child, name);
if (found != null) return found;
}
return null;
}
static RectTransform MakeRT(string name, RectTransform parent)
// ── Layout helpers (shared with the injected controls) ───────────────────
static RectTransform MakeRT(string name, Transform parent)
{
var go = new GameObject(name);
var rt = go.AddComponent<RectTransform>();
@@ -371,29 +542,18 @@ public class HostLobbyUI : MonoBehaviour
static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax, Vector2 offMin, Vector2 offMax)
static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax,
Vector2 offMin, Vector2 offMax)
{
rt.anchorMin = aMin; rt.anchorMax = aMax;
rt.offsetMin = offMin; rt.offsetMax = offMax;
}
static Image Img(RectTransform rt, Color c)
{
rt.gameObject.AddComponent<CanvasRenderer>();
var img = rt.gameObject.AddComponent<Image>(); img.color = c; return img;
}
static TextMeshProUGUI Txt(string text, RectTransform parent, float size, Color color,
TextAlignmentOptions align, bool bold = false)
{
var rt = MakeRT("T_" + text, parent);
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
tmp.text = text; tmp.fontSize = size; tmp.color = color; tmp.alignment = align;
if (bold) tmp.fontStyle = FontStyles.Bold;
return tmp;
rt.anchorMin = aMin;
rt.anchorMax = aMax;
rt.offsetMin = offMin;
rt.offsetMax = offMax;
}
}

View File

@@ -13,14 +13,21 @@ using System.Collections.Generic;
/// ActionButton proxim action button
/// SabotagePanel top-of-screen sabotage banner
/// SabotageTimer TMP countdown text inside SabotagePanel
/// MeetingPanel full-screen voting overlay
/// MeetingHeader TMP title
/// MeetingPlayerList TMP player list (text fallback)
/// SkipButton skip-vote button
/// VoteResultPanel sub-panel shown after voting
/// VoteResult TMP result text
/// 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
@@ -29,20 +36,29 @@ using System.Collections.Generic;
public class InGameHUDBuilder : MonoBehaviour
{
// ── Palette ───────────────────────────────────────────────────────────────
static Color H(string hex) { ColorUtility.TryParseHtmlString(hex, out var c); return c; }
static readonly Color C_BG = H("#0D0F1A");
static readonly Color C_BAR = H("#141927");
static readonly Color C_ACCENT = H("#3399FF");
static readonly Color C_GREEN = H("#2DB84B");
static readonly Color C_RED = H("#C43232");
static readonly Color C_ORANGE = H("#F08C1A");
static readonly Color C_YELLOW = H("#FFB800");
static readonly Color C_MUTED = new Color(0.47f, 0.53f, 0.67f);
static readonly Color C_ROW_A = H("#1A2035");
static readonly Color C_ROW_B = H("#161C2E");
// 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(); }
@@ -51,6 +67,25 @@ public class InGameHUDBuilder : MonoBehaviour
var rt = GetComponent<RectTransform>();
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<Canvas>());
// 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);
@@ -59,9 +94,51 @@ public class InGameHUDBuilder : MonoBehaviour
BuildSabotagePanel(rt);
BuildMeetingPanel(rt);
BuildGameEndPanel(rt);
BuildReconnectOverlay(rt);
BuildSpectatePanel(rt);
BuildToast(rt);
}
/// <summary>
/// Apply the project's standard CanvasScaler config to a Canvas. Idempotent -
/// adds the component if missing, otherwise updates settings in-place.
/// </summary>
public static void ConfigureCanvasScaler(Canvas canvas)
{
if (canvas == null) return;
var scaler = canvas.GetComponent<CanvasScaler>()
?? canvas.gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(kReferenceWidth, kReferenceHeight);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = kMatchWidthHeight;
scaler.referencePixelsPerUnit = 100f;
}
/// <summary>
/// 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.
/// </summary>
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)
{
@@ -166,45 +243,93 @@ public class InGameHUDBuilder : MonoBehaviour
}
// ── 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));
// Header
// Title
var hdr = Child("MeetingHeader", panel);
Anchor(hdr, new Vector2(0,0.86f), new Vector2(1,1), Vector2.zero, Vector2.zero);
Anchor(hdr, new Vector2(0,0.90f), new Vector2(1,1), Vector2.zero, Vector2.zero);
var hdrTmp = hdr.gameObject.AddComponent<TextMeshProUGUI>();
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<TextMeshProUGUI>();
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<Image>();
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<TextMeshProUGUI>();
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.22f), new Vector2(1,0.86f), Vector2.zero, Vector2.zero);
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.22f), new Vector2(1,0.86f), new Vector2(8,0), new Vector2(-8,0));
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<TextMeshProUGUI>();
fallbackTmp.text = ""; fallbackTmp.fontSize = 28; fallbackTmp.color = Color.white;
fallbackTmp.alignment = TextAlignmentOptions.TopLeft;
fallback.gameObject.SetActive(false); // hidden scroll list used instead
fallback.gameObject.SetActive(false); // hidden - scroll list used instead
// Skip button
// "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<TextMeshProUGUI>();
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.18f), Vector2.zero, Vector2.zero);
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<Button>();
skipBtn.targetGraphic = skipBg;
skipBtn.onClick.AddListener(() => GameManager.Instance?.CastVote(null));
TxtChild(skip, "⏭ SKIP", 36, Color.white, TextAlignmentOptions.Center, bold: true);
// Vote-result sub-panel (hidden until voting closes)
// 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.04f), new Vector2(1,0.22f), Vector2.zero, Vector2.zero);
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);
@@ -273,6 +398,58 @@ public class InGameHUDBuilder : MonoBehaviour
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<TextMeshProUGUI>();
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<TextMeshProUGUI>();
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<TextMeshProUGUI>();
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<TextMeshProUGUI>();
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)
{

View File

@@ -5,9 +5,30 @@ using TMPro;
using GeoSus.Client;
/// <summary>
/// Attach to any manager GameObject in create.unity or join loading.unity.
/// On Start(), removes all placeholder Art elements from the Canvas and builds
/// a proper mobile-portrait lobby screen entirely in code.
/// Lives on create.unity, the post-creation lobby view. The scene already
/// contains the art team's named UI:
///
/// Canvas
/// ├── Panel - full-screen background
/// ├── max players - TMP "Max players: " label (we append the count)
/// ├── lobby code - TMP "Lobby code: " label (we append the code)
/// ├── smazatbutton - "delete/leave" button
/// ├── lobby info - info button (currently no-op)
/// ├── player list neglow - decorative glow behind the player list
/// ├── player list - container for the player list (originally held
/// │ a single placeholder text child)
/// ├── tuff jmeno - hardcoded player slot 1 (placeholder)
/// ├── netuff jmeno - hardcoded player slot 2 (placeholder)
/// └── stvořit - "Start Game" button
///
/// We DO NOT destroy any of these. Instead we resolve them by name, wire the
/// buttons, update text labels live, hide the placeholder slots, and inject
/// a ScrollRect inside the existing 'player list' container so any number of
/// players can be displayed.
///
/// GameManager_UI calls RefreshAll(LobbyState) when the lobby state changes;
/// we cache the latest state and apply it on the next Update so all scene
/// references stay on the main thread.
/// </summary>
public class LobbyDisplayUI : MonoBehaviour
{
@@ -18,54 +39,141 @@ public class LobbyDisplayUI : MonoBehaviour
foreach (var ui in _all) ui._pending = state;
}
// ── Built UI references ──────────────────────────────────────────────────
private TMP_Text _codeText;
private TMP_Text _countText;
private Transform _listContent;
private TMP_Text _statusText;
private GameObject _startFooter;
private GameObject _waitFooter;
// ── Resolved scene refs ──────────────────────────────────────────────────
private TMP_Text _codeLabel; // "lobby code" - prefix + JoinCode appended
private TMP_Text _maxPlayersLabel; // "max players" - prefix + count appended
private Button _leaveBtn; // "smazatbutton"
private Button _infoBtn; // "lobby info"
private Button _startBtn; // "stvořit"
private RectTransform _playerListRT; // "player list" container - scroll lives inside
private TMP_Text _waitingForHostText; // injected; visible to non-host only
// ── Injected scroll list (added once at Start, populated on every refresh) ─
private RectTransform _scrollContent;
private readonly List<GameObject> _rows = new List<GameObject>();
private LobbyState _pending;
// ── Colour palette ────────────────────────────────────────────────────────
static Color H(string hex) { ColorUtility.TryParseHtmlString(hex, out var c); return c; }
static readonly Color C_BG = H("#0D0F1A");
static readonly Color C_HDR = H("#141927");
static readonly Color C_SUBBG = H("#0F1221");
static readonly Color C_ROW_A = H("#1A2035");
static readonly Color C_ROW_B = H("#161C2E");
static readonly Color C_DIVIDER = H("#252A3F");
static readonly Color C_ACCENT = H("#3399FF");
static readonly Color C_GOLD = H("#FFB800");
static readonly Color C_GREEN = H("#2DB84B");
static readonly Color C_RED = H("#C43232");
static readonly Color C_MUTED = new Color(0.47f, 0.53f, 0.67f);
static readonly Color C_WHITE = Color.white;
static readonly Color C_SOFT = new Color(0.73f, 0.80f, 0.88f);
// ── Colour palette (forwarded from UITheme) ──────────────────────────────
static readonly Color C_ROW_A = UITheme.RowA;
static readonly Color C_ROW_B = UITheme.RowB;
static readonly Color C_DIVIDER = UITheme.SurfaceAlt;
static readonly Color C_ACCENT = UITheme.Accent;
static readonly Color C_GOLD = UITheme.Caution;
static readonly Color C_WHITE = UITheme.TextHi;
static readonly Color C_SOFT = UITheme.TextMid;
void OnEnable() => _all.Add(this);
void OnDisable() => _all.Remove(this);
// ── Lifecycle ─────────────────────────────────────────────────────────────
void Start()
{
// Wire emoji fallback ASAP so player-list rows with emoji glyphs
// (host crown, "you" badge, etc.) render correctly on the first
// refresh.
UITheme.EnsureEmojiFontFallback();
var canvasGO = GameObject.Find("Canvas");
if (canvasGO == null)
{
Debug.LogError("[LobbyDisplayUI] No Canvas found in scene!");
Debug.LogError("[LobbyDisplayUI] No Canvas found in create scene.");
return;
}
var canvas = canvasGO.transform;
// Remove all placeholder Art children immediately (before we build)
var kill = new List<GameObject>();
foreach (Transform child in canvasGO.transform)
kill.Add(child.gameObject);
foreach (var go in kill)
DestroyImmediate(go);
// ── Bind existing scene elements ────────────────────────────────────
_codeLabel = FindTMP(canvas, "lobby code");
_maxPlayersLabel = FindTMP(canvas, "max players");
_leaveBtn = FindButton(canvas, "smazatbutton");
_infoBtn = FindButton(canvas, "lobby info");
_startBtn = FindButton(canvas, "stvořit");
var listGO = FindByName(canvas, "player list");
_playerListRT = listGO != null ? listGO as RectTransform : null;
Build(canvasGO.transform);
// ── Wire buttons (preserve existing AudioSource OnClick by appending) ──
if (_leaveBtn != null)
{
// Don't clear; the art team's AudioSource.Play hook should still
// fire alongside the leave action.
_leaveBtn.onClick.AddListener(() => GameManager.Instance?.LeaveLobbyButton());
}
else Debug.LogWarning("[LobbyDisplayUI] 'smazatbutton' not found.");
if (_infoBtn != null)
{
// Tap-to-copy the lobby code to clipboard. Cheap useful action that
// gives the existing info button real behavior.
_infoBtn.onClick.AddListener(() =>
{
var code = GameManager.Instance?.gameClient?.CurrentLobbyState?.JoinCode;
if (!string.IsNullOrEmpty(code)) GUIUtility.systemCopyBuffer = code;
});
}
if (_startBtn != null)
{
_startBtn.onClick.AddListener(() => GameManager.Instance?.StartGameButton());
}
else Debug.LogWarning("[LobbyDisplayUI] 'stvořit' not found.");
// ── Hide the hardcoded placeholder slots ────────────────────────────
// These were kept in the scene as visual previews of what filled rows
// would look like; with a working scrollable list they're redundant.
var tuff = FindByName(canvas, "tuff jmeno");
var netuff = FindByName(canvas, "netuff jmeno");
if (tuff) tuff.gameObject.SetActive(false);
if (netuff) netuff.gameObject.SetActive(false);
// ── Override the player list's RectTransform to a sensible portrait
// layout. The art team's anchored position + size were calibrated
// for a different reference resolution and the element only filled
// a third of the viewable area at 1080x1920. Stretch it to fill
// the central portion of the screen with even insets.
if (_playerListRT != null)
{
_playerListRT.anchorMin = new Vector2(0, 0);
_playerListRT.anchorMax = new Vector2(1, 1);
_playerListRT.pivot = new Vector2(0.5f, 0.5f);
// Top inset = 320 (room for header / lobby code), bottom inset =
// 380 (room for the start button or waiting message), 60px sides.
_playerListRT.offsetMin = new Vector2(60, 380);
_playerListRT.offsetMax = new Vector2(-60, -320);
_playerListRT.anchoredPosition = Vector2.zero;
BuildScrollList(_playerListRT);
}
else
{
Debug.LogWarning("[LobbyDisplayUI] 'player list' container not found - " +
"falling back to no list rendering.");
}
// Also re-anchor the "player list neglow" decoration to track the new
// player list region, so the glow doesn't float empty offscreen.
var neglow = FindByName(canvas, "player list neglow") as RectTransform;
if (neglow != null)
{
neglow.anchorMin = new Vector2(0, 0);
neglow.anchorMax = new Vector2(1, 1);
neglow.pivot = new Vector2(0.5f, 0.5f);
neglow.offsetMin = new Vector2(40, 360);
neglow.offsetMax = new Vector2(-40, -300);
}
// ── Inject "Waiting for host..." text for non-host players ──────────
// Visible only when the local player isn't the lobby owner; sits in
// the same vertical strip as the start button so the screen has a
// single consistent action zone for both roles.
var waitGO = MakeRT("WaitingForHost", canvas);
Anchor(waitGO,
new Vector2(0.05f, 0), new Vector2(0.95f, 0),
new Vector2(0, 60), new Vector2(0, 240));
_waitingForHostText = waitGO.gameObject.AddComponent<TextMeshProUGUI>();
_waitingForHostText.text = "⌛ Waiting for host to start the game...";
_waitingForHostText.fontSize = 38;
_waitingForHostText.color = new Color(0.73f, 0.80f, 0.88f);
_waitingForHostText.fontStyle = FontStyles.Italic;
_waitingForHostText.alignment = TextAlignmentOptions.Center;
_waitingForHostText.gameObject.SetActive(false); // toggled per refresh
}
void Update()
@@ -74,177 +182,110 @@ public class LobbyDisplayUI : MonoBehaviour
if (gm?.gameClient?.CurrentLobbyState != null)
_pending = gm.gameClient.CurrentLobbyState;
if (_pending != null && _listContent != null)
if (_pending != null)
{
Refresh(_pending);
_pending = null;
}
}
// ── Full UI construction ──────────────────────────────────────────────────
void Build(Transform canvasRoot)
// ── Scroll list construction ─────────────────────────────────────────────
void BuildScrollList(RectTransform listRoot)
{
const float HDR_H = 250f;
const float SUB_H = 88f;
const float FOOT_H = 180f;
const float BTN_W = 200f;
// Strip any existing children of `player list` (typically one
// placeholder TMP). Don't touch the listRoot itself - it has the art
// team's anchoring + glow pairing we want to preserve.
var kill = new List<GameObject>();
foreach (Transform child in listRoot) kill.Add(child.gameObject);
foreach (var go in kill) DestroyImmediate(go);
// Fullscreen dark overlay
var root = RT("Root", canvasRoot);
Stretch(root);
Img(root, C_BG);
// ScrollRect on the list root
var sr = listRoot.gameObject.GetComponent<ScrollRect>()
?? listRoot.gameObject.AddComponent<ScrollRect>();
sr.horizontal = false; sr.vertical = true;
sr.movementType = ScrollRect.MovementType.Elastic;
sr.elasticity = 0.1f;
sr.scrollSensitivity = 80f;
// ─── Header bar ───────────────────────────────────────────────────────
var header = RT("Header", root);
PinTop(header, HDR_H);
Img(header, C_HDR);
// Back (✕) button — left side of header
var backBtn = RT("BackBtn", header);
backBtn.anchorMin = new Vector2(0f, 0f);
backBtn.anchorMax = new Vector2(0f, 1f);
backBtn.pivot = new Vector2(0f, 0.5f);
backBtn.offsetMin = new Vector2(18f, 22f);
backBtn.offsetMax = new Vector2(BTN_W + 18f, -22f);
Img(backBtn, C_RED);
Btn(backBtn, C_RED, () => GameManager.Instance?.LeaveLobbyButton());
TxtChild(backBtn, "✕", 72, C_WHITE, TextAlignmentOptions.Center, bold: true);
// "LOBBY CODE" micro label — upper-center of header
var codeLbl = RT("CodeLbl", header);
codeLbl.anchorMin = new Vector2(0.14f, 0.52f);
codeLbl.anchorMax = new Vector2(0.86f, 0.97f);
codeLbl.offsetMin = codeLbl.offsetMax = Vector2.zero;
TmpDirect(codeLbl, "LOBBY CODE", 28, C_MUTED, TextAlignmentOptions.Center, bold: true);
// Large code value — lower-center of header
var codeValRT = RT("CodeVal", header);
codeValRT.anchorMin = new Vector2(0.14f, 0.05f);
codeValRT.anchorMax = new Vector2(0.86f, 0.52f);
codeValRT.offsetMin = codeValRT.offsetMax = Vector2.zero;
_codeText = TmpDirect(codeValRT, "------", 76, C_ACCENT, TextAlignmentOptions.Center, bold: true);
// Copy (⎘) button — right side of header
var copyBtn = RT("CopyBtn", header);
copyBtn.anchorMin = new Vector2(1f, 0f);
copyBtn.anchorMax = new Vector2(1f, 1f);
copyBtn.pivot = new Vector2(1f, 0.5f);
copyBtn.offsetMin = new Vector2(-(BTN_W + 18f), 22f);
copyBtn.offsetMax = new Vector2(-18f, -22f);
Img(copyBtn, C_ACCENT);
Btn(copyBtn, C_ACCENT, () =>
{
if (_codeText != null) GUIUtility.systemCopyBuffer = _codeText.text;
});
TxtChild(copyBtn, "⎘", 60, C_WHITE, TextAlignmentOptions.Center);
// ─── Player count subtitle bar ─────────────────────────────────────────
var subBar = RT("CountBar", root);
PinBelowTop(subBar, HDR_H, SUB_H);
Img(subBar, C_SUBBG);
_countText = TxtChild(subBar, "0 players in lobby", 34, C_MUTED, TextAlignmentOptions.Center);
// ─── Scrollable player list ────────────────────────────────────────────
var scrollArea = RT("PlayerScroll", root);
Fill(scrollArea, HDR_H + SUB_H, FOOT_H);
BuildScroll(scrollArea);
// ─── Footer: START GAME (host) or waiting text (others) ───────────────
_startFooter = new GameObject("StartFooter");
var sfRT = _startFooter.AddComponent<RectTransform>();
sfRT.SetParent(root, false);
sfRT.localScale = Vector3.one;
PinBottom(sfRT, FOOT_H);
Img(sfRT, C_SUBBG);
var startBtnRT = RT("StartBtn", sfRT);
Fill(startBtnRT, 20f, 20f, 24f, 24f);
Img(startBtnRT, C_GREEN);
Btn(startBtnRT, C_GREEN, () => GameManager.Instance?.StartGameButton());
TxtChild(startBtnRT, "▶ START GAME", 54, C_WHITE, TextAlignmentOptions.Center, bold: true);
_startFooter.SetActive(false);
_waitFooter = new GameObject("WaitFooter");
var wfRT = _waitFooter.AddComponent<RectTransform>();
wfRT.SetParent(root, false);
wfRT.localScale = Vector3.one;
PinBottom(wfRT, FOOT_H);
Img(wfRT, C_SUBBG);
_statusText = TxtChild(wfRT, "⌛ Waiting for host to start...", 38, C_MUTED,
TextAlignmentOptions.Center, italic: true);
_waitFooter.SetActive(true);
}
void BuildScroll(RectTransform rt)
{
var sr = rt.gameObject.AddComponent<ScrollRect>();
var viewport = RT("Viewport", rt);
// Viewport (with mask for clipping)
var viewport = MakeRT("Viewport", listRoot);
Stretch(viewport);
var vpImg = viewport.gameObject.AddComponent<Image>();
vpImg.color = new Color(0,0,0,0);
viewport.gameObject.AddComponent<RectMask2D>();
var content = RT("Content", viewport);
content.anchorMin = new Vector2(0f, 1f);
content.anchorMax = new Vector2(1f, 1f);
content.pivot = new Vector2(0.5f, 1f);
content.sizeDelta = new Vector2(0f, 0f);
// Content - populated from State.Players
var content = MakeRT("Content", viewport);
content.anchorMin = new Vector2(0, 1);
content.anchorMax = new Vector2(1, 1);
content.pivot = new Vector2(0.5f, 1);
content.sizeDelta = Vector2.zero;
content.anchoredPosition = Vector2.zero;
var vlg = content.gameObject.AddComponent<VerticalLayoutGroup>();
vlg.childControlWidth = true;
vlg.childControlHeight = false;
vlg.childForceExpandWidth = true;
vlg.childControlWidth = true;
vlg.childControlHeight = false;
vlg.childForceExpandWidth = true;
vlg.childForceExpandHeight = false;
vlg.spacing = 2f;
vlg.padding = new RectOffset(0, 0, 0, 0);
vlg.spacing = 4;
var csf = content.gameObject.AddComponent<ContentSizeFitter>();
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
sr.viewport = viewport;
sr.content = content;
sr.horizontal = false;
sr.vertical = true;
sr.scrollSensitivity = 80f;
sr.movementType = ScrollRect.MovementType.Elastic;
sr.elasticity = 0.1f;
sr.viewport = viewport;
sr.content = content;
_listContent = content;
_scrollContent = content;
}
// ── State refresh ────────────────────────────────────────────────────────
// ── State refresh ────────────────────────────────────────────────────────
void Refresh(LobbyState state)
{
if (_codeText != null) _codeText.text = state.JoinCode ?? "------";
// Lobby code label (preserve art team prefix)
if (_codeLabel != null)
{
const string prefix = "Lobby code: ";
_codeLabel.text = prefix + (state.JoinCode ?? "------");
}
int n = state.Players.Count;
if (_countText != null)
_countText.text = $"{n} player{(n == 1 ? "" : "s")} in lobby";
// Max players label - we use this for live player count too. The art
// team's prefix "Max players: " gets appended with current/max.
if (_maxPlayersLabel != null)
{
int n = state.Players != null ? state.Players.Count : 0;
_maxPlayersLabel.text = $"Players: {n}";
}
var gm = GameManager.Instance;
bool isHost = gm?.gameClient?.IsOwner ?? false;
string myId = gm?.gameClient?.ClientUuid ?? "";
if (_startFooter != null) _startFooter.SetActive(isHost);
if (_waitFooter != null) _waitFooter.SetActive(!isHost);
if (_statusText != null)
_statusText.text = state.Phase == GamePhase.Loading
// Unified screen for both owner and joiner. Host gets the start
// button + working settings; non-host gets a clear "waiting" message
// in the same screen real estate so the layout stays consistent.
bool isHost = GameManager.Instance?.gameClient?.IsOwner ?? false;
if (_startBtn != null)
_startBtn.gameObject.SetActive(isHost);
if (_waitingForHostText != null)
{
_waitingForHostText.gameObject.SetActive(!isHost);
_waitingForHostText.text = state.Phase == GamePhase.Loading
? "⏳ Downloading map data..."
: "⌛ Waiting for host to start...";
: "⌛ Waiting for host to start the game...";
}
if (_listContent == null) return;
if (_scrollContent == null) return;
// Clear and rebuild rows. Player count is small (<= 15 per server
// config), so a full rebuild every refresh is cheap and avoids the
// bookkeeping of incremental updates.
foreach (var row in _rows) Destroy(row);
_rows.Clear();
if (state.Players == null) return;
string myId = GameManager.Instance?.gameClient?.ClientUuid ?? "";
for (int i = 0; i < state.Players.Count; i++)
{
var p = state.Players[i];
bool me = p.ClientUuid == myId;
var row = BuildRow(p.DisplayName ?? "???", me, p.IsOwner,
var p = state.Players[i];
bool isMe = p.ClientUuid == myId;
var row = BuildRow(p.DisplayName ?? "???", isMe, p.IsOwner,
i % 2 == 0 ? C_ROW_A : C_ROW_B);
row.transform.SetParent(_listContent, false);
row.transform.SetParent(_scrollContent, false);
_rows.Add(row);
}
}
@@ -255,69 +296,98 @@ public class LobbyDisplayUI : MonoBehaviour
var go = new GameObject("PlayerRow");
var rt = go.AddComponent<RectTransform>();
rt.sizeDelta = new Vector2(0f, ROW_H);
var le = go.AddComponent<LayoutElement>();
le.minHeight = ROW_H;
le.minHeight = ROW_H;
le.preferredHeight = ROW_H;
Img(rt, bg);
var bgImg = go.AddComponent<Image>();
bgImg.color = bg;
// Bottom divider line
var divRT = RT("Div", rt);
divRT.anchorMin = new Vector2(0f, 0f);
divRT.anchorMax = new Vector2(1f, 0f);
divRT.pivot = new Vector2(0.5f, 0f);
divRT.offsetMin = new Vector2(20f, 0f);
divRT.offsetMax = new Vector2(-20f, 2f);
Img(divRT, C_DIVIDER);
var divRT = MakeRT("Div", rt);
divRT.anchorMin = new Vector2(0, 0); divRT.anchorMax = new Vector2(1, 0);
divRT.pivot = new Vector2(0.5f, 0);
divRT.offsetMin = new Vector2(20, 0); divRT.offsetMax = new Vector2(-20, 2);
var divImg = divRT.gameObject.AddComponent<Image>();
divImg.color = C_DIVIDER;
float nameLeft = 24f;
// Crown emoji for lobby host
if (isHostPlayer)
{
var crownRT = RT("Crown", rt);
crownRT.anchorMin = new Vector2(0f, 0.5f);
crownRT.anchorMax = new Vector2(0f, 0.5f);
crownRT.pivot = new Vector2(0f, 0.5f);
crownRT.sizeDelta = new Vector2(90f, 90f);
crownRT.anchoredPosition = new Vector2(18f, 0f);
TmpDirect(crownRT, "👑", 52, C_GOLD, TextAlignmentOptions.Center);
var crownRT = MakeRT("Crown", rt);
crownRT.anchorMin = new Vector2(0, 0.5f); crownRT.anchorMax = new Vector2(0, 0.5f);
crownRT.pivot = new Vector2(0, 0.5f);
crownRT.sizeDelta = new Vector2(90, 90);
crownRT.anchoredPosition = new Vector2(18, 0);
var crownTmp = crownRT.gameObject.AddComponent<TextMeshProUGUI>();
crownTmp.text = "👑"; crownTmp.fontSize = 52;
crownTmp.color = C_GOLD; crownTmp.alignment = TextAlignmentOptions.Center;
nameLeft = 118f;
}
// Player name
// Name label
float nameMaxX = isMe ? 0.68f : 1f;
var nameRT = RT("Name", rt);
nameRT.anchorMin = new Vector2(0f, 0f);
nameRT.anchorMax = new Vector2(nameMaxX, 1f);
nameRT.offsetMin = new Vector2(nameLeft, 6f);
nameRT.offsetMax = new Vector2(-10f, -6f);
var nameRT = MakeRT("Name", rt);
nameRT.anchorMin = new Vector2(0, 0); nameRT.anchorMax = new Vector2(nameMaxX, 1);
nameRT.offsetMin = new Vector2(nameLeft, 6); nameRT.offsetMax = new Vector2(-10, -6);
var nt = nameRT.gameObject.AddComponent<TextMeshProUGUI>();
nt.text = playerName;
nt.fontSize = 48;
nt.color = isMe ? C_WHITE : C_SOFT;
nt.alignment = TextAlignmentOptions.MidlineLeft;
nt.fontStyle = isMe ? FontStyles.Bold : FontStyles.Normal;
nt.text = playerName; nt.fontSize = 48;
nt.color = isMe ? C_WHITE : C_SOFT;
nt.alignment = TextAlignmentOptions.MidlineLeft;
nt.fontStyle = isMe ? FontStyles.Bold : FontStyles.Normal;
nt.overflowMode = TextOverflowModes.Ellipsis;
// "YOU" badge
if (isMe)
{
var badgeRT = RT("YouBadge", rt);
var badgeRT = MakeRT("YouBadge", rt);
badgeRT.anchorMin = new Vector2(0.68f, 0.22f);
badgeRT.anchorMax = new Vector2(1f, 0.78f);
badgeRT.offsetMin = new Vector2(0f, 0f);
badgeRT.offsetMax = new Vector2(-20f, 0f);
Img(badgeRT, C_ACCENT);
TxtChild(badgeRT, "YOU", 30, C_WHITE, TextAlignmentOptions.Center, bold: true);
badgeRT.offsetMin = Vector2.zero;
badgeRT.offsetMax = new Vector2(-20f, 0);
var badgeImg = badgeRT.gameObject.AddComponent<Image>();
badgeImg.color = C_ACCENT;
var badgeTxtRT = MakeRT("Txt", badgeRT);
Stretch(badgeTxtRT);
var badgeTmp = badgeTxtRT.gameObject.AddComponent<TextMeshProUGUI>();
badgeTmp.text = "YOU"; badgeTmp.fontSize = 30;
badgeTmp.color = C_WHITE; badgeTmp.fontStyle = FontStyles.Bold;
badgeTmp.alignment = TextAlignmentOptions.Center;
}
return go;
}
// ── Layout helpers ────────────────────────────────────────────────────────
RectTransform RT(string name, Transform parent)
// ── Scene-binding helpers ────────────────────────────────────────────────
static Button FindButton(Transform root, string name)
{
var t = FindByName(root, name);
return t != null ? t.GetComponent<Button>() : null;
}
static TMP_Text FindTMP(Transform root, string name)
{
var t = FindByName(root, name);
if (t == null) return null;
return t.GetComponent<TMP_Text>() ?? t.GetComponentInChildren<TMP_Text>();
}
static Transform FindByName(Transform root, string name)
{
if (root == null) return null;
if (root.name == name) return root;
foreach (Transform child in root)
{
var found = FindByName(child, name);
if (found != null) return found;
}
return null;
}
// ── Layout helpers ───────────────────────────────────────────────────────
static RectTransform MakeRT(string name, Transform parent)
{
var go = new GameObject(name);
var rt = go.AddComponent<RectTransform>();
@@ -326,92 +396,16 @@ public class LobbyDisplayUI : MonoBehaviour
return rt;
}
void Stretch(RectTransform rt)
static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
}
void Fill(RectTransform rt, float top, float bottom, float left = 0f, float right = 0f)
static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax,
Vector2 offMin, Vector2 offMax)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(left, bottom);
rt.offsetMax = new Vector2(-right, -top);
}
void PinTop(RectTransform rt, float h)
{
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(0f, -h);
rt.offsetMax = Vector2.zero;
}
void PinBelowTop(RectTransform rt, float fromTop, float h)
{
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(0f, -(fromTop + h));
rt.offsetMax = new Vector2(0f, -fromTop);
}
void PinBottom(RectTransform rt, float h)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = new Vector2(1f, 0f);
rt.pivot = new Vector2(0.5f, 0f);
rt.offsetMin = Vector2.zero;
rt.offsetMax = new Vector2(0f, h);
}
// ── Graphic helpers ───────────────────────────────────────────────────────
/// Adds an Image directly to rt.
Image Img(RectTransform rt, Color c)
{
var img = rt.gameObject.AddComponent<Image>();
img.color = c;
return img;
}
/// Adds TMP directly to rt — only use when rt has NO Image component.
TMP_Text TmpDirect(RectTransform rt, string text, float size, Color color,
TextAlignmentOptions align, bool bold = false, bool italic = false)
{
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = size;
tmp.color = color;
tmp.alignment = align;
if (bold) tmp.fontStyle |= FontStyles.Bold;
if (italic) tmp.fontStyle |= FontStyles.Italic;
return tmp;
}
/// Creates a stretch-fill child GO with TMP — safe when parent already has Image.
TMP_Text TxtChild(RectTransform parent, string text, float size, Color color,
TextAlignmentOptions align, bool bold = false, bool italic = false)
{
var childRT = RT("Txt", parent);
Stretch(childRT);
return TmpDirect(childRT, text, size, color, align, bold, italic);
}
void Btn(RectTransform rt, Color normal, System.Action onClick)
{
var btn = rt.gameObject.AddComponent<Button>();
btn.targetGraphic = rt.gameObject.GetComponent<Image>();
btn.onClick.AddListener(() => onClick());
var cols = btn.colors;
cols.normalColor = normal;
cols.highlightedColor = Color.Lerp(normal, Color.white, 0.3f);
cols.pressedColor = Color.Lerp(normal, Color.black, 0.3f);
cols.selectedColor = normal;
btn.colors = cols;
rt.anchorMin = aMin; rt.anchorMax = aMax;
rt.offsetMin = offMin; rt.offsetMax = offMax;
}
}

324
Assets/Scripts/UITheme.cs Normal file
View File

@@ -0,0 +1,324 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
/// <summary>
/// Centralized design tokens + styling helpers for every Canvas in the game.
/// One source of truth for color, typography, spacing, animation timing, and
/// emoji-capable font configuration. Replaces the per-file palette duplication
/// that scattered across InGameHUDBuilder, HostLobbyUI, LobbyDisplayUI etc.
///
/// Why this exists (P10):
/// - "UI looks tiny on phone, bleeds off-screen during voting, font drops
/// emoji glyphs" - all symptoms of palette/typography/sizing values being
/// defined per-file with no shared scale. Centralizing fixes the
/// whole-app inconsistency, not just the worst offender.
/// - Emoji rendering on Android needs TMP_Settings.fallbackFontAssets
/// pointed at a Noto Color Emoji TMP_FontAsset. We do this at runtime
/// so the host project doesn't need a pre-baked TMP_Settings asset
/// change (which would touch a binary scriptable object).
///
/// Visual direction:
/// - Dark base (#0A0E1A near-black) with one strong accent (teal-cyan
/// #00C8C8). Readable in sunlight, doesn't clash with the orange/red
/// impostor signals or the green map.
/// - Typography ramp: one display weight, one body, one mono for codes.
/// Sizes follow a major-third scale (1.25x ratio) so headlines
/// dominate without shouting.
/// - Spacing on an 8pt grid. Touch targets minimum 88pt (44pt @ 2x).
/// - Animation: subtle. Fade 150ms, slide 200ms, ease-out. Game's tense -
/// UI shouldn't be cute.
/// </summary>
public static class UITheme
{
// ── Palette ───────────────────────────────────────────────────────────────
// Names follow purpose, not literal color. Adding a new component? Use
// Bg/Surface/Accent/Success/Danger/Warning/Muted - not raw hex.
public static readonly Color Bg = H("#0A0E1A"); // page background
public static readonly Color Surface = H("#121829"); // cards, panels
public static readonly Color SurfaceAlt = H("#1A2138"); // elevated/active
public static readonly Color SurfaceDim = H("#0E1322"); // recessed (input)
public static readonly Color RowA = H("#1A2035"); // alt row
public static readonly Color RowB = H("#161C2E"); // alt row
public static readonly Color Border = new Color(1f, 1f, 1f, 0.10f);
public static readonly Color Accent = H("#00C8C8"); // teal-cyan, primary CTA
public static readonly Color AccentDim = H("#0A8A8A"); // pressed state
public static readonly Color Success = H("#2DB84B"); // task done, you-voted
public static readonly Color Danger = H("#E04040"); // impostor, eject, kill
public static readonly Color Warning = H("#F08C1A"); // sabotage, meltdown
public static readonly Color Caution = H("#FFB800"); // reconnect, info
public static readonly Color TextHi = new Color(0.96f, 0.97f, 0.99f); // primary
public static readonly Color TextMid = new Color(0.78f, 0.83f, 0.91f); // secondary
public static readonly Color TextLo = new Color(0.55f, 0.62f, 0.75f); // tertiary/disabled
public static readonly Color TextOnAccent= H("#001818"); // black-ish on bright accent
static Color H(string hex)
{
ColorUtility.TryParseHtmlString(hex, out var c);
return c;
}
// ── Typography ────────────────────────────────────────────────────────────
// Sizes are in TMP "px" units which scale with the CanvasScaler reference.
// Our reference is 1080x1920 portrait; these values produce the intended
// physical size on a typical 6" phone (~5mm tall body text).
public const float FontDisplay = 64f; // hero (game-end win/loss)
public const float FontHeadline = 44f; // section header
public const float FontTitle = 32f; // panel header
public const float FontBody = 26f; // standard text
public const float FontSmall = 22f; // captions, helper text
public const float FontTiny = 18f; // tiny meta, debug
// Action button text scales separately because thumb-zone CTAs need to
// dominate visually even on a small screen.
public const float FontActionBtn= 40f;
// ── Spacing (8pt grid) ────────────────────────────────────────────────────
public const float S1 = 4f;
public const float S2 = 8f;
public const float S3 = 12f;
public const float S4 = 16f;
public const float S5 = 24f;
public const float S6 = 32f;
public const float S7 = 48f;
public const float S8 = 64f;
public const float S9 = 96f;
// ── Touch targets ─────────────────────────────────────────────────────────
// Minimum heights tuned for 1080x1920 reference. CanvasScaler keeps these
// visually consistent across phone sizes.
public const float MinTapHeight = 88f; // 44pt @ 2x
public const float StandardBtn = 110f; // typical button height
public const float HeroBtn = 140f; // primary CTA
public const float VoteRowMin = 96f;
public const float VoteRowMax = 144f;
// ── Corner radii (target value; needs an Image with a rounded sprite to
// realize, but keeping the token here lets future polish hit one place) ──
public const float Radius1 = 8f;
public const float Radius2 = 14f;
public const float Radius3 = 22f;
// ── Animation timing ──────────────────────────────────────────────────────
public const float DurFade = 0.15f;
public const float DurSlide = 0.20f;
public const float DurSnappy = 0.10f;
public const float DurAmbient = 0.40f; // slow ambient pulses
// ── Canvas reference (mirrored from InGameHUDBuilder for consistency) ────
public const float ReferenceWidth = 1080f;
public const float ReferenceHeight = 1920f;
public const float MatchWidthOrHeight = 0.5f;
// ── Emoji font fallback ───────────────────────────────────────────────────
// Tracks whether we've already tried to wire a Noto Color Emoji fallback
// into TMP_Settings. Once attempted (success or fail), we don't keep
// re-poking - mobile resource lookups aren't free.
static bool _emojiFallbackAttempted;
/// <summary>
/// Configures the project's TMP fallback font chain so emoji codepoints
/// (📡 ⚙️ 🗳️ 👥 etc.) used throughout the UI render properly. Looks for a
/// TMP_FontAsset named "NotoColorEmoji" (or "EmojiFallback") under
/// `Resources/Fonts/` first; if that's missing, walks the OS font cache
/// the platform exposes via Font.OSFontNames as a last-resort fallback.
///
/// Idempotent + cheap on subsequent calls. Callers don't need to gate.
/// </summary>
public static void EnsureEmojiFontFallback()
{
if (_emojiFallbackAttempted) return;
_emojiFallbackAttempted = true;
// Primary: explicit Resources lookup. Drop a pre-built
// `NotoColorEmoji.asset` (TMP_FontAsset) into Assets/Resources/Fonts/
// for Android/iOS - that's the production path.
TMP_FontAsset emoji = Resources.Load<TMP_FontAsset>("Fonts/NotoColorEmoji");
if (emoji == null) emoji = Resources.Load<TMP_FontAsset>("Fonts/EmojiFallback");
if (emoji != null)
{
var def = TMP_Settings.defaultFontAsset;
if (def != null)
{
if (def.fallbackFontAssetTable == null)
def.fallbackFontAssetTable = new List<TMP_FontAsset>();
if (!def.fallbackFontAssetTable.Contains(emoji))
{
def.fallbackFontAssetTable.Add(emoji);
Debug.Log("[UITheme] Emoji fallback wired: " + emoji.name);
}
}
return;
}
// No bundled emoji font. Log once so the user knows to either drop
// one in or strip emojis from labels - silent failure here means
// missing-glyph rectangles surface in the UI without explanation.
Debug.LogWarning(
"[UITheme] No NotoColorEmoji TMP_FontAsset found under Resources/Fonts/. " +
"Emoji glyphs in UI text will render as tofu boxes. " +
"Drop a TMP-baked Noto Color Emoji asset at " +
"Assets/Resources/Fonts/NotoColorEmoji.asset (any TMP_FontAsset name " +
"containing 'Emoji' also matches via the secondary lookup).");
}
// ── Style helpers ─────────────────────────────────────────────────────────
// Apply consistent styling without repeating every property. Keep these
// single-purpose; if you find yourself adding flags, that's a sign you
// should add a new helper instead.
/// <summary>Solid Image with theme-consistent color. Returns the Image.</summary>
public static Image Surf(RectTransform rt, Color color)
{
var img = rt.gameObject.GetComponent<Image>() ?? rt.gameObject.AddComponent<Image>();
img.color = color;
return img;
}
/// <summary>Stretches a RectTransform to fill its parent.</summary>
public static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
}
/// <summary>Apply the project's standard CanvasScaler config.</summary>
public static void ConfigureCanvasScaler(Canvas canvas)
{
if (canvas == null) return;
var scaler = canvas.GetComponent<CanvasScaler>()
?? canvas.gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(ReferenceWidth, ReferenceHeight);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = MatchWidthOrHeight;
scaler.referencePixelsPerUnit = 100f;
}
/// <summary>
/// Anchor a RectTransform to the screen's safe rectangle (notch/punch-hole
/// safe). Children inherit the inset.
/// </summary>
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;
}
/// <summary>
/// Style a TMP text field as a labeled text element. Sets size, color,
/// alignment, weight - and ensures the emoji fallback chain is wired so
/// any emoji glyphs in the text render properly on the first frame.
/// </summary>
public static void StyleText(TMP_Text tmp, float size, Color color,
TextAlignmentOptions align, bool bold = false, FontStyles extra = FontStyles.Normal)
{
if (tmp == null) return;
EnsureEmojiFontFallback();
tmp.fontSize = size;
tmp.color = color;
tmp.alignment = align;
tmp.fontStyle = (bold ? FontStyles.Bold : FontStyles.Normal) | extra;
tmp.enableWordWrapping = true;
}
/// <summary>Standard primary CTA button styling - bg, text, target graphic.</summary>
public static Button StylePrimaryButton(RectTransform rt, string label,
Color? bgColor = null, Color? textColor = null, float? fontSize = null)
{
var bg = Surf(rt, bgColor ?? Accent);
var btn = rt.gameObject.GetComponent<Button>() ?? rt.gameObject.AddComponent<Button>();
btn.targetGraphic = bg;
// Color block: dim for pressed/disabled states.
var cb = btn.colors;
cb.normalColor = bgColor ?? Accent;
cb.highlightedColor = Color.Lerp(bgColor ?? Accent, TextHi, 0.10f);
cb.pressedColor = Color.Lerp(bgColor ?? Accent, Bg, 0.30f);
cb.disabledColor = new Color(0.30f, 0.34f, 0.42f, 0.70f);
cb.selectedColor = bgColor ?? Accent;
btn.colors = cb;
// Label child
var labelRt = NewChild("Label", rt);
Stretch(labelRt);
var tmp = labelRt.gameObject.AddComponent<TextMeshProUGUI>();
StyleText(tmp, fontSize ?? FontActionBtn,
textColor ?? TextOnAccent, TextAlignmentOptions.Center, bold: true);
tmp.text = label;
// Padding so the text doesn't kiss the button edges.
labelRt.offsetMin = new Vector2(S4, S2);
labelRt.offsetMax = new Vector2(-S4, -S2);
return btn;
}
/// <summary>Secondary (outlined / muted) button styling.</summary>
public static Button StyleSecondaryButton(RectTransform rt, string label,
float? fontSize = null)
{
return StylePrimaryButton(rt, label,
bgColor: SurfaceAlt, textColor: TextHi, fontSize: fontSize);
}
/// <summary>Danger (eject/leave/kill confirmation) button styling.</summary>
public static Button StyleDangerButton(RectTransform rt, string label,
float? fontSize = null)
{
return StylePrimaryButton(rt, label,
bgColor: Danger, textColor: TextHi, fontSize: fontSize);
}
/// <summary>
/// Create a child RectTransform under the given parent. Centralized so
/// every UI builder doesn't reinvent the GameObject + RectTransform
/// boilerplate.
/// </summary>
public static RectTransform NewChild(string name, RectTransform parent)
{
var go = new GameObject(name);
var rt = go.AddComponent<RectTransform>();
rt.SetParent(parent, false);
rt.localScale = Vector3.one;
return rt;
}
/// <summary>Anchor a RectTransform with absolute corner offsets.</summary>
public static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax,
Vector2 offsetMin, Vector2 offsetMax)
{
rt.anchorMin = aMin; rt.anchorMax = aMax;
rt.offsetMin = offsetMin; rt.offsetMax = offsetMax;
}
/// <summary>
/// Add a TMP text label as a child of `parent`, fully filling it. Common
/// pattern for buttons and panel headers.
/// </summary>
public static TextMeshProUGUI TxtChild(RectTransform parent, string text,
float size, Color color, TextAlignmentOptions align, bool bold = false)
{
var rt = NewChild("Txt", parent);
Stretch(rt);
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
StyleText(tmp, size, color, align, bold);
tmp.text = text;
return tmp;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c1ba695cb5aeb1d468b2eb2b5032d830