using System.Collections; using System.Collections.Generic; using System.Reflection; using TMPro; using UnityEngine; using UnityEngine.UI; public class SettingsScreen : MonoBehaviour { [Header("Панель с опциями")] [SerializeField] RectTransform OptionsRect; [Header("Панель с вариантами опций")] [SerializeField] RectTransform ValuesRect; [Header("Панель с статистикой")] [SerializeField] CountersView StatisticRect; [Header("Указатель опций")] [SerializeField] RectTransform PointerRect; [Header("Префаб текста настройки")] [SerializeField] GameObject SettingPrefab; [Header("Префаб текста опции")] [SerializeField] GameObject OptionPrefab; [Header("Префаб прогресс-бара (для Panel/Action)")] [SerializeField] GameObject ProgressBarPrefab; [Header("Префаб слайдера (для Svitcher)")] [SerializeField] GameObject SwitcherSliderPrefab; [Header("Настройки прокрутки")] [SerializeField] int maxVisibleSettings = 5; // максимально видимых элементов [SerializeField] float scrollSpeed = 10f; // скорость сглаживания прокрутки // Скроллбар UI [Header("UI Scrollbar")] [SerializeField] Scrollbar Scrollbar; List settingsLabels; List optionsLabels; Dictionary progressBars; // индекс настройки -> прогресс-бар Dictionary progressTexts; // индекс настройки -> текст процентов Dictionary switcherSliders; // индекс настройки -> слайдер для Svitcher substate currentState; List gameSettings => SettingsManager.instance.gameSettings.settings; int settingsAmount => SettingsManager.instance.gameSettings.settings.Count; List chosenSettingOptions => SettingsManager.instance.gameSettings.settings[currentSettingIndex].options; int chosenSettingOptionsAmount => chosenSettingOptions.Count; public int currentSettingIndex = 0; int currentOptionIndex = 0; [Header("Позиционирование настроек")] [Tooltip("Авто: вычислить starty/offsetY из высоты OptionsRect при открытии")] [SerializeField] bool autoLayout = true; [Tooltip("Отступ первого элемента от левого края (px)")] [SerializeField] float startx = 50; [Tooltip("Отступ первого элемента от верхнего края (px, отрицательный — вниз)")] [SerializeField] float starty = -50; [Tooltip("Горизонтальный сдвиг каждого следующего элемента (px, обычно 0)")] [SerializeField] float offsetx = 0; [Tooltip("Вертикальный шаг между элементами (px)")] [SerializeField] float offsety = 70; [Tooltip("Сдвиг значения/слайдера/прогресс-бара вправо от правого края лейбла (px)")] [SerializeField] float valueOffsetX = 0; [Tooltip("На сколько px указатель левее левого края лейбла (startx)")] [SerializeField] float pointerOffsetX = 35f; Coroutine chooseOption; [SerializeField] float optionsPadding = 20; // Переменные для прокрутки int scrollOffset = 0; // текущее смещение (индекс верхнего видимого элемента) float targetScrollY = 0f; // целевая позиция прокрутки float currentScrollY = 0f; // текущая позиция прокрутки (интерполируемая) // Вспомогательные переменные для скроллбара int maxScrollSteps = 0; // settingsAmount - maxVisibleSettings public void Open() { optionsLabels = new List(); settingsLabels = new List(); progressBars = new Dictionary(); progressTexts = new Dictionary(); switcherSliders = new Dictionary(); PointerRect.gameObject.SetActive(true); currentState = substate.SettingChoosing; currentSettingIndex = 0; // обнуляем прокрутку scrollOffset = 0; targetScrollY = 0f; currentScrollY = 0f; // Авто-расчёт шага и начального Y из реального размера контейнера if (autoLayout && OptionsRect != null) { // Canvas.ForceUpdateCanvases() гарантирует актуальный rect.height Canvas.ForceUpdateCanvases(); float h = OptionsRect.rect.height; if (h > 1f) { float step = h / maxVisibleSettings; offsety = step; starty = -(step * 0.5f); // первый элемент по центру первого слота } } CreateSettingsLabels(); StatisticRect.Initialize(); // Подписка на скроллбар if (Scrollbar != null) { // вычислим параметры maxScrollSteps = Mathf.Max(0, settingsAmount - maxVisibleSettings); Scrollbar.gameObject.SetActive(maxScrollSteps > 0); if (maxScrollSteps > 0) { // размер «видимого окна» на скроллбаре Scrollbar.size = (float)maxVisibleSettings / (float)settingsAmount; float value = (maxScrollSteps > 0) ? 1f - (float)scrollOffset / (float)maxScrollSteps : 1f; Scrollbar.SetValueWithoutNotify(value); Scrollbar.onValueChanged.AddListener(OnScrollbarChanged); } else { Scrollbar.onValueChanged.RemoveListener(OnScrollbarChanged); } } } public void Close() { // отписываемся от событий скроллбара if (Scrollbar != null) { Scrollbar.onValueChanged.RemoveListener(OnScrollbarChanged); } ClearOptionsPanel(); ClearSettingsPanel(); if (chooseOption != null) StopCoroutine(chooseOption); } /// True, когда открыта нижняя панель выбора опции (второй шаг Accept) public bool IsChoosingOption => currentState == substate.OptionChoosing; /// Отменяет выбор опции без сохранения — возвращает в режим навигации по настройкам public void CancelOption() { if (currentState != substate.OptionChoosing) return; if (chooseOption != null) StopCoroutine(chooseOption); ClearOptionsPanel(); PointerRect.gameObject.SetActive(true); currentState = substate.SettingChoosing; } public void Switch(int direction) { switch (currentState) { case substate.SettingChoosing: currentSettingIndex = (currentSettingIndex + settingsAmount + direction) % settingsAmount; UpdateScrollPosition(); // не устанавливаем pointer напрямую на целевой, пусть он позиционируется относительно currentScrollY UpdatePointerPosition(); break; case substate.OptionChoosing: currentOptionIndex = (currentOptionIndex + chosenSettingOptionsAmount + direction) % chosenSettingOptionsAmount;//ТУТ НАДО КОЛИЧЕСТВО ОПЦИЙ НАСТРОЙКИ ПОЛУЧИТЬ break; } //SoundManager.instance.playSwitch(); } //менять состояние, сохранять данные public void Accept() { if (currentState == substate.SettingChoosing) { createOptionsLabels(gameSettings[currentSettingIndex]); currentState = substate.OptionChoosing; PointerRect.gameObject.SetActive(false); if (chooseOption != null) StopCoroutine(chooseOption); chooseOption = StartCoroutine(DisplayChosenOption()); } else if (currentState == substate.OptionChoosing) { SettingsManager.instance.ChangeSetting(settingName: gameSettings[currentSettingIndex].settingName, newIndex: currentOptionIndex); PointerRect.gameObject.SetActive(true); ClearOptionsPanel(); //Обновляем для учета изменений ClearSettingsPanel(); CreateSettingsLabels(); if (chooseOption != null) StopCoroutine(chooseOption); currentState = substate.SettingChoosing; } //SoundManager.instance.playChoose(); } public void AcceptView() { StatisticRect.View(); } /// /// Можно ли открыть панель или выполнить экшн для текущей настройки /// public bool CanOpenPanel() { if (currentState != substate.SettingChoosing) return false; if (currentSettingIndex < 0 || currentSettingIndex >= gameSettings.Count) return false; SettingType type = gameSettings[currentSettingIndex].type; return type == SettingType.Panel || type == SettingType.Action; } /// /// Возвращает ID панели для текущей выбранной настройки (-1 если не панель) /// public int GetCurrentPanelId() { if (currentState != substate.SettingChoosing) return -1; if (currentSettingIndex < 0 || currentSettingIndex >= gameSettings.Count) return -1; return gameSettings[currentSettingIndex].panelId; } /// /// Возвращает тип текущей выбранной настройки /// public SettingType GetCurrentSettingType() { if (currentState != substate.SettingChoosing) return SettingType.Normal; if (currentSettingIndex < 0 || currentSettingIndex >= gameSettings.Count) return SettingType.Normal; return gameSettings[currentSettingIndex].type; } /// /// Обновляет заполнение прогресс-бара для текущей настройки (если она типа Panel/Action) /// public void UpdateProgressBar(float fillAmount) { fillAmount = Mathf.Clamp01(fillAmount); if (progressBars.TryGetValue(currentSettingIndex, out Image progressBar)) { progressBar.fillAmount = fillAmount; } if (progressTexts.TryGetValue(currentSettingIndex, out TextMeshProUGUI progressText)) { // Показываем текст только если прогресс-бар заполняется if (fillAmount > 0f) { progressText.gameObject.SetActive(true); int percentage = Mathf.RoundToInt(fillAmount * 100f); progressText.text = $"{percentage}%"; } else { progressText.gameObject.SetActive(false); } } } //Шерстим все существующие настройки //Создаем для каждой настройки текстовое поле private void CreateSettingsLabels() { // ...existing code... settingsLabels = new List(); for (int i = 0; i < SettingsManager.instance.gameSettings.settings.Count; i++) { createLabelFromSettingOption(SettingsManager.instance.gameSettings.settings[i], i); } // Обновляем прокрутку и позиции после создания UpdateScrollPosition(); currentScrollY = targetScrollY; UpdateSettingsPositions(); //Процедура которая собственно создает лейблы void createLabelFromSettingOption(SettingOption option, int index) { GameObject label = Instantiate(SettingPrefab, OptionsRect); label.name = option.settingName + " Setting"; settingsLabels.Add(label); RectTransform rt = label.GetComponent(); // Добавляем CanvasGroup для управления видимостью CanvasGroup canvasGroup = rt.GetComponent(); if (canvasGroup == null) canvasGroup = rt.gameObject.AddComponent(); // Изначальная позиция будет обновлена в UpdateSettingsPositions() rt.anchoredPosition = new Vector2(startx + offsetx * index, starty - offsety * index); //Загоняем название настройки TextMeshProUGUI text = rt.gameObject.GetComponent(); text.text = option.settingName; // реальная ширина лейбла (sizeDelta некорректен при стретч-якорях) float labelW = rt.rect.width > 1f ? rt.rect.width : rt.sizeDelta.x; // Для Panel/Action создаём прогресс-бар, для Svitcher — слайдер, для Normal — текст опц if (option.type == SettingType.Panel || option.type == SettingType.Action) { if (ProgressBarPrefab != null) { GameObject progressBarObj = Instantiate(ProgressBarPrefab, rt); progressBarObj.name = option.settingName + " ProgressBar"; RectTransform progressRT = progressBarObj.GetComponent(); progressRT.anchoredPosition = new Vector2(labelW + valueOffsetX, 0); Image progressImage = progressBarObj.GetComponentInChildren(); if (progressImage != null) { progressImage.fillAmount = 0f; progressBars[index] = progressImage; } // Находим TextMeshProUGUI для отображения процентов TextMeshProUGUI progressText = progressBarObj.GetComponentInChildren(); if (progressText != null) { progressText.text = "0%"; progressText.gameObject.SetActive(false); // скрываем до начала заполнения progressTexts[index] = progressText; } } } else if (option.type == SettingType.Svitcher) { // Создаём слайдер для переключателя if (SwitcherSliderPrefab != null) { GameObject sliderObj = Instantiate(SwitcherSliderPrefab, rt); sliderObj.name = option.settingName + " Slider"; RectTransform sliderRT = sliderObj.GetComponent(); sliderRT.anchoredPosition = new Vector2(labelW + valueOffsetX, 0); Slider slider = sliderObj.GetComponent(); if (slider != null) { // Устанавливаем начальное значение: инвертируем для корректного отображения // 0 (выкл визуально) = индекс 1 ("Выкл"), 1 (вкл визуально) = индекс 0 ("Вкл") slider.minValue = 0; slider.maxValue = 1; // ВАЖНО: используем SetValueWithoutNotify, чтобы не вызывать событие при инициализации int initialValue = (option.chosenIndex == 0) ? 1 : 0; slider.SetValueWithoutNotify(initialValue); // Сохраняем ссылку на слайдер switcherSliders[index] = slider; // НЕ подписываемся на onValueChanged - сохранение происходит через callback в ToggleSwitcher } } } else { // Обычная настройка — показываем текущую опцию GameObject label2 = Instantiate(OptionPrefab, rt); label2.name = option.settingName + " Value"; RectTransform option_rt = label2.GetComponent(); option_rt.anchoredPosition = new Vector2(labelW + valueOffsetX, 0); text = option_rt.gameObject.GetComponent(); text.text = option.ChosenOption; } } } // Вычисляем и задаём смещение прокрутки и целевую позицию private void UpdateScrollPosition() { if (settingsAmount <= maxVisibleSettings) { scrollOffset = 0; targetScrollY = 0f; if (Scrollbar != null) Scrollbar.gameObject.SetActive(false); return; } if (currentSettingIndex >= scrollOffset + maxVisibleSettings) { scrollOffset = currentSettingIndex - maxVisibleSettings + 1; } else if (currentSettingIndex < scrollOffset) { scrollOffset = currentSettingIndex; } targetScrollY = scrollOffset * offsety; // обновим скроллбар без вызова слушателя if (Scrollbar != null && maxScrollSteps > 0) { float value = 1f - (float)scrollOffset / (float)maxScrollSteps; Scrollbar.SetValueWithoutNotify(value); } } // Обновляем позицию указателя: ставим его слева от текущего лейбла настройки private void UpdatePointerPosition() { if (settingsLabels == null || currentSettingIndex < 0 || currentSettingIndex >= settingsLabels.Count) return; RectTransform labelRT = settingsLabels[currentSettingIndex].GetComponent(); if (labelRT == null) return; RectTransform pointerParent = PointerRect.parent as RectTransform; if (pointerParent == null) return; // Берём мировые координаты лейбла и конвертируем в локальное пространство родителя указателя // Это работает вне зависимости от того, в каком контейнере находится PointerRect Vector2 localPos = pointerParent.InverseTransformPoint(labelRT.position); // Ставим указатель левее лейбла на pointerOffsetX пикселей PointerRect.anchoredPosition = new Vector2(localPos.x - pointerOffsetX, localPos.y); } private void UpdateSettingsPositions() { if (settingsLabels == null) return; // Плавающее смещение в строках float scrollFloat = (offsety != 0) ? currentScrollY / offsety : 0f; for (int i = 0; i < settingsLabels.Count; i++) { RectTransform rt = settingsLabels[i].GetComponent(); // Вычисляем видимую позицию с учётом плавающего смещения float visiblePosition = i - scrollFloat; float targetY = starty - visiblePosition * offsety; rt.anchoredPosition = new Vector2(startx + offsetx * i, targetY); // Можно также добавить прозрачность для невидимых элементов CanvasGroup canvasGroup = rt.GetComponent(); if (canvasGroup == null) canvasGroup = rt.gameObject.AddComponent(); // Элементы вне видимой области делаем невидимыми if (i < scrollOffset || i >= scrollOffset + maxVisibleSettings) { canvasGroup.alpha = 0f; canvasGroup.interactable = false; canvasGroup.blocksRaycasts = false; } else { canvasGroup.alpha = 1f; canvasGroup.interactable = true; canvasGroup.blocksRaycasts = true; } } // Обновляем позицию указателя UpdatePointerPosition(); } // обрабтчик изменения значения Scrollbar (0..1). Значение 1 = верх (scrollOffset=0), 0 = низ (максимальное смещение) private void OnScrollbarChanged(float value) { if (settingsAmount <= maxVisibleSettings) return; maxScrollSteps = Mathf.Max(0, settingsAmount - maxVisibleSettings); if (maxScrollSteps == 0) return; // инвертируем значение: 1 -> 0, 0 -> maxScrollSteps float t = 1f - value; float desiredOffsetF = t * maxScrollSteps; // Применяем дробную цель к targetScrollY (плавнее) targetScrollY = desiredOffsetF * offsety; // и обновляем целевой scrollOffset целым числом для маски видимости scrollOffset = Mathf.Clamp(Mathf.RoundToInt(desiredOffsetF), 0, maxScrollSteps); } void Update() { // Плавная прокрутка if (currentState == substate.SettingChoosing && Mathf.Abs(currentScrollY - targetScrollY) > 0.1f) { currentScrollY = Mathf.Lerp(currentScrollY, targetScrollY, Time.deltaTime * scrollSpeed); UpdateSettingsPositions(); // при программной прокрутке обновляем скроллбар, чтобы отражать текущее положение if (Scrollbar != null && maxScrollSteps > 0) { float scrollFloat = currentScrollY / offsety; // может быть дробным float value = 1f - (scrollFloat / (float)maxScrollSteps); // защита от NaN/Inf if (float.IsNaN(value) || float.IsInfinity(value)) value = 1f; Scrollbar.SetValueWithoutNotify(Mathf.Clamp01(value)); } } else if (currentScrollY != targetScrollY) { currentScrollY = targetScrollY; UpdateSettingsPositions(); } } // Опции настроек void createOptionsLabels(SettingOption option) { if (optionsLabels == null) optionsLabels = new List(); currentOptionIndex = 0; // Убеждаемся что ValuesRect имеет актуальные размеры Canvas.ForceUpdateCanvases(); // rect.rect.width/height — реальный размер (sizeDelta неправильный при стретч-якорях) float containerW = ValuesRect.rect.width > 1f ? ValuesRect.rect.width : Mathf.Abs(ValuesRect.sizeDelta.x); float containerH = ValuesRect.rect.height > 1f ? ValuesRect.rect.height : Mathf.Abs(ValuesRect.sizeDelta.y); float labelWidth = (containerW - optionsPadding * 2) / option.options.Count; float labelHeight = containerH - optionsPadding * 2; foreach (string optionName in option.options) { GameObject label = Instantiate(OptionPrefab, ValuesRect); optionsLabels.Add(label); RectTransform rt = label.GetComponent(); // Явно фиксируем якорь в левый верхний угол контейнера. // Без этого Unity использует якорь из префаба (обычно center = 0.5,0.5), // из-за чего X=0 оказывается в середине панели, а не у левого края. rt.anchorMin = new Vector2(0f, 1f); rt.anchorMax = new Vector2(0f, 1f); rt.pivot = new Vector2(0f, 1f); rt.sizeDelta = new Vector2(labelWidth, labelHeight); // X: отступ + шаг * индекс // Y: отрицательный — в Unity UI движение вниз от верхнего края = отрицательный Y rt.anchoredPosition = new Vector2( optionsPadding + labelWidth * currentOptionIndex, -optionsPadding ); TextMeshProUGUI text = label.GetComponent(); text.text = optionName; text.alignment = TextAlignmentOptions.Center; text.color = Color.gray; currentOptionIndex++; } currentOptionIndex = option.chosenIndex; } void ClearSettingsPanel() { if (settingsLabels == null) return; foreach (GameObject label in settingsLabels) { if (label != null) Object.Destroy(label); } settingsLabels.Clear(); } void ClearOptionsPanel() { if (optionsLabels == null) return; foreach (GameObject label in optionsLabels) { if (label != null) Object.Destroy(label); } optionsLabels.Clear(); } IEnumerator DisplayChosenOption() { Color normalColor = Color.gray; Color highlightedColor = Color.white; int normalSize = 26; int chosenSize = 40; List options = new List(); if (optionsLabels != null) { foreach (GameObject label in optionsLabels) { options.Add(label.GetComponent()); } } while (true) { for (int i = 0; i < options.Count; i++) { if (i == currentOptionIndex) { options[i].color = Vector4.MoveTowards(options[i].color, highlightedColor, Time.deltaTime * 5); options[i].fontSize = Mathf.MoveTowards(options[i].fontSize, chosenSize, Time.deltaTime * 140); } else { options[i].color = Vector4.MoveTowards(options[i].color, normalColor, Time.deltaTime * 5); options[i].fontSize = Mathf.MoveTowards(options[i].fontSize, normalSize, Time.deltaTime * 140); } } yield return null; } } /// /// Переключает слайдер текущей выбранной настройки (для типа Svitcher) /// Вызывается из SettingsInput при нажатии кнопки /// public void ToggleSwitcher() { if (currentState != substate.SettingChoosing || !switcherSliders.TryGetValue(currentSettingIndex, out Slider slider)) return; // ВАЖНО: Округляем текущее значение до 0 или 1, чтобы избежать промежуточных значений при быстрых нажатиях int currentValue = Mathf.RoundToInt(slider.value); float newValue = currentValue == 0 ? 1f : 0f; string settingName = gameSettings[currentSettingIndex].settingName; // Сохраняем сразу — не ждём конца анимации. // ВАЖНО: slider.value инвертирован относительно chosenIndex (см. инициализацию: chosenIndex==0 → slider=1) // newValue==1 → визуально "вкл" → chosenIndex=0; newValue==0 → визуально "выкл" → chosenIndex=1 int newChosenIndex = Mathf.RoundToInt(newValue) == 1 ? 0 : 1; SettingsManager.instance.ChangeSetting(settingName, newChosenIndex); // Анимируем только визуально, без callback сохранения SmoothSlider smoothSlider = slider.GetComponent(); if (smoothSlider != null) smoothSlider.AnimateTo(newValue, null); else slider.value = newValue; } /// /// Напрямую меняет опцию Normal-настройки на +1/-1 и сохраняет изменение. /// Используется при управлении Left/Right в режиме SettingChoosing. /// public void SwitchOption(int direction) { if (currentState != substate.SettingChoosing) return; if (currentSettingIndex < 0 || currentSettingIndex >= gameSettings.Count) return; SettingOption option = gameSettings[currentSettingIndex]; if (option.type != SettingType.Normal) return; if (option.options == null || option.options.Count == 0) return; int newIndex = (option.chosenIndex + option.options.Count + direction) % option.options.Count; SettingsManager.instance.ChangeSetting(option.settingName, newIndex); // Перестраиваем лейблы чтобы значение обновилось на экране ClearSettingsPanel(); CreateSettingsLabels(); } enum substate { SettingChoosing, OptionChoosing } }