using System.Collections.Generic;
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.
///
/// Et c'est aussi pour ça que la photo est composée dans la texture et
/// non posée sur un deuxième quad : la tuile de home_3.0 et celle de
/// visitapp-web sont une photo plein cadre, arrondie, assombrie vers le bas
/// pour que le titre reste lisible. Un quad séparé ne peut ni suivre les coins
/// arrondis ni porter le dégradé sans un shader de plus.
///
public class MenuItemPanel : MonoBehaviour, IAimTarget
{
/// Taille d'une cellule de la grille du menu, et taille par défaut
/// d'un panneau. Un panneau qui couvre plusieurs cellules reçoit la sienne à la
/// construction (voir ).
public const float WidthMeters = 0.5f;
public const float HeightMeters = 0.34f;
///
/// Une commande — « Retour au menu » — n'est pas une tuile de contenu : elle est
/// large et basse, comme un bouton, et porte un chevron.
///
public const float ActionWidthMeters = 0.92f;
public const float ActionHeightMeters = 0.21f;
public enum Style { Card, Action }
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);
///
/// Une tuile qui porte une photo ne se teinte pas : la teinte de survol
/// virerait la photo au bleu. Elle s'éclaire — le panneau au repos est
/// légèrement assombri, et le survol le rend à sa pleine lumière.
///
static readonly Color PhotoIdle = new Color(0.80f, 0.82f, 0.86f, 1f);
static readonly Color PhotoHover = Color.white;
/// 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; }
/// Hauteur de ligne du titre d'une tuile, en mètres.
const float CardTextMeters = 0.038f;
/// Celui d'une commande, qui doit se lire d'un coup d'œil.
const float ActionTextMeters = 0.062f;
/// Place réservée au chevron sur la gauche d'une commande.
const float ChevronWidthMeters = 0.13f;
Material _background;
LineRenderer _progress;
Label _title;
Style _style;
bool _chevron = true;
///
/// La texture composée pour ce panneau — photo cadrée, coins arrondis, dégradé.
/// Gardée pour être détruite avec lui : elle est propre à ce panneau, et un menu
/// reconstruit à chaque nouvelle visite en laisserait une par tuile en mémoire.
///
Texture2D _composed;
float _width = WidthMeters;
float _height = HeightMeters;
Vector3 _baseScale = Vector3.one;
bool _baseScaleKnown;
bool _hovered;
float _hover;
float _appear = 1f;
float _appearElapsed;
bool _appearing;
///
/// et valent une
/// cellule par défaut : les vues qui n'ont pas de grille (retour, pagination)
/// appellent sans les passer et gardent le panneau historique.
///
public static MenuItemPanel Create(string id, string title, Transform parent,
float widthMeters = WidthMeters, float heightMeters = HeightMeters)
{
var root = new GameObject($"Menu — {title}");
root.transform.SetParent(parent, false);
var panel = root.AddComponent();
panel._width = widthMeters;
panel._height = heightMeters;
panel.Build(id, title, Style.Card);
return panel;
}
///
/// Une commande : chevron, texte grand, panneau large. Toutes les vues qui se
/// ferment passent par ici — sortir d'une 360 ou d'une scène 3D doit se lire de
/// la même façon, et surtout se lire tout court : un panneau de la taille
/// d'une tuile, avec un titre de tuile, ne se voyait pas.
///
public static MenuItemPanel CreateAction(string id, string label, Transform parent,
bool chevron = true)
{
var root = new GameObject($"Action — {label}");
root.transform.SetParent(parent, false);
var panel = root.AddComponent();
panel._width = ActionWidthMeters;
panel._height = ActionHeightMeters;
panel._chevron = chevron;
panel.Build(id, label, Style.Action);
return panel;
}
void Build(string id, string title, Style style)
{
Id = id;
_style = style;
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
background.name = "Background";
background.transform.SetParent(transform, false);
background.transform.localScale = new Vector3(_width, _height, 1f);
_background = background.GetComponent().material =
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_background.mainTexture = PanelTextureFor(_width, _height);
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 size = style == Style.Action ? ActionTextMeters : CardTextMeters;
_title = Label.Create(transform, "Title", Label.Anchor.MiddleCenter, size, Color.white);
// La coupure suit la largeur réelle : sur un panneau double, 22 caractères
// laisseraient une colonne de texte au milieu d'un panneau vide.
var columns = Mathf.Max(8, Mathf.RoundToInt(22f * _width * CardTextMeters
/ (WidthMeters * size)));
_title.Text = Label.Wrap(title, columns);
if (style == Style.Action)
{
// Le texte laisse sa place au chevron plutôt que de le chevaucher — et
// reprend tout le panneau quand il n'y en a pas : un choix de langue n'a
// pas de « précédent » à désigner.
_title.transform.localPosition = new Vector3(
_chevron ? ChevronWidthMeters * 0.5f : 0f, 0f, -0.01f);
if (_chevron) BuildChevron();
}
else
{
// Sans photo, le titre est tout ce qu'il y a : il occupe le panneau.
// Avec, il descend au-dessus du bord bas (voir SetImage).
_title.transform.localPosition = new Vector3(0f, 0f, -0.01f);
}
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(
_composed != null ? PhotoIdle : Idle,
_composed != null ? PhotoHover : 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 les deux teintes, 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.
///
/// La photo remplace le fond du panneau au lieu de se poser dessus : c'est
/// la même texture qui porte la photo, les coins arrondis et le dégradé du bas.
/// Le titre descend alors dans ce dégradé, comme sur la tuile de l'app mobile.
///
public void SetImage(Texture2D texture)
{
if (texture == null || _style != Style.Card) return;
if (_composed != null) Destroy(_composed);
_composed = ComposeCover(texture, _width, _height);
_background.mainTexture = _composed;
// Le titre est centré sur ce point : la moitié basse du dégradé, assez haut
// pour que deux lignes tiennent encore dans le panneau.
_title.transform.localPosition =
new Vector3(0f, -_height * 0.5f + TitleBaselineMeters, -0.01f);
}
/// Hauteur du centre du titre au-dessus du bord bas du panneau.
const float TitleBaselineMeters = 0.075f;
///
/// Résolution de la texture composée. Plus fine que celle du fond nu
/// () : un aplat supporte 512 px/m, une photo non.
///
const float PhotoPixelsPerMeter = 1024f;
///
/// La photo cadrée en cover, arrondie et assombrie vers le bas — la tuile
/// de home_3.0, refaite en pixels.
///
/// ⚠️ Les pixels de la source sont lus une fois, puis échantillonnés à la
/// main. GetPixelBilinear par pixel de sortie coûte un appel natif chacun :
/// sur six tuiles, ça se voyait comme un à-coup au moment où le menu se pose.
///
static Texture2D ComposeCover(Texture2D source, float widthMeters, float heightMeters)
{
var width = Mathf.Max(8, Mathf.RoundToInt(widthMeters * PhotoPixelsPerMeter));
var height = Mathf.Max(8, Mathf.RoundToInt(heightMeters * PhotoPixelsPerMeter));
var radius = CornerRadiusMeters * PhotoPixelsPerMeter;
var border = BorderMeters * PhotoPixelsPerMeter;
var pixels = new Color32[width * height];
var source32 = source.GetPixels32();
// Cover : l'image remplit le panneau et déborde sur son grand côté, plutôt
// que de laisser deux bandes vides. Le débordement est coupé des deux côtés,
// donc le sujet reste au centre.
var scale = Mathf.Max((float)width / source.width, (float)height / source.height);
var cropX = (source.width - width / scale) * 0.5f;
var cropY = (source.height - height / scale) * 0.5f;
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
var color = Sample(source32, source.width, source.height,
cropX + (x + 0.5f) / scale,
cropY + (y + 0.5f) / scale);
// Le dégradé de lisibilité : rien sur les 40 % du haut, puis on descend
// jusqu'à 72 % de noir au bord bas. Sans lui, un titre blanc sur une
// photo claire est illisible — et une tuile sur deux l'est.
var fromTop = 1f - (y + 0.5f) / height;
var shade = Mathf.InverseLerp(0.4f, 1f, fromTop) * 0.72f;
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
// Le liseré éclaircit la photo au lieu de la recouvrir : c'est ce qui
// détache la tuile d'un fond sombre sans faire un cadre blanc.
var lit = distance > -border ? 0.30f : 0f;
var value = Color.Lerp(color * (1f - shade), Color.white, lit);
value.a = Mathf.Clamp01(0.5f - distance / 1.2f);
pixels[y * width + x] = value;
}
// Avec mipmaps, contrairement au fond nu : une photo vue de loin, sans
// niveau réduit, scintille dès que la tête bouge — et dans un casque, la
// tête bouge tout le temps.
var texture = new Texture2D(width, height, TextureFormat.RGBA32, true)
{
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Trilinear
};
texture.SetPixels32(pixels);
texture.Apply();
return texture;
}
/// Échantillonnage bilinéaire, en pixels de la source.
static Color Sample(Color32[] pixels, int width, int height, float x, float y)
{
var fx = Mathf.Clamp(x - 0.5f, 0f, width - 1f);
var fy = Mathf.Clamp(y - 0.5f, 0f, height - 1f);
var x0 = (int)fx;
var y0 = (int)fy;
var x1 = Mathf.Min(x0 + 1, width - 1);
var y1 = Mathf.Min(y0 + 1, height - 1);
var tx = fx - x0;
var ty = fy - y0;
var top = Color.Lerp(pixels[y1 * width + x0], pixels[y1 * width + x1], tx);
var bottom = Color.Lerp(pixels[y0 * width + x0], pixels[y0 * width + x1], tx);
return Color.Lerp(bottom, top, ty);
}
///
/// Le chevron d'une commande, tracé plutôt que dessiné dans une texture : trois
/// points suffisent, et LineRenderer est déjà là pour l'anneau de visée.
/// Une police ne rendrait pas le même signe d'un casque à l'autre.
///
void BuildChevron()
{
var chevron = new GameObject("Chevron");
chevron.transform.SetParent(transform, false);
chevron.transform.localPosition =
new Vector3(-_width * 0.5f + ChevronWidthMeters * 0.62f, 0f, -0.01f);
var line = chevron.AddComponent();
line.useWorldSpace = false;
line.widthMultiplier = 0.013f;
line.numCapVertices = 2;
line.material = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
line.material.color = Color.white;
const float arm = 0.035f;
line.positionCount = 3;
line.SetPosition(0, new Vector3(arm * 0.7f, arm, 0f));
line.SetPosition(1, new Vector3(-arm * 0.7f, 0f, 0f));
line.SetPosition(2, new Vector3(arm * 0.7f, -arm, 0f));
}
/// La texture composée n'appartient qu'à ce panneau.
void OnDestroy()
{
if (_composed != null) Destroy(_composed);
}
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 readonly Dictionary PanelTextures = new Dictionary();
/// Résolution du dessin, en pixels par mètre de panneau.
const float PixelsPerMeter = 512f;
/// Rayon des coins, en mètres — identique sur tous les panneaux.
const float CornerRadiusMeters = 0.059f;
/// Épaisseur du liseré, en mètres.
const float BorderMeters = 0.006f;
///
/// Le rectangle arrondi d'un panneau. 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.
///
/// ⚠️ Une texture par taille de panneau, pas une pour tous. Une texture unique
/// étirée sur un panneau double donne des coins ovales et un liseré deux fois plus
/// épais d'un côté que de l'autre — le panneau double ne ressemble plus au simple.
/// Le rayon et le liseré sont donc dits en mètres et convertis ici, ce qui les rend
/// constants quelle que soit la taille. Il n'y a qu'une poignée de tailles dans une
/// grille bento, donc autant de textures, gardées pour la durée de l'app.
///
static Texture2D PanelTextureFor(float widthMeters, float heightMeters)
{
var key = Mathf.RoundToInt(widthMeters * 1000f) * 10000
+ Mathf.RoundToInt(heightMeters * 1000f);
if (PanelTextures.TryGetValue(key, out var cached) && cached != null) return cached;
var width = Mathf.Max(8, Mathf.RoundToInt(widthMeters * PixelsPerMeter));
var height = Mathf.Max(8, Mathf.RoundToInt(heightMeters * PixelsPerMeter));
var radius = CornerRadiusMeters * PixelsPerMeter;
var border = BorderMeters * PixelsPerMeter;
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();
PanelTextures[key] = texture;
return texture;
}
/// 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;
}
}
}