using UnityEngine; using UnityEngine.UI; using UnityEngine.SceneManagement; using TMPro; using GeoSus.Client; /// /// 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). /// public class HostLobbyUI : MonoBehaviour { // ── 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 (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; // ── Layout constants ──────────────────────────────────────────────────── const float ROW_HEIGHT = 130f; const float HEADER_HEIGHT = 90f; const float SLIDER_HEIGHT = 160f; void Start() { // 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; // 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 in host lobby scene."); return; } var canvas = canvasGO.transform; // ── 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."); 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."); // ── 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); } // ── Action handlers ────────────────────────────────────────────────────── void OnCreateClicked() { var gm = GameManager.Instance; 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.pendingSettings = BuildOverrides(); gm.CreateLobbyButton(); } /// /// 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. /// GameSettingsOverrides BuildOverrides() { 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, }; } // ── Settings ScrollRect ───────────────────────────────────────────────── /// /// 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. /// void BuildSettingsScroll(Transform canvas, Transform backT, Transform stvoritT) { // Force the canvas's layout to settle so GetWorldCorners returns the // real positioned rects rather than authored-time placeholders. Canvas.ForceUpdateCanvases(); 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(); bgImg.color = new Color(0f, 0f, 0f, 0.55f); var sr = scrollRT.gameObject.AddComponent(); var vp = MakeRT("Viewport", scrollRT); Stretch(vp); vp.gameObject.AddComponent(); 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(); 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(); 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(); le.preferredHeight = HEADER_HEIGHT; le.minHeight = HEADER_HEIGHT; var bg = rt.gameObject.AddComponent(); 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(); 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 onChange) { var rt = MakeRT("Row_" + label, parent); var le = rt.gameObject.AddComponent(); 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 onChange, System.Func formatter) { var rt = MakeRT("Row_" + label, parent); var le = rt.gameObject.AddComponent(); 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(); 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(); 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.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 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(); 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.55f, 0.10f), new Vector2(0.69f, 0.90f), Vector2.zero, Vector2.zero); var minusBtn = MakeButton(minusRT, "-", C_BTN_BG); // 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(); valTmp.text = captured.ToString(); valTmp.fontSize = 40; valTmp.color = C_TEXT; valTmp.alignment = TextAlignmentOptions.Center; valTmp.fontStyle = FontStyles.Bold; // Plus button var plusRT = MakeRT("Plus", parent); 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); valTmp.text = captured.ToString(); onChange?.Invoke(captured); }); plusBtn.onClick.AddListener(() => { captured = Mathf.Min(max, captured + 1); valTmp.text = captured.ToString(); onChange?.Invoke(captured); }); } Button MakeButton(RectTransform rt, string label, Color bg) { var img = rt.gameObject.AddComponent(); img.color = bg; var btn = rt.gameObject.AddComponent