Danro/Assets/Scripts/UI/Modal/PrizeStandSettingsModal.cs
Danil Iarosh cb2e83a596
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
feat(ui, prizes, spawners, editor): expand prize flow and add spawning/editor tooling
- доработан flow призовой витрины и связанного UI:
  добавлены `PrizeStandButtonView` и `RulesPrizeListPage`, обновлены
  `PrizeStandController`, `PrizeStandPrize`, `PrizeStandModal`,
  `PrizeStandSettingsModal`, `LevelPrizeSettingsModal`, `QRCodeSettingsModal`,
  `BalanceDisplay`, `ButtonTextFocusColor` и связанные prefab'ы,
  чтобы улучшить настройку, навигацию, отображение кнопок и prize flow
- расширена система настроек и сохранения значений:
  `OptionPicker` получил отдельный формат `ValueSelector`,
  обновлены `SettingsDatabase`, `SettingEntryDrawer`, `SettingUIItem`,
  `SettingsUIGenerator`, `ValueSelectorRow` и `MainSettingsDatabase.asset`,
  а сохранение option picker-значений, включая `gameplay.gameDifficulty`,
  исправлено и синхронизировано с остальными настройками
- добавлены универсальные спавнеры сущностей:
  реализованы `EntitySpawner` и `EntitySpawnerManager` с поддержкой
  весов префабов, точек спавна, заполнения по проценту, ограничений
  на количество и автоспавна при инициализации
- доработана логика игрового спавна и сложности:
  процент инициализационного спавна может зависеть от настройки
  `gameplay.gameDifficulty` с градациями 25/50/75/100,
  в одном цикле массового спавна повторно не используется та же точка,
  а gizmo спавнера теперь показывает только сами точки без линий
- упрощена работа в редакторе Unity и добавлены editor-инструменты:
  в `GameManager` лицензия пропускается при запуске в `UNITY_EDITOR`,
  а `HierarchySelectionCounter`, `HierarchySelectionCountOverlay`
  и `HierarchyBulkRenamerWindow` добавляют подсчет выделения,
  realtime overlay в `SceneView` и массовое переименование объектов
  по правилам нумерации из Unity Project Settings
- синхронизированы сцены, ресурсы и UI-ассеты под новый flow:
  обновлены `MainMenu`, `Level UI`, уровни `1-1` и `1-2`,
  строки настроек, кнопки, TMP/font assets, изображения UI,
  `prizes.json` и recovery-данные после правок интерфейса и логики
- очищены и переразложены часть внешних и служебных ресурсов:
  удалён пакет `Lava_Flowing_Shader`, переименованы/заменены
  отдельные UI-prefab'ы и добавлены сопутствующие проектные артефакты,
  которые сейчас используются обновлённой конфигурацией проекта
2026-04-28 14:05:05 +03:00

