Danro/Assets/Scripts/UI/FX/UISelectableFocusGlow.cs
Danil Iarosh e8000ffdca
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
feat(vfx, ui, com): add custom vfx, polish ui and update backlight protocol
- Интегрирована система кастомных визуальных эффектов (VFX):
  - Добавлена новая утилита MvpVFXUtility и настройки MvpVFXSpawnSettings для спавна эффектов.
  - Реализован пул эффектов VFXPool с поддержкой отслеживания версий спавна, сброса оверрайдов и анимации масштаба по времени.
  - Добавлены эффекты прыжка и приземления в PlayerAnimationEvents.
  - Интегрирован эффект DeathVignetteController для плавного затемнения экрана при смерти игрока.
  - Добавлены частицы stompImpactVFX при уничтожении врагов прыжком сверху (EnemyController).
  - Реализован эффект конфетти finishVFX при успешном завершении уровня (LevelFinish).
  - Интегрированы эффекты искр при сборе монеток, кристаллов и других собираемых предметов (Collectible, EnvironmentalCollectibleBase).
- Улучшен и визуально доработан пользовательский интерфейс (UI):
  - Добавлен скрипт QRCodeTierHighlight для цветовой подсветки QR-кодов в зависимости от тира приза.
  - Реализована пульсация блока приза в баннере победы (WinBannerController).
  - Обновлено обозначение валюты в BalanceDisplay с "₽" на "руб.".
  - Добавлены новые префабы, сцены, ассеты тайлсетов и шейдеров для настройки визуального стиля.
- Обновлён протокол и инициализация подсветки COM-платы:
  - Изменен префикс команд подсветки с 85/86 на 8550, а диапазон эффектов обновлен с 40...52 на 10...22 (LightController, LightMode, LightCycleDurations).
  - Исправлена хардкод-команда сброса подсветки при инициализации в ComDeviceClient с устаревшей 8600 на новую 8550 для предотвращения рассинхронизации буфера обмена платы.
2026-07-07 16:52:05 +03:00

