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; } } }