diff --git a/unity-overlay/Assets/Icons/MyInfoMate_icon.png b/unity-overlay/Assets/Icons/MyInfoMate_icon.png
new file mode 100644
index 0000000..9274e38
Binary files /dev/null and b/unity-overlay/Assets/Icons/MyInfoMate_icon.png differ
diff --git a/unity-overlay/Assets/Icons/MyInfoMate_icon.png.meta b/unity-overlay/Assets/Icons/MyInfoMate_icon.png.meta
new file mode 100644
index 0000000..af79492
--- /dev/null
+++ b/unity-overlay/Assets/Icons/MyInfoMate_icon.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ isReadable: 0
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Icons/MyInfoMate_icon_background.png b/unity-overlay/Assets/Icons/MyInfoMate_icon_background.png
new file mode 100644
index 0000000..7ac9182
Binary files /dev/null and b/unity-overlay/Assets/Icons/MyInfoMate_icon_background.png differ
diff --git a/unity-overlay/Assets/Icons/MyInfoMate_icon_background.png.meta b/unity-overlay/Assets/Icons/MyInfoMate_icon_background.png.meta
new file mode 100644
index 0000000..872baa8
--- /dev/null
+++ b/unity-overlay/Assets/Icons/MyInfoMate_icon_background.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ isReadable: 0
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Icons/MyInfoMate_icon_foreground.png b/unity-overlay/Assets/Icons/MyInfoMate_icon_foreground.png
new file mode 100644
index 0000000..82fac8b
Binary files /dev/null and b/unity-overlay/Assets/Icons/MyInfoMate_icon_foreground.png differ
diff --git a/unity-overlay/Assets/Icons/MyInfoMate_icon_foreground.png.meta b/unity-overlay/Assets/Icons/MyInfoMate_icon_foreground.png.meta
new file mode 100644
index 0000000..c34203d
--- /dev/null
+++ b/unity-overlay/Assets/Icons/MyInfoMate_icon_foreground.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ isReadable: 0
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/ar.png b/unity-overlay/Assets/Resources/Flags/ar.png
new file mode 100644
index 0000000..395928c
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/ar.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/ar.png.meta b/unity-overlay/Assets/Resources/Flags/ar.png.meta
new file mode 100644
index 0000000..1d28d2c
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/ar.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60008b2c74e8d9a0f3c5e7d1b4008
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/cn.png b/unity-overlay/Assets/Resources/Flags/cn.png
new file mode 100644
index 0000000..344eab5
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/cn.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/cn.png.meta b/unity-overlay/Assets/Resources/Flags/cn.png.meta
new file mode 100644
index 0000000..8f55b45
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/cn.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60009b2c74e8d9a0f3c5e7d1b4009
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/de.png b/unity-overlay/Assets/Resources/Flags/de.png
new file mode 100644
index 0000000..c4caaaf
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/de.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/de.png.meta b/unity-overlay/Assets/Resources/Flags/de.png.meta
new file mode 100644
index 0000000..a6e853d
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/de.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60003b2c74e8d9a0f3c5e7d1b4003
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/en.png b/unity-overlay/Assets/Resources/Flags/en.png
new file mode 100644
index 0000000..b81442d
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/en.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/en.png.meta b/unity-overlay/Assets/Resources/Flags/en.png.meta
new file mode 100644
index 0000000..9c62a24
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/en.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60001b2c74e8d9a0f3c5e7d1b4001
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/es.png b/unity-overlay/Assets/Resources/Flags/es.png
new file mode 100644
index 0000000..f19308d
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/es.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/es.png.meta b/unity-overlay/Assets/Resources/Flags/es.png.meta
new file mode 100644
index 0000000..35951df
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/es.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60004b2c74e8d9a0f3c5e7d1b4004
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/fr.png b/unity-overlay/Assets/Resources/Flags/fr.png
new file mode 100644
index 0000000..feff210
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/fr.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/fr.png.meta b/unity-overlay/Assets/Resources/Flags/fr.png.meta
new file mode 100644
index 0000000..60e5afb
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/fr.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60000b2c74e8d9a0f3c5e7d1b4000
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/it.png b/unity-overlay/Assets/Resources/Flags/it.png
new file mode 100644
index 0000000..81013f0
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/it.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/it.png.meta b/unity-overlay/Assets/Resources/Flags/it.png.meta
new file mode 100644
index 0000000..222c26d
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/it.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60005b2c74e8d9a0f3c5e7d1b4005
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/nl.png b/unity-overlay/Assets/Resources/Flags/nl.png
new file mode 100644
index 0000000..e456302
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/nl.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/nl.png.meta b/unity-overlay/Assets/Resources/Flags/nl.png.meta
new file mode 100644
index 0000000..52b1daf
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/nl.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60002b2c74e8d9a0f3c5e7d1b4002
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/pl.png b/unity-overlay/Assets/Resources/Flags/pl.png
new file mode 100644
index 0000000..91d8931
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/pl.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/pl.png.meta b/unity-overlay/Assets/Resources/Flags/pl.png.meta
new file mode 100644
index 0000000..0743932
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/pl.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60006b2c74e8d9a0f3c5e7d1b4006
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Resources/Flags/uk.png b/unity-overlay/Assets/Resources/Flags/uk.png
new file mode 100644
index 0000000..8219610
Binary files /dev/null and b/unity-overlay/Assets/Resources/Flags/uk.png differ
diff --git a/unity-overlay/Assets/Resources/Flags/uk.png.meta b/unity-overlay/Assets/Resources/Flags/uk.png.meta
new file mode 100644
index 0000000..72dfc46
--- /dev/null
+++ b/unity-overlay/Assets/Resources/Flags/uk.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60007b2c74e8d9a0f3c5e7d1b4007
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity-overlay/Assets/Scripts/Boot/MenuBootstrap.cs b/unity-overlay/Assets/Scripts/Boot/MenuBootstrap.cs
index 0ceec3f..43d45e3 100644
--- a/unity-overlay/Assets/Scripts/Boot/MenuBootstrap.cs
+++ b/unity-overlay/Assets/Scripts/Boot/MenuBootstrap.cs
@@ -35,12 +35,25 @@ namespace MyInfoMate.Vr.Boot
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
+ ///
+ /// 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 .
+ ///
[SerializeField] string language = "FR";
[SerializeField] bool forgetPairing;
Transform _head;
FloatingMenu _menu;
+ LanguagePicker _picker;
+
+ ///
+ /// La langue de la visite en cours. 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.
+ ///
+ string _language;
Telemetry _telemetry;
ConfigurationExport _export;
SceneMessage _message;
@@ -77,9 +90,10 @@ namespace MyInfoMate.Vr.Boot
_client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var client = _client;
- var content = new ContentService(client, pairing.ConfigurationId);
+ _deviceId = pairing.DeviceId;
+ _content = new ContentService(client, pairing.ConfigurationId, _deviceId);
- var loaded = await content.LoadAsync();
+ var loaded = await _content.LoadAsync();
if (!loaded.Ok)
{
Show(loaded.Error);
@@ -95,27 +109,112 @@ namespace MyInfoMate.Vr.Boot
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);
+ 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.
- var fresh = await content.RefreshAsync();
+ await RefreshContentAsync();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// Demande sa langue au visiteur, et ne rend la main qu'une fois qu'il a répondu.
+ ///
+ /// Le choix vit le temps d'une visite. 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 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.
+ ///
+ 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();
+
+ _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;
+ }
+
+ ///
+ /// Va voir si le contenu a changé, et ne reconstruit que s'il a changé —
+ /// RefreshAsync rend null quand l'export est identique à celui en
+ /// cache, et quand le serveur n'a pas répondu.
+ ///
+ /// ⚠️ À n'appeler qu'aux moments où personne ne regarde le menu : 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.
+ ///
+ 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.");
- BuildMenu(fresh);
- await _menu.LoadImagesAsync(fresh, client);
- await ApplyBackdrop(fresh, client);
-
- _ = ContentPreloader.RunAsync(fresh, client);
+ await ApplyAsync(fresh);
}
void BuildMenu(ConfigurationExport export)
@@ -131,7 +230,7 @@ namespace MyInfoMate.Vr.Boot
_kiosk.Ended += StartNewVisit;
}
- _menu.Build(export, language);
+ _menu.Build(export, _language);
}
///
@@ -139,12 +238,31 @@ namespace MyInfoMate.Vr.Boot
/// 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()
+ 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)
@@ -155,7 +273,7 @@ namespace MyInfoMate.Vr.Boot
var section = _export?.Sections?.Find(s => s.Id == sectionId);
var title = section != null
- ? ConfigurationExport.Translate(section.Title, language) ?? section.Label
+ ? ConfigurationExport.Translate(section.Title, _language) ?? section.Label
: sectionId;
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
@@ -169,7 +287,7 @@ namespace MyInfoMate.Vr.Boot
{
OpenSection(sectionId);
- _skybox = SkyboxView.Create(_head);
+ _skybox = SkyboxView.Create(_head, _language);
_skybox.Closed += CloseSection;
_skybox.Interacted += () => _kiosk?.NotifyActivity();
@@ -183,14 +301,14 @@ namespace MyInfoMate.Vr.Boot
{
OpenSection(sectionId);
- _model = Scene3DView.Create(_head);
+ _model = Scene3DView.Create(_head, _language);
_model.Closed += CloseSection;
_model.Interacted += () => _kiosk?.NotifyActivity();
- if (!await _model.LoadAsync(section, _export, language))
+ if (!await _model.LoadAsync(section, _export, _language))
{
CloseSection();
- Show($"{title}\n\nCette scène 3D n'a pas pu être chargée.");
+ Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.Scene3DFailed, _language)}");
}
return;
}
@@ -200,17 +318,17 @@ namespace MyInfoMate.Vr.Boot
// 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.");
+ Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.NotSupported, _language)}");
return;
}
OpenSection(sectionId);
- _view = PagedView.Create(_head);
+ _view = PagedView.Create(_head, _language);
_view.Closed += CloseSection;
_view.Interacted += () => _kiosk?.NotifyActivity();
- await SectionPages.FillAsync(_view, section, _client, language);
+ await SectionPages.FillAsync(_view, section, _client, _language);
}
///
@@ -225,13 +343,18 @@ namespace MyInfoMate.Vr.Boot
/// ⚠️ 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 deviceId 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.
///
async void OnConfigurationChanged(string configurationId)
{
CloseSection();
- var content = new ContentService(_client, configurationId);
- var loaded = await content.LoadAsync();
+ _content = new ContentService(_client, configurationId, _deviceId);
+ var loaded = await _content.LoadAsync();
if (!loaded.Ok)
{
@@ -239,14 +362,13 @@ namespace MyInfoMate.Vr.Boot
return;
}
- BuildMenu(loaded.Export);
+ // 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 _menu.LoadImagesAsync(loaded.Export, _client);
- await ApplyBackdrop(loaded.Export, _client);
-
- _ = ContentPreloader.RunAsync(loaded.Export, _client);
+ await ApplyAsync(loaded.Export);
}
///
@@ -311,6 +433,14 @@ namespace MyInfoMate.Vr.Boot
ImmersiveBackdrop _backdrop;
Scene3DView _model;
ApiClient _client;
+ ContentService _content;
+
+ ///
+ /// 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
+ /// construit ici doit le recevoir.
+ ///
+ string _deviceId;
Kiosk.KioskSession _kiosk;
string _openedSectionId;
float _openedAt;
diff --git a/unity-overlay/Assets/Scripts/Menu/BentoLayout.cs b/unity-overlay/Assets/Scripts/Menu/BentoLayout.cs
new file mode 100644
index 0000000..7f38979
--- /dev/null
+++ b/unity-overlay/Assets/Scripts/Menu/BentoLayout.cs
@@ -0,0 +1,123 @@
+using System.Collections.Generic;
+
+namespace MyInfoMate.Vr.Menu
+{
+ ///
+ /// Placement « bento » dense sur une grille de colonnes — le socle du menu flottant.
+ ///
+ /// Port C# de myinfomate_layout (Dart), qui sert au manager et à
+ /// mymuseum-visitapp ; le web refait le même placement en CSS Grid. Trois
+ /// implémentations, une seule règle : chaque item va à la première cellule libre,
+ /// parcours ligne par ligne, qui l'accueille — soit grid-auto-flow: dense.
+ ///
+ /// Porté plutôt que calculé par le serveur : l'export reste une description du
+ /// contenu, pas une mise en page. Un placement calculé à distance ferait qu'un vieux
+ /// contenu en cache porterait une vieille mise en page.
+ ///
+ public static class BentoLayout
+ {
+ public struct Item
+ {
+ public string Id;
+ public int ColSpan;
+ public int RowSpan;
+
+ public Item(string id, int colSpan = 1, int rowSpan = 1)
+ {
+ Id = id;
+ ColSpan = colSpan;
+ RowSpan = rowSpan;
+ }
+ }
+
+ public struct Placement
+ {
+ public string Id;
+ public int Col;
+ public int Row;
+ public int ColSpan;
+ public int RowSpan;
+ }
+
+ public struct Result
+ {
+ public List Placements;
+ public int RowCount;
+ }
+
+ public static Result Compute(IList- items, int columns)
+ {
+ var cols = columns < 1 ? 1 : columns;
+ var occupied = new List();
+
+ void EnsureRow(int r)
+ {
+ while (occupied.Count <= r) occupied.Add(new bool[cols]);
+ }
+
+ bool Fits(int r, int c, int cs, int rs)
+ {
+ if (c + cs > cols) return false;
+ for (var rr = r; rr < r + rs; rr++)
+ {
+ EnsureRow(rr);
+ for (var cc = c; cc < c + cs; cc++)
+ if (occupied[rr][cc]) return false;
+ }
+ return true;
+ }
+
+ void Fill(int r, int c, int cs, int rs)
+ {
+ for (var rr = r; rr < r + rs; rr++)
+ {
+ EnsureRow(rr);
+ for (var cc = c; cc < c + cs; cc++) occupied[rr][cc] = true;
+ }
+ }
+
+ var placements = new List();
+
+ foreach (var item in items)
+ {
+ var cs = item.ColSpan < 1 ? 1 : (item.ColSpan > cols ? cols : item.ColSpan);
+ var rs = item.RowSpan < 1 ? 1 : item.RowSpan;
+
+ var placed = false;
+ var r = 0;
+ while (!placed)
+ {
+ for (var c = 0; c + cs <= cols; c++)
+ {
+ if (!Fits(r, c, cs, rs)) continue;
+
+ Fill(r, c, cs, rs);
+ placements.Add(new Placement
+ {
+ Id = item.Id,
+ Col = c,
+ Row = r,
+ ColSpan = cs,
+ RowSpan = rs,
+ });
+ placed = true;
+ break;
+ }
+ if (!placed) r++;
+ }
+ }
+
+ var rowCount = occupied.Count;
+ while (rowCount > 0 && IsEmpty(occupied[rowCount - 1])) rowCount--;
+
+ return new Result { Placements = placements, RowCount = rowCount };
+ }
+
+ static bool IsEmpty(bool[] row)
+ {
+ foreach (var cell in row)
+ if (cell) return false;
+ return true;
+ }
+ }
+}
diff --git a/unity-overlay/Assets/Scripts/Menu/FloatingMenu.cs b/unity-overlay/Assets/Scripts/Menu/FloatingMenu.cs
index b81415e..d1454ca 100644
--- a/unity-overlay/Assets/Scripts/Menu/FloatingMenu.cs
+++ b/unity-overlay/Assets/Scripts/Menu/FloatingMenu.cs
@@ -25,11 +25,30 @@ namespace MyInfoMate.Vr.Menu
const float HeightMeters = 1.45f;
- /// Écart angulaire entre deux panneaux, assez large pour que le regard tranche.
- const float StepDegrees = 16f;
+ ///
+ /// Écart entre deux tuiles, dans les deux sens. Serré, et c'est voulu : la
+ /// grille du menu doit se lire comme celle de l'app mobile — des tuiles qui se
+ /// touchent presque, donc une planche. Espacées, les mêmes tuiles se lisent comme
+ /// des panneaux flottants sans rapport les uns avec les autres.
+ ///
+ const float GapMeters = 0.03f;
- /// Au-delà, on passe à une seconde rangée plutôt que d'encercler le visiteur.
- const int MaxPerRow = 5;
+ ///
+ /// Écart angulaire entre deux colonnes, déduit de la largeur d'une cellule
+ /// et du rayon de l'arc : c'est le pas qui donne exactement
+ /// entre deux tuiles voisines. Le poser en dur ferait dépendre l'allure de la
+ /// grille d'un réglage sans rapport avec elle.
+ ///
+ static float StepDegrees =>
+ (MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg;
+
+ ///
+ /// Colonnes de la grille bento. Au-delà on passe à une rangée de plus plutôt que
+ /// d'encercler le visiteur : six colonnes font environ 83° d'ouverture, dans les
+ /// ±60° de confort une fois compté que le regard balaie. Doit rester aligné sur
+ /// `kVrMenuColumns` côté manager-app, qui dessine l'aperçu de cette même grille.
+ ///
+ const int Columns = 6;
readonly List _panels = new List();
@@ -82,23 +101,64 @@ namespace MyInfoMate.Vr.Menu
var sections = export.RootSections();
- for (var i = 0; i < sections.Count; i++)
+ var items = new List(sections.Count);
+ foreach (var section in sections)
+ {
+ items.Add(new BentoLayout.Item(
+ section.Id,
+ section.GridColSpan ?? 1,
+ section.GridRowSpan ?? 1));
+ }
+
+ var layout = BentoLayout.Compute(items, Columns);
+
+ // Un placement par item, dans l'ordre des items — donc dans celui des
+ // sections. C'est ce qui garde `_panels[i]` apparié à `sections[i]`, dont
+ // LoadImagesAsync dépend.
+ var placements = layout.Placements;
+
+ // La cascade doit suivre l'ordre de lecture de la grille, pas celui de la
+ // liste : avec des spans, une section placée plus loin dans la liste peut
+ // occuper une cellule plus haut à gauche, et les deux ordres divergent.
+ var readingRank = new int[placements.Count];
+ var byReading = new List(placements.Count);
+ for (var i = 0; i < placements.Count; i++) byReading.Add(i);
+ byReading.Sort((a, b) => placements[a].Row != placements[b].Row
+ ? placements[a].Row.CompareTo(placements[b].Row)
+ : placements[a].Col.CompareTo(placements[b].Col));
+ for (var r = 0; r < byReading.Count; r++) readingRank[byReading[r]] = r;
+
+ for (var i = 0; i < sections.Count && i < placements.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);
+ var placement = placements[i];
+
+ var panel = MenuItemPanel.Create(
+ sections[i].Id,
+ title,
+ transform,
+ CellsWidth(placement.ColSpan),
+ CellsHeight(placement.RowSpan));
+
+ Place(panel.transform, placement);
// 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);
+ panel.PlayAppear(readingRank[i] * 0.04f);
_panels.Add(panel);
}
}
+ static float CellsWidth(int colSpan) =>
+ colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * GapMeters;
+
+ static float CellsHeight(int rowSpan) =>
+ rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * GapMeters;
+
///
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
/// le premier octet d'image ne soit téléchargé.
@@ -118,19 +178,32 @@ namespace MyInfoMate.Vr.Menu
var texture = new Texture2D(2, 2);
if (texture.LoadImage(File.ReadAllBytes(path)))
_panels[i].SetImage(texture);
+
+ // La vignette est composée dans la texture du panneau : la source ne sert
+ // plus, et une photo de plein format gardée par tuile finirait par tenir
+ // plus de place que tout le reste du menu.
+ Destroy(texture);
}
}
- void Place(Transform panel, int index, int total)
+ ///
+ /// Mappe une cellule de la grille sur l'arc : la colonne devient un angle, la
+ /// rangée une hauteur. La grille est centrée sur , pas sur le
+ /// nombre d'items — une rangée incomplète reste alignée sur celle du dessus, comme
+ /// dans l'aperçu du manager.
+ ///
+ void Place(Transform panel, BentoLayout.Placement placement)
{
- var row = index / MaxPerRow;
- var inRow = index % MaxPerRow;
- var countInRow = Mathf.Min(total - row * MaxPerRow, MaxPerRow);
-
- var angle = (inRow - (countInRow - 1) / 2f) * StepDegrees;
+ var angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
- var height = HeightMeters - row * (MenuItemPanel.HeightMeters + 0.06f);
+ var rowStep = MenuItemPanel.HeightMeters + GapMeters;
+
+ // Le panneau est centré sur les rangées qu'il couvre, sinon un panneau double
+ // déborderait vers le haut au lieu de descendre.
+ var height = HeightMeters
+ - placement.Row * rowStep
+ - (placement.RowSpan - 1) * rowStep / 2f;
panel.localPosition = new Vector3(
Mathf.Sin(radians) * RadiusMeters,
diff --git a/unity-overlay/Assets/Scripts/Menu/LanguagePicker.cs b/unity-overlay/Assets/Scripts/Menu/LanguagePicker.cs
new file mode 100644
index 0000000..91d5433
--- /dev/null
+++ b/unity-overlay/Assets/Scripts/Menu/LanguagePicker.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+using MyInfoMate.Vr.Ui;
+using UnityEngine;
+
+namespace MyInfoMate.Vr.Menu
+{
+ ///
+ /// Le choix de la langue, posé avant le menu.
+ ///
+ /// Pourquoi le demander, et pourquoi à chaque visite. Un casque en borne passe
+ /// de main en main : la langue n'est pas un réglage de l'appareil, c'est une propriété
+ /// du visiteur en cours. Elle vit donc le temps d'une visite et rien de plus — rien
+ /// n'est écrit sur le disque, et le visiteur suivant est de nouveau accueilli par cet
+ /// écran. C'est la même logique que la session de statistiques (voir
+ /// ), et pour la même raison.
+ ///
+ /// Changer de langue ne coûte aucun appel réseau : l'export porte toutes les
+ /// traductions d'un coup — c'est explicitement l'arbitrage de
+ /// ConfigurationExport.FetchAsync. Le menu se reconstruit simplement avec
+ /// l'autre langue, y compris hors ligne.
+ ///
+ /// Ce sont les mêmes tuiles que le menu, avec un drapeau à la place de la
+ /// photo de section : c'est le premier écran de la visite, et il doit annoncer la
+ /// grammaire de ce qui suit plutôt que d'être un formulaire posé devant. Les drapeaux
+ /// sont ceux de l'app mobile, à l'identique.
+ ///
+ /// L'écran ne s'affiche pas quand la visite n'est offerte que dans une langue : un
+ /// choix à une possibilité n'est pas un choix, c'est un écran de plus avant le contenu.
+ ///
+ public class LanguagePicker : MonoBehaviour
+ {
+ const float RadiusMeters = 2.2f;
+
+ /// Hauteur du centre de la rangée de tuiles.
+ const float HeightMeters = 1.35f;
+
+ /// Le même écart que la grille du menu, pour la même raison.
+ const float GapMeters = 0.03f;
+
+ static float StepDegrees =>
+ (MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg;
+
+ /// Le code de la langue choisie, tel qu'il part dans les traductions.
+ public event Action Chosen;
+
+ public static LanguagePicker Create(Transform head, IList languages)
+ {
+ var root = new GameObject("LanguagePicker");
+ var picker = root.AddComponent();
+
+ if (head != null)
+ {
+ var forward = head.forward;
+ forward.y = 0f;
+ if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
+
+ root.transform.position = new Vector3(head.position.x, 0f, head.position.z);
+ root.transform.rotation = Quaternion.LookRotation(forward.normalized);
+ }
+
+ var selector = root.AddComponent();
+ selector.Configure(head);
+ selector.Selected += picker.OnSelected;
+
+ picker.Build(languages);
+ return picker;
+ }
+
+ void Build(IList languages)
+ {
+ // La consigne est écrite dans chacune des langues proposées : celui
+ // qui ne lit pas le français doit pouvoir comprendre l'écran qui lui demande
+ // s'il lit le français.
+ var prompts = new List();
+ foreach (var language in languages)
+ {
+ var prompt = Strings.Get(Strings.ChooseLanguage, language);
+ if (!prompts.Contains(prompt)) prompts.Add(prompt);
+ }
+
+ var heading = Label.Create(transform, "Heading", Label.Anchor.MiddleCenter,
+ 0.075f, Color.white);
+ heading.Text = string.Join("\n", prompts);
+ heading.transform.localPosition = new Vector3(
+ 0f,
+ HeightMeters + MenuItemPanel.HeightMeters * 0.5f + 0.13f + prompts.Count * 0.055f,
+ RadiusMeters);
+
+ for (var i = 0; i < languages.Count; i++)
+ {
+ var panel = MenuItemPanel.Create(
+ languages[i], Strings.NameOf(languages[i]), transform);
+
+ // Une langue sans drapeau garde la tuile nue, avec son nom au centre :
+ // c'est déjà lisible, et c'est ce que fait une tuile de section sans photo.
+ var flag = Flag(languages[i]);
+ if (flag != null) panel.SetImage(flag);
+
+ Place(panel.transform, i, languages.Count);
+ panel.PlayAppear(i * 0.06f);
+ }
+ }
+
+ ///
+ /// Le drapeau d'une langue, ou null. ⚠️ La texture vient de Resources et est
+ /// partagée : contrairement aux vignettes de section, elle ne se détruit pas
+ /// avec la tuile — Unity la garde pour toute la vie de l'app, et la détruire ferait
+ /// un drapeau vide à la visite suivante.
+ ///
+ static Texture2D Flag(string language) =>
+ Resources.Load("Flags/" + Strings.Normalize(language).ToLowerInvariant());
+
+ ///
+ /// Les tuiles sur l'arc du menu, centrées sur le regard : c'est la même géométrie
+ /// que , à une rangée.
+ ///
+ void Place(Transform panel, int index, int count)
+ {
+ var angle = (index - (count - 1) * 0.5f) * StepDegrees;
+ var radians = angle * Mathf.Deg2Rad;
+
+ panel.localPosition = new Vector3(
+ Mathf.Sin(radians) * RadiusMeters,
+ HeightMeters,
+ Mathf.Cos(radians) * RadiusMeters);
+
+ panel.localRotation = Quaternion.Euler(0f, angle, 0f);
+ }
+
+ void OnSelected(IAimTarget target)
+ {
+ if (target is MenuItemPanel panel) Chosen?.Invoke(panel.Id);
+ }
+ }
+}
diff --git a/unity-overlay/Assets/Scripts/Menu/MenuItemPanel.cs b/unity-overlay/Assets/Scripts/Menu/MenuItemPanel.cs
index ef434ca..3f9dac6 100644
--- a/unity-overlay/Assets/Scripts/Menu/MenuItemPanel.cs
+++ b/unity-overlay/Assets/Scripts/Menu/MenuItemPanel.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using MyInfoMate.Vr.Ui;
using UnityEngine;
@@ -20,15 +21,44 @@ namespace MyInfoMate.Vr.Menu
/// 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.
+ ///
+ /// Et c'est aussi pour ça que la photo est composée dans la texture et
+ /// non posée sur un deuxième quad : la tuile de home_3.0 et celle de
+ /// visitapp-web 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.
///
public class MenuItemPanel : MonoBehaviour, IAimTarget
{
+ /// Taille d'une cellule 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 ).
public const float WidthMeters = 0.5f;
+
public const float HeightMeters = 0.34f;
+ ///
+ /// Une commande — « Retour au menu » — n'est pas une tuile de contenu : elle est
+ /// large et basse, comme un bouton, et porte un chevron.
+ ///
+ 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);
+ ///
+ /// Une tuile qui porte une photo ne se teinte pas : la teinte de survol
+ /// virerait la photo au bleu. Elle s'éclaire — le panneau au repos est
+ /// légèrement assombri, et le survol le rend à sa pleine lumière.
+ ///
+ static readonly Color PhotoIdle = new Color(0.80f, 0.82f, 0.86f, 1f);
+
+ static readonly Color PhotoHover = Color.white;
+
/// Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.
const float HoverScale = 1.07f;
@@ -44,9 +74,30 @@ namespace MyInfoMate.Vr.Menu
///
public string Id { get; private set; }
+ /// Hauteur de ligne du titre d'une tuile, en mètres.
+ const float CardTextMeters = 0.038f;
+
+ /// Celui d'une commande, qui doit se lire d'un coup d'œil.
+ const float ActionTextMeters = 0.062f;
+
+ /// Place réservée au chevron sur la gauche d'une commande.
+ const float ChevronWidthMeters = 0.13f;
+
Material _background;
LineRenderer _progress;
- Transform _image;
+ Label _title;
+ Style _style;
+ bool _chevron = true;
+
+ ///
+ /// 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.
+ ///
+ Texture2D _composed;
+
+ float _width = WidthMeters;
+ float _height = HeightMeters;
Vector3 _baseScale = Vector3.one;
bool _baseScaleKnown;
@@ -58,28 +109,57 @@ namespace MyInfoMate.Vr.Menu
float _appearElapsed;
bool _appearing;
- public static MenuItemPanel Create(string id, string title, Transform parent)
+ ///
+ /// et 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.
+ ///
+ 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();
- panel.Build(id, title);
+ panel._width = widthMeters;
+ panel._height = heightMeters;
+ panel.Build(id, title, Style.Card);
return panel;
}
- void Build(string id, string title)
+ ///
+ /// 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 tout court : un panneau de la taille
+ /// d'une tuile, avec un titre de tuile, ne se voyait pas.
+ ///
+ 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();
+ 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(WidthMeters, HeightMeters, 1f);
+ background.transform.localScale = new Vector3(_width, _height, 1f);
_background = background.GetComponent().material =
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
- _background.mainTexture = PanelTexture;
+ _background.mainTexture = PanelTextureFor(_width, _height);
Transparent(_background);
_background.color = Idle;
@@ -96,9 +176,31 @@ namespace MyInfoMate.Vr.Menu
var box = background.AddComponent();
box.size = new Vector3(1f, 1f, 0.02f);
- var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
- label.transform.localPosition = new Vector3(0f, -HeightMeters * 0.30f, -0.01f);
- label.Text = Label.Wrap(title, 22);
+ 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();
}
@@ -128,7 +230,10 @@ namespace MyInfoMate.Vr.Menu
_hover = Mathf.MoveTowards(
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
- var color = Color.Lerp(Idle, Hover, _hover);
+ var color = Color.Lerp(
+ _composed != null ? PhotoIdle : Idle,
+ _composed != null ? PhotoHover : Hover,
+ _hover);
if (_appearing)
{
@@ -139,7 +244,7 @@ namespace MyInfoMate.Vr.Menu
// 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 Idle/Hover, jamais modifiée
+ // 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));
@@ -174,31 +279,147 @@ namespace MyInfoMate.Vr.Menu
/// 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 remplace 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.
///
public void SetImage(Texture2D texture)
{
- if (texture == null) return;
+ if (texture == null || _style != Style.Card) return;
- if (_image == null)
+ 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);
+ }
+
+ /// Hauteur du centre du titre au-dessus du bord bas du panneau.
+ const float TitleBaselineMeters = 0.075f;
+
+ ///
+ /// Résolution de la texture composée. Plus fine que celle du fond nu
+ /// () : un aplat supporte 512 px/m, une photo non.
+ ///
+ const float PhotoPixelsPerMeter = 1024f;
+
+ ///
+ /// La photo cadrée en cover, arrondie et assombrie vers le bas — la tuile
+ /// de home_3.0, refaite en pixels.
+ ///
+ /// ⚠️ Les pixels de la source sont lus une fois, puis échantillonnés à la
+ /// main. GetPixelBilinear 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.
+ ///
+ 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 quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
- quad.name = "Image";
- quad.transform.SetParent(transform, false);
- quad.transform.localPosition = new Vector3(0f, HeightMeters * 0.16f, -0.005f);
- quad.transform.localScale = new Vector3(WidthMeters * 0.86f, HeightMeters * 0.5f, 1f);
+ var color = Sample(source32, source.width, source.height,
+ cropX + (x + 0.5f) / scale,
+ cropY + (y + 0.5f) / scale);
- // L'image ne doit pas intercepter le regard : c'est le panneau qui est
- // la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
- Destroy(quad.GetComponent());
- _image = quad.transform;
+ // 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;
}
- var material = new Material(Shader.Find("Universal Render Pipeline/Unlit"))
+ // 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)
{
- mainTexture = texture
+ wrapMode = TextureWrapMode.Clamp,
+ filterMode = FilterMode.Trilinear
};
- NoCulling(material);
- _image.GetComponent().material = material;
+
+ texture.SetPixels32(pixels);
+ texture.Apply();
+ return texture;
+ }
+
+ /// Échantillonnage bilinéaire, en pixels de la source.
+ 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);
+ }
+
+ ///
+ /// Le chevron d'une commande, tracé plutôt que dessiné dans une texture : trois
+ /// points suffisent, et LineRenderer est déjà là pour l'anneau de visée.
+ /// Une police ne rendrait pas le même signe d'un casque à l'autre.
+ ///
+ 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();
+ 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));
+ }
+
+ /// La texture composée n'appartient qu'à ce panneau.
+ void OnDestroy()
+ {
+ if (_composed != null) Destroy(_composed);
}
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
@@ -218,52 +439,67 @@ namespace MyInfoMate.Vr.Menu
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
}
- static Texture2D _panelTexture;
+ static readonly Dictionary PanelTextures = new Dictionary();
+
+ /// Résolution du dessin, en pixels par mètre de panneau.
+ const float PixelsPerMeter = 512f;
+
+ /// Rayon des coins, en mètres — identique sur tous les panneaux.
+ const float CornerRadiusMeters = 0.059f;
+
+ /// Épaisseur du liseré, en mètres.
+ const float BorderMeters = 0.006f;
///
- /// Le rectangle arrondi, généré une fois pour tous les panneaux. 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.
+ /// 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.
+ ///
+ /// ⚠️ Une texture par taille de panneau, pas une pour tous. 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.
///
- static Texture2D PanelTexture
+ static Texture2D PanelTextureFor(float widthMeters, float heightMeters)
{
- get
+ 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)
{
- if (_panelTexture != null) return _panelTexture;
+ wrapMode = TextureWrapMode.Clamp,
+ filterMode = FilterMode.Bilinear
+ };
- const int width = 256;
- const int height = 176;
- const float radius = 30f;
- const float border = 3f;
+ var pixels = new Color32[width * height];
- var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
- {
- wrapMode = TextureWrapMode.Clamp,
- filterMode = FilterMode.Bilinear
- };
+ 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);
- var pixels = new Color32[width * height];
+ // 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;
- 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();
-
- _panelTexture = texture;
- return _panelTexture;
+ pixels[y * width + x] = new Color(lit, lit, lit, alpha);
}
+
+ texture.SetPixels32(pixels);
+ texture.Apply();
+
+ PanelTextures[key] = texture;
+ return texture;
}
/// Distance signée au rectangle arrondi, en pixels.
diff --git a/unity-overlay/Assets/Scripts/Menu/PagedView.cs b/unity-overlay/Assets/Scripts/Menu/PagedView.cs
index 9f8b10c..6fea27f 100644
--- a/unity-overlay/Assets/Scripts/Menu/PagedView.cs
+++ b/unity-overlay/Assets/Scripts/Menu/PagedView.cs
@@ -49,6 +49,7 @@ namespace MyInfoMate.Vr.Menu
Ui.Label _text;
MenuItemPanel _previous;
MenuItemPanel _next;
+ string _language;
int _index;
/// Le visiteur veut revenir au menu.
@@ -62,10 +63,11 @@ namespace MyInfoMate.Vr.Menu
public int PageCount => _pages.Count;
- public static PagedView Create(Transform head)
+ public static PagedView Create(Transform head, string language)
{
var root = new GameObject("PagedView");
var view = root.AddComponent();
+ view._language = language;
if (head != null)
{
@@ -109,7 +111,11 @@ namespace MyInfoMate.Vr.Menu
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
_next = Command(CommandNext, "▶", ImageWidthMeters * 0.62f, CenterHeight);
- Command(CommandBack, "Retour au menu", 0f, CenterHeight - ImageHeightMeters * 0.95f);
+
+ var back = MenuItemPanel.CreateAction(
+ CommandBack, Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
+ back.transform.localPosition = new Vector3(
+ 0f, CenterHeight - ImageHeightMeters * 0.95f, DistanceMeters - 0.02f);
UpdateCommands();
}
diff --git a/unity-overlay/Assets/Scripts/Menu/Scene3DView.cs b/unity-overlay/Assets/Scripts/Menu/Scene3DView.cs
index 7fe5ca0..2033581 100644
--- a/unity-overlay/Assets/Scripts/Menu/Scene3DView.cs
+++ b/unity-overlay/Assets/Scripts/Menu/Scene3DView.cs
@@ -33,7 +33,7 @@ namespace MyInfoMate.Vr.Menu
public event Action Closed;
public event Action Interacted;
- public static Scene3DView Create(Transform head)
+ public static Scene3DView Create(Transform head, string language)
{
var root = new GameObject("Scene3DView");
var view = root.AddComponent();
@@ -53,9 +53,9 @@ namespace MyInfoMate.Vr.Menu
selector.Configure(head);
selector.Selected += view.OnSelected;
- view._back = MenuItemPanel.Create("__back", "Retour au menu", root.transform);
- view._back.transform.localPosition = new Vector3(0f, 0.9f, 2.2f);
- view._back.transform.localScale = Vector3.one * 0.6f;
+ view._back = MenuItemPanel.CreateAction(
+ "__back", Ui.Strings.Get(Ui.Strings.BackToMenu, language), root.transform);
+ view._back.transform.localPosition = new Vector3(0f, 0.85f, 2.2f);
return view;
}
diff --git a/unity-overlay/Assets/Scripts/Menu/SectionPages.cs b/unity-overlay/Assets/Scripts/Menu/SectionPages.cs
index 4ae3e8d..84c0803 100644
--- a/unity-overlay/Assets/Scripts/Menu/SectionPages.cs
+++ b/unity-overlay/Assets/Scripts/Menu/SectionPages.cs
@@ -47,7 +47,7 @@ namespace MyInfoMate.Vr.Menu
}
if (view != null && view.PageCount == 0)
- view.ShowEmpty("Ce contenu est vide.");
+ view.ShowEmpty(Ui.Strings.Get(Ui.Strings.EmptyContent, language));
}
static async Task FillSlider(PagedView view,
@@ -155,7 +155,7 @@ namespace MyInfoMate.Vr.Menu
}
if (view.PageCount == 0)
- view.ShowEmpty("Rien au programme aujourd'hui.");
+ view.ShowEmpty(Ui.Strings.Get(Ui.Strings.NothingToday, language));
}
static async Task LoadImage(ApiClient client, string url)
diff --git a/unity-overlay/Assets/Scripts/Menu/SkyboxView.cs b/unity-overlay/Assets/Scripts/Menu/SkyboxView.cs
index 6dbe5d0..911034f 100644
--- a/unity-overlay/Assets/Scripts/Menu/SkyboxView.cs
+++ b/unity-overlay/Assets/Scripts/Menu/SkyboxView.cs
@@ -39,15 +39,17 @@ namespace MyInfoMate.Vr.Menu
Texture2D _image;
MenuItemPanel _back;
Transform _head;
+ string _language;
public event Action Closed;
public event Action Interacted;
- public static SkyboxView Create(Transform head)
+ public static SkyboxView Create(Transform head, string language)
{
var root = new GameObject("SkyboxView");
var view = root.AddComponent();
view._head = head;
+ view._language = language;
if (head != null)
{
@@ -75,8 +77,8 @@ namespace MyInfoMate.Vr.Menu
if (_skybox == null) return;
// Le seul élément posé dans la scène : de quoi ressortir.
- _back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform);
- _back.transform.localScale = Vector3.one * 0.6f;
+ _back = MenuItemPanel.CreateAction(
+ "__back", Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
PlaceBack();
}
diff --git a/unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs b/unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs
index 196ff6a..535f20e 100644
--- a/unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs
+++ b/unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs
@@ -14,15 +14,24 @@ namespace MyInfoMate.Vr.Net
/// est le DTO de l'import/export du back-office, déjà stable, et c'est aussi celui que
/// mymuseum-visitapp télécharge pour une visite hors ligne.
///
- /// ⚠️ L'export ne rend qu'une langue à la fois. language traverse jusqu'à
- /// Section.GetReferencedResourceIds(language) côté serveur : changer de langue
- /// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la
- /// question ouverte du lot XR-3 ; en attendant, on recharge.
+ /// L'export porte toutes les langues à la fois, et c'est ce qui permet au
+ /// visiteur de choisir la sienne au début de sa visite sans aucun appel réseau : le
+ /// paramètre language du serveur ne filtre que les ressources, jamais
+ /// les textes. Le détail de l'arbitrage est sur .
///
public class ConfigurationExport
{
public string Id;
public string Label;
+
+ ///
+ /// Les langues dans lesquelles cette visite est proposée, telles que le
+ /// gestionnaire les a cochées. Le champ vient de ConfigurationDTO, dont
+ /// l'export hérite ; il peut manquer sur un contenu ancien — d'où le repli de
+ /// .
+ ///
+ public List Languages = new List();
+
public List Sections = new List();
///
@@ -129,6 +138,15 @@ namespace MyInfoMate.Vr.Net
public bool IsSubSection;
public string ParentId;
public int? Order;
+
+ ///
+ /// Taille de la section dans la grille du menu, en cellules. null vaut 1 —
+ /// un contenu créé avant le bento VR reste une cellule simple.
+ ///
+ public int? GridColSpan;
+
+ public int? GridRowSpan;
+
public string ImageSource;
public List Title = new List();
public List Description = new List();
@@ -249,16 +267,50 @@ namespace MyInfoMate.Vr.Net
public string Value;
}
- /// Le texte dans la langue demandée, ou la première traduction disponible.
+ ///
+ /// Le texte dans la langue du visiteur.
+ ///
+ /// Trois essais, dans cet ordre : la langue demandée, le français, puis la
+ /// première traduction qui porte quelque chose. C'est exactement la chaîne
+ /// de t() côté visitapp-web et de son équivalent Flutter — un gestionnaire
+ /// qui coche une langue sans la traduire doit voir la même chose sur les quatre
+ /// canaux, sinon on lui fait chercher un bug qui n'existe pas.
+ ///
+ /// Une traduction vide ne compte pas : le manager en crée une par langue cochée,
+ /// remplie ou non, et s'arrêter dessus rendrait une tuile sans nom.
+ ///
public static string Translate(List translations, string language)
{
if (translations == null || translations.Count == 0) return null;
- foreach (var t in translations)
- if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase))
- return PlainText(t.Value);
+ return Find(translations, language)
+ ?? Find(translations, Ui.Strings.Fallback)
+ ?? First(translations);
+ }
- return PlainText(translations[0].Value);
+ static string Find(List translations, string language)
+ {
+ foreach (var translation in translations)
+ {
+ if (!string.Equals(translation.Language, language,
+ System.StringComparison.OrdinalIgnoreCase)) continue;
+
+ var text = PlainText(translation.Value);
+ if (!string.IsNullOrWhiteSpace(text)) return text;
+ }
+
+ return null;
+ }
+
+ static string First(List translations)
+ {
+ foreach (var translation in translations)
+ {
+ var text = PlainText(translation.Value);
+ if (!string.IsNullOrWhiteSpace(text)) return text;
+ }
+
+ return null;
}
///
@@ -285,6 +337,37 @@ namespace MyInfoMate.Vr.Net
return text.Trim();
}
+ ///
+ /// Ce qu'on peut proposer au visiteur, normalisé en majuscules et sans doublon.
+ ///
+ /// Le champ languages de la configuration fait foi : c'est la liste que le
+ /// gestionnaire a choisie, et elle inclut une langue même quand une section n'y est
+ /// pas encore traduite. À défaut — contenu antérieur au champ — on retombe sur les
+ /// langues réellement présentes dans les titres, ce qui donne au moins de quoi
+ /// afficher un choix juste.
+ ///
+ public List AvailableLanguages()
+ {
+ var codes = new List();
+
+ void Add(string language)
+ {
+ if (string.IsNullOrWhiteSpace(language)) return;
+
+ var code = Ui.Strings.Normalize(language);
+ if (!codes.Contains(code)) codes.Add(code);
+ }
+
+ foreach (var language in Languages) Add(language);
+ if (codes.Count > 0) return codes;
+
+ foreach (var section in Sections)
+ foreach (var title in section.Title)
+ Add(title.Language);
+
+ return codes;
+ }
+
/// Les sections de premier niveau, dans l'ordre du manager.
public List RootSections()
{
diff --git a/unity-overlay/Assets/Scripts/Net/ContentService.cs b/unity-overlay/Assets/Scripts/Net/ContentService.cs
index f2f9769..f26733a 100644
--- a/unity-overlay/Assets/Scripts/Net/ContentService.cs
+++ b/unity-overlay/Assets/Scripts/Net/ContentService.cs
@@ -24,10 +24,19 @@ namespace MyInfoMate.Vr.Net
readonly ApiClient _client;
readonly string _configurationId;
- public ContentService(ApiClient client, string configurationId)
+ ///
+ /// Ce casque. Il désigne le lien application/configuration côté serveur, donc
+ /// l'agencement qui lui est propre : ordre des sections, tailles de panneaux et
+ /// sections masquées pour le canal VR seulement. Sans lui, l'export rend la
+ /// configuration nue, telle que la voient le mobile et le web.
+ ///
+ readonly string _deviceId;
+
+ public ContentService(ApiClient client, string configurationId, string deviceId = null)
{
_client = client;
_configurationId = configurationId;
+ _deviceId = deviceId;
}
public class Loaded
@@ -68,6 +77,10 @@ namespace MyInfoMate.Vr.Net
return await FetchAsync();
}
+ string ExportPath => string.IsNullOrEmpty(_deviceId)
+ ? $"/api/configuration/{_configurationId}/export"
+ : $"/api/configuration/{_configurationId}/export?deviceId={_deviceId}";
+
///
/// Rafraîchit le cache sans bloquer l'affichage. Renvoie le contenu neuf, ou null
/// si rien n'a changé ou si le serveur n'a pas répondu — dans les deux cas
@@ -76,8 +89,7 @@ namespace MyInfoMate.Vr.Net
public async Task RefreshAsync()
{
var before = ContentCache.ReadExport(_configurationId);
- var raw = await _client.GetRawAsync(
- $"/api/configuration/{_configurationId}/export");
+ var raw = await _client.GetRawAsync(ExportPath);
if (!raw.Ok)
{
@@ -97,8 +109,7 @@ namespace MyInfoMate.Vr.Net
async Task FetchAsync()
{
- var raw = await _client.GetRawAsync(
- $"/api/configuration/{_configurationId}/export");
+ var raw = await _client.GetRawAsync(ExportPath);
if (!raw.Ok) return new Loaded { Error = raw.Error };
diff --git a/unity-overlay/Assets/Scripts/Net/Telemetry.cs b/unity-overlay/Assets/Scripts/Net/Telemetry.cs
index 61b20b3..e689be2 100644
--- a/unity-overlay/Assets/Scripts/Net/Telemetry.cs
+++ b/unity-overlay/Assets/Scripts/Net/Telemetry.cs
@@ -29,7 +29,14 @@ namespace MyInfoMate.Vr.Net
readonly ApiClient _client;
readonly string _instanceId;
readonly string _configurationId;
- readonly string _language;
+
+ ///
+ /// La langue de la visite en cours. Elle n'est pas fixée à la construction : le
+ /// visiteur la choisit au début de sa visite, sur un casque qui tourne
+ /// depuis le matin — et une visite comptée dans la mauvaise langue vaut mieux
+ /// corrigée que figée.
+ ///
+ public string Language { get; set; }
///
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
@@ -43,7 +50,7 @@ namespace MyInfoMate.Vr.Net
_client = client;
_instanceId = instanceId;
_configurationId = configurationId;
- _language = language;
+ Language = language;
}
public void StartNewSession() => SessionId = NewSessionId();
@@ -69,7 +76,7 @@ namespace MyInfoMate.Vr.Net
sessionId = SessionId,
eventType,
appType = AppTypeVr,
- language = _language,
+ language = Language,
durationSeconds,
metadata,
timestamp = DateTime.UtcNow
diff --git a/unity-overlay/Assets/Scripts/Scene/HotspotInstance.cs b/unity-overlay/Assets/Scripts/Scene/HotspotInstance.cs
index 2790bad..698122e 100644
--- a/unity-overlay/Assets/Scripts/Scene/HotspotInstance.cs
+++ b/unity-overlay/Assets/Scripts/Scene/HotspotInstance.cs
@@ -171,8 +171,12 @@ namespace MyInfoMate.Vr.Scene
{
if (string.IsNullOrEmpty(content.AssetId)) continue;
+ // Sans OrdinalIgnoreCase, un export qui écrit « fr » ne répond pas à un
+ // choix normalisé en « FR » : le hotspot resterait muet sans rien dire.
var hasText = content.Description != null
- && content.Description.Exists(t => t.Language == language);
+ && content.Description.Exists(
+ t => string.Equals(t.Language, language,
+ System.StringComparison.OrdinalIgnoreCase));
if (hasText || _hotspot.Contents.Count == 1) return content.AssetId;
}
diff --git a/unity-overlay/Assets/Scripts/Ui/Label.cs b/unity-overlay/Assets/Scripts/Ui/Label.cs
index c5f4956..2b487db 100644
--- a/unity-overlay/Assets/Scripts/Ui/Label.cs
+++ b/unity-overlay/Assets/Scripts/Ui/Label.cs
@@ -1,4 +1,4 @@
-using TMPro;
+using TMPro;
using UnityEngine;
namespace MyInfoMate.Vr.Ui
@@ -63,6 +63,15 @@ namespace MyInfoMate.Vr.Ui
// Sans rect assez large, TMP tronque au lieu d'afficher.
tmp.rectTransform.sizeDelta = new Vector2(4f, 2f);
+ // ⚠️ Le pivot, sinon le texte part en l'air. TMP aligne dans son rect, et
+ // ce rect est centré sur l'origine : un texte aligné en haut d'un rect de
+ // 2 m s'affiche donc 1 m au-dessus du point où on a posé le Label. En
+ // remontant le pivot, le haut du rect est l'origine — c'est ce que fait
+ // TextMesh en UpperCenter, sur lequel les positions des appelants sont
+ // calées.
+ tmp.rectTransform.pivot = new Vector2(
+ 0.5f, anchor == Anchor.TopCenter ? 1f : 0.5f);
+
_tmp = tmp;
return;
}
diff --git a/unity-overlay/Assets/Scripts/Ui/Strings.cs b/unity-overlay/Assets/Scripts/Ui/Strings.cs
new file mode 100644
index 0000000..9fa6119
--- /dev/null
+++ b/unity-overlay/Assets/Scripts/Ui/Strings.cs
@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+
+namespace MyInfoMate.Vr.Ui
+{
+ ///
+ /// Les textes de l'app elle-même — « Retour au menu », « Ce contenu est vide » —
+ /// par opposition aux textes du contenu, qui viennent de l'export et passent par
+ /// ConfigurationExport.Translate.
+ ///
+ /// Pourquoi une table en dur plutôt que le mécanisme du manager. Il y a une
+ /// dizaine de phrases, elles ne changent pas avec le contenu, et un casque démarre
+ /// souvent sans réseau : les faire venir du serveur ferait dépendre le bouton retour
+ /// d'une requête. Le manager, lui, a des centaines de libellés et un back-office pour
+ /// les éditer — ce n'est pas le même problème.
+ ///
+ /// Les quatre langues sont celles que le champ languages d'une configuration
+ /// propose. Une langue inconnue retombe sur le français plutôt que d'afficher une
+ /// clé : un visiteur qui lit la mauvaise langue s'en sort, un visiteur qui lit
+ /// backToMenu non.
+ ///
+ public static class Strings
+ {
+ public const string BackToMenu = "backToMenu";
+ public const string ChooseLanguage = "chooseLanguage";
+ public const string EmptyContent = "emptyContent";
+ public const string NothingToday = "nothingToday";
+ public const string NotSupported = "notSupported";
+ public const string Scene3DFailed = "scene3dFailed";
+
+ static readonly Dictionary> Table =
+ new Dictionary>
+ {
+ ["FR"] = new Dictionary
+ {
+ [BackToMenu] = "Retour au menu",
+ [ChooseLanguage] = "Choisissez votre langue",
+ [EmptyContent] = "Ce contenu est vide.",
+ [NothingToday] = "Rien au programme aujourd'hui.",
+ [NotSupported] = "Ce type de contenu n'est pas encore affichable dans le casque.",
+ [Scene3DFailed] = "Cette scène 3D n'a pas pu être chargée.",
+ },
+ ["EN"] = new Dictionary
+ {
+ [BackToMenu] = "Back to menu",
+ [ChooseLanguage] = "Choose your language",
+ [EmptyContent] = "This content is empty.",
+ [NothingToday] = "Nothing on the programme today.",
+ [NotSupported] = "This type of content cannot be shown in the headset yet.",
+ [Scene3DFailed] = "This 3D scene could not be loaded.",
+ },
+ ["NL"] = new Dictionary
+ {
+ [BackToMenu] = "Terug naar het menu",
+ [ChooseLanguage] = "Kies uw taal",
+ [EmptyContent] = "Deze inhoud is leeg.",
+ [NothingToday] = "Vandaag staat er niets op het programma.",
+ [NotSupported] = "Dit soort inhoud is nog niet zichtbaar in de bril.",
+ [Scene3DFailed] = "Deze 3D-scène kon niet worden geladen.",
+ },
+ ["DE"] = new Dictionary
+ {
+ [BackToMenu] = "Zurück zum Menü",
+ [ChooseLanguage] = "Wählen Sie Ihre Sprache",
+ [EmptyContent] = "Dieser Inhalt ist leer.",
+ [NothingToday] = "Heute steht nichts auf dem Programm.",
+ [NotSupported] = "Diese Art von Inhalt ist in der Brille noch nicht verfügbar.",
+ [Scene3DFailed] = "Diese 3D-Szene konnte nicht geladen werden.",
+ },
+ };
+
+ ///
+ /// Le nom d'une langue dans cette langue. C'est la seule façon de nommer un
+ /// choix de langue : « Dutch » ne dit rien à quelqu'un qui cherche « Nederlands ».
+ ///
+ static readonly Dictionary Names = new Dictionary
+ {
+ ["FR"] = "Français",
+ ["EN"] = "English",
+ ["NL"] = "Nederlands",
+ ["DE"] = "Deutsch",
+ };
+
+ public const string Fallback = "FR";
+
+ public static string Get(string key, string language)
+ {
+ var code = Normalize(language);
+
+ if (Table.TryGetValue(code, out var texts) && texts.TryGetValue(key, out var text))
+ return text;
+
+ return Table[Fallback][key];
+ }
+
+ /// Le nom à afficher pour une langue, son code à défaut.
+ public static string NameOf(string language)
+ {
+ var code = Normalize(language);
+ return Names.TryGetValue(code, out var name) ? name : code;
+ }
+
+ ///
+ /// L'export écrit les langues tantôt en fr, tantôt en FR — et les
+ /// traductions d'un même contenu ne suivent pas toujours la casse du champ
+ /// languages de la configuration. Tout passe par ici.
+ ///
+ public static string Normalize(string language) =>
+ string.IsNullOrEmpty(language) ? Fallback : language.Trim().ToUpperInvariant();
+ }
+}
diff --git a/unity/MyInfoMateVR/Assets/Icons.meta b/unity/MyInfoMateVR/Assets/Icons.meta
new file mode 100644
index 0000000..603f268
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Icons.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 3078ca055dd32e34d87eb6ecdcf06e42
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon.png b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon.png
new file mode 100644
index 0000000..9274e38
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon.png differ
diff --git a/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon.png.meta b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon.png.meta
new file mode 100644
index 0000000..af79492
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ isReadable: 0
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_background.png b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_background.png
new file mode 100644
index 0000000..7ac9182
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_background.png differ
diff --git a/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_background.png.meta b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_background.png.meta
new file mode 100644
index 0000000..872baa8
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_background.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ isReadable: 0
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_foreground.png b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_foreground.png
new file mode 100644
index 0000000..82fac8b
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_foreground.png differ
diff --git a/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_foreground.png.meta b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_foreground.png.meta
new file mode 100644
index 0000000..c34203d
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_foreground.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ isReadable: 0
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/ar.png b/unity/MyInfoMateVR/Assets/Resources/Flags/ar.png
new file mode 100644
index 0000000..395928c
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/ar.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/ar.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/ar.png.meta
new file mode 100644
index 0000000..1d28d2c
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/ar.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60008b2c74e8d9a0f3c5e7d1b4008
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/cn.png b/unity/MyInfoMateVR/Assets/Resources/Flags/cn.png
new file mode 100644
index 0000000..344eab5
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/cn.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/cn.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/cn.png.meta
new file mode 100644
index 0000000..8f55b45
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/cn.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60009b2c74e8d9a0f3c5e7d1b4009
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/de.png b/unity/MyInfoMateVR/Assets/Resources/Flags/de.png
new file mode 100644
index 0000000..c4caaaf
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/de.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/de.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/de.png.meta
new file mode 100644
index 0000000..a6e853d
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/de.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60003b2c74e8d9a0f3c5e7d1b4003
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/en.png b/unity/MyInfoMateVR/Assets/Resources/Flags/en.png
new file mode 100644
index 0000000..b81442d
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/en.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/en.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/en.png.meta
new file mode 100644
index 0000000..9c62a24
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/en.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60001b2c74e8d9a0f3c5e7d1b4001
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/es.png b/unity/MyInfoMateVR/Assets/Resources/Flags/es.png
new file mode 100644
index 0000000..f19308d
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/es.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/es.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/es.png.meta
new file mode 100644
index 0000000..35951df
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/es.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60004b2c74e8d9a0f3c5e7d1b4004
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/fr.png b/unity/MyInfoMateVR/Assets/Resources/Flags/fr.png
new file mode 100644
index 0000000..feff210
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/fr.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/fr.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/fr.png.meta
new file mode 100644
index 0000000..60e5afb
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/fr.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60000b2c74e8d9a0f3c5e7d1b4000
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/it.png b/unity/MyInfoMateVR/Assets/Resources/Flags/it.png
new file mode 100644
index 0000000..81013f0
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/it.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/it.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/it.png.meta
new file mode 100644
index 0000000..222c26d
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/it.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60005b2c74e8d9a0f3c5e7d1b4005
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/nl.png b/unity/MyInfoMateVR/Assets/Resources/Flags/nl.png
new file mode 100644
index 0000000..e456302
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/nl.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/nl.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/nl.png.meta
new file mode 100644
index 0000000..52b1daf
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/nl.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60002b2c74e8d9a0f3c5e7d1b4002
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/pl.png b/unity/MyInfoMateVR/Assets/Resources/Flags/pl.png
new file mode 100644
index 0000000..91d8931
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/pl.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/pl.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/pl.png.meta
new file mode 100644
index 0000000..0743932
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/pl.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60006b2c74e8d9a0f3c5e7d1b4006
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/uk.png b/unity/MyInfoMateVR/Assets/Resources/Flags/uk.png
new file mode 100644
index 0000000..8219610
Binary files /dev/null and b/unity/MyInfoMateVR/Assets/Resources/Flags/uk.png differ
diff --git a/unity/MyInfoMateVR/Assets/Resources/Flags/uk.png.meta b/unity/MyInfoMateVR/Assets/Resources/Flags/uk.png.meta
new file mode 100644
index 0000000..72dfc46
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Resources/Flags/uk.png.meta
@@ -0,0 +1,27 @@
+fileFormatVersion: 2
+guid: f1a60007b2c74e8d9a0f3c5e7d1b4007
+TextureImporter:
+ externalObjects: {}
+ serializedVersion: 12
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ isReadable: 1
+ npotScale: 0
+ textureType: 0
+ textureShape: 1
+ maxTextureSize: 512
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ wrapU: 1
+ wrapV: 1
+ textureFormat: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteMode: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/unity/MyInfoMateVR/Assets/Scenes/Boot.unity b/unity/MyInfoMateVR/Assets/Scenes/Boot.unity
index 8895d46..e2f3716 100644
--- a/unity/MyInfoMateVR/Assets/Scenes/Boot.unity
+++ b/unity/MyInfoMateVR/Assets/Scenes/Boot.unity
@@ -1035,7 +1035,7 @@ MonoBehaviour:
_monoscopic: 0
_sharpenType: 0
_disableRecording: 0
- _enableDynamicResolution: 0
+ _enableDynamicResolution: 1
minDynamicResolutionScale: 1
maxDynamicResolutionScale: 1
quest2MinDynamicResolutionScale: 0.7
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Boot/MenuBootstrap.cs b/unity/MyInfoMateVR/Assets/Scripts/Boot/MenuBootstrap.cs
index c4c0cc5..43d45e3 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Boot/MenuBootstrap.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Boot/MenuBootstrap.cs
@@ -35,12 +35,25 @@ namespace MyInfoMate.Vr.Boot
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
+ ///
+ /// 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 .
+ ///
[SerializeField] string language = "FR";
[SerializeField] bool forgetPairing;
Transform _head;
FloatingMenu _menu;
+ LanguagePicker _picker;
+
+ ///
+ /// La langue de la visite en cours. 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.
+ ///
+ string _language;
Telemetry _telemetry;
ConfigurationExport _export;
SceneMessage _message;
@@ -113,13 +126,75 @@ namespace MyInfoMate.Vr.Boot
///
async Task ApplyAsync(ConfigurationExport export)
{
- BuildMenu(export);
- await _menu.LoadImagesAsync(export, _client);
+ _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);
+ }
+
+ ///
+ /// Demande sa langue au visiteur, et ne rend la main qu'une fois qu'il a répondu.
+ ///
+ /// Le choix vit le temps d'une visite. 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 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.
+ ///
+ 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();
+
+ _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;
}
///
@@ -155,7 +230,7 @@ namespace MyInfoMate.Vr.Boot
_kiosk.Ended += StartNewVisit;
}
- _menu.Build(export, language);
+ _menu.Build(export, _language);
}
///
@@ -163,13 +238,26 @@ namespace MyInfoMate.Vr.Boot
/// 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()
+ 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,
@@ -185,7 +273,7 @@ namespace MyInfoMate.Vr.Boot
var section = _export?.Sections?.Find(s => s.Id == sectionId);
var title = section != null
- ? ConfigurationExport.Translate(section.Title, language) ?? section.Label
+ ? ConfigurationExport.Translate(section.Title, _language) ?? section.Label
: sectionId;
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
@@ -199,7 +287,7 @@ namespace MyInfoMate.Vr.Boot
{
OpenSection(sectionId);
- _skybox = SkyboxView.Create(_head);
+ _skybox = SkyboxView.Create(_head, _language);
_skybox.Closed += CloseSection;
_skybox.Interacted += () => _kiosk?.NotifyActivity();
@@ -213,14 +301,14 @@ namespace MyInfoMate.Vr.Boot
{
OpenSection(sectionId);
- _model = Scene3DView.Create(_head);
+ _model = Scene3DView.Create(_head, _language);
_model.Closed += CloseSection;
_model.Interacted += () => _kiosk?.NotifyActivity();
- if (!await _model.LoadAsync(section, _export, language))
+ if (!await _model.LoadAsync(section, _export, _language))
{
CloseSection();
- Show($"{title}\n\nCette scène 3D n'a pas pu être chargée.");
+ Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.Scene3DFailed, _language)}");
}
return;
}
@@ -230,17 +318,17 @@ namespace MyInfoMate.Vr.Boot
// 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.");
+ Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.NotSupported, _language)}");
return;
}
OpenSection(sectionId);
- _view = PagedView.Create(_head);
+ _view = PagedView.Create(_head, _language);
_view.Closed += CloseSection;
_view.Interacted += () => _kiosk?.NotifyActivity();
- await SectionPages.FillAsync(_view, section, _client, language);
+ await SectionPages.FillAsync(_view, section, _client, _language);
}
///
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Menu/BentoLayout.cs.meta b/unity/MyInfoMateVR/Assets/Scripts/Menu/BentoLayout.cs.meta
new file mode 100644
index 0000000..9438534
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Scripts/Menu/BentoLayout.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3aae3324a55246341918e0f85a7657c1
\ No newline at end of file
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Menu/FloatingMenu.cs b/unity/MyInfoMateVR/Assets/Scripts/Menu/FloatingMenu.cs
index f5b026e..d1454ca 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Menu/FloatingMenu.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Menu/FloatingMenu.cs
@@ -25,28 +25,31 @@ namespace MyInfoMate.Vr.Menu
const float HeightMeters = 1.45f;
- /// Écart angulaire entre deux panneaux, assez large pour que le regard tranche.
- const float StepDegrees = 16f;
+ ///
+ /// Écart entre deux tuiles, dans les deux sens. Serré, et c'est voulu : la
+ /// grille du menu doit se lire comme celle de l'app mobile — des tuiles qui se
+ /// touchent presque, donc une planche. Espacées, les mêmes tuiles se lisent comme
+ /// des panneaux flottants sans rapport les uns avec les autres.
+ ///
+ const float GapMeters = 0.03f;
+
+ ///
+ /// Écart angulaire entre deux colonnes, déduit de la largeur d'une cellule
+ /// et du rayon de l'arc : c'est le pas qui donne exactement
+ /// entre deux tuiles voisines. Le poser en dur ferait dépendre l'allure de la
+ /// grille d'un réglage sans rapport avec elle.
+ ///
+ static float StepDegrees =>
+ (MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg;
///
/// Colonnes de la grille bento. Au-delà on passe à une rangée de plus plutôt que
- /// d'encercler le visiteur : 6 × 16° font 80° d'ouverture, dans les ±60° de confort
- /// une fois compté que le regard balaie. Doit rester aligné sur `kVrMenuColumns`
- /// côté manager-app, qui dessine l'aperçu de cette même grille.
+ /// d'encercler le visiteur : six colonnes font environ 83° d'ouverture, dans les
+ /// ±60° de confort une fois compté que le regard balaie. Doit rester aligné sur
+ /// `kVrMenuColumns` côté manager-app, qui dessine l'aperçu de cette même grille.
///
const int Columns = 6;
- /// Écart vertical entre deux rangées.
- const float RowGapMeters = 0.06f;
-
- ///
- /// Écart horizontal, déduit de l'arc : un pas de à
- /// mesure plus qu'une cellule, la différence est le gap.
- /// Le calculer évite qu'un panneau double déborde sur son voisin.
- ///
- static float ColumnGapMeters =>
- RadiusMeters * StepDegrees * Mathf.Deg2Rad - MenuItemPanel.WidthMeters;
-
readonly List _panels = new List();
AimSelector _selector;
@@ -151,10 +154,10 @@ namespace MyInfoMate.Vr.Menu
}
static float CellsWidth(int colSpan) =>
- colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * ColumnGapMeters;
+ colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * GapMeters;
static float CellsHeight(int rowSpan) =>
- rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * RowGapMeters;
+ rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * GapMeters;
///
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
@@ -175,6 +178,11 @@ namespace MyInfoMate.Vr.Menu
var texture = new Texture2D(2, 2);
if (texture.LoadImage(File.ReadAllBytes(path)))
_panels[i].SetImage(texture);
+
+ // La vignette est composée dans la texture du panneau : la source ne sert
+ // plus, et une photo de plein format gardée par tuile finirait par tenir
+ // plus de place que tout le reste du menu.
+ Destroy(texture);
}
}
@@ -189,7 +197,7 @@ namespace MyInfoMate.Vr.Menu
var angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
- var rowStep = MenuItemPanel.HeightMeters + RowGapMeters;
+ var rowStep = MenuItemPanel.HeightMeters + GapMeters;
// Le panneau est centré sur les rangées qu'il couvre, sinon un panneau double
// déborderait vers le haut au lieu de descendre.
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Menu/LanguagePicker.cs b/unity/MyInfoMateVR/Assets/Scripts/Menu/LanguagePicker.cs
new file mode 100644
index 0000000..91d5433
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Scripts/Menu/LanguagePicker.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+using MyInfoMate.Vr.Ui;
+using UnityEngine;
+
+namespace MyInfoMate.Vr.Menu
+{
+ ///
+ /// Le choix de la langue, posé avant le menu.
+ ///
+ /// Pourquoi le demander, et pourquoi à chaque visite. Un casque en borne passe
+ /// de main en main : la langue n'est pas un réglage de l'appareil, c'est une propriété
+ /// du visiteur en cours. Elle vit donc le temps d'une visite et rien de plus — rien
+ /// n'est écrit sur le disque, et le visiteur suivant est de nouveau accueilli par cet
+ /// écran. C'est la même logique que la session de statistiques (voir
+ /// ), et pour la même raison.
+ ///
+ /// Changer de langue ne coûte aucun appel réseau : l'export porte toutes les
+ /// traductions d'un coup — c'est explicitement l'arbitrage de
+ /// ConfigurationExport.FetchAsync. Le menu se reconstruit simplement avec
+ /// l'autre langue, y compris hors ligne.
+ ///
+ /// Ce sont les mêmes tuiles que le menu, avec un drapeau à la place de la
+ /// photo de section : c'est le premier écran de la visite, et il doit annoncer la
+ /// grammaire de ce qui suit plutôt que d'être un formulaire posé devant. Les drapeaux
+ /// sont ceux de l'app mobile, à l'identique.
+ ///
+ /// L'écran ne s'affiche pas quand la visite n'est offerte que dans une langue : un
+ /// choix à une possibilité n'est pas un choix, c'est un écran de plus avant le contenu.
+ ///
+ public class LanguagePicker : MonoBehaviour
+ {
+ const float RadiusMeters = 2.2f;
+
+ /// Hauteur du centre de la rangée de tuiles.
+ const float HeightMeters = 1.35f;
+
+ /// Le même écart que la grille du menu, pour la même raison.
+ const float GapMeters = 0.03f;
+
+ static float StepDegrees =>
+ (MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg;
+
+ /// Le code de la langue choisie, tel qu'il part dans les traductions.
+ public event Action Chosen;
+
+ public static LanguagePicker Create(Transform head, IList languages)
+ {
+ var root = new GameObject("LanguagePicker");
+ var picker = root.AddComponent();
+
+ if (head != null)
+ {
+ var forward = head.forward;
+ forward.y = 0f;
+ if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
+
+ root.transform.position = new Vector3(head.position.x, 0f, head.position.z);
+ root.transform.rotation = Quaternion.LookRotation(forward.normalized);
+ }
+
+ var selector = root.AddComponent();
+ selector.Configure(head);
+ selector.Selected += picker.OnSelected;
+
+ picker.Build(languages);
+ return picker;
+ }
+
+ void Build(IList languages)
+ {
+ // La consigne est écrite dans chacune des langues proposées : celui
+ // qui ne lit pas le français doit pouvoir comprendre l'écran qui lui demande
+ // s'il lit le français.
+ var prompts = new List();
+ foreach (var language in languages)
+ {
+ var prompt = Strings.Get(Strings.ChooseLanguage, language);
+ if (!prompts.Contains(prompt)) prompts.Add(prompt);
+ }
+
+ var heading = Label.Create(transform, "Heading", Label.Anchor.MiddleCenter,
+ 0.075f, Color.white);
+ heading.Text = string.Join("\n", prompts);
+ heading.transform.localPosition = new Vector3(
+ 0f,
+ HeightMeters + MenuItemPanel.HeightMeters * 0.5f + 0.13f + prompts.Count * 0.055f,
+ RadiusMeters);
+
+ for (var i = 0; i < languages.Count; i++)
+ {
+ var panel = MenuItemPanel.Create(
+ languages[i], Strings.NameOf(languages[i]), transform);
+
+ // Une langue sans drapeau garde la tuile nue, avec son nom au centre :
+ // c'est déjà lisible, et c'est ce que fait une tuile de section sans photo.
+ var flag = Flag(languages[i]);
+ if (flag != null) panel.SetImage(flag);
+
+ Place(panel.transform, i, languages.Count);
+ panel.PlayAppear(i * 0.06f);
+ }
+ }
+
+ ///
+ /// Le drapeau d'une langue, ou null. ⚠️ La texture vient de Resources et est
+ /// partagée : contrairement aux vignettes de section, elle ne se détruit pas
+ /// avec la tuile — Unity la garde pour toute la vie de l'app, et la détruire ferait
+ /// un drapeau vide à la visite suivante.
+ ///
+ static Texture2D Flag(string language) =>
+ Resources.Load("Flags/" + Strings.Normalize(language).ToLowerInvariant());
+
+ ///
+ /// Les tuiles sur l'arc du menu, centrées sur le regard : c'est la même géométrie
+ /// que , à une rangée.
+ ///
+ void Place(Transform panel, int index, int count)
+ {
+ var angle = (index - (count - 1) * 0.5f) * StepDegrees;
+ var radians = angle * Mathf.Deg2Rad;
+
+ panel.localPosition = new Vector3(
+ Mathf.Sin(radians) * RadiusMeters,
+ HeightMeters,
+ Mathf.Cos(radians) * RadiusMeters);
+
+ panel.localRotation = Quaternion.Euler(0f, angle, 0f);
+ }
+
+ void OnSelected(IAimTarget target)
+ {
+ if (target is MenuItemPanel panel) Chosen?.Invoke(panel.Id);
+ }
+ }
+}
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Menu/MenuItemPanel.cs b/unity/MyInfoMateVR/Assets/Scripts/Menu/MenuItemPanel.cs
index 92a0d1f..3f9dac6 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Menu/MenuItemPanel.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Menu/MenuItemPanel.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using MyInfoMate.Vr.Ui;
using UnityEngine;
@@ -20,6 +21,12 @@ namespace MyInfoMate.Vr.Menu
/// 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.
+ ///
+ /// Et c'est aussi pour ça que la photo est composée dans la texture et
+ /// non posée sur un deuxième quad : la tuile de home_3.0 et celle de
+ /// visitapp-web 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.
///
public class MenuItemPanel : MonoBehaviour, IAimTarget
{
@@ -30,9 +37,28 @@ namespace MyInfoMate.Vr.Menu
public const float HeightMeters = 0.34f;
+ ///
+ /// Une commande — « Retour au menu » — n'est pas une tuile de contenu : elle est
+ /// large et basse, comme un bouton, et porte un chevron.
+ ///
+ 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);
+ ///
+ /// Une tuile qui porte une photo ne se teinte pas : la teinte de survol
+ /// virerait la photo au bleu. Elle s'éclaire — le panneau au repos est
+ /// légèrement assombri, et le survol le rend à sa pleine lumière.
+ ///
+ static readonly Color PhotoIdle = new Color(0.80f, 0.82f, 0.86f, 1f);
+
+ static readonly Color PhotoHover = Color.white;
+
/// Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.
const float HoverScale = 1.07f;
@@ -48,9 +74,27 @@ namespace MyInfoMate.Vr.Menu
///
public string Id { get; private set; }
+ /// Hauteur de ligne du titre d'une tuile, en mètres.
+ const float CardTextMeters = 0.038f;
+
+ /// Celui d'une commande, qui doit se lire d'un coup d'œil.
+ const float ActionTextMeters = 0.062f;
+
+ /// Place réservée au chevron sur la gauche d'une commande.
+ const float ChevronWidthMeters = 0.13f;
+
Material _background;
LineRenderer _progress;
- Transform _image;
+ Label _title;
+ Style _style;
+ bool _chevron = true;
+
+ ///
+ /// 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.
+ ///
+ Texture2D _composed;
float _width = WidthMeters;
float _height = HeightMeters;
@@ -79,13 +123,34 @@ namespace MyInfoMate.Vr.Menu
var panel = root.AddComponent();
panel._width = widthMeters;
panel._height = heightMeters;
- panel.Build(id, title);
+ panel.Build(id, title, Style.Card);
return panel;
}
- void Build(string id, string title)
+ ///
+ /// 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 tout court : un panneau de la taille
+ /// d'une tuile, avec un titre de tuile, ne se voyait pas.
+ ///
+ 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();
+ 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";
@@ -94,7 +159,7 @@ namespace MyInfoMate.Vr.Menu
_background = background.GetComponent().material =
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
- _background.mainTexture = PanelTexture;
+ _background.mainTexture = PanelTextureFor(_width, _height);
Transparent(_background);
_background.color = Idle;
@@ -111,12 +176,31 @@ namespace MyInfoMate.Vr.Menu
var box = background.AddComponent();
box.size = new Vector3(1f, 1f, 0.02f);
- var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
- label.transform.localPosition = new Vector3(0f, -_height * 0.30f, -0.01f);
+ 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.
- label.Text = Label.Wrap(title, Mathf.Max(8, Mathf.RoundToInt(22f * _width / WidthMeters)));
+ 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();
}
@@ -146,7 +230,10 @@ namespace MyInfoMate.Vr.Menu
_hover = Mathf.MoveTowards(
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
- var color = Color.Lerp(Idle, Hover, _hover);
+ var color = Color.Lerp(
+ _composed != null ? PhotoIdle : Idle,
+ _composed != null ? PhotoHover : Hover,
+ _hover);
if (_appearing)
{
@@ -157,7 +244,7 @@ namespace MyInfoMate.Vr.Menu
// 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 Idle/Hover, jamais modifiée
+ // 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));
@@ -192,31 +279,147 @@ namespace MyInfoMate.Vr.Menu
/// 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 remplace 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.
///
public void SetImage(Texture2D texture)
{
- if (texture == null) return;
+ if (texture == null || _style != Style.Card) return;
- if (_image == null)
+ 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);
+ }
+
+ /// Hauteur du centre du titre au-dessus du bord bas du panneau.
+ const float TitleBaselineMeters = 0.075f;
+
+ ///
+ /// Résolution de la texture composée. Plus fine que celle du fond nu
+ /// () : un aplat supporte 512 px/m, une photo non.
+ ///
+ const float PhotoPixelsPerMeter = 1024f;
+
+ ///
+ /// La photo cadrée en cover, arrondie et assombrie vers le bas — la tuile
+ /// de home_3.0, refaite en pixels.
+ ///
+ /// ⚠️ Les pixels de la source sont lus une fois, puis échantillonnés à la
+ /// main. GetPixelBilinear 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.
+ ///
+ 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 quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
- quad.name = "Image";
- quad.transform.SetParent(transform, false);
- quad.transform.localPosition = new Vector3(0f, _height * 0.16f, -0.005f);
- quad.transform.localScale = new Vector3(_width * 0.86f, _height * 0.5f, 1f);
+ var color = Sample(source32, source.width, source.height,
+ cropX + (x + 0.5f) / scale,
+ cropY + (y + 0.5f) / scale);
- // L'image ne doit pas intercepter le regard : c'est le panneau qui est
- // la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
- Destroy(quad.GetComponent());
- _image = quad.transform;
+ // 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;
}
- var material = new Material(Shader.Find("Universal Render Pipeline/Unlit"))
+ // 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)
{
- mainTexture = texture
+ wrapMode = TextureWrapMode.Clamp,
+ filterMode = FilterMode.Trilinear
};
- NoCulling(material);
- _image.GetComponent().material = material;
+
+ texture.SetPixels32(pixels);
+ texture.Apply();
+ return texture;
+ }
+
+ /// Échantillonnage bilinéaire, en pixels de la source.
+ 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);
+ }
+
+ ///
+ /// Le chevron d'une commande, tracé plutôt que dessiné dans une texture : trois
+ /// points suffisent, et LineRenderer est déjà là pour l'anneau de visée.
+ /// Une police ne rendrait pas le même signe d'un casque à l'autre.
+ ///
+ 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();
+ 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));
+ }
+
+ /// La texture composée n'appartient qu'à ce panneau.
+ void OnDestroy()
+ {
+ if (_composed != null) Destroy(_composed);
}
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
@@ -236,52 +439,67 @@ namespace MyInfoMate.Vr.Menu
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
}
- static Texture2D _panelTexture;
+ static readonly Dictionary PanelTextures = new Dictionary();
+
+ /// Résolution du dessin, en pixels par mètre de panneau.
+ const float PixelsPerMeter = 512f;
+
+ /// Rayon des coins, en mètres — identique sur tous les panneaux.
+ const float CornerRadiusMeters = 0.059f;
+
+ /// Épaisseur du liseré, en mètres.
+ const float BorderMeters = 0.006f;
///
- /// Le rectangle arrondi, généré une fois pour tous les panneaux. 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.
+ /// 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.
+ ///
+ /// ⚠️ Une texture par taille de panneau, pas une pour tous. 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.
///
- static Texture2D PanelTexture
+ static Texture2D PanelTextureFor(float widthMeters, float heightMeters)
{
- get
+ 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)
{
- if (_panelTexture != null) return _panelTexture;
+ wrapMode = TextureWrapMode.Clamp,
+ filterMode = FilterMode.Bilinear
+ };
- const int width = 256;
- const int height = 176;
- const float radius = 30f;
- const float border = 3f;
+ var pixels = new Color32[width * height];
- var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
- {
- wrapMode = TextureWrapMode.Clamp,
- filterMode = FilterMode.Bilinear
- };
+ 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);
- var pixels = new Color32[width * height];
+ // 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;
- 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();
-
- _panelTexture = texture;
- return _panelTexture;
+ pixels[y * width + x] = new Color(lit, lit, lit, alpha);
}
+
+ texture.SetPixels32(pixels);
+ texture.Apply();
+
+ PanelTextures[key] = texture;
+ return texture;
}
/// Distance signée au rectangle arrondi, en pixels.
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Menu/PagedView.cs b/unity/MyInfoMateVR/Assets/Scripts/Menu/PagedView.cs
index 9f8b10c..6fea27f 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Menu/PagedView.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Menu/PagedView.cs
@@ -49,6 +49,7 @@ namespace MyInfoMate.Vr.Menu
Ui.Label _text;
MenuItemPanel _previous;
MenuItemPanel _next;
+ string _language;
int _index;
/// Le visiteur veut revenir au menu.
@@ -62,10 +63,11 @@ namespace MyInfoMate.Vr.Menu
public int PageCount => _pages.Count;
- public static PagedView Create(Transform head)
+ public static PagedView Create(Transform head, string language)
{
var root = new GameObject("PagedView");
var view = root.AddComponent();
+ view._language = language;
if (head != null)
{
@@ -109,7 +111,11 @@ namespace MyInfoMate.Vr.Menu
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
_next = Command(CommandNext, "▶", ImageWidthMeters * 0.62f, CenterHeight);
- Command(CommandBack, "Retour au menu", 0f, CenterHeight - ImageHeightMeters * 0.95f);
+
+ var back = MenuItemPanel.CreateAction(
+ CommandBack, Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
+ back.transform.localPosition = new Vector3(
+ 0f, CenterHeight - ImageHeightMeters * 0.95f, DistanceMeters - 0.02f);
UpdateCommands();
}
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Menu/Scene3DView.cs b/unity/MyInfoMateVR/Assets/Scripts/Menu/Scene3DView.cs
index 7fe5ca0..2033581 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Menu/Scene3DView.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Menu/Scene3DView.cs
@@ -33,7 +33,7 @@ namespace MyInfoMate.Vr.Menu
public event Action Closed;
public event Action Interacted;
- public static Scene3DView Create(Transform head)
+ public static Scene3DView Create(Transform head, string language)
{
var root = new GameObject("Scene3DView");
var view = root.AddComponent();
@@ -53,9 +53,9 @@ namespace MyInfoMate.Vr.Menu
selector.Configure(head);
selector.Selected += view.OnSelected;
- view._back = MenuItemPanel.Create("__back", "Retour au menu", root.transform);
- view._back.transform.localPosition = new Vector3(0f, 0.9f, 2.2f);
- view._back.transform.localScale = Vector3.one * 0.6f;
+ view._back = MenuItemPanel.CreateAction(
+ "__back", Ui.Strings.Get(Ui.Strings.BackToMenu, language), root.transform);
+ view._back.transform.localPosition = new Vector3(0f, 0.85f, 2.2f);
return view;
}
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Menu/SectionPages.cs b/unity/MyInfoMateVR/Assets/Scripts/Menu/SectionPages.cs
index 4ae3e8d..84c0803 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Menu/SectionPages.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Menu/SectionPages.cs
@@ -47,7 +47,7 @@ namespace MyInfoMate.Vr.Menu
}
if (view != null && view.PageCount == 0)
- view.ShowEmpty("Ce contenu est vide.");
+ view.ShowEmpty(Ui.Strings.Get(Ui.Strings.EmptyContent, language));
}
static async Task FillSlider(PagedView view,
@@ -155,7 +155,7 @@ namespace MyInfoMate.Vr.Menu
}
if (view.PageCount == 0)
- view.ShowEmpty("Rien au programme aujourd'hui.");
+ view.ShowEmpty(Ui.Strings.Get(Ui.Strings.NothingToday, language));
}
static async Task LoadImage(ApiClient client, string url)
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Menu/SkyboxView.cs b/unity/MyInfoMateVR/Assets/Scripts/Menu/SkyboxView.cs
index 6dbe5d0..911034f 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Menu/SkyboxView.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Menu/SkyboxView.cs
@@ -39,15 +39,17 @@ namespace MyInfoMate.Vr.Menu
Texture2D _image;
MenuItemPanel _back;
Transform _head;
+ string _language;
public event Action Closed;
public event Action Interacted;
- public static SkyboxView Create(Transform head)
+ public static SkyboxView Create(Transform head, string language)
{
var root = new GameObject("SkyboxView");
var view = root.AddComponent();
view._head = head;
+ view._language = language;
if (head != null)
{
@@ -75,8 +77,8 @@ namespace MyInfoMate.Vr.Menu
if (_skybox == null) return;
// Le seul élément posé dans la scène : de quoi ressortir.
- _back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform);
- _back.transform.localScale = Vector3.one * 0.6f;
+ _back = MenuItemPanel.CreateAction(
+ "__back", Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
PlaceBack();
}
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Net/ConfigurationExport.cs b/unity/MyInfoMateVR/Assets/Scripts/Net/ConfigurationExport.cs
index 8ae8aaf..535f20e 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Net/ConfigurationExport.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Net/ConfigurationExport.cs
@@ -14,15 +14,24 @@ namespace MyInfoMate.Vr.Net
/// est le DTO de l'import/export du back-office, déjà stable, et c'est aussi celui que
/// mymuseum-visitapp télécharge pour une visite hors ligne.
///
- /// ⚠️ L'export ne rend qu'une langue à la fois. language traverse jusqu'à
- /// Section.GetReferencedResourceIds(language) côté serveur : changer de langue
- /// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la
- /// question ouverte du lot XR-3 ; en attendant, on recharge.
+ /// L'export porte toutes les langues à la fois, et c'est ce qui permet au
+ /// visiteur de choisir la sienne au début de sa visite sans aucun appel réseau : le
+ /// paramètre language du serveur ne filtre que les ressources, jamais
+ /// les textes. Le détail de l'arbitrage est sur .
///
public class ConfigurationExport
{
public string Id;
public string Label;
+
+ ///
+ /// Les langues dans lesquelles cette visite est proposée, telles que le
+ /// gestionnaire les a cochées. Le champ vient de ConfigurationDTO, dont
+ /// l'export hérite ; il peut manquer sur un contenu ancien — d'où le repli de
+ /// .
+ ///
+ public List Languages = new List();
+
public List Sections = new List();
///
@@ -258,16 +267,50 @@ namespace MyInfoMate.Vr.Net
public string Value;
}
- /// Le texte dans la langue demandée, ou la première traduction disponible.
+ ///
+ /// Le texte dans la langue du visiteur.
+ ///
+ /// Trois essais, dans cet ordre : la langue demandée, le français, puis la
+ /// première traduction qui porte quelque chose. C'est exactement la chaîne
+ /// de t() côté visitapp-web et de son équivalent Flutter — un gestionnaire
+ /// qui coche une langue sans la traduire doit voir la même chose sur les quatre
+ /// canaux, sinon on lui fait chercher un bug qui n'existe pas.
+ ///
+ /// Une traduction vide ne compte pas : le manager en crée une par langue cochée,
+ /// remplie ou non, et s'arrêter dessus rendrait une tuile sans nom.
+ ///
public static string Translate(List translations, string language)
{
if (translations == null || translations.Count == 0) return null;
- foreach (var t in translations)
- if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase))
- return PlainText(t.Value);
+ return Find(translations, language)
+ ?? Find(translations, Ui.Strings.Fallback)
+ ?? First(translations);
+ }
- return PlainText(translations[0].Value);
+ static string Find(List translations, string language)
+ {
+ foreach (var translation in translations)
+ {
+ if (!string.Equals(translation.Language, language,
+ System.StringComparison.OrdinalIgnoreCase)) continue;
+
+ var text = PlainText(translation.Value);
+ if (!string.IsNullOrWhiteSpace(text)) return text;
+ }
+
+ return null;
+ }
+
+ static string First(List translations)
+ {
+ foreach (var translation in translations)
+ {
+ var text = PlainText(translation.Value);
+ if (!string.IsNullOrWhiteSpace(text)) return text;
+ }
+
+ return null;
}
///
@@ -294,6 +337,37 @@ namespace MyInfoMate.Vr.Net
return text.Trim();
}
+ ///
+ /// Ce qu'on peut proposer au visiteur, normalisé en majuscules et sans doublon.
+ ///
+ /// Le champ languages de la configuration fait foi : c'est la liste que le
+ /// gestionnaire a choisie, et elle inclut une langue même quand une section n'y est
+ /// pas encore traduite. À défaut — contenu antérieur au champ — on retombe sur les
+ /// langues réellement présentes dans les titres, ce qui donne au moins de quoi
+ /// afficher un choix juste.
+ ///
+ public List AvailableLanguages()
+ {
+ var codes = new List();
+
+ void Add(string language)
+ {
+ if (string.IsNullOrWhiteSpace(language)) return;
+
+ var code = Ui.Strings.Normalize(language);
+ if (!codes.Contains(code)) codes.Add(code);
+ }
+
+ foreach (var language in Languages) Add(language);
+ if (codes.Count > 0) return codes;
+
+ foreach (var section in Sections)
+ foreach (var title in section.Title)
+ Add(title.Language);
+
+ return codes;
+ }
+
/// Les sections de premier niveau, dans l'ordre du manager.
public List RootSections()
{
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Net/Telemetry.cs b/unity/MyInfoMateVR/Assets/Scripts/Net/Telemetry.cs
index 61b20b3..e689be2 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Net/Telemetry.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Net/Telemetry.cs
@@ -29,7 +29,14 @@ namespace MyInfoMate.Vr.Net
readonly ApiClient _client;
readonly string _instanceId;
readonly string _configurationId;
- readonly string _language;
+
+ ///
+ /// La langue de la visite en cours. Elle n'est pas fixée à la construction : le
+ /// visiteur la choisit au début de sa visite, sur un casque qui tourne
+ /// depuis le matin — et une visite comptée dans la mauvaise langue vaut mieux
+ /// corrigée que figée.
+ ///
+ public string Language { get; set; }
///
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
@@ -43,7 +50,7 @@ namespace MyInfoMate.Vr.Net
_client = client;
_instanceId = instanceId;
_configurationId = configurationId;
- _language = language;
+ Language = language;
}
public void StartNewSession() => SessionId = NewSessionId();
@@ -69,7 +76,7 @@ namespace MyInfoMate.Vr.Net
sessionId = SessionId,
eventType,
appType = AppTypeVr,
- language = _language,
+ language = Language,
durationSeconds,
metadata,
timestamp = DateTime.UtcNow
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Scene/HotspotInstance.cs b/unity/MyInfoMateVR/Assets/Scripts/Scene/HotspotInstance.cs
index 2790bad..698122e 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Scene/HotspotInstance.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Scene/HotspotInstance.cs
@@ -171,8 +171,12 @@ namespace MyInfoMate.Vr.Scene
{
if (string.IsNullOrEmpty(content.AssetId)) continue;
+ // Sans OrdinalIgnoreCase, un export qui écrit « fr » ne répond pas à un
+ // choix normalisé en « FR » : le hotspot resterait muet sans rien dire.
var hasText = content.Description != null
- && content.Description.Exists(t => t.Language == language);
+ && content.Description.Exists(
+ t => string.Equals(t.Language, language,
+ System.StringComparison.OrdinalIgnoreCase));
if (hasText || _hotspot.Contents.Count == 1) return content.AssetId;
}
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Ui/Label.cs b/unity/MyInfoMateVR/Assets/Scripts/Ui/Label.cs
index c5f4956..2b487db 100644
--- a/unity/MyInfoMateVR/Assets/Scripts/Ui/Label.cs
+++ b/unity/MyInfoMateVR/Assets/Scripts/Ui/Label.cs
@@ -1,4 +1,4 @@
-using TMPro;
+using TMPro;
using UnityEngine;
namespace MyInfoMate.Vr.Ui
@@ -63,6 +63,15 @@ namespace MyInfoMate.Vr.Ui
// Sans rect assez large, TMP tronque au lieu d'afficher.
tmp.rectTransform.sizeDelta = new Vector2(4f, 2f);
+ // ⚠️ Le pivot, sinon le texte part en l'air. TMP aligne dans son rect, et
+ // ce rect est centré sur l'origine : un texte aligné en haut d'un rect de
+ // 2 m s'affiche donc 1 m au-dessus du point où on a posé le Label. En
+ // remontant le pivot, le haut du rect est l'origine — c'est ce que fait
+ // TextMesh en UpperCenter, sur lequel les positions des appelants sont
+ // calées.
+ tmp.rectTransform.pivot = new Vector2(
+ 0.5f, anchor == Anchor.TopCenter ? 1f : 0.5f);
+
_tmp = tmp;
return;
}
diff --git a/unity/MyInfoMateVR/Assets/Scripts/Ui/Strings.cs b/unity/MyInfoMateVR/Assets/Scripts/Ui/Strings.cs
new file mode 100644
index 0000000..9fa6119
--- /dev/null
+++ b/unity/MyInfoMateVR/Assets/Scripts/Ui/Strings.cs
@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+
+namespace MyInfoMate.Vr.Ui
+{
+ ///
+ /// Les textes de l'app elle-même — « Retour au menu », « Ce contenu est vide » —
+ /// par opposition aux textes du contenu, qui viennent de l'export et passent par
+ /// ConfigurationExport.Translate.
+ ///
+ /// Pourquoi une table en dur plutôt que le mécanisme du manager. Il y a une
+ /// dizaine de phrases, elles ne changent pas avec le contenu, et un casque démarre
+ /// souvent sans réseau : les faire venir du serveur ferait dépendre le bouton retour
+ /// d'une requête. Le manager, lui, a des centaines de libellés et un back-office pour
+ /// les éditer — ce n'est pas le même problème.
+ ///
+ /// Les quatre langues sont celles que le champ languages d'une configuration
+ /// propose. Une langue inconnue retombe sur le français plutôt que d'afficher une
+ /// clé : un visiteur qui lit la mauvaise langue s'en sort, un visiteur qui lit
+ /// backToMenu non.
+ ///
+ public static class Strings
+ {
+ public const string BackToMenu = "backToMenu";
+ public const string ChooseLanguage = "chooseLanguage";
+ public const string EmptyContent = "emptyContent";
+ public const string NothingToday = "nothingToday";
+ public const string NotSupported = "notSupported";
+ public const string Scene3DFailed = "scene3dFailed";
+
+ static readonly Dictionary> Table =
+ new Dictionary>
+ {
+ ["FR"] = new Dictionary
+ {
+ [BackToMenu] = "Retour au menu",
+ [ChooseLanguage] = "Choisissez votre langue",
+ [EmptyContent] = "Ce contenu est vide.",
+ [NothingToday] = "Rien au programme aujourd'hui.",
+ [NotSupported] = "Ce type de contenu n'est pas encore affichable dans le casque.",
+ [Scene3DFailed] = "Cette scène 3D n'a pas pu être chargée.",
+ },
+ ["EN"] = new Dictionary
+ {
+ [BackToMenu] = "Back to menu",
+ [ChooseLanguage] = "Choose your language",
+ [EmptyContent] = "This content is empty.",
+ [NothingToday] = "Nothing on the programme today.",
+ [NotSupported] = "This type of content cannot be shown in the headset yet.",
+ [Scene3DFailed] = "This 3D scene could not be loaded.",
+ },
+ ["NL"] = new Dictionary
+ {
+ [BackToMenu] = "Terug naar het menu",
+ [ChooseLanguage] = "Kies uw taal",
+ [EmptyContent] = "Deze inhoud is leeg.",
+ [NothingToday] = "Vandaag staat er niets op het programma.",
+ [NotSupported] = "Dit soort inhoud is nog niet zichtbaar in de bril.",
+ [Scene3DFailed] = "Deze 3D-scène kon niet worden geladen.",
+ },
+ ["DE"] = new Dictionary
+ {
+ [BackToMenu] = "Zurück zum Menü",
+ [ChooseLanguage] = "Wählen Sie Ihre Sprache",
+ [EmptyContent] = "Dieser Inhalt ist leer.",
+ [NothingToday] = "Heute steht nichts auf dem Programm.",
+ [NotSupported] = "Diese Art von Inhalt ist in der Brille noch nicht verfügbar.",
+ [Scene3DFailed] = "Diese 3D-Szene konnte nicht geladen werden.",
+ },
+ };
+
+ ///
+ /// Le nom d'une langue dans cette langue. C'est la seule façon de nommer un
+ /// choix de langue : « Dutch » ne dit rien à quelqu'un qui cherche « Nederlands ».
+ ///
+ static readonly Dictionary Names = new Dictionary
+ {
+ ["FR"] = "Français",
+ ["EN"] = "English",
+ ["NL"] = "Nederlands",
+ ["DE"] = "Deutsch",
+ };
+
+ public const string Fallback = "FR";
+
+ public static string Get(string key, string language)
+ {
+ var code = Normalize(language);
+
+ if (Table.TryGetValue(code, out var texts) && texts.TryGetValue(key, out var text))
+ return text;
+
+ return Table[Fallback][key];
+ }
+
+ /// Le nom à afficher pour une langue, son code à défaut.
+ public static string NameOf(string language)
+ {
+ var code = Normalize(language);
+ return Names.TryGetValue(code, out var name) ? name : code;
+ }
+
+ ///
+ /// L'export écrit les langues tantôt en fr, tantôt en FR — et les
+ /// traductions d'un même contenu ne suivent pas toujours la casse du champ
+ /// languages de la configuration. Tout passe par ici.
+ ///
+ public static string Normalize(string language) =>
+ string.IsNullOrEmpty(language) ? Fallback : language.Trim().ToUpperInvariant();
+ }
+}
diff --git a/unity/MyInfoMateVR/ProjectSettings/ProjectSettings.asset b/unity/MyInfoMateVR/ProjectSettings/ProjectSettings.asset
index 5a917d6..5bbb531 100644
--- a/unity/MyInfoMateVR/ProjectSettings/ProjectSettings.asset
+++ b/unity/MyInfoMateVR/ProjectSettings/ProjectSettings.asset
@@ -13,7 +13,7 @@ PlayerSettings:
useOnDemandResources: 0
accelerometerFrequency: 60
companyName: UNOV
- productName: MyInfoMateVR
+ productName: MyInfoMate
defaultCursor: {fileID: 0}
cursorHotspot: {x: 0, y: 0}
m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1}
@@ -396,92 +396,116 @@ PlayerSettings:
m_SubKind: App Store
- m_BuildTarget: Android
m_Icons:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 432
m_Height: 432
m_Kind: 2
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 324
m_Height: 324
m_Kind: 2
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 216
m_Height: 216
m_Kind: 2
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 162
m_Height: 162
m_Kind: 2
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 108
m_Height: 108
m_Kind: 2
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 81
m_Height: 81
m_Kind: 2
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 192
m_Height: 192
m_Kind: 1
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 144
m_Height: 144
m_Kind: 1
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 96
m_Height: 96
m_Kind: 1
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 72
m_Height: 72
m_Kind: 1
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 48
m_Height: 48
m_Kind: 1
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 36
m_Height: 36
m_Kind: 1
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 192
m_Height: 192
m_Kind: 0
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 144
m_Height: 144
m_Kind: 0
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 96
m_Height: 96
m_Kind: 0
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 72
m_Height: 72
m_Kind: 0
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 48
m_Height: 48
m_Kind: 0
m_SubKind:
- - m_Textures: []
+ - m_Textures:
+ - {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 36
m_Height: 36
m_Kind: 0