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>
455 lines
18 KiB
C#
455 lines
18 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";
|
|
|
|
/// <summary>
|
|
/// La langue de repli : celle qu'on prend quand la visite n'en propose qu'une, ou
|
|
/// qu'elle n'en déclare aucune. Dès qu'il y a un choix, c'est le visiteur qui
|
|
/// tranche au début de sa visite — voir <see cref="ChooseLanguageAsync"/>.
|
|
/// </summary>
|
|
[SerializeField] string language = "FR";
|
|
|
|
[SerializeField] bool forgetPairing;
|
|
|
|
Transform _head;
|
|
FloatingMenu _menu;
|
|
LanguagePicker _picker;
|
|
|
|
/// <summary>
|
|
/// La langue de la visite <b>en cours</b>. Nulle tant que le visiteur n'a pas
|
|
/// choisi, et remise à null à chaque nouvelle visite : elle n'est jamais écrite
|
|
/// sur le disque, donc un casque qu'on rallume repose la question.
|
|
/// </summary>
|
|
string _language;
|
|
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)
|
|
{
|
|
_export = export;
|
|
|
|
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);
|
|
|
|
// Avant le menu, et pas après : le menu est déjà écrit dans une langue, et
|
|
// le construire pour le reconstruire aussitôt ferait rejouer sa cascade sous
|
|
// les yeux du visiteur.
|
|
if (_language == null) await ChooseLanguageAsync(export);
|
|
|
|
await BuildMenuAsync(export);
|
|
}
|
|
|
|
async Task BuildMenuAsync(ConfigurationExport export)
|
|
{
|
|
BuildMenu(export);
|
|
await _menu.LoadImagesAsync(export, _client);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Demande sa langue au visiteur, et ne rend la main qu'une fois qu'il a répondu.
|
|
///
|
|
/// <b>Le choix vit le temps d'une visite.</b> Sur une borne, la langue n'est pas un
|
|
/// réglage de l'appareil : c'est une propriété de la personne qui a le casque sur
|
|
/// la tête. Rien n'est persisté, et <see cref="StartNewVisit"/> repose la question
|
|
/// au suivant.
|
|
///
|
|
/// Une visite qui n'existe que dans une langue ne demande rien : un choix à une
|
|
/// possibilité est un écran de plus avant le contenu, pas un service.
|
|
/// </summary>
|
|
async Task ChooseLanguageAsync(ConfigurationExport export)
|
|
{
|
|
var languages = export.AvailableLanguages();
|
|
|
|
if (languages.Count <= 1)
|
|
{
|
|
SetLanguage(languages.Count == 1 ? languages[0] : language);
|
|
return;
|
|
}
|
|
|
|
if (_menu != null) _menu.gameObject.SetActive(false);
|
|
|
|
var choice = new TaskCompletionSource<string>();
|
|
|
|
_picker = LanguagePicker.Create(_head, languages);
|
|
_picker.Chosen += chosen =>
|
|
{
|
|
_kiosk?.NotifyActivity();
|
|
choice.TrySetResult(chosen);
|
|
};
|
|
|
|
SetLanguage(await choice.Task);
|
|
|
|
Destroy(_picker.gameObject);
|
|
_picker = null;
|
|
|
|
if (_menu != null) _menu.gameObject.SetActive(true);
|
|
}
|
|
|
|
void SetLanguage(string chosen)
|
|
{
|
|
_language = Ui.Strings.Normalize(chosen);
|
|
|
|
// La télémétrie est ouverte avant le choix : sans ça, toute la visite serait
|
|
// comptée dans la langue de repli du casque.
|
|
if (_telemetry != null) _telemetry.Language = _language;
|
|
}
|
|
|
|
/// <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>
|
|
async void StartNewVisit()
|
|
{
|
|
// Le choix de langue est déjà posé devant quelqu'un : le casque vient d'être
|
|
// reposé pendant qu'on le lisait, et repartir de zéro effacerait l'écran sous
|
|
// les yeux de celui qui est en train d'y répondre.
|
|
if (_picker != null) return;
|
|
|
|
CloseSection();
|
|
_menu?.Recenter();
|
|
_telemetry?.StartNewSession();
|
|
Debug.Log("[Kiosk] Nouvelle visite.");
|
|
|
|
// La langue appartient au visiteur, pas au casque : le suivant rechoisit.
|
|
_language = null;
|
|
if (_export != null)
|
|
{
|
|
await ChooseLanguageAsync(_export);
|
|
await BuildMenuAsync(_export);
|
|
}
|
|
|
|
// 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, _language);
|
|
_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, _language);
|
|
_model.Closed += CloseSection;
|
|
_model.Interacted += () => _kiosk?.NotifyActivity();
|
|
|
|
if (!await _model.LoadAsync(section, _export, _language))
|
|
{
|
|
CloseSection();
|
|
Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.Scene3DFailed, _language)}");
|
|
}
|
|
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\n{Ui.Strings.Get(Ui.Strings.NotSupported, _language)}");
|
|
return;
|
|
}
|
|
|
|
OpenSection(sectionId);
|
|
|
|
_view = PagedView.Create(_head, _language);
|
|
_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);
|
|
}
|
|
}
|
|
}
|