using MyInfoMate.Vr.Ui;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
///
/// Un panneau du menu flottant : une image, un titre, et l'anneau de progression de
/// la visée — item E5 du lot XR-4.
///
/// L'anneau ne se remplit qu'en visée à la tête : la manette et la main ont une
/// gâchette et un pincement, donc rien à attendre (voir ).
///
/// Tout est construit par code, avec le même shader que
/// (Universal Render Pipeline/Unlit).
/// Ce n'est pas un détail de style : ce shader est déjà dans Always Included
/// Shaders, et tout shader supplémentaire embarqué coûte des milliers de
/// variantes et des dizaines de minutes de build (voir le README de l'overlay).
///
/// Les coins arrondis et le liseré ne viennent donc pas d'un shader, mais
/// d'une texture générée une fois et partagée par tous les panneaux : un rectangle
/// arrondi en alpha, avec un bord plus clair. C'est la seule façon d'avoir une
/// matière sans payer une deuxième compilation de variantes.
///
public class MenuItemPanel : MonoBehaviour, IAimTarget
{
public const float WidthMeters = 0.5f;
public const float HeightMeters = 0.34f;
static readonly Color Idle = new Color(0.10f, 0.13f, 0.17f, 0.92f);
static readonly Color Hover = new Color(0.20f, 0.42f, 0.60f, 0.98f);
/// Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.
const float HoverScale = 1.07f;
const float TransitionSpeed = 12f;
/// Assez court pour ne pas faire attendre, assez long pour se voir.
const float AppearSeconds = 0.35f;
///
/// Ce que le panneau désigne : un id de section dans le menu, une commande
/// (« suivant », « retour ») ailleurs. Le panneau ne sait pas ce que ça veut
/// dire, et c'est ce qui le rend réutilisable.
///
public string Id { get; private set; }
Material _background;
LineRenderer _progress;
Transform _image;
Vector3 _baseScale = Vector3.one;
bool _baseScaleKnown;
bool _hovered;
float _hover;
float _appear = 1f;
float _appearElapsed;
bool _appearing;
public static MenuItemPanel Create(string id, string title, Transform parent)
{
var root = new GameObject($"Menu — {title}");
root.transform.SetParent(parent, false);
var panel = root.AddComponent();
panel.Build(id, title);
return panel;
}
void Build(string id, string title)
{
Id = id;
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
background.name = "Background";
background.transform.SetParent(transform, false);
background.transform.localScale = new Vector3(WidthMeters, HeightMeters, 1f);
_background = background.GetComponent().material =
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_background.mainTexture = PanelTexture;
Transparent(_background);
_background.color = Idle;
// Double face. La normale d'un Quad et l'orientation lisible d'un texte ne
// pointent pas du même côté : avec le culling par défaut, l'un des deux
// disparaît, et on ne le découvre que dans le casque.
NoCulling(_background);
// Le collider est ce que la visée touche. Celui d'un Quad est un
// MeshCollider, qu'on ne peut pas passer en trigger sans qu'il soit convexe :
// une boîte fine fait le même travail, couvre tout le panneau — texte
// compris — et ne pose aucune question à la physique.
Destroy(background.GetComponent());
var box = background.AddComponent();
box.size = new Vector3(1f, 1f, 0.02f);
var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
label.transform.localPosition = new Vector3(0f, -HeightMeters * 0.30f, -0.01f);
label.Text = Label.Wrap(title, 22);
BuildProgressRing();
}
void Start()
{
// Après le Create : PagedView réduit ses commandes à 0,55 juste après. Capter
// l'échelle ici évite que le survol et l'apparition l'écrasent.
_baseScale = transform.localScale;
_baseScaleKnown = true;
}
///
/// Fait entrer le panneau. décale son arrivée :
/// c'est ce décalage, et lui seul, qui fait la cascade quand le menu se pose.
///
public void PlayAppear(float delaySeconds)
{
_appearing = true;
_appearElapsed = -delaySeconds;
_appear = 0f;
ApplyScale();
}
void Update()
{
_hover = Mathf.MoveTowards(
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
var color = Color.Lerp(Idle, Hover, _hover);
if (_appearing)
{
_appearElapsed += Time.deltaTime;
var t = Mathf.Clamp01(_appearElapsed / AppearSeconds);
// Un léger dépassement : le panneau arrive, dépasse sa taille d'un cheveu
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
_appear = _appearElapsed <= 0f ? 0f : Overshoot(t);
// La couleur est toujours recalculée depuis Idle/Hover, jamais modifiée
// sur place : une opacité multipliée image après image tomberait à zéro.
color.a *= Mathf.Clamp01(_appearElapsed / (AppearSeconds * 0.6f));
if (t >= 1f)
{
_appearing = false;
_appear = 1f;
}
}
_background.color = color;
ApplyScale();
}
static float Overshoot(float t)
{
const float tension = 1.7f;
var u = t - 1f;
return 1f + u * u * ((tension + 1f) * u + tension);
}
void ApplyScale()
{
if (!_baseScaleKnown) return;
transform.localScale = _baseScale
* Mathf.Lerp(1f, HoverScale, _hover)
* Mathf.Lerp(0.7f, 1f, _appear);
}
///
/// L'image de la section, une fois téléchargée. Un panneau sans image reste
/// lisible : le titre suffit, et une borne hors ligne au premier démarrage n'a
/// encore aucun média en cache.
///
public void SetImage(Texture2D texture)
{
if (texture == null) return;
if (_image == null)
{
var quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
quad.name = "Image";
quad.transform.SetParent(transform, false);
quad.transform.localPosition = new Vector3(0f, HeightMeters * 0.16f, -0.005f);
quad.transform.localScale = new Vector3(WidthMeters * 0.86f, HeightMeters * 0.5f, 1f);
// L'image ne doit pas intercepter le regard : c'est le panneau qui est
// la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
Destroy(quad.GetComponent());
_image = quad.transform;
}
var material = new Material(Shader.Find("Universal Render Pipeline/Unlit"))
{
mainTexture = texture
};
NoCulling(material);
_image.GetComponent().material = material;
}
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
///
/// URP/Unlit est opaque par défaut : sans ces cinq lignes, l'alpha d'une couleur
/// est simplement ignoré et le panneau est un rectangle plein aux coins carrés,
/// texture arrondie ou pas.
///
static void Transparent(Material material)
{
material.SetFloat("_Surface", 1f);
material.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
material.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
material.SetFloat("_ZWrite", 0f);
material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
}
static Texture2D _panelTexture;
///
/// Le rectangle arrondi, généré une fois pour tous les panneaux. L'alpha porte la
/// forme et l'anticrénelage du bord ; le RGB porte le liseré, plus clair que
/// l'intérieur — le matériau le teinte ensuite, donc un seul dessin sert au repos
/// comme au survol.
///
static Texture2D PanelTexture
{
get
{
if (_panelTexture != null) return _panelTexture;
const int width = 256;
const int height = 176;
const float radius = 30f;
const float border = 3f;
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
var pixels = new Color32[width * height];
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
// distance < 0 dedans, > 0 dehors. 1,2 px de dégradé suffisent à
// effacer l'escalier du bord à 2,20 m.
var alpha = Mathf.Clamp01(0.5f - distance / 1.2f);
var lit = distance > -border ? 1f : 0.72f;
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
}
texture.SetPixels32(pixels);
texture.Apply();
_panelTexture = texture;
return _panelTexture;
}
}
/// Distance signée au rectangle arrondi, en pixels.
static float RoundedRectDistance(float x, float y, float width, float height, float radius)
{
var dx = Mathf.Abs(x - width / 2f) - (width / 2f - radius);
var dy = Mathf.Abs(y - height / 2f) - (height / 2f - radius);
var outside = new Vector2(Mathf.Max(dx, 0f), Mathf.Max(dy, 0f)).magnitude;
return outside + Mathf.Min(Mathf.Max(dx, dy), 0f) - radius;
}
void BuildProgressRing()
{
const int segments = 48;
var ring = new GameObject("Progress");
ring.transform.SetParent(transform, false);
ring.transform.localPosition = new Vector3(0f, 0f, -0.02f);
_progress = ring.AddComponent();
_progress.useWorldSpace = false;
_progress.loop = false;
_progress.positionCount = 0;
_progress.widthMultiplier = 0.006f;
_progress.material = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_progress.material.color = new Color(0.4f, 0.8f, 1f, 0.9f);
_ringPoints = new Vector3[segments];
const float radius = 0.055f;
for (var i = 0; i < segments; i++)
{
// Départ en haut, sens horaire : c'est le sens d'une minuterie, et
// n'importe qui le lit sans explication.
var angle = Mathf.PI / 2f - i * Mathf.PI * 2f / (segments - 1);
_ringPoints[i] = new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0f);
}
}
Vector3[] _ringPoints;
public void OnAimEnter()
{
_hovered = true;
MenuFeedback.Hover();
}
public void OnAimProgress(float ratio)
{
var count = Mathf.RoundToInt(ratio * _ringPoints.Length);
_progress.positionCount = count;
for (var i = 0; i < count; i++)
_progress.SetPosition(i, _ringPoints[i]);
}
public void OnAimExit()
{
_hovered = false;
_progress.positionCount = 0;
}
}
}