367 lines
14 KiB
C#
367 lines
14 KiB
C#
using System.Threading.Tasks;
|
|
using MyInfoMate.Vr.Menu;
|
|
using MyInfoMate.Vr.Net;
|
|
using MyInfoMate.Vr.Scene;
|
|
using UnityEngine;
|
|
|
|
namespace MyInfoMate.Vr.Boot
|
|
{
|
|
/// <summary>
|
|
/// L'app du canal VR, telle qu'elle tient aujourd'hui : appairage (E2), contenu
|
|
/// servi depuis le cache (E3-E4), <b>menu flottant</b> (E5) et télémétrie (E9).
|
|
///
|
|
/// C'est le successeur de <see cref="PairingBootstrap"/>, 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 : <b>Slider</b>, <b>Map</b> et <b>Event</b>
|
|
/// (E8), le <b>Parcours</b> rendu comme une Map, et la <b>Video 360</b> — 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 <b>Scène 3D</b> (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.
|
|
/// </summary>
|
|
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;
|
|
_deviceId = pairing.DeviceId;
|
|
_content = new ContentService(client, pairing.ConfigurationId, _deviceId);
|
|
|
|
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;
|
|
|
|
await ApplyAsync(loaded.Export);
|
|
|
|
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.
|
|
await RefreshContentAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pose un contenu : le menu, ses vignettes, le décor, puis le téléchargement du
|
|
/// reste. Les trois chemins qui changent de contenu — démarrage, rafraîchissement,
|
|
/// changement de configuration — passent tous par ici, sinon l'un des quatre gestes
|
|
/// finit par manquer à l'un d'eux.
|
|
/// </summary>
|
|
async Task ApplyAsync(ConfigurationExport export)
|
|
{
|
|
BuildMenu(export);
|
|
await _menu.LoadImagesAsync(export, _client);
|
|
await ApplyBackdrop(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(export, _client);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Va voir si le contenu a changé, et ne reconstruit que s'il a changé —
|
|
/// <c>RefreshAsync</c> rend <c>null</c> quand l'export est identique à celui en
|
|
/// cache, et quand le serveur n'a pas répondu.
|
|
///
|
|
/// ⚠️ À n'appeler qu'aux moments où <b>personne ne regarde le menu</b> : au
|
|
/// démarrage, et entre deux visites. Un menu qui se reconstruit sous les yeux d'un
|
|
/// visiteur rejoue la cascade d'apparition et déplace ce qu'il visait.
|
|
/// </summary>
|
|
async Task RefreshContentAsync()
|
|
{
|
|
if (_content == null) return;
|
|
|
|
var fresh = await _content.RefreshAsync();
|
|
if (fresh == null || _menu == null) return;
|
|
|
|
Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit.");
|
|
await ApplyAsync(fresh);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
void StartNewVisit()
|
|
{
|
|
CloseSection();
|
|
_menu?.Recenter();
|
|
_telemetry?.StartNewSession();
|
|
Debug.Log("[Kiosk] Nouvelle visite.");
|
|
|
|
// Le seul moment où reconstruire le menu ne dérange personne. Sans ça, un
|
|
// casque allumé toute la journée montre le contenu qu'il avait au démarrage :
|
|
// le battement de FleetReporter ne voit qu'un changement de configuration,
|
|
// pas une modification de son contenu.
|
|
_ = RefreshContentAsync();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retour au menu. La durée passée dans la section part avec le
|
|
/// <c>SectionLeave</c> : c'est elle qui dit si un contenu retient, et sans elle
|
|
/// les stats ne comptent que des ouvertures.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// ⚠️ Le <c>deviceId</c> repart avec : c'est lui qui désigne le lien de ce casque,
|
|
/// donc son agencement — bento, ordre des sections, sections masquées pour le seul
|
|
/// canal VR. Sans lui, le casque bascule sur la configuration nue et perd sa mise
|
|
/// en page jusqu'au prochain démarrage.
|
|
/// </summary>
|
|
async void OnConfigurationChanged(string configurationId)
|
|
{
|
|
CloseSection();
|
|
|
|
_content = new ContentService(_client, configurationId, _deviceId);
|
|
var loaded = await _content.LoadAsync();
|
|
|
|
if (!loaded.Ok)
|
|
{
|
|
Debug.LogWarning($"[Menu] Nouvelle configuration illisible : {loaded.Error}");
|
|
return;
|
|
}
|
|
|
|
// Avant l'application : recentrer ne dépend que du menu déjà en place, et
|
|
// attendre le chargement des vignettes le laisserait le temps d'une requête
|
|
// dans le dos du visiteur.
|
|
_menu?.Recenter();
|
|
_telemetry?.StartNewSession();
|
|
|
|
await ApplyAsync(loaded.Export);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// La ressource 360 d'une section, ou null. <c>Source</c> 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.
|
|
/// </summary>
|
|
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;
|
|
ContentService _content;
|
|
|
|
/// <summary>
|
|
/// Ce casque, gardé pour toute la vie de l'app : il désigne le lien de
|
|
/// configuration, donc l'agencement propre au canal VR. Tout
|
|
/// <see cref="ContentService"/> construit ici doit le recevoir.
|
|
/// </summary>
|
|
string _deviceId;
|
|
Kiosk.KioskSession _kiosk;
|
|
string _openedSectionId;
|
|
float _openedAt;
|
|
|
|
void Show(string message)
|
|
{
|
|
if (_message != null) Destroy(_message.gameObject);
|
|
_message = SceneMessage.Show(message, _head);
|
|
}
|
|
}
|
|
}
|