Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
- завершен спринт 24 марта - 2 апреля с опережением плана и закрытием задач раньше срока - расширена и стабилизирована система призов: добавлены и доработаны QR-tier конфиги, новые типы и модели данных, сохранение `qrCodes` в `prizes.json`, fallback-логика и синхронизация через `PrizeSystemManager`, `PrizeDatabase` и `LevelPrizeConfig` - переработан flow настройки QR-призов: добавлены `QRCodeSettingsModal` и связанный UI-код, разнесены короткий текст приза и полный текст сообщения, добавлены шаблоны форматирования, корректная подстановка текста в QR и в UI-модалках, а также сохранение конфигурации между запусками - доработан flow суперприза и текстовых настроек: добавлены fallback-правила показа, корректная обработка пустых строк и строк из одних пробелов, обновлены binders и показ текста в меню - реализована и обновлена отдельная настройка витрины: добавлена `PrizeStandSettingsModal`, обновлена навигация строк, логика выбора ячейки и порогов очков, циклический выбор ячеек, ускорение изменения значений и исправления для keyboard/gamepad UX - доработана игровая модалка витрины `PrizeStandModal`: добавлен вывод очков игрока, форматируемый текст счета, улучшено отображение ячеек и связанная логика выбора приза - исправлен подсчет очков и интеграция с prize flow: `ScoreSystem` переведен на событийное обновление текущего счета, добавлены `CurrentScore`, `CurrentScoreChanged` и корректный display score, витрина и связанные UI-элементы теперь получают актуальные очки, а не нулевые или несвоевременные preview-значения - переработаны модальные окна и навигация UI: улучшены `Modal`, `ModalManager`, `UIRowPointer`, `RulesModal`, `QRCodeModal`, `PrizeStandModal`, `LevelPrizeSettingsModal` и др., чтобы фокус, указатель и submit работали консистентно без мыши - обновлен win/prize UI: доработаны `WinBannerController`, `GameEndUIController`, `MainMenu`, `QRCodeUIController`, `TopPanelTextController` и настройки UI, улучшены тексты баннеров, отображение призов и итогового счета - обновлены сцены и ассеты под новый flow: `MainMenu`, `Level UI`, prefab'ы строк настроек и кнопок, глобальные префабы, шрифты TMP, иконки, recovery-данные и ресурсы UI - добавлены вспомогательные UI-компоненты и служебные скрипты: `ButtonTextFocusColor`, `SelectableDescriptionTarget`, `QRTier` и другие изменения, поддерживающие новый конфигурируемый prize UI
639 lines
19 KiB
C#
639 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
using UnityEngine.InputSystem;
|
|
using UnityEngine.UI;
|
|
using RR.Common.Managers;
|
|
using RR.Common.Prizes;
|
|
|
|
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;
|
|
|
|
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;
|
|
base.OnDisable();
|
|
}
|
|
|
|
protected override void OnReturnedToTop()
|
|
{
|
|
_axisHeldY = false;
|
|
ResetHorizontalHold();
|
|
_pendingFocusTarget = null;
|
|
_pendingFocusFrames = 0;
|
|
RefreshRowHighlights();
|
|
}
|
|
|
|
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;
|
|
|
|
RefreshUI();
|
|
RefreshRowHighlights();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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 = string.Empty;
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
}
|
|
}
|