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