using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using MyInfoMate.Vr.Net; using UnityEngine; namespace MyInfoMate.Vr.Menu { /// /// Le menu flottant — le hub de l'app, item E5 du lot XR-4. /// /// C'est le rendu de la SectionMenu du CMS, et le seul type de section dont /// le plan dit qu'il est « nécessaire » : tout le reste s'atteint depuis lui. /// /// Disposition en arc, pas en grille plate. Une grille plate oblige à tourner /// les yeux vers ses bords, qui sont alors vus de biais ; un arc centré sur le /// visiteur présente chaque panneau de face, à distance constante. C'est le même /// principe qu'un pupitre incurvé. /// public class FloatingMenu : MonoBehaviour { /// 2,2 m : au-delà le texte devient petit, en deçà l'œil doit converger. const float RadiusMeters = 2.2f; const float HeightMeters = 1.45f; /// Écart angulaire entre deux panneaux, assez large pour que le regard tranche. const float StepDegrees = 16f; /// Au-delà, on passe à une seconde rangée plutôt que d'encercler le visiteur. const int MaxPerRow = 5; readonly List _panels = new List(); AimSelector _selector; /// L'id de la section choisie. public event Action SectionChosen; public static FloatingMenu Create(Transform head) { var root = new GameObject("FloatingMenu"); var menu = root.AddComponent(); menu._selector = root.AddComponent(); menu._selector.Configure(head); menu._selector.Selected += menu.OnSelected; menu._head = head; menu.Recenter(); return menu; } Transform _head; /// /// Pose le menu devant le visiteur. Appelé au démarrage et à chaque nouvelle /// visite (mode borne) : le visiteur suivant n'arrive pas forcément orienté comme /// le précédent, et un menu resté dans son dos est un menu introuvable. /// /// Entre deux recentrages, le menu ne suit pas la tête : un menu qui suit /// est impossible à viser, et donne le mal de cœur. /// public void Recenter() { if (_head == null) return; var forward = _head.forward; forward.y = 0f; if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward; transform.position = new Vector3(_head.position.x, 0f, _head.position.z); transform.rotation = Quaternion.LookRotation(forward.normalized); } public void Build(ConfigurationExport export, string language) { foreach (var panel in _panels) Destroy(panel.gameObject); _panels.Clear(); var sections = export.RootSections(); for (var i = 0; i < sections.Count; i++) { var title = ConfigurationExport.Translate(sections[i].Title, language) ?? sections[i].Label; var panel = MenuItemPanel.Create(sections[i].Id, title, transform); Place(panel.transform, i, sections.Count); // La cascade : 40 ms d'écart entre deux panneaux. Assez pour qu'on voie // le menu se construire, assez peu pour que le dernier soit là avant // qu'on ait fini de tourner la tête. panel.PlayAppear(i * 0.04f); _panels.Add(panel); } } /// /// Les vignettes arrivent après coup : le menu doit être utilisable avant que /// le premier octet d'image ne soit téléchargé. /// public async Task LoadImagesAsync(ConfigurationExport export, ApiClient client) { var sections = export.RootSections(); for (var i = 0; i < sections.Count && i < _panels.Count; i++) { var source = sections[i].ImageSource; if (string.IsNullOrEmpty(source)) continue; var path = await ContentCache.MediaPathAsync(client, source); if (path == null || _panels[i] == null) continue; var texture = new Texture2D(2, 2); if (texture.LoadImage(File.ReadAllBytes(path))) _panels[i].SetImage(texture); } } void Place(Transform panel, int index, int total) { var row = index / MaxPerRow; var inRow = index % MaxPerRow; var countInRow = Mathf.Min(total - row * MaxPerRow, MaxPerRow); var angle = (inRow - (countInRow - 1) / 2f) * StepDegrees; var radians = angle * Mathf.Deg2Rad; var height = HeightMeters - row * (MenuItemPanel.HeightMeters + 0.06f); panel.localPosition = new Vector3( Mathf.Sin(radians) * RadiusMeters, height, Mathf.Cos(radians) * RadiusMeters); // Chaque panneau pivote vers le centre de l'arc — donc vers le visiteur. panel.localRotation = Quaternion.Euler(0f, angle, 0f); } void OnSelected(IAimTarget target) { if (target is MenuItemPanel panel) SectionChosen?.Invoke(panel.Id); } } }