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
337 lines
11 KiB
C#
337 lines
11 KiB
C#
using UnityEngine;
|
||
using UnityEngine.EventSystems;
|
||
|
||
namespace RR.Common.UI
|
||
{
|
||
/// <summary>
|
||
/// Указатель-стрелка, который плавно перемещается к произвольному UI-элементу.
|
||
/// Управляется извне через <see cref="SetTarget(RectTransform)"/> или <see cref="SetTarget(int)"/>.
|
||
/// </summary>
|
||
public class UIRowPointer : MonoBehaviour
|
||
{
|
||
[Header("Указатель")]
|
||
[Tooltip("RectTransform указателя (стрелка, иконка и т.д.).")]
|
||
[SerializeField] private RectTransform pointer;
|
||
|
||
[Header("Цели (опционально)")]
|
||
[Tooltip("Фиксированные цели для SetTarget(int). Можно не заполнять, если используется SetTarget(RectTransform).")]
|
||
[SerializeField] private RectTransform[] targets;
|
||
|
||
[Header("Движение")]
|
||
[Tooltip("Скорость плавного перемещения. 0 — мгновенно.")]
|
||
[SerializeField] private float smoothSpeed = 12f;
|
||
|
||
[Tooltip("Смещение указателя относительно центра цели (в локальных координатах родителя указателя).")]
|
||
[SerializeField] private Vector2 offset;
|
||
|
||
[Header("Покачивание")]
|
||
[Tooltip("Включить покачивание указателя по горизонтали.")]
|
||
[SerializeField] private bool wobble = true;
|
||
|
||
[Tooltip("Амплитуда покачивания (пикселей).")]
|
||
[SerializeField] private float wobbleAmplitude = 4f;
|
||
|
||
[Tooltip("Скорость покачивания (циклов в секунду).")]
|
||
[SerializeField] private float wobbleSpeed = 2f;
|
||
|
||
[Header("Selection Tracking")]
|
||
[Tooltip("Задержка перед скрытием указателя, если выбранный элемент временно не найден.")]
|
||
[SerializeField] private float unmatchedHideDelay = 0.15f;
|
||
|
||
// ── Состояние ──
|
||
private RectTransform _currentTarget;
|
||
private Vector2 _targetPos;
|
||
private Vector2 _baseX;
|
||
private bool _ready;
|
||
private bool _followSelection;
|
||
private Transform _selectionScope;
|
||
private bool _hideWhenSelectionUnmatched;
|
||
private readonly System.Collections.Generic.List<SelectionBinding> _selectionBindings = new();
|
||
private float _unmatchedSelectionTime;
|
||
|
||
private sealed class SelectionBinding
|
||
{
|
||
public Transform SelectionRoot;
|
||
public RectTransform PointerTarget;
|
||
public bool HidePointer;
|
||
}
|
||
|
||
// ────────────── Публичный API ──────────────
|
||
|
||
/// <summary>
|
||
/// Перемещает указатель к произвольному <see cref="RectTransform"/>.
|
||
/// </summary>
|
||
public void SetTarget(RectTransform target)
|
||
{
|
||
if (target == null) return;
|
||
|
||
_currentTarget = target;
|
||
_targetPos = CalculatePosition(target);
|
||
|
||
if (!_ready)
|
||
{
|
||
if (pointer) pointer.anchoredPosition = _targetPos;
|
||
_ready = true;
|
||
}
|
||
|
||
ShowPointer();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Перемещает указатель к цели по индексу из массива <see cref="targets"/>.
|
||
/// </summary>
|
||
public void SetTarget(int index)
|
||
{
|
||
if (targets == null || index < 0 || index >= targets.Length) return;
|
||
SetTarget(targets[index]);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Скрывает указатель.
|
||
/// </summary>
|
||
public void Hide()
|
||
{
|
||
if (pointer) pointer.gameObject.SetActive(false);
|
||
_currentTarget = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Задаёт массив целей из кода (если не задан через инспектор).
|
||
/// </summary>
|
||
public void SetTargets(RectTransform[] newTargets)
|
||
{
|
||
targets = newTargets;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Переключает указатель в режим следования за выбранным UI-элементом.
|
||
/// </summary>
|
||
public void FollowSelection(Transform scope, bool hideWhenSelectionUnmatched = false)
|
||
{
|
||
_selectionScope = scope;
|
||
_hideWhenSelectionUnmatched = hideWhenSelectionUnmatched;
|
||
_followSelection = true;
|
||
_unmatchedSelectionTime = 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Отключает режим следования за выбранным UI-элементом.
|
||
/// </summary>
|
||
public void StopFollowingSelection()
|
||
{
|
||
_followSelection = false;
|
||
_selectionScope = null;
|
||
_hideWhenSelectionUnmatched = false;
|
||
_unmatchedSelectionTime = 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Очищает runtime-привязки выбранных элементов к строкам указателя.
|
||
/// </summary>
|
||
public void ClearSelectionBindings()
|
||
{
|
||
_selectionBindings.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Привязывает выбранный UI-элемент или его дочерние элементы к строке указателя.
|
||
/// </summary>
|
||
public void RegisterSelectionBinding(Transform selectionRoot, RectTransform pointerTarget)
|
||
{
|
||
if (selectionRoot == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_selectionBindings.Add(new SelectionBinding
|
||
{
|
||
SelectionRoot = selectionRoot,
|
||
PointerTarget = pointerTarget,
|
||
HidePointer = false
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Привязывает выбранный UI-элемент к скрытию указателя.
|
||
/// </summary>
|
||
public void RegisterHideBinding(Transform selectionRoot)
|
||
{
|
||
if (selectionRoot == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_selectionBindings.Add(new SelectionBinding
|
||
{
|
||
SelectionRoot = selectionRoot,
|
||
PointerTarget = null,
|
||
HidePointer = true
|
||
});
|
||
}
|
||
|
||
// ────────────── Lifecycle ──────────────
|
||
|
||
private void Awake()
|
||
{
|
||
if (pointer) _baseX = new Vector2(pointer.anchoredPosition.x, 0f);
|
||
}
|
||
|
||
private void LateUpdate()
|
||
{
|
||
UpdateSelectionTracking();
|
||
|
||
if (!pointer || _currentTarget == null) return;
|
||
|
||
// Пересчитываем позицию (цель может двигаться из-за layout)
|
||
_targetPos = CalculatePosition(_currentTarget);
|
||
|
||
// Плавное перемещение
|
||
Vector2 pos = smoothSpeed > 0
|
||
? Vector2.Lerp(pointer.anchoredPosition, _targetPos, Time.unscaledDeltaTime * smoothSpeed)
|
||
: _targetPos;
|
||
|
||
// Покачивание
|
||
if (wobble)
|
||
pos.x += Mathf.Sin(Time.unscaledTime * wobbleSpeed * Mathf.PI * 2f) * wobbleAmplitude;
|
||
|
||
pointer.anchoredPosition = pos;
|
||
}
|
||
|
||
// ────────────── Внутреннее ──────────────
|
||
|
||
private void UpdateSelectionTracking()
|
||
{
|
||
if (!_followSelection)
|
||
{
|
||
return;
|
||
}
|
||
|
||
GameObject selected = EventSystem.current != null ? EventSystem.current.currentSelectedGameObject : null;
|
||
if (selected == null)
|
||
{
|
||
HandleUnmatchedSelection();
|
||
return;
|
||
}
|
||
|
||
Transform selectedTransform = selected.transform;
|
||
if (_selectionScope != null && !selectedTransform.IsChildOf(_selectionScope))
|
||
{
|
||
HandleUnmatchedSelection();
|
||
return;
|
||
}
|
||
|
||
SelectionBinding binding = FindSelectionBinding(selectedTransform);
|
||
if (binding != null)
|
||
{
|
||
_unmatchedSelectionTime = 0f;
|
||
|
||
if (binding.HidePointer || binding.PointerTarget == null)
|
||
{
|
||
Hide();
|
||
return;
|
||
}
|
||
|
||
SetTarget(binding.PointerTarget);
|
||
return;
|
||
}
|
||
|
||
RectTransform trackedTarget = FindTrackedTarget(selectedTransform);
|
||
if (trackedTarget != null)
|
||
{
|
||
_unmatchedSelectionTime = 0f;
|
||
SetTarget(trackedTarget);
|
||
return;
|
||
}
|
||
|
||
HandleUnmatchedSelection();
|
||
}
|
||
|
||
private SelectionBinding FindSelectionBinding(Transform selectedTransform)
|
||
{
|
||
for (int i = 0; i < _selectionBindings.Count; i++)
|
||
{
|
||
SelectionBinding binding = _selectionBindings[i];
|
||
if (binding?.SelectionRoot == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (selectedTransform == binding.SelectionRoot || selectedTransform.IsChildOf(binding.SelectionRoot))
|
||
{
|
||
return binding;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private RectTransform FindTrackedTarget(Transform selectedTransform)
|
||
{
|
||
if (targets == null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
for (int i = 0; i < targets.Length; i++)
|
||
{
|
||
RectTransform target = targets[i];
|
||
if (target == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (selectedTransform == target || selectedTransform.IsChildOf(target))
|
||
{
|
||
return target;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private void HandleUnmatchedSelection()
|
||
{
|
||
if (_hideWhenSelectionUnmatched)
|
||
{
|
||
_unmatchedSelectionTime += Time.unscaledDeltaTime;
|
||
if (_unmatchedSelectionTime >= unmatchedHideDelay)
|
||
{
|
||
Hide();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
_unmatchedSelectionTime = 0f;
|
||
}
|
||
|
||
private Vector2 CalculatePosition(RectTransform target)
|
||
{
|
||
RectTransform parent = pointer.parent as RectTransform;
|
||
if (parent == null) return pointer.anchoredPosition;
|
||
|
||
Vector3[] corners = new Vector3[4];
|
||
target.GetWorldCorners(corners);
|
||
Vector3 worldCenter = (corners[0] + corners[2]) * 0.5f;
|
||
|
||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||
parent,
|
||
RectTransformUtility.WorldToScreenPoint(null, worldCenter),
|
||
null,
|
||
out Vector2 localPos
|
||
);
|
||
|
||
return new Vector2(
|
||
_baseX.x + offset.x,
|
||
localPos.y + offset.y
|
||
);
|
||
}
|
||
|
||
private void ShowPointer()
|
||
{
|
||
if (pointer && !pointer.gameObject.activeSelf)
|
||
pointer.gameObject.SetActive(true);
|
||
}
|
||
}
|
||
}
|