777 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using RR.Common.Controls;
using RR.Common.Managers;
using RR.Common.Prizes;
using ControlsBoxStatus = RR.Common.Controls.BoxStatus;
namespace RR.Common.UI
{
/// <summary>
/// Отдельная модалка настройки глобальной конфигурации витрины.
/// Позволяет менять выбранную ячейку и порог очков для нее.
/// </summary>
public class PrizeStandSettingsModal : Modal
{
[Header("UI")]
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text connectionStatusLabel;
[SerializeField] private TMP_Text boxCountLabel;
[SerializeField] private TMP_Text selectedBoxLabel;
[SerializeField] private TMP_Text thresholdLabel;
[SerializeField] private TMP_Text detailsText;
[Header("Rows")]
[SerializeField] private RectTransform selectedBoxRow;
[SerializeField] private RectTransform thresholdRow;
[Header("Pointer")]
[SerializeField] private UIRowPointer rowPointer;
[Header("Close")]
[SerializeField] private Button closeModalButton;
[Header("Navigation")]
[SerializeField] private InputActionReference navigateAction;
[SerializeField] private float deadZone = 0.5f;
[Header("Limits")]
[SerializeField] private int defaultBoxesCount = 5;
private const int RowSelectedBox = 0;
private const int RowThreshold = 1;
private const int RowClose = 2;
private const int RowCount = 3;
private const int ThresholdStep = 1;
private const int MaxStepMultiplier = 10;
private const float HoldResetTimeout = 0.25f;
private const float InitialHorizontalRepeatDelay = 0.25f;
private const float ThresholdAccelerationTime = 0.8f;
private const float MinimumHorizontalRepeatInterval = 0.03f;
private PrizeStandSettings _settings;
private int _selectedBoxIndex;
private int _activeRow;
private bool _axisHeldY;
private Selectable _pendingFocusTarget;
private int _pendingFocusFrames;
private Selectable _selectedBoxRowSelectable;
private Selectable _thresholdRowSelectable;
private PrizeStandRowMoveHandler _selectedBoxMoveHandler;
private PrizeStandRowMoveHandler _thresholdMoveHandler;
private Dictionary<int, ControlsBoxStatus> _boxStatuses = new();
private bool _statusesLoading;
private int _statusRequestVersion;
public event Action OnApplied;
protected override void OnEnable()
{
base.OnEnable();
if (navigateAction != null && navigateAction.action != null)
{
navigateAction.action.Enable();
}
}
protected override void OnDisable()
{
_axisHeldY = false;
ResetHorizontalHold();
_pendingFocusTarget = null;
_pendingFocusFrames = 0;
_statusRequestVersion++;
base.OnDisable();
}
protected override void OnReturnedToTop()
{
_axisHeldY = false;
ResetHorizontalHold();
_pendingFocusTarget = null;
_pendingFocusFrames = 0;
RefreshRowHighlights();
RequestStatusesRefresh();
}
public override void Open()
{
PrepareForEditing();
base.Open();
RefreshRowHighlights();
}
public void OpenForEditing()
{
Open();
}
private void PrepareForEditing()
{
PrizeSystemManager manager = PrizeSystemManager.Instance;
_settings = manager != null
? CopyPrizeStandSettings(manager.GetPrizeStandSettings())
: CreateDefaultSettings();
EnsureSettingsInitialized();
AutoResolveRows();
AutoResolveValueLabels();
EnsureRowSelectables();
_selectedBoxIndex = 0;
_activeRow = RowSelectedBox;
_boxStatuses.Clear();
_statusesLoading = false;
_statusRequestVersion = 0;
RefreshUI();
RefreshRowHighlights();
RequestStatusesRefresh();
}
public override void Close()
{
Save();
base.Close();
}
private void Update()
{
if (!_isOpen)
{
return;
}
ProcessNavigationInput();
}
private void LateUpdate()
{
if (!_isOpen)
{
return;
}
ApplyPendingFocus();
}
private void ProcessNavigationInput()
{
if (navigateAction == null || navigateAction.action == null)
{
_axisHeldY = false;
return;
}
Vector2 value = navigateAction.action.ReadValue<Vector2>();
if (Mathf.Abs(value.y) >= deadZone)
{
if (!_axisHeldY)
{
_axisHeldY = true;
MoveRow(value.y > 0f ? -1 : 1);
}
}
else
{
_axisHeldY = false;
}
}
private void MoveRow(int delta)
{
_activeRow = (_activeRow + delta + RowCount) % RowCount;
ResetHorizontalHold();
RefreshRowHighlights();
}
private void AdjustSelectedBox(int direction)
{
EnsureSettingsInitialized();
int boxCount = _settings.boxes.Count;
if (boxCount == 0)
{
return;
}
_selectedBoxIndex = (_selectedBoxIndex + direction) % boxCount;
if (_selectedBoxIndex < 0)
{
_selectedBoxIndex += boxCount;
}
RefreshUI();
RequestStatusesRefresh();
}
internal void HandleRowMove(int rowIndex, int direction, int multiplier)
{
if (!_isOpen)
{
return;
}
switch (rowIndex)
{
case RowSelectedBox:
AdjustSelectedBox(direction);
break;
case RowThreshold:
AdjustThreshold(direction, multiplier);
break;
}
}
private void AdjustThreshold(int direction, int multiplier)
{
PrizeStandBoxConfig box = GetSelectedBox();
if (box == null)
{
return;
}
box.requiredScore = Mathf.Max(0, box.requiredScore + direction * ThresholdStep * Mathf.Max(1, multiplier));
RefreshUI();
}
private void Save()
{
PrizeSystemManager manager = PrizeSystemManager.Instance;
if (manager == null)
{
Debug.LogError("[PrizeStandSettingsModal] PrizeSystemManager недоступен.");
return;
}
EnsureSettingsInitialized();
NormalizeBoxes();
manager.SetPrizeStandSettings(_settings);
OnApplied?.Invoke();
}
private void RefreshUI()
{
EnsureSettingsInitialized();
PrizeStandConnectionInfo connectionInfo = PrizeSystemManager.Instance != null
? PrizeSystemManager.Instance.GetPrizeStandConnectionInfo()
: new PrizeStandConnectionInfo(false, 0);
PrizeStandBoxConfig selectedBox = GetSelectedBox();
if (titleText != null)
{
titleText.text = "Настройки витрины";
}
if (connectionStatusLabel != null)
{
connectionStatusLabel.text = connectionInfo.IsConnected
? "Витрина подключена"
: "Витрина не подключена";
}
if (boxCountLabel != null)
{
boxCountLabel.text = _settings.boxes.Count.ToString();
}
if (selectedBoxLabel != null)
{
selectedBoxLabel.text = selectedBox != null ? selectedBox.boxNumber.ToString() : "-";
}
if (thresholdLabel != null)
{
thresholdLabel.text = selectedBox != null ? selectedBox.requiredScore.ToString() : "-";
}
if (detailsText != null)
detailsText.text = BuildDetailsText(connectionInfo, selectedBox);
}
private string BuildDetailsText(PrizeStandConnectionInfo connectionInfo, PrizeStandBoxConfig selectedBox)
{
if (!connectionInfo.IsConnected)
{
return "Витрина не подключена. Доступна только локальная настройка порогов.";
}
int readyCount = 0;
int readyWithCardCount = 0;
int emptyCount = 0;
int openedCount = 0;
int errorCount = 0;
int unknownCount = 0;
foreach (KeyValuePair<int, ControlsBoxStatus> entry in _boxStatuses)
{
switch (entry.Value)
{
case ControlsBoxStatus.OK:
readyCount++;
break;
case ControlsBoxStatus.OKplusCard:
readyWithCardCount++;
break;
case ControlsBoxStatus.Empty:
emptyCount++;
break;
case ControlsBoxStatus.Open:
case ControlsBoxStatus.OPENED:
openedCount++;
break;
case ControlsBoxStatus.OPEN_ERROR:
case ControlsBoxStatus.Error:
case ControlsBoxStatus.ParsingError:
errorCount++;
break;
default:
unknownCount++;
break;
}
}
string selectedStatusText = selectedBox != null
? $"Статус ячейки {selectedBox.boxNumber}: {GetStatusDescription(GetSelectedBoxStatus(selectedBox.boxNumber))}."
: "Ячейка не выбрана.";
string summaryText = _statusesLoading
? "Статусы витрины обновляются..."
: $"Готово: {readyCount}, готово с картой: {readyWithCardCount}, пусто: {emptyCount}, открыто: {openedCount}, ошибок: {errorCount}, неизвестно: {unknownCount}.";
return $"{selectedStatusText}\n{summaryText}";
}
private void RefreshRowHighlights()
{
Selectable selectableToFocus = null;
if (_activeRow == RowClose)
{
if (rowPointer != null) rowPointer.Hide();
selectableToFocus = closeModalButton;
}
else
{
RectTransform target = _activeRow switch
{
RowSelectedBox => selectedBoxRow,
RowThreshold => thresholdRow,
_ => selectedBoxRow
};
selectableToFocus = _activeRow switch
{
RowSelectedBox => _selectedBoxRowSelectable,
RowThreshold => _thresholdRowSelectable,
_ => _selectedBoxRowSelectable
};
if (rowPointer != null && target != null)
{
rowPointer.SetTarget(target);
}
}
FocusSelectable(selectableToFocus);
}
private void AutoResolveRows()
{
if (selectedBoxRow == null && selectedBoxLabel != null)
{
selectedBoxRow = selectedBoxLabel.transform.parent as RectTransform;
}
if (thresholdRow == null && thresholdLabel != null)
{
thresholdRow = thresholdLabel.transform.parent as RectTransform;
}
}
private void AutoResolveValueLabels()
{
selectedBoxLabel = ResolveValueLabel(selectedBoxRow, selectedBoxLabel);
thresholdLabel = ResolveValueLabel(thresholdRow, thresholdLabel);
}
private static TMP_Text ResolveValueLabel(RectTransform row, TMP_Text current)
{
if (row == null)
{
return current;
}
TMP_Text[] texts = row.GetComponentsInChildren<TMP_Text>(true);
TMP_Text best = null;
float bestScore = float.MaxValue;
for (int i = 0; i < texts.Length; i++)
{
TMP_Text text = texts[i];
if (text == null)
{
continue;
}
RectTransform textRect = text.rectTransform;
if (textRect == null || !textRect.IsChildOf(row))
{
continue;
}
Vector3 localCenter = row.InverseTransformPoint(textRect.TransformPoint(textRect.rect.center));
float score = Mathf.Abs(localCenter.x) + Mathf.Abs(localCenter.y) * 0.25f;
if (best == null || score < bestScore)
{
best = text;
bestScore = score;
}
}
if (best != null)
{
return best;
}
return current;
}
private void EnsureRowSelectables()
{
_selectedBoxRowSelectable = EnsureRowSelectable(selectedBoxRow);
_thresholdRowSelectable = EnsureRowSelectable(thresholdRow);
_selectedBoxMoveHandler = EnsureRowMoveHandler(selectedBoxRow, RowSelectedBox);
_thresholdMoveHandler = EnsureRowMoveHandler(thresholdRow, RowThreshold);
}
private Selectable EnsureRowSelectable(RectTransform row)
{
if (row == null)
{
return null;
}
Selectable selectable = row.GetComponent<Selectable>();
if (selectable == null)
{
selectable = row.gameObject.AddComponent<Selectable>();
selectable.transition = Selectable.Transition.None;
selectable.navigation = new Navigation { mode = Navigation.Mode.None };
}
selectable.interactable = true;
return selectable;
}
private PrizeStandRowMoveHandler EnsureRowMoveHandler(RectTransform row, int rowIndex)
{
if (row == null)
{
return null;
}
PrizeStandRowMoveHandler handler = row.GetComponent<PrizeStandRowMoveHandler>();
if (handler == null)
{
handler = row.gameObject.AddComponent<PrizeStandRowMoveHandler>();
}
handler.Initialize(this, rowIndex);
return handler;
}
private void FocusSelectable(Selectable selectable)
{
if (selectable == null || !selectable.gameObject.activeInHierarchy)
{
return;
}
SetFirstSelected(selectable);
_pendingFocusTarget = selectable;
_pendingFocusFrames = 2;
ApplyPendingFocus();
}
private void ApplyPendingFocus()
{
if (_pendingFocusTarget == null || _pendingFocusFrames <= 0)
{
return;
}
if (EventSystem.current == null || !_pendingFocusTarget.gameObject.activeInHierarchy)
{
_pendingFocusTarget = null;
_pendingFocusFrames = 0;
return;
}
if (EventSystem.current.currentSelectedGameObject != _pendingFocusTarget.gameObject)
{
EventSystem.current.SetSelectedGameObject(null);
EventSystem.current.SetSelectedGameObject(_pendingFocusTarget.gameObject);
_pendingFocusTarget.Select();
}
_pendingFocusFrames--;
if (_pendingFocusFrames <= 0)
{
_pendingFocusTarget = null;
}
}
private void EnsureSettingsInitialized()
{
_settings ??= CreateDefaultSettings();
_settings.boxes ??= new List<PrizeStandBoxConfig>();
if (_settings.boxes.Count == 0)
{
for (int i = 1; i <= defaultBoxesCount; i++)
{
_settings.boxes.Add(new PrizeStandBoxConfig { boxNumber = i, requiredScore = 0 });
}
}
NormalizeBoxes();
}
private void ResetHorizontalHold()
{
_selectedBoxMoveHandler?.ResetHold();
_thresholdMoveHandler?.ResetHold();
}
private ControlsBoxStatus GetSelectedBoxStatus(int boxNumber)
{
return _boxStatuses.TryGetValue(boxNumber, out ControlsBoxStatus status)
? status
: ControlsBoxStatus.NotFound;
}
private async void RequestStatusesRefresh()
{
PrizeStandController stand = ComSystemManager.GetPrizeStandController();
PrizeStandConnectionInfo connectionInfo = PrizeSystemManager.Instance != null
? PrizeSystemManager.Instance.GetPrizeStandConnectionInfo()
: new PrizeStandConnectionInfo(false, 0);
if (stand == null || !connectionInfo.IsConnected)
{
_boxStatuses.Clear();
_statusesLoading = false;
RefreshUI();
return;
}
int requestVersion = ++_statusRequestVersion;
_statusesLoading = true;
RefreshUI();
try
{
Dictionary<int, ControlsBoxStatus> statuses = await stand.GetStatusesAsync();
if (requestVersion != _statusRequestVersion || this == null || !gameObject)
{
return;
}
_boxStatuses = statuses ?? new Dictionary<int, ControlsBoxStatus>();
}
catch (Exception ex)
{
if (requestVersion != _statusRequestVersion || this == null || !gameObject)
{
return;
}
_boxStatuses.Clear();
Debug.LogWarning($"[PrizeStandSettingsModal] Не удалось обновить статусы витрины: {ex.Message}");
}
finally
{
if (requestVersion == _statusRequestVersion && this != null && gameObject)
{
_statusesLoading = false;
RefreshUI();
}
}
}
private static string GetStatusDescription(ControlsBoxStatus status)
{
return status switch
{
ControlsBoxStatus.OK => "готова к выдаче",
ControlsBoxStatus.OKplusCard => "готова к выдаче и работе по карте",
ControlsBoxStatus.Empty => "пустая",
ControlsBoxStatus.Open => "открыта",
ControlsBoxStatus.OPENED => "только что открывалась успешно",
ControlsBoxStatus.OPEN_ERROR => "ошибка открытия",
ControlsBoxStatus.Error => "ошибка ячейки",
ControlsBoxStatus.ParsingError => "ошибка чтения статуса",
ControlsBoxStatus.NotFound => "нет данных",
_ => status.ToString()
};
}
private void NormalizeBoxes()
{
_settings.boxes.Sort((a, b) => a.boxNumber.CompareTo(b.boxNumber));
for (int i = 0; i < _settings.boxes.Count; i++)
{
_settings.boxes[i] ??= new PrizeStandBoxConfig();
_settings.boxes[i].boxNumber = i + 1;
_settings.boxes[i].requiredScore = Mathf.Max(0, _settings.boxes[i].requiredScore);
}
}
private PrizeStandBoxConfig GetSelectedBox()
{
EnsureSettingsInitialized();
if (_settings.boxes.Count == 0)
{
return null;
}
_selectedBoxIndex = Mathf.Clamp(_selectedBoxIndex, 0, _settings.boxes.Count - 1);
return _settings.boxes[_selectedBoxIndex];
}
private PrizeStandSettings CreateDefaultSettings()
{
PrizeStandSettings settings = new PrizeStandSettings();
for (int i = 1; i <= defaultBoxesCount; i++)
{
settings.boxes.Add(new PrizeStandBoxConfig { boxNumber = i, requiredScore = 0 });
}
return settings;
}
private static PrizeStandSettings CopyPrizeStandSettings(PrizeStandSettings src)
{
PrizeStandSettings copy = new PrizeStandSettings();
if (src?.boxes == null)
{
return copy;
}
for (int i = 0; i < src.boxes.Count; i++)
{
PrizeStandBoxConfig box = src.boxes[i];
if (box == null)
{
continue;
}
copy.boxes.Add(new PrizeStandBoxConfig
{
boxNumber = box.boxNumber,
requiredScore = box.requiredScore
});
}
return copy;
}
}
[DisallowMultipleComponent]
public sealed class PrizeStandRowMoveHandler : MonoBehaviour, IMoveHandler, IDeselectHandler
{
private const int MaxStepMultiplier = 10;
private const int MultiplierStartCount = 6;
private const int MultiplierMaxCount = 30;
private const float HoldResetTimeout = 0.25f;
private PrizeStandSettingsModal _owner;
private int _rowIndex;
private int _holdDirection;
private int _holdCount;
private float _lastMoveTime;
public void Initialize(PrizeStandSettingsModal owner, int rowIndex)
{
_owner = owner;
_rowIndex = rowIndex;
ResetHold();
}
public void OnMove(AxisEventData eventData)
{
if (_owner == null)
{
return;
}
switch (eventData.moveDir)
{
case MoveDirection.Left:
ApplyMove(-1);
eventData.Use();
break;
case MoveDirection.Right:
ApplyMove(1);
eventData.Use();
break;
case MoveDirection.Up:
case MoveDirection.Down:
ResetHold();
break;
}
}
public void OnDeselect(BaseEventData eventData)
{
ResetHold();
}
private void OnDisable()
{
ResetHold();
}
public void ResetHold()
{
_holdDirection = 0;
_holdCount = 0;
_lastMoveTime = 0f;
}
private void ApplyMove(int direction)
{
float now = Time.unscaledTime;
if (_holdDirection != direction || (now - _lastMoveTime) > HoldResetTimeout)
{
_holdDirection = direction;
_holdCount = 0;
}
_lastMoveTime = now;
_holdCount++;
int multiplier = 1;
if (_holdCount > MultiplierStartCount)
{
float progress = Mathf.Clamp01(
(float)(_holdCount - MultiplierStartCount) /
Mathf.Max(1, MultiplierMaxCount - MultiplierStartCount));
multiplier = Mathf.Max(1, Mathf.RoundToInt(Mathf.Lerp(1f, MaxStepMultiplier, progress)));
}
_owner.HandleRowMove(_rowIndex, direction, multiplier);
}
}
}