using System.Threading.Tasks; using MyInfoMate.Vr.Menu; using MyInfoMate.Vr.Net; using MyInfoMate.Vr.Scene; using UnityEngine; namespace MyInfoMate.Vr.Boot { /// /// L'app du canal VR, telle qu'elle tient aujourd'hui : appairage (E2), contenu /// servi depuis le cache (E3-E4), menu flottant (E5) et télémétrie (E9). /// /// C'est le successeur de , qui reste à côté : celui-ci /// prouve la chaîne réseau en une phrase, celui-là est l'app. Tant que rien n'a tourné /// sur un vrai casque, pouvoir revenir au plus simple en un clic évite de confondre /// un problème d'appairage avec un problème de rendu. /// /// Les quatre types retenus au §8 s'ouvrent : Slider, Map et Event /// (E8), le Parcours rendu comme une Map, et la Video 360 — celle-ci /// n'est pas un type de section mais une section Video dont le média est une ressource /// 360, livrée le 2026-09-12. S'y ajoute la Scène 3D (E7). Choisir une section /// non gérée affiche son titre et le dit — un menu qui ne réagit pas se lit comme une /// panne. /// /// La session de borne (E10) vit ici aussi : elle décide quand la visite est finie. /// public class MenuBootstrap : MonoBehaviour { [Header("Serveur")] [SerializeField] string baseUrl = "https://api.mymuseum.be"; [Header("Appairage — code PIN de l'instance (première fois seulement)")] [SerializeField] string pinCode = ""; [Header("Nom affiché dans l'onglet XR du manager")] [SerializeField] string headsetName = "Quest"; [SerializeField] string language = "FR"; [SerializeField] bool forgetPairing; Transform _head; FloatingMenu _menu; Telemetry _telemetry; ConfigurationExport _export; SceneMessage _message; async void Start() { _head = Camera.main != null ? Camera.main.transform : null; if (_head == null) Debug.LogError("[Menu] Aucune caméra active : le rig VR n'est pas dans la " + "scène. Meta → Tools → Building Blocks → Camera Rig."); if (forgetPairing) PairingService.Forget(); var pairing = PairingService.Restore(); if (pairing == null) { if (string.IsNullOrEmpty(pinCode)) { Show("Ce casque n'est pas appairé. Renseignez le code PIN du lieu " + "dans l'inspecteur, puis relancez."); return; } var paired = await new PairingService().PairAsync(baseUrl, pinCode, headsetName); if (!paired.Ok) { Show(paired.Error); return; } pairing = paired.Value; } _client = new ApiClient(pairing.BaseUrl, pairing.ApiKey); var client = _client; var content = new ContentService(client, pairing.ConfigurationId); var loaded = await content.LoadAsync(); if (!loaded.Ok) { Show(loaded.Error); return; } _telemetry = new Telemetry(client, pairing.InstanceId, pairing.ConfigurationId, language); // XR-5 : sans ce battement, l'onglet XR du manager affiche « — » pour la // batterie et la version, et ne sait jamais si un casque est tombé. var fleet = Kiosk.FleetReporter.Attach( gameObject, client, pairing.DeviceId, pairing.ConfigurationId); if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged; BuildMenu(loaded.Export); await _menu.LoadImagesAsync(loaded.Export, client); await ApplyBackdrop(loaded.Export, client); // E4 — le reste de la visite part sur le disque pendant que le visiteur // regarde le menu. Sans await : c'est long, et personne ne doit l'attendre. _ = ContentPreloader.RunAsync(loaded.Export, client); if (!loaded.FromCache) return; // Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne, // et ne se voit que s'il apporte quelque chose. var fresh = await content.RefreshAsync(); if (fresh == null || _menu == null) return; Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit."); BuildMenu(fresh); await _menu.LoadImagesAsync(fresh, client); await ApplyBackdrop(fresh, client); _ = ContentPreloader.RunAsync(fresh, client); } void BuildMenu(ConfigurationExport export) { _export = export; if (_menu == null) { _menu = FloatingMenu.Create(_head); _menu.SectionChosen += OnSectionChosen; _kiosk = Kiosk.KioskSession.Attach(gameObject, _head); _kiosk.Ended += StartNewVisit; } _menu.Build(export, language); } /// /// Fin de visite : casque reposé, ou plus rien depuis un long moment. On revient /// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de /// statistiques — sinon une journée de borne compterait pour un seul visiteur. /// void StartNewVisit() { CloseSection(); _menu?.Recenter(); _telemetry?.StartNewSession(); Debug.Log("[Kiosk] Nouvelle visite."); } async void OnSectionChosen(string sectionId) { _kiosk?.NotifyActivity(); _telemetry?.MenuItemTap(sectionId); _telemetry?.SectionView(sectionId); var section = _export?.Sections?.Find(s => s.Id == sectionId); var title = section != null ? ConfigurationExport.Translate(section.Title, language) ?? section.Label : sectionId; Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})"); if (section == null) return; // Une section Video dont le média est une ressource 360 est le seul cas qui // ne se feuillette pas : elle devient le ciel. var immersive = ImmersiveResourceOf(section); if (immersive != null) { OpenSection(sectionId); _skybox = SkyboxView.Create(_head); _skybox.Closed += CloseSection; _skybox.Interacted += () => _kiosk?.NotifyActivity(); await _skybox.LoadAsync(immersive, _client); return; } // Une maquette 3D n'est pas une page : elle se regarde en volume, avec ses // points posés dessus. if (section.Type == ConfigurationExport.SectionType.Scene3D) { OpenSection(sectionId); _model = Scene3DView.Create(_head); _model.Closed += CloseSection; _model.Interacted += () => _kiosk?.NotifyActivity(); if (!await _model.LoadAsync(section, _export, language)) { CloseSection(); Show($"{title}\n\nCette scène 3D n'a pas pu être chargée."); } return; } if (!SectionPages.CanRender(section.Type)) { // Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés. // Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit // comme une panne. Show($"{title}\n\nCe type de contenu n'est pas encore affichable dans le casque."); return; } OpenSection(sectionId); _view = PagedView.Create(_head); _view.Closed += CloseSection; _view.Interacted += () => _kiosk?.NotifyActivity(); await SectionPages.FillAsync(_view, section, _client, language); } /// /// Retour au menu. La durée passée dans la section part avec le /// SectionLeave : c'est elle qui dit si un contenu retient, et sans elle /// les stats ne comptent que des ouvertures. /// /// /// Le manager a assigné une autre configuration à ce casque : on la charge et on /// repart du menu, sans redémarrer l'app. /// /// ⚠️ On ferme ce qui est ouvert d'abord. Un visiteur qui regardait une galerie /// au moment du changement doit revenir au menu — pas rester dans un contenu qui /// n'appartient plus à sa visite. /// async void OnConfigurationChanged(string configurationId) { CloseSection(); var content = new ContentService(_client, configurationId); var loaded = await content.LoadAsync(); if (!loaded.Ok) { Debug.LogWarning($"[Menu] Nouvelle configuration illisible : {loaded.Error}"); return; } BuildMenu(loaded.Export); _menu?.Recenter(); _telemetry?.StartNewSession(); await _menu.LoadImagesAsync(loaded.Export, _client); await ApplyBackdrop(loaded.Export, _client); _ = ContentPreloader.RunAsync(loaded.Export, _client); } /// /// Pose le décor du lieu derrière le menu. Détruit le précédent d'abord : un /// changement de configuration ne doit pas laisser le panorama de l'ancienne /// visite, et deux fonds superposés font deux vidéos qui tournent. /// async Task ApplyBackdrop(ConfigurationExport export, ApiClient client) { if (_backdrop != null) Destroy(_backdrop.gameObject); _backdrop = await Menu.ImmersiveBackdrop.ApplyAsync(export, client); } void OpenSection(string sectionId) { _openedSectionId = sectionId; _openedAt = Time.time; _menu.gameObject.SetActive(false); } /// /// La ressource 360 d'une section, ou null. Source porte soit un id de /// ressource, soit une URL externe (YouTube) — cette dernière n'est pas une 360 /// téléchargeable, donc pas un cas immersif. /// ConfigurationExport.Resource ImmersiveResourceOf(ConfigurationExport.SectionSummary section) { if (section.SourceIsUrl) return null; var resource = _export?.FindResource(section.Source); return resource != null && resource.IsImmersive ? resource : null; } void CloseSection() { if (_view != null) Destroy(_view.gameObject); _view = null; if (_skybox != null) Destroy(_skybox.gameObject); _skybox = null; // Une scène 3D laissée derrière soi reste plantée dans le décor du menu — et // son GLB continue d'occuper la mémoire du casque. if (_model != null) Destroy(_model.gameObject); _model = null; // Le message d'une section non gérée resterait affiché par-dessus le menu. if (_message != null) Destroy(_message.gameObject); _message = null; if (_openedSectionId != null) { _telemetry?.SectionLeave(_openedSectionId, Mathf.RoundToInt(Time.time - _openedAt)); _openedSectionId = null; } if (_menu != null) _menu.gameObject.SetActive(true); } PagedView _view; SkyboxView _skybox; ImmersiveBackdrop _backdrop; Scene3DView _model; ApiClient _client; Kiosk.KioskSession _kiosk; string _openedSectionId; float _openedAt; void Show(string message) { if (_message != null) Destroy(_message.gameObject); _message = SceneMessage.Show(message, _head); } } }