326 lines
11 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.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace RR.Common.UI.FX
{
/// <summary>
/// Мягкое свечение для кнопок при фокусе с клавиатуры/джойстика или наведении мышью.
/// </summary>
[DisallowMultipleComponent]
[AddComponentMenu("RR/UI/FX/UI Selectable Focus Glow")]
public sealed class UISelectableFocusGlow : MonoBehaviour, ISelectHandler, IDeselectHandler, IPointerEnterHandler, IPointerExitHandler
{
private const int TextureSize = 96;
private static Sprite _glowSprite;
[SerializeField] private Color glowColor = new(1f, 0.92f, 0.38f, 1f);
[SerializeField] private bool useButtonColor = true;
[SerializeField, Range(0f, 1f)] private float colorLighten = 0.35f;
[SerializeField, Range(0f, 1f)] private float maxAlpha = 0.55f;
[SerializeField, Min(0f)] private float outlineDistance = 3f;
[SerializeField] private bool useTargetSpriteShape = true;
[SerializeField, Range(4, 16)] private int outlineSamples = 8;
[SerializeField, Range(1, 4)] private int blurLayers = 2;
[SerializeField, Min(0f)] private float blurSpread = 1.5f;
[SerializeField, Min(0.01f)] private float fadeSpeed = 12f;
[SerializeField, Min(0f)] private float pulseScale = 0.025f;
[SerializeField, Min(0.01f)] private float pulseDuration = 1.1f;
private Selectable _selectable;
private Image _targetImage;
private Image _glowImage;
private RectTransform _glowRect;
private GameObject _glowObject;
private readonly List<Image> _glowLayers = new();
private bool _isPointerInside;
private float _currentAlpha;
private void Awake()
{
ResolveReferences();
EnsureGlowView();
SetGlowAlpha(0f);
}
private void OnEnable()
{
ResolveReferences();
EnsureGlowView();
SyncGlowRect(1f);
}
private void OnDisable()
{
_isPointerInside = false;
SetGlowAlpha(0f);
}
private void Update()
{
EnsureGlowView();
float targetAlpha = IsFocusedNow() ? maxAlpha : 0f;
_currentAlpha = Mathf.MoveTowards(_currentAlpha, targetAlpha, fadeSpeed * Time.unscaledDeltaTime);
SetGlowAlpha(_currentAlpha);
if (_glowRect != null)
{
float wave = (Mathf.Sin(Time.unscaledTime / pulseDuration * Mathf.PI * 2f) + 1f) * 0.5f;
float scale = 1f + wave * pulseScale * Mathf.Clamp01(_currentAlpha / Mathf.Max(0.001f, maxAlpha));
SyncGlowRect(scale);
}
}
public void OnSelect(BaseEventData eventData)
{
_currentAlpha = Mathf.Max(_currentAlpha, maxAlpha * 0.5f);
}
public void OnDeselect(BaseEventData eventData)
{
}
public void OnPointerEnter(PointerEventData eventData)
{
_isPointerInside = true;
_currentAlpha = Mathf.Max(_currentAlpha, maxAlpha * 0.5f);
}
public void OnPointerExit(PointerEventData eventData)
{
_isPointerInside = false;
}
private void ResolveReferences()
{
if (_selectable == null)
{
_selectable = GetComponent<Selectable>();
}
if (_targetImage == null)
{
_targetImage = GetComponent<Image>();
}
}
private void EnsureGlowView()
{
if (_glowImage != null)
{
return;
}
Transform glowParent = transform.parent != null ? transform.parent : transform;
_glowObject = new GameObject($"{name} Focus Glow", typeof(RectTransform), typeof(LayoutElement));
_glowObject.transform.SetParent(glowParent, false);
_glowObject.GetComponent<LayoutElement>().ignoreLayout = true;
_glowRect = _glowObject.GetComponent<RectTransform>();
_glowImage = null;
_glowObject.transform.SetSiblingIndex(transform.GetSiblingIndex());
SyncGlowRect(1f);
RebuildGlowLayers();
}
private void OnDestroy()
{
if (_glowObject != null)
{
Destroy(_glowObject);
}
}
private void SyncGlowRect(float scale)
{
if (_glowRect == null)
{
return;
}
RectTransform targetRect = transform as RectTransform;
if (targetRect == null)
{
return;
}
_glowRect.anchorMin = targetRect.anchorMin;
_glowRect.anchorMax = targetRect.anchorMax;
_glowRect.pivot = targetRect.pivot;
_glowRect.anchoredPosition = targetRect.anchoredPosition;
_glowRect.sizeDelta = targetRect.sizeDelta;
_glowRect.localRotation = targetRect.localRotation;
_glowRect.localScale = targetRect.localScale * scale;
if (_glowRect.parent == targetRect.parent)
{
_glowRect.SetSiblingIndex(Mathf.Max(0, targetRect.GetSiblingIndex() - 1));
}
}
private void RebuildGlowLayers()
{
_glowLayers.Clear();
int samples = Mathf.Max(4, outlineSamples);
int layerIndex = 0;
for (int blur = 0; blur < Mathf.Max(1, blurLayers); blur++)
{
float distance = outlineDistance + blur * blurSpread;
for (int sample = 0; sample < samples; sample++)
{
string layerName = $"Focus Outline {layerIndex + 1}";
Transform existingLayer = _glowRect.Find(layerName);
GameObject layerObject = existingLayer != null
? existingLayer.gameObject
: new GameObject(layerName, typeof(RectTransform), typeof(Image));
layerObject.transform.SetParent(_glowRect, false);
layerObject.transform.SetSiblingIndex(layerIndex);
float angle = sample / (float)samples * Mathf.PI * 2f;
Vector2 offset = new(Mathf.Cos(angle) * distance, Mathf.Sin(angle) * distance);
RectTransform layerRect = layerObject.GetComponent<RectTransform>();
layerRect.anchorMin = Vector2.zero;
layerRect.anchorMax = Vector2.one;
layerRect.offsetMin = offset;
layerRect.offsetMax = offset;
Image layerImage = layerObject.GetComponent<Image>();
ApplyGlowSprite(layerImage);
layerImage.raycastTarget = false;
_glowLayers.Add(layerImage);
if (layerIndex == 0)
{
_glowImage = layerImage;
}
layerIndex++;
}
}
}
private void ApplyGlowSprite(Image image)
{
if (image == null)
{
return;
}
if (_targetImage != null && useTargetSpriteShape && _targetImage.sprite != null)
{
image.sprite = _targetImage.sprite;
image.type = _targetImage.type;
image.preserveAspect = _targetImage.preserveAspect;
image.fillCenter = true;
image.fillMethod = _targetImage.fillMethod;
image.fillOrigin = _targetImage.fillOrigin;
image.fillAmount = _targetImage.fillAmount;
image.fillClockwise = _targetImage.fillClockwise;
image.pixelsPerUnitMultiplier = _targetImage.pixelsPerUnitMultiplier;
return;
}
image.sprite = GetGlowSprite();
image.type = Image.Type.Sliced;
image.preserveAspect = false;
}
private bool IsFocusedNow()
{
if (!isActiveAndEnabled || _selectable == null || !_selectable.IsActive() || !_selectable.IsInteractable())
{
return false;
}
bool isSelected = EventSystem.current != null
&& EventSystem.current.currentSelectedGameObject == gameObject;
return isSelected || _isPointerInside;
}
private void SetGlowAlpha(float alpha)
{
if (_glowLayers.Count == 0)
{
return;
}
for (int i = 0; i < _glowLayers.Count; i++)
{
Image layer = _glowLayers[i];
if (layer == null)
{
continue;
}
int samples = Mathf.Max(4, outlineSamples);
int blurIndex = i / samples;
float layerT = Mathf.Max(1, blurLayers) <= 1 ? 0f : blurIndex / (float)(Mathf.Max(1, blurLayers) - 1);
Color color = ResolveGlowColor();
color.a = alpha * Mathf.Lerp(0.36f, 0.12f, layerT);
layer.color = color;
layer.enabled = alpha > 0.001f;
}
}
private Color ResolveGlowColor()
{
if (!useButtonColor || _targetImage == null)
{
return glowColor;
}
Color color = Color.Lerp(_targetImage.color, Color.white, colorLighten);
color.a = 1f;
return color;
}
private static Sprite GetGlowSprite()
{
if (_glowSprite != null)
{
return _glowSprite;
}
Texture2D texture = new(TextureSize, TextureSize, TextureFormat.RGBA32, false)
{
name = "Runtime UI Focus Glow",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
Vector2 center = new(0.5f, 0.5f);
for (int y = 0; y < TextureSize; y++)
{
for (int x = 0; x < TextureSize; x++)
{
Vector2 uv = new(x / (TextureSize - 1f), y / (TextureSize - 1f));
float edgeDistance = Mathf.Max(Mathf.Abs(uv.x - center.x), Mathf.Abs(uv.y - center.y));
float alpha = Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(0.25f, 0.5f, edgeDistance));
alpha *= 1f - Mathf.SmoothStep(0.48f, 0.5f, edgeDistance);
texture.SetPixel(x, y, new Color(1f, 1f, 1f, alpha));
}
}
texture.Apply();
_glowSprite = Sprite.Create(
texture,
new Rect(0f, 0f, TextureSize, TextureSize),
new Vector2(0.5f, 0.5f),
100f,
0,
SpriteMeshType.FullRect,
new Vector4(24f, 24f, 24f, 24f));
return _glowSprite;
}
}
}