Les tuiles suivent maintenant les mêmes colSpan × rowSpan que les autres canaux, sur six colonnes — environ 83° d'ouverture, dans le confort du regard — avec un écart serré assumé : espacées, les tuiles se lisent comme des panneaux sans rapport plutôt que comme une planche. Le pas angulaire se déduit de la largeur de cellule et du rayon de l'arc, plutôt que d'être posé en dur. S'y ajoute le sélecteur de langue avec ses drapeaux, et les libellés de l'interface sortis du code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
567 lines
24 KiB
C#
567 lines
24 KiB
C#
using System.Collections.Generic;
|
|
using MyInfoMate.Vr.Ui;
|
|
using UnityEngine;
|
|
|
|
namespace MyInfoMate.Vr.Menu
|
|
{
|
|
/// <summary>
|
|
/// Un panneau du menu flottant : une image, un titre, et l'anneau de progression de
|
|
/// la visée — item <b>E5</b> 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 <see cref="AimSelector"/>).
|
|
///
|
|
/// Tout est construit par code, avec le <b>même shader</b> que
|
|
/// <see cref="Scene.NavigationBounds"/> (<c>Universal Render Pipeline/Unlit</c>).
|
|
/// Ce n'est pas un détail de style : ce shader est déjà dans <i>Always Included
|
|
/// Shaders</i>, 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).
|
|
///
|
|
/// <b>Les coins arrondis et le liseré ne viennent donc pas d'un shader</b>, 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.
|
|
///
|
|
/// <b>Et c'est aussi pour ça que la photo est <i>composée dans</i> la texture</b> et
|
|
/// non posée sur un deuxième quad : la tuile de <c>home_3.0</c> et celle de
|
|
/// <c>visitapp-web</c> 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.
|
|
/// </summary>
|
|
public class MenuItemPanel : MonoBehaviour, IAimTarget
|
|
{
|
|
/// <summary>Taille d'<b>une cellule</b> 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 <see cref="Create"/>).</summary>
|
|
public const float WidthMeters = 0.5f;
|
|
|
|
public const float HeightMeters = 0.34f;
|
|
|
|
/// <summary>
|
|
/// Une commande — « Retour au menu » — n'est pas une tuile de contenu : elle est
|
|
/// large et basse, comme un bouton, et porte un chevron.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Une tuile qui porte une photo ne se <b>teinte</b> pas : la teinte de survol
|
|
/// virerait la photo au bleu. Elle s'<b>éclaire</b> — le panneau au repos est
|
|
/// légèrement assombri, et le survol le rend à sa pleine lumière.
|
|
/// </summary>
|
|
static readonly Color PhotoIdle = new Color(0.80f, 0.82f, 0.86f, 1f);
|
|
|
|
static readonly Color PhotoHover = Color.white;
|
|
|
|
/// <summary>Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.</summary>
|
|
const float HoverScale = 1.07f;
|
|
|
|
const float TransitionSpeed = 12f;
|
|
|
|
/// <summary>Assez court pour ne pas faire attendre, assez long pour se voir.</summary>
|
|
const float AppearSeconds = 0.35f;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public string Id { get; private set; }
|
|
|
|
/// <summary>Hauteur de ligne du titre d'une tuile, en mètres.</summary>
|
|
const float CardTextMeters = 0.038f;
|
|
|
|
/// <summary>Celui d'une commande, qui doit se lire d'un coup d'œil.</summary>
|
|
const float ActionTextMeters = 0.062f;
|
|
|
|
/// <summary>Place réservée au chevron sur la gauche d'une commande.</summary>
|
|
const float ChevronWidthMeters = 0.13f;
|
|
|
|
Material _background;
|
|
LineRenderer _progress;
|
|
Label _title;
|
|
Style _style;
|
|
bool _chevron = true;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// <paramref name="widthMeters"/> et <paramref name="heightMeters"/> 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.
|
|
/// </summary>
|
|
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<MenuItemPanel>();
|
|
panel._width = widthMeters;
|
|
panel._height = heightMeters;
|
|
panel.Build(id, title, Style.Card);
|
|
return panel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <b>tout court</b> : un panneau de la taille
|
|
/// d'une tuile, avec un titre de tuile, ne se voyait pas.
|
|
/// </summary>
|
|
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<MenuItemPanel>();
|
|
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<Renderer>().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<MeshCollider>());
|
|
var box = background.AddComponent<BoxCollider>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fait entrer le panneau. <paramref name="delaySeconds"/> décale son arrivée :
|
|
/// c'est ce décalage, et lui seul, qui fait la cascade quand le menu se pose.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <b>remplace</b> 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Hauteur du centre du titre au-dessus du bord bas du panneau.</summary>
|
|
const float TitleBaselineMeters = 0.075f;
|
|
|
|
/// <summary>
|
|
/// Résolution de la texture composée. Plus fine que celle du fond nu
|
|
/// (<see cref="PixelsPerMeter"/>) : un aplat supporte 512 px/m, une photo non.
|
|
/// </summary>
|
|
const float PhotoPixelsPerMeter = 1024f;
|
|
|
|
/// <summary>
|
|
/// La photo cadrée en <b>cover</b>, arrondie et assombrie vers le bas — la tuile
|
|
/// de <c>home_3.0</c>, refaite en pixels.
|
|
///
|
|
/// ⚠️ <b>Les pixels de la source sont lus une fois</b>, puis échantillonnés à la
|
|
/// main. <c>GetPixelBilinear</c> 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Échantillonnage bilinéaire, en pixels de la source.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Le chevron d'une commande, tracé plutôt que dessiné dans une texture : trois
|
|
/// points suffisent, et <c>LineRenderer</c> est déjà là pour l'anneau de visée.
|
|
/// Une police ne rendrait pas le même signe d'un casque à l'autre.
|
|
/// </summary>
|
|
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<LineRenderer>();
|
|
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));
|
|
}
|
|
|
|
/// <summary>La texture composée n'appartient qu'à ce panneau.</summary>
|
|
void OnDestroy()
|
|
{
|
|
if (_composed != null) Destroy(_composed);
|
|
}
|
|
|
|
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<int, Texture2D> PanelTextures = new Dictionary<int, Texture2D>();
|
|
|
|
/// <summary>Résolution du dessin, en pixels par mètre de panneau.</summary>
|
|
const float PixelsPerMeter = 512f;
|
|
|
|
/// <summary>Rayon des coins, en mètres — identique sur tous les panneaux.</summary>
|
|
const float CornerRadiusMeters = 0.059f;
|
|
|
|
/// <summary>Épaisseur du liseré, en mètres.</summary>
|
|
const float BorderMeters = 0.006f;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// ⚠️ <b>Une texture par taille de panneau, pas une pour tous.</b> 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>
|
|
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<LineRenderer>();
|
|
_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;
|
|
}
|
|
}
|
|
}
|