Menu du casque : la grille bento, et le choix de la langue
Les tuiles suivent maintenant les mêmes colSpan × rowSpan que les autres canaux, sur six colonnes — environ 83° d'ouverture, dans le confort du regard — avec un écart serré assumé : espacées, les tuiles se lisent comme des panneaux sans rapport plutôt que comme une planche. Le pas angulaire se déduit de la largeur de cellule et du rayon de l'arc, plutôt que d'être posé en dur. S'y ajoute le sélecteur de langue avec ses drapeaux, et les libellés de l'interface sortis du code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BIN
unity-overlay/Assets/Icons/MyInfoMate_icon.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
27
unity-overlay/Assets/Icons/MyInfoMate_icon.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Icons/MyInfoMate_icon_background.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
@ -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:
|
||||||
BIN
unity-overlay/Assets/Icons/MyInfoMate_icon_foreground.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/ar.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
27
unity-overlay/Assets/Resources/Flags/ar.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/cn.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
27
unity-overlay/Assets/Resources/Flags/cn.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/de.png
Normal file
|
After Width: | Height: | Size: 225 B |
27
unity-overlay/Assets/Resources/Flags/de.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/en.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
27
unity-overlay/Assets/Resources/Flags/en.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/es.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
27
unity-overlay/Assets/Resources/Flags/es.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/fr.png
Normal file
|
After Width: | Height: | Size: 233 B |
27
unity-overlay/Assets/Resources/Flags/fr.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/it.png
Normal file
|
After Width: | Height: | Size: 705 B |
27
unity-overlay/Assets/Resources/Flags/it.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/nl.png
Normal file
|
After Width: | Height: | Size: 219 B |
27
unity-overlay/Assets/Resources/Flags/nl.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/pl.png
Normal file
|
After Width: | Height: | Size: 189 B |
27
unity-overlay/Assets/Resources/Flags/pl.png.meta
Normal file
@ -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:
|
||||||
BIN
unity-overlay/Assets/Resources/Flags/uk.png
Normal file
|
After Width: | Height: | Size: 279 B |
27
unity-overlay/Assets/Resources/Flags/uk.png.meta
Normal file
@ -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:
|
||||||
@ -35,12 +35,25 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
[Header("Nom affiché dans l'onglet XR du manager")]
|
[Header("Nom affiché dans l'onglet XR du manager")]
|
||||||
[SerializeField] string headsetName = "Quest";
|
[SerializeField] string headsetName = "Quest";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La langue de repli : celle qu'on prend quand la visite n'en propose qu'une, ou
|
||||||
|
/// qu'elle n'en déclare aucune. Dès qu'il y a un choix, c'est le visiteur qui
|
||||||
|
/// tranche au début de sa visite — voir <see cref="ChooseLanguageAsync"/>.
|
||||||
|
/// </summary>
|
||||||
[SerializeField] string language = "FR";
|
[SerializeField] string language = "FR";
|
||||||
|
|
||||||
[SerializeField] bool forgetPairing;
|
[SerializeField] bool forgetPairing;
|
||||||
|
|
||||||
Transform _head;
|
Transform _head;
|
||||||
FloatingMenu _menu;
|
FloatingMenu _menu;
|
||||||
|
LanguagePicker _picker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La langue de la visite <b>en cours</b>. Nulle tant que le visiteur n'a pas
|
||||||
|
/// choisi, et remise à null à chaque nouvelle visite : elle n'est jamais écrite
|
||||||
|
/// sur le disque, donc un casque qu'on rallume repose la question.
|
||||||
|
/// </summary>
|
||||||
|
string _language;
|
||||||
Telemetry _telemetry;
|
Telemetry _telemetry;
|
||||||
ConfigurationExport _export;
|
ConfigurationExport _export;
|
||||||
SceneMessage _message;
|
SceneMessage _message;
|
||||||
@ -77,9 +90,10 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
|
|
||||||
_client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
|
_client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
|
||||||
var client = _client;
|
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)
|
if (!loaded.Ok)
|
||||||
{
|
{
|
||||||
Show(loaded.Error);
|
Show(loaded.Error);
|
||||||
@ -95,27 +109,112 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
|
|
||||||
if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged;
|
if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged;
|
||||||
|
|
||||||
BuildMenu(loaded.Export);
|
await ApplyAsync(loaded.Export);
|
||||||
await _menu.LoadImagesAsync(loaded.Export, client);
|
|
||||||
await ApplyBackdrop(loaded.Export, client);
|
|
||||||
|
|
||||||
// E4 — le reste de la visite part sur le disque pendant que le visiteur
|
|
||||||
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
|
|
||||||
_ = ContentPreloader.RunAsync(loaded.Export, client);
|
|
||||||
|
|
||||||
if (!loaded.FromCache) return;
|
if (!loaded.FromCache) return;
|
||||||
|
|
||||||
// Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne,
|
// Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne,
|
||||||
// et ne se voit que s'il apporte quelque chose.
|
// et ne se voit que s'il apporte quelque chose.
|
||||||
var fresh = await content.RefreshAsync();
|
await RefreshContentAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pose un contenu : le menu, ses vignettes, le décor, puis le téléchargement du
|
||||||
|
/// reste. Les trois chemins qui changent de contenu — démarrage, rafraîchissement,
|
||||||
|
/// changement de configuration — passent tous par ici, sinon l'un des quatre gestes
|
||||||
|
/// finit par manquer à l'un d'eux.
|
||||||
|
/// </summary>
|
||||||
|
async Task ApplyAsync(ConfigurationExport export)
|
||||||
|
{
|
||||||
|
_export = export;
|
||||||
|
|
||||||
|
await ApplyBackdrop(export, _client);
|
||||||
|
|
||||||
|
// E4 — le reste de la visite part sur le disque pendant que le visiteur
|
||||||
|
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
|
||||||
|
_ = ContentPreloader.RunAsync(export, _client);
|
||||||
|
|
||||||
|
// Avant le menu, et pas après : le menu est déjà écrit dans une langue, et
|
||||||
|
// le construire pour le reconstruire aussitôt ferait rejouer sa cascade sous
|
||||||
|
// les yeux du visiteur.
|
||||||
|
if (_language == null) await ChooseLanguageAsync(export);
|
||||||
|
|
||||||
|
await BuildMenuAsync(export);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task BuildMenuAsync(ConfigurationExport export)
|
||||||
|
{
|
||||||
|
BuildMenu(export);
|
||||||
|
await _menu.LoadImagesAsync(export, _client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Demande sa langue au visiteur, et ne rend la main qu'une fois qu'il a répondu.
|
||||||
|
///
|
||||||
|
/// <b>Le choix vit le temps d'une visite.</b> Sur une borne, la langue n'est pas un
|
||||||
|
/// réglage de l'appareil : c'est une propriété de la personne qui a le casque sur
|
||||||
|
/// la tête. Rien n'est persisté, et <see cref="StartNewVisit"/> repose la question
|
||||||
|
/// au suivant.
|
||||||
|
///
|
||||||
|
/// Une visite qui n'existe que dans une langue ne demande rien : un choix à une
|
||||||
|
/// possibilité est un écran de plus avant le contenu, pas un service.
|
||||||
|
/// </summary>
|
||||||
|
async Task ChooseLanguageAsync(ConfigurationExport export)
|
||||||
|
{
|
||||||
|
var languages = export.AvailableLanguages();
|
||||||
|
|
||||||
|
if (languages.Count <= 1)
|
||||||
|
{
|
||||||
|
SetLanguage(languages.Count == 1 ? languages[0] : language);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_menu != null) _menu.gameObject.SetActive(false);
|
||||||
|
|
||||||
|
var choice = new TaskCompletionSource<string>();
|
||||||
|
|
||||||
|
_picker = LanguagePicker.Create(_head, languages);
|
||||||
|
_picker.Chosen += chosen =>
|
||||||
|
{
|
||||||
|
_kiosk?.NotifyActivity();
|
||||||
|
choice.TrySetResult(chosen);
|
||||||
|
};
|
||||||
|
|
||||||
|
SetLanguage(await choice.Task);
|
||||||
|
|
||||||
|
Destroy(_picker.gameObject);
|
||||||
|
_picker = null;
|
||||||
|
|
||||||
|
if (_menu != null) _menu.gameObject.SetActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetLanguage(string chosen)
|
||||||
|
{
|
||||||
|
_language = Ui.Strings.Normalize(chosen);
|
||||||
|
|
||||||
|
// La télémétrie est ouverte avant le choix : sans ça, toute la visite serait
|
||||||
|
// comptée dans la langue de repli du casque.
|
||||||
|
if (_telemetry != null) _telemetry.Language = _language;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Va voir si le contenu a changé, et ne reconstruit que s'il a changé —
|
||||||
|
/// <c>RefreshAsync</c> rend <c>null</c> quand l'export est identique à celui en
|
||||||
|
/// cache, et quand le serveur n'a pas répondu.
|
||||||
|
///
|
||||||
|
/// ⚠️ À n'appeler qu'aux moments où <b>personne ne regarde le menu</b> : au
|
||||||
|
/// démarrage, et entre deux visites. Un menu qui se reconstruit sous les yeux d'un
|
||||||
|
/// visiteur rejoue la cascade d'apparition et déplace ce qu'il visait.
|
||||||
|
/// </summary>
|
||||||
|
async Task RefreshContentAsync()
|
||||||
|
{
|
||||||
|
if (_content == null) return;
|
||||||
|
|
||||||
|
var fresh = await _content.RefreshAsync();
|
||||||
if (fresh == null || _menu == null) return;
|
if (fresh == null || _menu == null) return;
|
||||||
|
|
||||||
Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit.");
|
Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit.");
|
||||||
BuildMenu(fresh);
|
await ApplyAsync(fresh);
|
||||||
await _menu.LoadImagesAsync(fresh, client);
|
|
||||||
await ApplyBackdrop(fresh, client);
|
|
||||||
|
|
||||||
_ = ContentPreloader.RunAsync(fresh, client);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void BuildMenu(ConfigurationExport export)
|
void BuildMenu(ConfigurationExport export)
|
||||||
@ -131,7 +230,7 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
_kiosk.Ended += StartNewVisit;
|
_kiosk.Ended += StartNewVisit;
|
||||||
}
|
}
|
||||||
|
|
||||||
_menu.Build(export, language);
|
_menu.Build(export, _language);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -139,12 +238,31 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
/// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de
|
/// 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.
|
/// statistiques — sinon une journée de borne compterait pour un seul visiteur.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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();
|
CloseSection();
|
||||||
_menu?.Recenter();
|
_menu?.Recenter();
|
||||||
_telemetry?.StartNewSession();
|
_telemetry?.StartNewSession();
|
||||||
Debug.Log("[Kiosk] Nouvelle visite.");
|
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)
|
async void OnSectionChosen(string sectionId)
|
||||||
@ -155,7 +273,7 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
|
|
||||||
var section = _export?.Sections?.Find(s => s.Id == sectionId);
|
var section = _export?.Sections?.Find(s => s.Id == sectionId);
|
||||||
var title = section != null
|
var title = section != null
|
||||||
? ConfigurationExport.Translate(section.Title, language) ?? section.Label
|
? ConfigurationExport.Translate(section.Title, _language) ?? section.Label
|
||||||
: sectionId;
|
: sectionId;
|
||||||
|
|
||||||
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
|
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
|
||||||
@ -169,7 +287,7 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
{
|
{
|
||||||
OpenSection(sectionId);
|
OpenSection(sectionId);
|
||||||
|
|
||||||
_skybox = SkyboxView.Create(_head);
|
_skybox = SkyboxView.Create(_head, _language);
|
||||||
_skybox.Closed += CloseSection;
|
_skybox.Closed += CloseSection;
|
||||||
_skybox.Interacted += () => _kiosk?.NotifyActivity();
|
_skybox.Interacted += () => _kiosk?.NotifyActivity();
|
||||||
|
|
||||||
@ -183,14 +301,14 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
{
|
{
|
||||||
OpenSection(sectionId);
|
OpenSection(sectionId);
|
||||||
|
|
||||||
_model = Scene3DView.Create(_head);
|
_model = Scene3DView.Create(_head, _language);
|
||||||
_model.Closed += CloseSection;
|
_model.Closed += CloseSection;
|
||||||
_model.Interacted += () => _kiosk?.NotifyActivity();
|
_model.Interacted += () => _kiosk?.NotifyActivity();
|
||||||
|
|
||||||
if (!await _model.LoadAsync(section, _export, language))
|
if (!await _model.LoadAsync(section, _export, _language))
|
||||||
{
|
{
|
||||||
CloseSection();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@ -200,17 +318,17 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
// Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés.
|
// 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
|
// Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit
|
||||||
// comme une panne.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OpenSection(sectionId);
|
OpenSection(sectionId);
|
||||||
|
|
||||||
_view = PagedView.Create(_head);
|
_view = PagedView.Create(_head, _language);
|
||||||
_view.Closed += CloseSection;
|
_view.Closed += CloseSection;
|
||||||
_view.Interacted += () => _kiosk?.NotifyActivity();
|
_view.Interacted += () => _kiosk?.NotifyActivity();
|
||||||
|
|
||||||
await SectionPages.FillAsync(_view, section, _client, language);
|
await SectionPages.FillAsync(_view, section, _client, _language);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -225,13 +343,18 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
/// ⚠️ On ferme ce qui est ouvert d'abord. Un visiteur qui regardait une galerie
|
/// ⚠️ 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
|
/// au moment du changement doit revenir au menu — pas rester dans un contenu qui
|
||||||
/// n'appartient plus à sa visite.
|
/// n'appartient plus à sa visite.
|
||||||
|
///
|
||||||
|
/// ⚠️ Le <c>deviceId</c> repart avec : c'est lui qui désigne le lien de ce casque,
|
||||||
|
/// donc son agencement — bento, ordre des sections, sections masquées pour le seul
|
||||||
|
/// canal VR. Sans lui, le casque bascule sur la configuration nue et perd sa mise
|
||||||
|
/// en page jusqu'au prochain démarrage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
async void OnConfigurationChanged(string configurationId)
|
async void OnConfigurationChanged(string configurationId)
|
||||||
{
|
{
|
||||||
CloseSection();
|
CloseSection();
|
||||||
|
|
||||||
var content = new ContentService(_client, configurationId);
|
_content = new ContentService(_client, configurationId, _deviceId);
|
||||||
var loaded = await content.LoadAsync();
|
var loaded = await _content.LoadAsync();
|
||||||
|
|
||||||
if (!loaded.Ok)
|
if (!loaded.Ok)
|
||||||
{
|
{
|
||||||
@ -239,14 +362,13 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
return;
|
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();
|
_menu?.Recenter();
|
||||||
_telemetry?.StartNewSession();
|
_telemetry?.StartNewSession();
|
||||||
|
|
||||||
await _menu.LoadImagesAsync(loaded.Export, _client);
|
await ApplyAsync(loaded.Export);
|
||||||
await ApplyBackdrop(loaded.Export, _client);
|
|
||||||
|
|
||||||
_ = ContentPreloader.RunAsync(loaded.Export, _client);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -311,6 +433,14 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
ImmersiveBackdrop _backdrop;
|
ImmersiveBackdrop _backdrop;
|
||||||
Scene3DView _model;
|
Scene3DView _model;
|
||||||
ApiClient _client;
|
ApiClient _client;
|
||||||
|
ContentService _content;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ce casque, gardé pour toute la vie de l'app : il désigne le lien de
|
||||||
|
/// configuration, donc l'agencement propre au canal VR. Tout
|
||||||
|
/// <see cref="ContentService"/> construit ici doit le recevoir.
|
||||||
|
/// </summary>
|
||||||
|
string _deviceId;
|
||||||
Kiosk.KioskSession _kiosk;
|
Kiosk.KioskSession _kiosk;
|
||||||
string _openedSectionId;
|
string _openedSectionId;
|
||||||
float _openedAt;
|
float _openedAt;
|
||||||
|
|||||||
123
unity-overlay/Assets/Scripts/Menu/BentoLayout.cs
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace MyInfoMate.Vr.Menu
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Placement « bento » dense sur une grille de colonnes — le socle du menu flottant.
|
||||||
|
///
|
||||||
|
/// <b>Port C# de <c>myinfomate_layout</c></b> (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 <c>grid-auto-flow: dense</c>.
|
||||||
|
///
|
||||||
|
/// <b>Porté plutôt que calculé par le serveur</b> : 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.
|
||||||
|
/// </summary>
|
||||||
|
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<Placement> Placements;
|
||||||
|
public int RowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result Compute(IList<Item> items, int columns)
|
||||||
|
{
|
||||||
|
var cols = columns < 1 ? 1 : columns;
|
||||||
|
var occupied = new List<bool[]>();
|
||||||
|
|
||||||
|
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<Placement>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,11 +25,30 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
const float HeightMeters = 1.45f;
|
const float HeightMeters = 1.45f;
|
||||||
|
|
||||||
/// <summary>Écart angulaire entre deux panneaux, assez large pour que le regard tranche.</summary>
|
/// <summary>
|
||||||
const float StepDegrees = 16f;
|
/// Écart entre deux tuiles, dans les deux sens. <b>Serré, et c'est voulu</b> : 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.
|
||||||
|
/// </summary>
|
||||||
|
const float GapMeters = 0.03f;
|
||||||
|
|
||||||
/// <summary>Au-delà, on passe à une seconde rangée plutôt que d'encercler le visiteur.</summary>
|
/// <summary>
|
||||||
const int MaxPerRow = 5;
|
/// Écart angulaire entre deux colonnes, <b>déduit</b> de la largeur d'une cellule
|
||||||
|
/// et du rayon de l'arc : c'est le pas qui donne exactement <see cref="GapMeters"/>
|
||||||
|
/// entre deux tuiles voisines. Le poser en dur ferait dépendre l'allure de la
|
||||||
|
/// grille d'un réglage sans rapport avec elle.
|
||||||
|
/// </summary>
|
||||||
|
static float StepDegrees =>
|
||||||
|
(MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
const int Columns = 6;
|
||||||
|
|
||||||
readonly List<MenuItemPanel> _panels = new List<MenuItemPanel>();
|
readonly List<MenuItemPanel> _panels = new List<MenuItemPanel>();
|
||||||
|
|
||||||
@ -82,23 +101,64 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
var sections = export.RootSections();
|
var sections = export.RootSections();
|
||||||
|
|
||||||
for (var i = 0; i < sections.Count; i++)
|
var items = new List<BentoLayout.Item>(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<int>(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)
|
var title = ConfigurationExport.Translate(sections[i].Title, language)
|
||||||
?? sections[i].Label;
|
?? sections[i].Label;
|
||||||
|
|
||||||
var panel = MenuItemPanel.Create(sections[i].Id, title, transform);
|
var placement = placements[i];
|
||||||
Place(panel.transform, i, sections.Count);
|
|
||||||
|
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
|
// 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
|
// le menu se construire, assez peu pour que le dernier soit là avant
|
||||||
// qu'on ait fini de tourner la tête.
|
// qu'on ait fini de tourner la tête.
|
||||||
panel.PlayAppear(i * 0.04f);
|
panel.PlayAppear(readingRank[i] * 0.04f);
|
||||||
|
|
||||||
_panels.Add(panel);
|
_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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
|
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
|
||||||
/// le premier octet d'image ne soit téléchargé.
|
/// le premier octet d'image ne soit téléchargé.
|
||||||
@ -118,19 +178,32 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
var texture = new Texture2D(2, 2);
|
var texture = new Texture2D(2, 2);
|
||||||
if (texture.LoadImage(File.ReadAllBytes(path)))
|
if (texture.LoadImage(File.ReadAllBytes(path)))
|
||||||
_panels[i].SetImage(texture);
|
_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)
|
/// <summary>
|
||||||
|
/// 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 <see cref="Columns"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
void Place(Transform panel, BentoLayout.Placement placement)
|
||||||
{
|
{
|
||||||
var row = index / MaxPerRow;
|
var angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
|
||||||
var inRow = index % MaxPerRow;
|
|
||||||
var countInRow = Mathf.Min(total - row * MaxPerRow, MaxPerRow);
|
|
||||||
|
|
||||||
var angle = (inRow - (countInRow - 1) / 2f) * StepDegrees;
|
|
||||||
var radians = angle * Mathf.Deg2Rad;
|
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(
|
panel.localPosition = new Vector3(
|
||||||
Mathf.Sin(radians) * RadiusMeters,
|
Mathf.Sin(radians) * RadiusMeters,
|
||||||
|
|||||||
136
unity-overlay/Assets/Scripts/Menu/LanguagePicker.cs
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using MyInfoMate.Vr.Ui;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace MyInfoMate.Vr.Menu
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Le choix de la langue, posé <b>avant le menu</b>.
|
||||||
|
///
|
||||||
|
/// <b>Pourquoi le demander, et pourquoi à chaque visite.</b> 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
|
||||||
|
/// <see cref="Kiosk.KioskSession"/>), et pour la même raison.
|
||||||
|
///
|
||||||
|
/// <b>Changer de langue ne coûte aucun appel réseau</b> : l'export porte toutes les
|
||||||
|
/// traductions d'un coup — c'est explicitement l'arbitrage de
|
||||||
|
/// <c>ConfigurationExport.FetchAsync</c>. Le menu se reconstruit simplement avec
|
||||||
|
/// l'autre langue, y compris hors ligne.
|
||||||
|
///
|
||||||
|
/// <b>Ce sont les mêmes tuiles que le menu</b>, 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.
|
||||||
|
/// </summary>
|
||||||
|
public class LanguagePicker : MonoBehaviour
|
||||||
|
{
|
||||||
|
const float RadiusMeters = 2.2f;
|
||||||
|
|
||||||
|
/// <summary>Hauteur du centre de la rangée de tuiles.</summary>
|
||||||
|
const float HeightMeters = 1.35f;
|
||||||
|
|
||||||
|
/// <summary>Le même écart que la grille du menu, pour la même raison.</summary>
|
||||||
|
const float GapMeters = 0.03f;
|
||||||
|
|
||||||
|
static float StepDegrees =>
|
||||||
|
(MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg;
|
||||||
|
|
||||||
|
/// <summary>Le code de la langue choisie, tel qu'il part dans les traductions.</summary>
|
||||||
|
public event Action<string> Chosen;
|
||||||
|
|
||||||
|
public static LanguagePicker Create(Transform head, IList<string> languages)
|
||||||
|
{
|
||||||
|
var root = new GameObject("LanguagePicker");
|
||||||
|
var picker = root.AddComponent<LanguagePicker>();
|
||||||
|
|
||||||
|
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<AimSelector>();
|
||||||
|
selector.Configure(head);
|
||||||
|
selector.Selected += picker.OnSelected;
|
||||||
|
|
||||||
|
picker.Build(languages);
|
||||||
|
return picker;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Build(IList<string> languages)
|
||||||
|
{
|
||||||
|
// La consigne est écrite dans <b>chacune</b> 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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le drapeau d'une langue, ou null. ⚠️ La texture vient de <c>Resources</c> et est
|
||||||
|
/// <b>partagée</b> : 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.
|
||||||
|
/// </summary>
|
||||||
|
static Texture2D Flag(string language) =>
|
||||||
|
Resources.Load<Texture2D>("Flags/" + Strings.Normalize(language).ToLowerInvariant());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Les tuiles sur l'arc du menu, centrées sur le regard : c'est la même géométrie
|
||||||
|
/// que <see cref="FloatingMenu"/>, à une rangée.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using MyInfoMate.Vr.Ui;
|
using MyInfoMate.Vr.Ui;
|
||||||
using UnityEngine;
|
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
|
/// 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
|
/// 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.
|
/// matière sans payer une deuxième compilation de variantes.
|
||||||
|
///
|
||||||
|
/// <b>Et c'est aussi pour ça que la photo est <i>composée dans</i> la texture</b> et
|
||||||
|
/// non posée sur un deuxième quad : la tuile de <c>home_3.0</c> et celle de
|
||||||
|
/// <c>visitapp-web</c> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MenuItemPanel : MonoBehaviour, IAimTarget
|
public class MenuItemPanel : MonoBehaviour, IAimTarget
|
||||||
{
|
{
|
||||||
|
/// <summary>Taille d'<b>une cellule</b> 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 <see cref="Create"/>).</summary>
|
||||||
public const float WidthMeters = 0.5f;
|
public const float WidthMeters = 0.5f;
|
||||||
|
|
||||||
public const float HeightMeters = 0.34f;
|
public const float HeightMeters = 0.34f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Une commande — « Retour au menu » — n'est pas une tuile de contenu : elle est
|
||||||
|
/// large et basse, comme un bouton, et porte un chevron.
|
||||||
|
/// </summary>
|
||||||
|
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 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);
|
static readonly Color Hover = new Color(0.20f, 0.42f, 0.60f, 0.98f);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Une tuile qui porte une photo ne se <b>teinte</b> pas : la teinte de survol
|
||||||
|
/// virerait la photo au bleu. Elle s'<b>éclaire</b> — le panneau au repos est
|
||||||
|
/// légèrement assombri, et le survol le rend à sa pleine lumière.
|
||||||
|
/// </summary>
|
||||||
|
static readonly Color PhotoIdle = new Color(0.80f, 0.82f, 0.86f, 1f);
|
||||||
|
|
||||||
|
static readonly Color PhotoHover = Color.white;
|
||||||
|
|
||||||
/// <summary>Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.</summary>
|
/// <summary>Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.</summary>
|
||||||
const float HoverScale = 1.07f;
|
const float HoverScale = 1.07f;
|
||||||
|
|
||||||
@ -44,9 +74,30 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Id { get; private set; }
|
public string Id { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Hauteur de ligne du titre d'une tuile, en mètres.</summary>
|
||||||
|
const float CardTextMeters = 0.038f;
|
||||||
|
|
||||||
|
/// <summary>Celui d'une commande, qui doit se lire d'un coup d'œil.</summary>
|
||||||
|
const float ActionTextMeters = 0.062f;
|
||||||
|
|
||||||
|
/// <summary>Place réservée au chevron sur la gauche d'une commande.</summary>
|
||||||
|
const float ChevronWidthMeters = 0.13f;
|
||||||
|
|
||||||
Material _background;
|
Material _background;
|
||||||
LineRenderer _progress;
|
LineRenderer _progress;
|
||||||
Transform _image;
|
Label _title;
|
||||||
|
Style _style;
|
||||||
|
bool _chevron = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Texture2D _composed;
|
||||||
|
|
||||||
|
float _width = WidthMeters;
|
||||||
|
float _height = HeightMeters;
|
||||||
|
|
||||||
Vector3 _baseScale = Vector3.one;
|
Vector3 _baseScale = Vector3.one;
|
||||||
bool _baseScaleKnown;
|
bool _baseScaleKnown;
|
||||||
@ -58,28 +109,57 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
float _appearElapsed;
|
float _appearElapsed;
|
||||||
bool _appearing;
|
bool _appearing;
|
||||||
|
|
||||||
public static MenuItemPanel Create(string id, string title, Transform parent)
|
/// <summary>
|
||||||
|
/// <paramref name="widthMeters"/> et <paramref name="heightMeters"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static MenuItemPanel Create(string id, string title, Transform parent,
|
||||||
|
float widthMeters = WidthMeters, float heightMeters = HeightMeters)
|
||||||
{
|
{
|
||||||
var root = new GameObject($"Menu — {title}");
|
var root = new GameObject($"Menu — {title}");
|
||||||
root.transform.SetParent(parent, false);
|
root.transform.SetParent(parent, false);
|
||||||
|
|
||||||
var panel = root.AddComponent<MenuItemPanel>();
|
var panel = root.AddComponent<MenuItemPanel>();
|
||||||
panel.Build(id, title);
|
panel._width = widthMeters;
|
||||||
|
panel._height = heightMeters;
|
||||||
|
panel.Build(id, title, Style.Card);
|
||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Build(string id, string title)
|
/// <summary>
|
||||||
|
/// 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 <b>tout court</b> : un panneau de la taille
|
||||||
|
/// d'une tuile, avec un titre de tuile, ne se voyait pas.
|
||||||
|
/// </summary>
|
||||||
|
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<MenuItemPanel>();
|
||||||
|
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;
|
Id = id;
|
||||||
|
_style = style;
|
||||||
|
|
||||||
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
||||||
background.name = "Background";
|
background.name = "Background";
|
||||||
background.transform.SetParent(transform, false);
|
background.transform.SetParent(transform, false);
|
||||||
background.transform.localScale = new Vector3(WidthMeters, HeightMeters, 1f);
|
background.transform.localScale = new Vector3(_width, _height, 1f);
|
||||||
|
|
||||||
_background = background.GetComponent<Renderer>().material =
|
_background = background.GetComponent<Renderer>().material =
|
||||||
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
|
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
|
||||||
_background.mainTexture = PanelTexture;
|
_background.mainTexture = PanelTextureFor(_width, _height);
|
||||||
Transparent(_background);
|
Transparent(_background);
|
||||||
_background.color = Idle;
|
_background.color = Idle;
|
||||||
|
|
||||||
@ -96,9 +176,31 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
var box = background.AddComponent<BoxCollider>();
|
var box = background.AddComponent<BoxCollider>();
|
||||||
box.size = new Vector3(1f, 1f, 0.02f);
|
box.size = new Vector3(1f, 1f, 0.02f);
|
||||||
|
|
||||||
var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
|
var size = style == Style.Action ? ActionTextMeters : CardTextMeters;
|
||||||
label.transform.localPosition = new Vector3(0f, -HeightMeters * 0.30f, -0.01f);
|
_title = Label.Create(transform, "Title", Label.Anchor.MiddleCenter, size, Color.white);
|
||||||
label.Text = Label.Wrap(title, 22);
|
|
||||||
|
// 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();
|
BuildProgressRing();
|
||||||
}
|
}
|
||||||
@ -128,7 +230,10 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
_hover = Mathf.MoveTowards(
|
_hover = Mathf.MoveTowards(
|
||||||
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
|
_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)
|
if (_appearing)
|
||||||
{
|
{
|
||||||
@ -139,7 +244,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
|
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
|
||||||
_appear = _appearElapsed <= 0f ? 0f : Overshoot(t);
|
_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.
|
// sur place : une opacité multipliée image après image tomberait à zéro.
|
||||||
color.a *= Mathf.Clamp01(_appearElapsed / (AppearSeconds * 0.6f));
|
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
|
/// 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
|
/// lisible : le titre suffit, et une borne hors ligne au premier démarrage n'a
|
||||||
/// encore aucun média en cache.
|
/// encore aucun média en cache.
|
||||||
|
///
|
||||||
|
/// La photo <b>remplace</b> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetImage(Texture2D texture)
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hauteur du centre du titre au-dessus du bord bas du panneau.</summary>
|
||||||
|
const float TitleBaselineMeters = 0.075f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résolution de la texture composée. Plus fine que celle du fond nu
|
||||||
|
/// (<see cref="PixelsPerMeter"/>) : un aplat supporte 512 px/m, une photo non.
|
||||||
|
/// </summary>
|
||||||
|
const float PhotoPixelsPerMeter = 1024f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La photo cadrée en <b>cover</b>, arrondie et assombrie vers le bas — la tuile
|
||||||
|
/// de <c>home_3.0</c>, refaite en pixels.
|
||||||
|
///
|
||||||
|
/// ⚠️ <b>Les pixels de la source sont lus une fois</b>, puis échantillonnés à la
|
||||||
|
/// main. <c>GetPixelBilinear</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
var color = Sample(source32, source.width, source.height,
|
||||||
quad.name = "Image";
|
cropX + (x + 0.5f) / scale,
|
||||||
quad.transform.SetParent(transform, false);
|
cropY + (y + 0.5f) / scale);
|
||||||
quad.transform.localPosition = new Vector3(0f, HeightMeters * 0.16f, -0.005f);
|
|
||||||
quad.transform.localScale = new Vector3(WidthMeters * 0.86f, HeightMeters * 0.5f, 1f);
|
|
||||||
|
|
||||||
// L'image ne doit pas intercepter le regard : c'est le panneau qui est
|
// Le dégradé de lisibilité : rien sur les 40 % du haut, puis on descend
|
||||||
// la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
|
// jusqu'à 72 % de noir au bord bas. Sans lui, un titre blanc sur une
|
||||||
Destroy(quad.GetComponent<Collider>());
|
// photo claire est illisible — et une tuile sur deux l'est.
|
||||||
_image = quad.transform;
|
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<Renderer>().material = material;
|
texture.SetPixels32(pixels);
|
||||||
|
texture.Apply();
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Échantillonnage bilinéaire, en pixels de la source.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le chevron d'une commande, tracé plutôt que dessiné dans une texture : trois
|
||||||
|
/// points suffisent, et <c>LineRenderer</c> est déjà là pour l'anneau de visée.
|
||||||
|
/// Une police ne rendrait pas le même signe d'un casque à l'autre.
|
||||||
|
/// </summary>
|
||||||
|
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<LineRenderer>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>La texture composée n'appartient qu'à ce panneau.</summary>
|
||||||
|
void OnDestroy()
|
||||||
|
{
|
||||||
|
if (_composed != null) Destroy(_composed);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
|
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
|
||||||
@ -218,52 +439,67 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
|
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Texture2D _panelTexture;
|
static readonly Dictionary<int, Texture2D> PanelTextures = new Dictionary<int, Texture2D>();
|
||||||
|
|
||||||
|
/// <summary>Résolution du dessin, en pixels par mètre de panneau.</summary>
|
||||||
|
const float PixelsPerMeter = 512f;
|
||||||
|
|
||||||
|
/// <summary>Rayon des coins, en mètres — identique sur tous les panneaux.</summary>
|
||||||
|
const float CornerRadiusMeters = 0.059f;
|
||||||
|
|
||||||
|
/// <summary>Épaisseur du liseré, en mètres.</summary>
|
||||||
|
const float BorderMeters = 0.006f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Le rectangle arrondi, généré une fois pour tous les panneaux. L'alpha porte la
|
/// Le rectangle arrondi d'un panneau. L'alpha porte la forme et l'anticrénelage du
|
||||||
/// forme et l'anticrénelage du bord ; le RGB porte le liseré, plus clair que
|
/// bord ; le RGB porte le liseré, plus clair que l'intérieur — le matériau le teinte
|
||||||
/// l'intérieur — le matériau le teinte ensuite, donc un seul dessin sert au repos
|
/// ensuite, donc un seul dessin sert au repos comme au survol.
|
||||||
/// comme au survol.
|
///
|
||||||
|
/// ⚠️ <b>Une texture par taille de panneau, pas une pour tous.</b> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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;
|
var pixels = new Color32[width * height];
|
||||||
const int height = 176;
|
|
||||||
const float radius = 30f;
|
|
||||||
const float border = 3f;
|
|
||||||
|
|
||||||
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
|
for (var y = 0; y < height; y++)
|
||||||
{
|
for (var x = 0; x < width; x++)
|
||||||
wrapMode = TextureWrapMode.Clamp,
|
{
|
||||||
filterMode = FilterMode.Bilinear
|
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++)
|
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
texture.SetPixels32(pixels);
|
||||||
|
texture.Apply();
|
||||||
|
|
||||||
|
PanelTextures[key] = texture;
|
||||||
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>
|
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>
|
||||||
|
|||||||
@ -49,6 +49,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
Ui.Label _text;
|
Ui.Label _text;
|
||||||
MenuItemPanel _previous;
|
MenuItemPanel _previous;
|
||||||
MenuItemPanel _next;
|
MenuItemPanel _next;
|
||||||
|
string _language;
|
||||||
int _index;
|
int _index;
|
||||||
|
|
||||||
/// <summary>Le visiteur veut revenir au menu.</summary>
|
/// <summary>Le visiteur veut revenir au menu.</summary>
|
||||||
@ -62,10 +63,11 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
public int PageCount => _pages.Count;
|
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 root = new GameObject("PagedView");
|
||||||
var view = root.AddComponent<PagedView>();
|
var view = root.AddComponent<PagedView>();
|
||||||
|
view._language = language;
|
||||||
|
|
||||||
if (head != null)
|
if (head != null)
|
||||||
{
|
{
|
||||||
@ -109,7 +111,11 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
|
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
|
||||||
_next = Command(CommandNext, "▶", 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();
|
UpdateCommands();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
public event Action Closed;
|
public event Action Closed;
|
||||||
public event Action Interacted;
|
public event Action Interacted;
|
||||||
|
|
||||||
public static Scene3DView Create(Transform head)
|
public static Scene3DView Create(Transform head, string language)
|
||||||
{
|
{
|
||||||
var root = new GameObject("Scene3DView");
|
var root = new GameObject("Scene3DView");
|
||||||
var view = root.AddComponent<Scene3DView>();
|
var view = root.AddComponent<Scene3DView>();
|
||||||
@ -53,9 +53,9 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
selector.Configure(head);
|
selector.Configure(head);
|
||||||
selector.Selected += view.OnSelected;
|
selector.Selected += view.OnSelected;
|
||||||
|
|
||||||
view._back = MenuItemPanel.Create("__back", "Retour au menu", root.transform);
|
view._back = MenuItemPanel.CreateAction(
|
||||||
view._back.transform.localPosition = new Vector3(0f, 0.9f, 2.2f);
|
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, language), root.transform);
|
||||||
view._back.transform.localScale = Vector3.one * 0.6f;
|
view._back.transform.localPosition = new Vector3(0f, 0.85f, 2.2f);
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (view != null && view.PageCount == 0)
|
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,
|
static async Task FillSlider(PagedView view,
|
||||||
@ -155,7 +155,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (view.PageCount == 0)
|
if (view.PageCount == 0)
|
||||||
view.ShowEmpty("Rien au programme aujourd'hui.");
|
view.ShowEmpty(Ui.Strings.Get(Ui.Strings.NothingToday, language));
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task<Texture2D> LoadImage(ApiClient client, string url)
|
static async Task<Texture2D> LoadImage(ApiClient client, string url)
|
||||||
|
|||||||
@ -39,15 +39,17 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
Texture2D _image;
|
Texture2D _image;
|
||||||
MenuItemPanel _back;
|
MenuItemPanel _back;
|
||||||
Transform _head;
|
Transform _head;
|
||||||
|
string _language;
|
||||||
|
|
||||||
public event Action Closed;
|
public event Action Closed;
|
||||||
public event Action Interacted;
|
public event Action Interacted;
|
||||||
|
|
||||||
public static SkyboxView Create(Transform head)
|
public static SkyboxView Create(Transform head, string language)
|
||||||
{
|
{
|
||||||
var root = new GameObject("SkyboxView");
|
var root = new GameObject("SkyboxView");
|
||||||
var view = root.AddComponent<SkyboxView>();
|
var view = root.AddComponent<SkyboxView>();
|
||||||
view._head = head;
|
view._head = head;
|
||||||
|
view._language = language;
|
||||||
|
|
||||||
if (head != null)
|
if (head != null)
|
||||||
{
|
{
|
||||||
@ -75,8 +77,8 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
if (_skybox == null) return;
|
if (_skybox == null) return;
|
||||||
|
|
||||||
// Le seul élément posé dans la scène : de quoi ressortir.
|
// Le seul élément posé dans la scène : de quoi ressortir.
|
||||||
_back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform);
|
_back = MenuItemPanel.CreateAction(
|
||||||
_back.transform.localScale = Vector3.one * 0.6f;
|
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
|
||||||
PlaceBack();
|
PlaceBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
/// 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.
|
/// mymuseum-visitapp télécharge pour une visite hors ligne.
|
||||||
///
|
///
|
||||||
/// ⚠️ <b>L'export ne rend qu'une langue à la fois.</b> <c>language</c> traverse jusqu'à
|
/// <b>L'export porte toutes les langues à la fois</b>, et c'est ce qui permet au
|
||||||
/// <c>Section.GetReferencedResourceIds(language)</c> côté serveur : changer de langue
|
/// visiteur de choisir la sienne au début de sa visite sans aucun appel réseau : le
|
||||||
/// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la
|
/// paramètre <c>language</c> du serveur ne filtre que les <b>ressources</b>, jamais
|
||||||
/// question ouverte du lot XR-3 ; en attendant, on recharge.
|
/// les textes. Le détail de l'arbitrage est sur <see cref="FetchAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConfigurationExport
|
public class ConfigurationExport
|
||||||
{
|
{
|
||||||
public string Id;
|
public string Id;
|
||||||
public string Label;
|
public string Label;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Les langues dans lesquelles cette visite est proposée, telles que le
|
||||||
|
/// gestionnaire les a cochées. Le champ vient de <c>ConfigurationDTO</c>, dont
|
||||||
|
/// l'export hérite ; il peut manquer sur un contenu ancien — d'où le repli de
|
||||||
|
/// <see cref="AvailableLanguages"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Languages = new List<string>();
|
||||||
|
|
||||||
public List<SectionSummary> Sections = new List<SectionSummary>();
|
public List<SectionSummary> Sections = new List<SectionSummary>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -129,6 +138,15 @@ namespace MyInfoMate.Vr.Net
|
|||||||
public bool IsSubSection;
|
public bool IsSubSection;
|
||||||
public string ParentId;
|
public string ParentId;
|
||||||
public int? Order;
|
public int? Order;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int? GridColSpan;
|
||||||
|
|
||||||
|
public int? GridRowSpan;
|
||||||
|
|
||||||
public string ImageSource;
|
public string ImageSource;
|
||||||
public List<Translation> Title = new List<Translation>();
|
public List<Translation> Title = new List<Translation>();
|
||||||
public List<Translation> Description = new List<Translation>();
|
public List<Translation> Description = new List<Translation>();
|
||||||
@ -249,16 +267,50 @@ namespace MyInfoMate.Vr.Net
|
|||||||
public string Value;
|
public string Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Le texte dans la langue demandée, ou la première traduction disponible.</summary>
|
/// <summary>
|
||||||
|
/// Le texte dans la langue du visiteur.
|
||||||
|
///
|
||||||
|
/// <b>Trois essais, dans cet ordre</b> : la langue demandée, le français, puis la
|
||||||
|
/// première traduction qui porte quelque chose. C'est <i>exactement</i> la chaîne
|
||||||
|
/// de <c>t()</c> 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.
|
||||||
|
/// </summary>
|
||||||
public static string Translate(List<Translation> translations, string language)
|
public static string Translate(List<Translation> translations, string language)
|
||||||
{
|
{
|
||||||
if (translations == null || translations.Count == 0) return null;
|
if (translations == null || translations.Count == 0) return null;
|
||||||
|
|
||||||
foreach (var t in translations)
|
return Find(translations, language)
|
||||||
if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase))
|
?? Find(translations, Ui.Strings.Fallback)
|
||||||
return PlainText(t.Value);
|
?? First(translations);
|
||||||
|
}
|
||||||
|
|
||||||
return PlainText(translations[0].Value);
|
static string Find(List<Translation> 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<Translation> translations)
|
||||||
|
{
|
||||||
|
foreach (var translation in translations)
|
||||||
|
{
|
||||||
|
var text = PlainText(translation.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(text)) return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -285,6 +337,37 @@ namespace MyInfoMate.Vr.Net
|
|||||||
return text.Trim();
|
return text.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ce qu'on peut proposer au visiteur, normalisé en majuscules et sans doublon.
|
||||||
|
///
|
||||||
|
/// Le champ <c>languages</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> AvailableLanguages()
|
||||||
|
{
|
||||||
|
var codes = new List<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Les sections de premier niveau, dans l'ordre du manager.</summary>
|
/// <summary>Les sections de premier niveau, dans l'ordre du manager.</summary>
|
||||||
public List<SectionSummary> RootSections()
|
public List<SectionSummary> RootSections()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -24,10 +24,19 @@ namespace MyInfoMate.Vr.Net
|
|||||||
readonly ApiClient _client;
|
readonly ApiClient _client;
|
||||||
readonly string _configurationId;
|
readonly string _configurationId;
|
||||||
|
|
||||||
public ContentService(ApiClient client, string configurationId)
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
readonly string _deviceId;
|
||||||
|
|
||||||
|
public ContentService(ApiClient client, string configurationId, string deviceId = null)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
_configurationId = configurationId;
|
_configurationId = configurationId;
|
||||||
|
_deviceId = deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Loaded
|
public class Loaded
|
||||||
@ -68,6 +77,10 @@ namespace MyInfoMate.Vr.Net
|
|||||||
return await FetchAsync();
|
return await FetchAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string ExportPath => string.IsNullOrEmpty(_deviceId)
|
||||||
|
? $"/api/configuration/{_configurationId}/export"
|
||||||
|
: $"/api/configuration/{_configurationId}/export?deviceId={_deviceId}";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rafraîchit le cache sans bloquer l'affichage. Renvoie le contenu neuf, ou null
|
/// 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
|
/// 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<ConfigurationExport> RefreshAsync()
|
public async Task<ConfigurationExport> RefreshAsync()
|
||||||
{
|
{
|
||||||
var before = ContentCache.ReadExport(_configurationId);
|
var before = ContentCache.ReadExport(_configurationId);
|
||||||
var raw = await _client.GetRawAsync(
|
var raw = await _client.GetRawAsync(ExportPath);
|
||||||
$"/api/configuration/{_configurationId}/export");
|
|
||||||
|
|
||||||
if (!raw.Ok)
|
if (!raw.Ok)
|
||||||
{
|
{
|
||||||
@ -97,8 +109,7 @@ namespace MyInfoMate.Vr.Net
|
|||||||
|
|
||||||
async Task<Loaded> FetchAsync()
|
async Task<Loaded> FetchAsync()
|
||||||
{
|
{
|
||||||
var raw = await _client.GetRawAsync(
|
var raw = await _client.GetRawAsync(ExportPath);
|
||||||
$"/api/configuration/{_configurationId}/export");
|
|
||||||
|
|
||||||
if (!raw.Ok) return new Loaded { Error = raw.Error };
|
if (!raw.Ok) return new Loaded { Error = raw.Error };
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,14 @@ namespace MyInfoMate.Vr.Net
|
|||||||
readonly ApiClient _client;
|
readonly ApiClient _client;
|
||||||
readonly string _instanceId;
|
readonly string _instanceId;
|
||||||
readonly string _configurationId;
|
readonly string _configurationId;
|
||||||
readonly string _language;
|
|
||||||
|
/// <summary>
|
||||||
|
/// La langue de la visite en cours. Elle n'est pas fixée à la construction : le
|
||||||
|
/// visiteur la choisit au début de <b>sa</b> 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.
|
||||||
|
/// </summary>
|
||||||
|
public string Language { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
|
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
|
||||||
@ -43,7 +50,7 @@ namespace MyInfoMate.Vr.Net
|
|||||||
_client = client;
|
_client = client;
|
||||||
_instanceId = instanceId;
|
_instanceId = instanceId;
|
||||||
_configurationId = configurationId;
|
_configurationId = configurationId;
|
||||||
_language = language;
|
Language = language;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StartNewSession() => SessionId = NewSessionId();
|
public void StartNewSession() => SessionId = NewSessionId();
|
||||||
@ -69,7 +76,7 @@ namespace MyInfoMate.Vr.Net
|
|||||||
sessionId = SessionId,
|
sessionId = SessionId,
|
||||||
eventType,
|
eventType,
|
||||||
appType = AppTypeVr,
|
appType = AppTypeVr,
|
||||||
language = _language,
|
language = Language,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
metadata,
|
metadata,
|
||||||
timestamp = DateTime.UtcNow
|
timestamp = DateTime.UtcNow
|
||||||
|
|||||||
@ -171,8 +171,12 @@ namespace MyInfoMate.Vr.Scene
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(content.AssetId)) continue;
|
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
|
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;
|
if (hasText || _hotspot.Contents.Count == 1) return content.AssetId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using TMPro;
|
using TMPro;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace MyInfoMate.Vr.Ui
|
namespace MyInfoMate.Vr.Ui
|
||||||
@ -63,6 +63,15 @@ namespace MyInfoMate.Vr.Ui
|
|||||||
// Sans rect assez large, TMP tronque au lieu d'afficher.
|
// Sans rect assez large, TMP tronque au lieu d'afficher.
|
||||||
tmp.rectTransform.sizeDelta = new Vector2(4f, 2f);
|
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;
|
_tmp = tmp;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
110
unity-overlay/Assets/Scripts/Ui/Strings.cs
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace MyInfoMate.Vr.Ui
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>ConfigurationExport.Translate</c>.
|
||||||
|
///
|
||||||
|
/// <b>Pourquoi une table en dur plutôt que le mécanisme du manager.</b> 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 <c>languages</c> 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
|
||||||
|
/// <c>backToMenu</c> non.
|
||||||
|
/// </summary>
|
||||||
|
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<string, Dictionary<string, string>> Table =
|
||||||
|
new Dictionary<string, Dictionary<string, string>>
|
||||||
|
{
|
||||||
|
["FR"] = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[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<string, string>
|
||||||
|
{
|
||||||
|
[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<string, string>
|
||||||
|
{
|
||||||
|
[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<string, string>
|
||||||
|
{
|
||||||
|
[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.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le nom d'une langue <b>dans cette langue</b>. C'est la seule façon de nommer un
|
||||||
|
/// choix de langue : « Dutch » ne dit rien à quelqu'un qui cherche « Nederlands ».
|
||||||
|
/// </summary>
|
||||||
|
static readonly Dictionary<string, string> Names = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Le nom à afficher pour une langue, son code à défaut.</summary>
|
||||||
|
public static string NameOf(string language)
|
||||||
|
{
|
||||||
|
var code = Normalize(language);
|
||||||
|
return Names.TryGetValue(code, out var name) ? name : code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L'export écrit les langues tantôt en <c>fr</c>, tantôt en <c>FR</c> — et les
|
||||||
|
/// traductions d'un même contenu ne suivent pas toujours la casse du champ
|
||||||
|
/// <c>languages</c> de la configuration. Tout passe par ici.
|
||||||
|
/// </summary>
|
||||||
|
public static string Normalize(string language) =>
|
||||||
|
string.IsNullOrEmpty(language) ? Fallback : language.Trim().ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
unity/MyInfoMateVR/Assets/Icons.meta
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3078ca055dd32e34d87eb6ecdcf06e42
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
27
unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_background.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Icons/MyInfoMate_icon_foreground.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/ar.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
27
unity/MyInfoMateVR/Assets/Resources/Flags/ar.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/cn.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
27
unity/MyInfoMateVR/Assets/Resources/Flags/cn.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/de.png
Normal file
|
After Width: | Height: | Size: 225 B |
27
unity/MyInfoMateVR/Assets/Resources/Flags/de.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/en.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
27
unity/MyInfoMateVR/Assets/Resources/Flags/en.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/es.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
27
unity/MyInfoMateVR/Assets/Resources/Flags/es.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/fr.png
Normal file
|
After Width: | Height: | Size: 233 B |
27
unity/MyInfoMateVR/Assets/Resources/Flags/fr.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/it.png
Normal file
|
After Width: | Height: | Size: 705 B |
27
unity/MyInfoMateVR/Assets/Resources/Flags/it.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/nl.png
Normal file
|
After Width: | Height: | Size: 219 B |
27
unity/MyInfoMateVR/Assets/Resources/Flags/nl.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/pl.png
Normal file
|
After Width: | Height: | Size: 189 B |
27
unity/MyInfoMateVR/Assets/Resources/Flags/pl.png.meta
Normal file
@ -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:
|
||||||
BIN
unity/MyInfoMateVR/Assets/Resources/Flags/uk.png
Normal file
|
After Width: | Height: | Size: 279 B |
27
unity/MyInfoMateVR/Assets/Resources/Flags/uk.png.meta
Normal file
@ -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:
|
||||||
@ -1035,7 +1035,7 @@ MonoBehaviour:
|
|||||||
_monoscopic: 0
|
_monoscopic: 0
|
||||||
_sharpenType: 0
|
_sharpenType: 0
|
||||||
_disableRecording: 0
|
_disableRecording: 0
|
||||||
_enableDynamicResolution: 0
|
_enableDynamicResolution: 1
|
||||||
minDynamicResolutionScale: 1
|
minDynamicResolutionScale: 1
|
||||||
maxDynamicResolutionScale: 1
|
maxDynamicResolutionScale: 1
|
||||||
quest2MinDynamicResolutionScale: 0.7
|
quest2MinDynamicResolutionScale: 0.7
|
||||||
|
|||||||
@ -35,12 +35,25 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
[Header("Nom affiché dans l'onglet XR du manager")]
|
[Header("Nom affiché dans l'onglet XR du manager")]
|
||||||
[SerializeField] string headsetName = "Quest";
|
[SerializeField] string headsetName = "Quest";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La langue de repli : celle qu'on prend quand la visite n'en propose qu'une, ou
|
||||||
|
/// qu'elle n'en déclare aucune. Dès qu'il y a un choix, c'est le visiteur qui
|
||||||
|
/// tranche au début de sa visite — voir <see cref="ChooseLanguageAsync"/>.
|
||||||
|
/// </summary>
|
||||||
[SerializeField] string language = "FR";
|
[SerializeField] string language = "FR";
|
||||||
|
|
||||||
[SerializeField] bool forgetPairing;
|
[SerializeField] bool forgetPairing;
|
||||||
|
|
||||||
Transform _head;
|
Transform _head;
|
||||||
FloatingMenu _menu;
|
FloatingMenu _menu;
|
||||||
|
LanguagePicker _picker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La langue de la visite <b>en cours</b>. Nulle tant que le visiteur n'a pas
|
||||||
|
/// choisi, et remise à null à chaque nouvelle visite : elle n'est jamais écrite
|
||||||
|
/// sur le disque, donc un casque qu'on rallume repose la question.
|
||||||
|
/// </summary>
|
||||||
|
string _language;
|
||||||
Telemetry _telemetry;
|
Telemetry _telemetry;
|
||||||
ConfigurationExport _export;
|
ConfigurationExport _export;
|
||||||
SceneMessage _message;
|
SceneMessage _message;
|
||||||
@ -113,13 +126,75 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
async Task ApplyAsync(ConfigurationExport export)
|
async Task ApplyAsync(ConfigurationExport export)
|
||||||
{
|
{
|
||||||
BuildMenu(export);
|
_export = export;
|
||||||
await _menu.LoadImagesAsync(export, _client);
|
|
||||||
await ApplyBackdrop(export, _client);
|
await ApplyBackdrop(export, _client);
|
||||||
|
|
||||||
// E4 — le reste de la visite part sur le disque pendant que le visiteur
|
// 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.
|
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
|
||||||
_ = ContentPreloader.RunAsync(export, _client);
|
_ = ContentPreloader.RunAsync(export, _client);
|
||||||
|
|
||||||
|
// Avant le menu, et pas après : le menu est déjà écrit dans une langue, et
|
||||||
|
// le construire pour le reconstruire aussitôt ferait rejouer sa cascade sous
|
||||||
|
// les yeux du visiteur.
|
||||||
|
if (_language == null) await ChooseLanguageAsync(export);
|
||||||
|
|
||||||
|
await BuildMenuAsync(export);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task BuildMenuAsync(ConfigurationExport export)
|
||||||
|
{
|
||||||
|
BuildMenu(export);
|
||||||
|
await _menu.LoadImagesAsync(export, _client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Demande sa langue au visiteur, et ne rend la main qu'une fois qu'il a répondu.
|
||||||
|
///
|
||||||
|
/// <b>Le choix vit le temps d'une visite.</b> Sur une borne, la langue n'est pas un
|
||||||
|
/// réglage de l'appareil : c'est une propriété de la personne qui a le casque sur
|
||||||
|
/// la tête. Rien n'est persisté, et <see cref="StartNewVisit"/> repose la question
|
||||||
|
/// au suivant.
|
||||||
|
///
|
||||||
|
/// Une visite qui n'existe que dans une langue ne demande rien : un choix à une
|
||||||
|
/// possibilité est un écran de plus avant le contenu, pas un service.
|
||||||
|
/// </summary>
|
||||||
|
async Task ChooseLanguageAsync(ConfigurationExport export)
|
||||||
|
{
|
||||||
|
var languages = export.AvailableLanguages();
|
||||||
|
|
||||||
|
if (languages.Count <= 1)
|
||||||
|
{
|
||||||
|
SetLanguage(languages.Count == 1 ? languages[0] : language);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_menu != null) _menu.gameObject.SetActive(false);
|
||||||
|
|
||||||
|
var choice = new TaskCompletionSource<string>();
|
||||||
|
|
||||||
|
_picker = LanguagePicker.Create(_head, languages);
|
||||||
|
_picker.Chosen += chosen =>
|
||||||
|
{
|
||||||
|
_kiosk?.NotifyActivity();
|
||||||
|
choice.TrySetResult(chosen);
|
||||||
|
};
|
||||||
|
|
||||||
|
SetLanguage(await choice.Task);
|
||||||
|
|
||||||
|
Destroy(_picker.gameObject);
|
||||||
|
_picker = null;
|
||||||
|
|
||||||
|
if (_menu != null) _menu.gameObject.SetActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetLanguage(string chosen)
|
||||||
|
{
|
||||||
|
_language = Ui.Strings.Normalize(chosen);
|
||||||
|
|
||||||
|
// La télémétrie est ouverte avant le choix : sans ça, toute la visite serait
|
||||||
|
// comptée dans la langue de repli du casque.
|
||||||
|
if (_telemetry != null) _telemetry.Language = _language;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -155,7 +230,7 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
_kiosk.Ended += StartNewVisit;
|
_kiosk.Ended += StartNewVisit;
|
||||||
}
|
}
|
||||||
|
|
||||||
_menu.Build(export, language);
|
_menu.Build(export, _language);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -163,13 +238,26 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
/// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de
|
/// 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.
|
/// statistiques — sinon une journée de borne compterait pour un seul visiteur.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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();
|
CloseSection();
|
||||||
_menu?.Recenter();
|
_menu?.Recenter();
|
||||||
_telemetry?.StartNewSession();
|
_telemetry?.StartNewSession();
|
||||||
Debug.Log("[Kiosk] Nouvelle visite.");
|
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
|
// 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 :
|
// 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,
|
// 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 section = _export?.Sections?.Find(s => s.Id == sectionId);
|
||||||
var title = section != null
|
var title = section != null
|
||||||
? ConfigurationExport.Translate(section.Title, language) ?? section.Label
|
? ConfigurationExport.Translate(section.Title, _language) ?? section.Label
|
||||||
: sectionId;
|
: sectionId;
|
||||||
|
|
||||||
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
|
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
|
||||||
@ -199,7 +287,7 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
{
|
{
|
||||||
OpenSection(sectionId);
|
OpenSection(sectionId);
|
||||||
|
|
||||||
_skybox = SkyboxView.Create(_head);
|
_skybox = SkyboxView.Create(_head, _language);
|
||||||
_skybox.Closed += CloseSection;
|
_skybox.Closed += CloseSection;
|
||||||
_skybox.Interacted += () => _kiosk?.NotifyActivity();
|
_skybox.Interacted += () => _kiosk?.NotifyActivity();
|
||||||
|
|
||||||
@ -213,14 +301,14 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
{
|
{
|
||||||
OpenSection(sectionId);
|
OpenSection(sectionId);
|
||||||
|
|
||||||
_model = Scene3DView.Create(_head);
|
_model = Scene3DView.Create(_head, _language);
|
||||||
_model.Closed += CloseSection;
|
_model.Closed += CloseSection;
|
||||||
_model.Interacted += () => _kiosk?.NotifyActivity();
|
_model.Interacted += () => _kiosk?.NotifyActivity();
|
||||||
|
|
||||||
if (!await _model.LoadAsync(section, _export, language))
|
if (!await _model.LoadAsync(section, _export, _language))
|
||||||
{
|
{
|
||||||
CloseSection();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@ -230,17 +318,17 @@ namespace MyInfoMate.Vr.Boot
|
|||||||
// Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés.
|
// 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
|
// Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit
|
||||||
// comme une panne.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OpenSection(sectionId);
|
OpenSection(sectionId);
|
||||||
|
|
||||||
_view = PagedView.Create(_head);
|
_view = PagedView.Create(_head, _language);
|
||||||
_view.Closed += CloseSection;
|
_view.Closed += CloseSection;
|
||||||
_view.Interacted += () => _kiosk?.NotifyActivity();
|
_view.Interacted += () => _kiosk?.NotifyActivity();
|
||||||
|
|
||||||
await SectionPages.FillAsync(_view, section, _client, language);
|
await SectionPages.FillAsync(_view, section, _client, _language);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3aae3324a55246341918e0f85a7657c1
|
||||||
@ -25,28 +25,31 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
const float HeightMeters = 1.45f;
|
const float HeightMeters = 1.45f;
|
||||||
|
|
||||||
/// <summary>Écart angulaire entre deux panneaux, assez large pour que le regard tranche.</summary>
|
/// <summary>
|
||||||
const float StepDegrees = 16f;
|
/// Écart entre deux tuiles, dans les deux sens. <b>Serré, et c'est voulu</b> : 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.
|
||||||
|
/// </summary>
|
||||||
|
const float GapMeters = 0.03f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Écart angulaire entre deux colonnes, <b>déduit</b> de la largeur d'une cellule
|
||||||
|
/// et du rayon de l'arc : c'est le pas qui donne exactement <see cref="GapMeters"/>
|
||||||
|
/// entre deux tuiles voisines. Le poser en dur ferait dépendre l'allure de la
|
||||||
|
/// grille d'un réglage sans rapport avec elle.
|
||||||
|
/// </summary>
|
||||||
|
static float StepDegrees =>
|
||||||
|
(MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Colonnes de la grille bento. Au-delà on passe à une rangée de plus plutôt que
|
/// 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
|
/// d'encercler le visiteur : six colonnes font environ 83° d'ouverture, dans les
|
||||||
/// une fois compté que le regard balaie. Doit rester aligné sur `kVrMenuColumns`
|
/// ±60° de confort une fois compté que le regard balaie. Doit rester aligné sur
|
||||||
/// côté manager-app, qui dessine l'aperçu de cette même grille.
|
/// `kVrMenuColumns` côté manager-app, qui dessine l'aperçu de cette même grille.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
const int Columns = 6;
|
const int Columns = 6;
|
||||||
|
|
||||||
/// <summary>Écart vertical entre deux rangées.</summary>
|
|
||||||
const float RowGapMeters = 0.06f;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Écart horizontal, déduit de l'arc : un pas de <see cref="StepDegrees"/> à
|
|
||||||
/// <see cref="RadiusMeters"/> mesure plus qu'une cellule, la différence est le gap.
|
|
||||||
/// Le calculer évite qu'un panneau double déborde sur son voisin.
|
|
||||||
/// </summary>
|
|
||||||
static float ColumnGapMeters =>
|
|
||||||
RadiusMeters * StepDegrees * Mathf.Deg2Rad - MenuItemPanel.WidthMeters;
|
|
||||||
|
|
||||||
readonly List<MenuItemPanel> _panels = new List<MenuItemPanel>();
|
readonly List<MenuItemPanel> _panels = new List<MenuItemPanel>();
|
||||||
|
|
||||||
AimSelector _selector;
|
AimSelector _selector;
|
||||||
@ -151,10 +154,10 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
}
|
}
|
||||||
|
|
||||||
static float CellsWidth(int colSpan) =>
|
static float CellsWidth(int colSpan) =>
|
||||||
colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * ColumnGapMeters;
|
colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * GapMeters;
|
||||||
|
|
||||||
static float CellsHeight(int rowSpan) =>
|
static float CellsHeight(int rowSpan) =>
|
||||||
rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * RowGapMeters;
|
rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * GapMeters;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
|
/// 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);
|
var texture = new Texture2D(2, 2);
|
||||||
if (texture.LoadImage(File.ReadAllBytes(path)))
|
if (texture.LoadImage(File.ReadAllBytes(path)))
|
||||||
_panels[i].SetImage(texture);
|
_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 angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
|
||||||
var radians = angle * Mathf.Deg2Rad;
|
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
|
// Le panneau est centré sur les rangées qu'il couvre, sinon un panneau double
|
||||||
// déborderait vers le haut au lieu de descendre.
|
// déborderait vers le haut au lieu de descendre.
|
||||||
|
|||||||
136
unity/MyInfoMateVR/Assets/Scripts/Menu/LanguagePicker.cs
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using MyInfoMate.Vr.Ui;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace MyInfoMate.Vr.Menu
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Le choix de la langue, posé <b>avant le menu</b>.
|
||||||
|
///
|
||||||
|
/// <b>Pourquoi le demander, et pourquoi à chaque visite.</b> 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
|
||||||
|
/// <see cref="Kiosk.KioskSession"/>), et pour la même raison.
|
||||||
|
///
|
||||||
|
/// <b>Changer de langue ne coûte aucun appel réseau</b> : l'export porte toutes les
|
||||||
|
/// traductions d'un coup — c'est explicitement l'arbitrage de
|
||||||
|
/// <c>ConfigurationExport.FetchAsync</c>. Le menu se reconstruit simplement avec
|
||||||
|
/// l'autre langue, y compris hors ligne.
|
||||||
|
///
|
||||||
|
/// <b>Ce sont les mêmes tuiles que le menu</b>, 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.
|
||||||
|
/// </summary>
|
||||||
|
public class LanguagePicker : MonoBehaviour
|
||||||
|
{
|
||||||
|
const float RadiusMeters = 2.2f;
|
||||||
|
|
||||||
|
/// <summary>Hauteur du centre de la rangée de tuiles.</summary>
|
||||||
|
const float HeightMeters = 1.35f;
|
||||||
|
|
||||||
|
/// <summary>Le même écart que la grille du menu, pour la même raison.</summary>
|
||||||
|
const float GapMeters = 0.03f;
|
||||||
|
|
||||||
|
static float StepDegrees =>
|
||||||
|
(MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg;
|
||||||
|
|
||||||
|
/// <summary>Le code de la langue choisie, tel qu'il part dans les traductions.</summary>
|
||||||
|
public event Action<string> Chosen;
|
||||||
|
|
||||||
|
public static LanguagePicker Create(Transform head, IList<string> languages)
|
||||||
|
{
|
||||||
|
var root = new GameObject("LanguagePicker");
|
||||||
|
var picker = root.AddComponent<LanguagePicker>();
|
||||||
|
|
||||||
|
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<AimSelector>();
|
||||||
|
selector.Configure(head);
|
||||||
|
selector.Selected += picker.OnSelected;
|
||||||
|
|
||||||
|
picker.Build(languages);
|
||||||
|
return picker;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Build(IList<string> languages)
|
||||||
|
{
|
||||||
|
// La consigne est écrite dans <b>chacune</b> 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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le drapeau d'une langue, ou null. ⚠️ La texture vient de <c>Resources</c> et est
|
||||||
|
/// <b>partagée</b> : 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.
|
||||||
|
/// </summary>
|
||||||
|
static Texture2D Flag(string language) =>
|
||||||
|
Resources.Load<Texture2D>("Flags/" + Strings.Normalize(language).ToLowerInvariant());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Les tuiles sur l'arc du menu, centrées sur le regard : c'est la même géométrie
|
||||||
|
/// que <see cref="FloatingMenu"/>, à une rangée.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using MyInfoMate.Vr.Ui;
|
using MyInfoMate.Vr.Ui;
|
||||||
using UnityEngine;
|
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
|
/// 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
|
/// 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.
|
/// matière sans payer une deuxième compilation de variantes.
|
||||||
|
///
|
||||||
|
/// <b>Et c'est aussi pour ça que la photo est <i>composée dans</i> la texture</b> et
|
||||||
|
/// non posée sur un deuxième quad : la tuile de <c>home_3.0</c> et celle de
|
||||||
|
/// <c>visitapp-web</c> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MenuItemPanel : MonoBehaviour, IAimTarget
|
public class MenuItemPanel : MonoBehaviour, IAimTarget
|
||||||
{
|
{
|
||||||
@ -30,9 +37,28 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
public const float HeightMeters = 0.34f;
|
public const float HeightMeters = 0.34f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Une commande — « Retour au menu » — n'est pas une tuile de contenu : elle est
|
||||||
|
/// large et basse, comme un bouton, et porte un chevron.
|
||||||
|
/// </summary>
|
||||||
|
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 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);
|
static readonly Color Hover = new Color(0.20f, 0.42f, 0.60f, 0.98f);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Une tuile qui porte une photo ne se <b>teinte</b> pas : la teinte de survol
|
||||||
|
/// virerait la photo au bleu. Elle s'<b>éclaire</b> — le panneau au repos est
|
||||||
|
/// légèrement assombri, et le survol le rend à sa pleine lumière.
|
||||||
|
/// </summary>
|
||||||
|
static readonly Color PhotoIdle = new Color(0.80f, 0.82f, 0.86f, 1f);
|
||||||
|
|
||||||
|
static readonly Color PhotoHover = Color.white;
|
||||||
|
|
||||||
/// <summary>Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.</summary>
|
/// <summary>Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.</summary>
|
||||||
const float HoverScale = 1.07f;
|
const float HoverScale = 1.07f;
|
||||||
|
|
||||||
@ -48,9 +74,27 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Id { get; private set; }
|
public string Id { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Hauteur de ligne du titre d'une tuile, en mètres.</summary>
|
||||||
|
const float CardTextMeters = 0.038f;
|
||||||
|
|
||||||
|
/// <summary>Celui d'une commande, qui doit se lire d'un coup d'œil.</summary>
|
||||||
|
const float ActionTextMeters = 0.062f;
|
||||||
|
|
||||||
|
/// <summary>Place réservée au chevron sur la gauche d'une commande.</summary>
|
||||||
|
const float ChevronWidthMeters = 0.13f;
|
||||||
|
|
||||||
Material _background;
|
Material _background;
|
||||||
LineRenderer _progress;
|
LineRenderer _progress;
|
||||||
Transform _image;
|
Label _title;
|
||||||
|
Style _style;
|
||||||
|
bool _chevron = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Texture2D _composed;
|
||||||
|
|
||||||
float _width = WidthMeters;
|
float _width = WidthMeters;
|
||||||
float _height = HeightMeters;
|
float _height = HeightMeters;
|
||||||
@ -79,13 +123,34 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
var panel = root.AddComponent<MenuItemPanel>();
|
var panel = root.AddComponent<MenuItemPanel>();
|
||||||
panel._width = widthMeters;
|
panel._width = widthMeters;
|
||||||
panel._height = heightMeters;
|
panel._height = heightMeters;
|
||||||
panel.Build(id, title);
|
panel.Build(id, title, Style.Card);
|
||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Build(string id, string title)
|
/// <summary>
|
||||||
|
/// 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 <b>tout court</b> : un panneau de la taille
|
||||||
|
/// d'une tuile, avec un titre de tuile, ne se voyait pas.
|
||||||
|
/// </summary>
|
||||||
|
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<MenuItemPanel>();
|
||||||
|
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;
|
Id = id;
|
||||||
|
_style = style;
|
||||||
|
|
||||||
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
||||||
background.name = "Background";
|
background.name = "Background";
|
||||||
@ -94,7 +159,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
_background = background.GetComponent<Renderer>().material =
|
_background = background.GetComponent<Renderer>().material =
|
||||||
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
|
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
|
||||||
_background.mainTexture = PanelTexture;
|
_background.mainTexture = PanelTextureFor(_width, _height);
|
||||||
Transparent(_background);
|
Transparent(_background);
|
||||||
_background.color = Idle;
|
_background.color = Idle;
|
||||||
|
|
||||||
@ -111,12 +176,31 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
var box = background.AddComponent<BoxCollider>();
|
var box = background.AddComponent<BoxCollider>();
|
||||||
box.size = new Vector3(1f, 1f, 0.02f);
|
box.size = new Vector3(1f, 1f, 0.02f);
|
||||||
|
|
||||||
var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
|
var size = style == Style.Action ? ActionTextMeters : CardTextMeters;
|
||||||
label.transform.localPosition = new Vector3(0f, -_height * 0.30f, -0.01f);
|
_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
|
// 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.
|
// 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();
|
BuildProgressRing();
|
||||||
}
|
}
|
||||||
@ -146,7 +230,10 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
_hover = Mathf.MoveTowards(
|
_hover = Mathf.MoveTowards(
|
||||||
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
|
_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)
|
if (_appearing)
|
||||||
{
|
{
|
||||||
@ -157,7 +244,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
|
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
|
||||||
_appear = _appearElapsed <= 0f ? 0f : Overshoot(t);
|
_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.
|
// sur place : une opacité multipliée image après image tomberait à zéro.
|
||||||
color.a *= Mathf.Clamp01(_appearElapsed / (AppearSeconds * 0.6f));
|
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
|
/// 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
|
/// lisible : le titre suffit, et une borne hors ligne au premier démarrage n'a
|
||||||
/// encore aucun média en cache.
|
/// encore aucun média en cache.
|
||||||
|
///
|
||||||
|
/// La photo <b>remplace</b> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetImage(Texture2D texture)
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hauteur du centre du titre au-dessus du bord bas du panneau.</summary>
|
||||||
|
const float TitleBaselineMeters = 0.075f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résolution de la texture composée. Plus fine que celle du fond nu
|
||||||
|
/// (<see cref="PixelsPerMeter"/>) : un aplat supporte 512 px/m, une photo non.
|
||||||
|
/// </summary>
|
||||||
|
const float PhotoPixelsPerMeter = 1024f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// La photo cadrée en <b>cover</b>, arrondie et assombrie vers le bas — la tuile
|
||||||
|
/// de <c>home_3.0</c>, refaite en pixels.
|
||||||
|
///
|
||||||
|
/// ⚠️ <b>Les pixels de la source sont lus une fois</b>, puis échantillonnés à la
|
||||||
|
/// main. <c>GetPixelBilinear</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
var color = Sample(source32, source.width, source.height,
|
||||||
quad.name = "Image";
|
cropX + (x + 0.5f) / scale,
|
||||||
quad.transform.SetParent(transform, false);
|
cropY + (y + 0.5f) / scale);
|
||||||
quad.transform.localPosition = new Vector3(0f, _height * 0.16f, -0.005f);
|
|
||||||
quad.transform.localScale = new Vector3(_width * 0.86f, _height * 0.5f, 1f);
|
|
||||||
|
|
||||||
// L'image ne doit pas intercepter le regard : c'est le panneau qui est
|
// Le dégradé de lisibilité : rien sur les 40 % du haut, puis on descend
|
||||||
// la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
|
// jusqu'à 72 % de noir au bord bas. Sans lui, un titre blanc sur une
|
||||||
Destroy(quad.GetComponent<Collider>());
|
// photo claire est illisible — et une tuile sur deux l'est.
|
||||||
_image = quad.transform;
|
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<Renderer>().material = material;
|
texture.SetPixels32(pixels);
|
||||||
|
texture.Apply();
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Échantillonnage bilinéaire, en pixels de la source.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le chevron d'une commande, tracé plutôt que dessiné dans une texture : trois
|
||||||
|
/// points suffisent, et <c>LineRenderer</c> est déjà là pour l'anneau de visée.
|
||||||
|
/// Une police ne rendrait pas le même signe d'un casque à l'autre.
|
||||||
|
/// </summary>
|
||||||
|
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<LineRenderer>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>La texture composée n'appartient qu'à ce panneau.</summary>
|
||||||
|
void OnDestroy()
|
||||||
|
{
|
||||||
|
if (_composed != null) Destroy(_composed);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
|
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
|
||||||
@ -236,52 +439,67 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
|
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Texture2D _panelTexture;
|
static readonly Dictionary<int, Texture2D> PanelTextures = new Dictionary<int, Texture2D>();
|
||||||
|
|
||||||
|
/// <summary>Résolution du dessin, en pixels par mètre de panneau.</summary>
|
||||||
|
const float PixelsPerMeter = 512f;
|
||||||
|
|
||||||
|
/// <summary>Rayon des coins, en mètres — identique sur tous les panneaux.</summary>
|
||||||
|
const float CornerRadiusMeters = 0.059f;
|
||||||
|
|
||||||
|
/// <summary>Épaisseur du liseré, en mètres.</summary>
|
||||||
|
const float BorderMeters = 0.006f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Le rectangle arrondi, généré une fois pour tous les panneaux. L'alpha porte la
|
/// Le rectangle arrondi d'un panneau. L'alpha porte la forme et l'anticrénelage du
|
||||||
/// forme et l'anticrénelage du bord ; le RGB porte le liseré, plus clair que
|
/// bord ; le RGB porte le liseré, plus clair que l'intérieur — le matériau le teinte
|
||||||
/// l'intérieur — le matériau le teinte ensuite, donc un seul dessin sert au repos
|
/// ensuite, donc un seul dessin sert au repos comme au survol.
|
||||||
/// comme au survol.
|
///
|
||||||
|
/// ⚠️ <b>Une texture par taille de panneau, pas une pour tous.</b> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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;
|
var pixels = new Color32[width * height];
|
||||||
const int height = 176;
|
|
||||||
const float radius = 30f;
|
|
||||||
const float border = 3f;
|
|
||||||
|
|
||||||
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
|
for (var y = 0; y < height; y++)
|
||||||
{
|
for (var x = 0; x < width; x++)
|
||||||
wrapMode = TextureWrapMode.Clamp,
|
{
|
||||||
filterMode = FilterMode.Bilinear
|
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++)
|
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
texture.SetPixels32(pixels);
|
||||||
|
texture.Apply();
|
||||||
|
|
||||||
|
PanelTextures[key] = texture;
|
||||||
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>
|
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>
|
||||||
|
|||||||
@ -49,6 +49,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
Ui.Label _text;
|
Ui.Label _text;
|
||||||
MenuItemPanel _previous;
|
MenuItemPanel _previous;
|
||||||
MenuItemPanel _next;
|
MenuItemPanel _next;
|
||||||
|
string _language;
|
||||||
int _index;
|
int _index;
|
||||||
|
|
||||||
/// <summary>Le visiteur veut revenir au menu.</summary>
|
/// <summary>Le visiteur veut revenir au menu.</summary>
|
||||||
@ -62,10 +63,11 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
public int PageCount => _pages.Count;
|
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 root = new GameObject("PagedView");
|
||||||
var view = root.AddComponent<PagedView>();
|
var view = root.AddComponent<PagedView>();
|
||||||
|
view._language = language;
|
||||||
|
|
||||||
if (head != null)
|
if (head != null)
|
||||||
{
|
{
|
||||||
@ -109,7 +111,11 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
|
|
||||||
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
|
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
|
||||||
_next = Command(CommandNext, "▶", 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();
|
UpdateCommands();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
public event Action Closed;
|
public event Action Closed;
|
||||||
public event Action Interacted;
|
public event Action Interacted;
|
||||||
|
|
||||||
public static Scene3DView Create(Transform head)
|
public static Scene3DView Create(Transform head, string language)
|
||||||
{
|
{
|
||||||
var root = new GameObject("Scene3DView");
|
var root = new GameObject("Scene3DView");
|
||||||
var view = root.AddComponent<Scene3DView>();
|
var view = root.AddComponent<Scene3DView>();
|
||||||
@ -53,9 +53,9 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
selector.Configure(head);
|
selector.Configure(head);
|
||||||
selector.Selected += view.OnSelected;
|
selector.Selected += view.OnSelected;
|
||||||
|
|
||||||
view._back = MenuItemPanel.Create("__back", "Retour au menu", root.transform);
|
view._back = MenuItemPanel.CreateAction(
|
||||||
view._back.transform.localPosition = new Vector3(0f, 0.9f, 2.2f);
|
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, language), root.transform);
|
||||||
view._back.transform.localScale = Vector3.one * 0.6f;
|
view._back.transform.localPosition = new Vector3(0f, 0.85f, 2.2f);
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (view != null && view.PageCount == 0)
|
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,
|
static async Task FillSlider(PagedView view,
|
||||||
@ -155,7 +155,7 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (view.PageCount == 0)
|
if (view.PageCount == 0)
|
||||||
view.ShowEmpty("Rien au programme aujourd'hui.");
|
view.ShowEmpty(Ui.Strings.Get(Ui.Strings.NothingToday, language));
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task<Texture2D> LoadImage(ApiClient client, string url)
|
static async Task<Texture2D> LoadImage(ApiClient client, string url)
|
||||||
|
|||||||
@ -39,15 +39,17 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
Texture2D _image;
|
Texture2D _image;
|
||||||
MenuItemPanel _back;
|
MenuItemPanel _back;
|
||||||
Transform _head;
|
Transform _head;
|
||||||
|
string _language;
|
||||||
|
|
||||||
public event Action Closed;
|
public event Action Closed;
|
||||||
public event Action Interacted;
|
public event Action Interacted;
|
||||||
|
|
||||||
public static SkyboxView Create(Transform head)
|
public static SkyboxView Create(Transform head, string language)
|
||||||
{
|
{
|
||||||
var root = new GameObject("SkyboxView");
|
var root = new GameObject("SkyboxView");
|
||||||
var view = root.AddComponent<SkyboxView>();
|
var view = root.AddComponent<SkyboxView>();
|
||||||
view._head = head;
|
view._head = head;
|
||||||
|
view._language = language;
|
||||||
|
|
||||||
if (head != null)
|
if (head != null)
|
||||||
{
|
{
|
||||||
@ -75,8 +77,8 @@ namespace MyInfoMate.Vr.Menu
|
|||||||
if (_skybox == null) return;
|
if (_skybox == null) return;
|
||||||
|
|
||||||
// Le seul élément posé dans la scène : de quoi ressortir.
|
// Le seul élément posé dans la scène : de quoi ressortir.
|
||||||
_back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform);
|
_back = MenuItemPanel.CreateAction(
|
||||||
_back.transform.localScale = Vector3.one * 0.6f;
|
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
|
||||||
PlaceBack();
|
PlaceBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
/// 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.
|
/// mymuseum-visitapp télécharge pour une visite hors ligne.
|
||||||
///
|
///
|
||||||
/// ⚠️ <b>L'export ne rend qu'une langue à la fois.</b> <c>language</c> traverse jusqu'à
|
/// <b>L'export porte toutes les langues à la fois</b>, et c'est ce qui permet au
|
||||||
/// <c>Section.GetReferencedResourceIds(language)</c> côté serveur : changer de langue
|
/// visiteur de choisir la sienne au début de sa visite sans aucun appel réseau : le
|
||||||
/// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la
|
/// paramètre <c>language</c> du serveur ne filtre que les <b>ressources</b>, jamais
|
||||||
/// question ouverte du lot XR-3 ; en attendant, on recharge.
|
/// les textes. Le détail de l'arbitrage est sur <see cref="FetchAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConfigurationExport
|
public class ConfigurationExport
|
||||||
{
|
{
|
||||||
public string Id;
|
public string Id;
|
||||||
public string Label;
|
public string Label;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Les langues dans lesquelles cette visite est proposée, telles que le
|
||||||
|
/// gestionnaire les a cochées. Le champ vient de <c>ConfigurationDTO</c>, dont
|
||||||
|
/// l'export hérite ; il peut manquer sur un contenu ancien — d'où le repli de
|
||||||
|
/// <see cref="AvailableLanguages"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Languages = new List<string>();
|
||||||
|
|
||||||
public List<SectionSummary> Sections = new List<SectionSummary>();
|
public List<SectionSummary> Sections = new List<SectionSummary>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -258,16 +267,50 @@ namespace MyInfoMate.Vr.Net
|
|||||||
public string Value;
|
public string Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Le texte dans la langue demandée, ou la première traduction disponible.</summary>
|
/// <summary>
|
||||||
|
/// Le texte dans la langue du visiteur.
|
||||||
|
///
|
||||||
|
/// <b>Trois essais, dans cet ordre</b> : la langue demandée, le français, puis la
|
||||||
|
/// première traduction qui porte quelque chose. C'est <i>exactement</i> la chaîne
|
||||||
|
/// de <c>t()</c> 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.
|
||||||
|
/// </summary>
|
||||||
public static string Translate(List<Translation> translations, string language)
|
public static string Translate(List<Translation> translations, string language)
|
||||||
{
|
{
|
||||||
if (translations == null || translations.Count == 0) return null;
|
if (translations == null || translations.Count == 0) return null;
|
||||||
|
|
||||||
foreach (var t in translations)
|
return Find(translations, language)
|
||||||
if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase))
|
?? Find(translations, Ui.Strings.Fallback)
|
||||||
return PlainText(t.Value);
|
?? First(translations);
|
||||||
|
}
|
||||||
|
|
||||||
return PlainText(translations[0].Value);
|
static string Find(List<Translation> 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<Translation> translations)
|
||||||
|
{
|
||||||
|
foreach (var translation in translations)
|
||||||
|
{
|
||||||
|
var text = PlainText(translation.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(text)) return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -294,6 +337,37 @@ namespace MyInfoMate.Vr.Net
|
|||||||
return text.Trim();
|
return text.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ce qu'on peut proposer au visiteur, normalisé en majuscules et sans doublon.
|
||||||
|
///
|
||||||
|
/// Le champ <c>languages</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> AvailableLanguages()
|
||||||
|
{
|
||||||
|
var codes = new List<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Les sections de premier niveau, dans l'ordre du manager.</summary>
|
/// <summary>Les sections de premier niveau, dans l'ordre du manager.</summary>
|
||||||
public List<SectionSummary> RootSections()
|
public List<SectionSummary> RootSections()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -29,7 +29,14 @@ namespace MyInfoMate.Vr.Net
|
|||||||
readonly ApiClient _client;
|
readonly ApiClient _client;
|
||||||
readonly string _instanceId;
|
readonly string _instanceId;
|
||||||
readonly string _configurationId;
|
readonly string _configurationId;
|
||||||
readonly string _language;
|
|
||||||
|
/// <summary>
|
||||||
|
/// La langue de la visite en cours. Elle n'est pas fixée à la construction : le
|
||||||
|
/// visiteur la choisit au début de <b>sa</b> 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.
|
||||||
|
/// </summary>
|
||||||
|
public string Language { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
|
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
|
||||||
@ -43,7 +50,7 @@ namespace MyInfoMate.Vr.Net
|
|||||||
_client = client;
|
_client = client;
|
||||||
_instanceId = instanceId;
|
_instanceId = instanceId;
|
||||||
_configurationId = configurationId;
|
_configurationId = configurationId;
|
||||||
_language = language;
|
Language = language;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StartNewSession() => SessionId = NewSessionId();
|
public void StartNewSession() => SessionId = NewSessionId();
|
||||||
@ -69,7 +76,7 @@ namespace MyInfoMate.Vr.Net
|
|||||||
sessionId = SessionId,
|
sessionId = SessionId,
|
||||||
eventType,
|
eventType,
|
||||||
appType = AppTypeVr,
|
appType = AppTypeVr,
|
||||||
language = _language,
|
language = Language,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
metadata,
|
metadata,
|
||||||
timestamp = DateTime.UtcNow
|
timestamp = DateTime.UtcNow
|
||||||
|
|||||||
@ -171,8 +171,12 @@ namespace MyInfoMate.Vr.Scene
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(content.AssetId)) continue;
|
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
|
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;
|
if (hasText || _hotspot.Contents.Count == 1) return content.AssetId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
using TMPro;
|
using TMPro;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace MyInfoMate.Vr.Ui
|
namespace MyInfoMate.Vr.Ui
|
||||||
@ -63,6 +63,15 @@ namespace MyInfoMate.Vr.Ui
|
|||||||
// Sans rect assez large, TMP tronque au lieu d'afficher.
|
// Sans rect assez large, TMP tronque au lieu d'afficher.
|
||||||
tmp.rectTransform.sizeDelta = new Vector2(4f, 2f);
|
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;
|
_tmp = tmp;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
110
unity/MyInfoMateVR/Assets/Scripts/Ui/Strings.cs
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace MyInfoMate.Vr.Ui
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>ConfigurationExport.Translate</c>.
|
||||||
|
///
|
||||||
|
/// <b>Pourquoi une table en dur plutôt que le mécanisme du manager.</b> 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 <c>languages</c> 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
|
||||||
|
/// <c>backToMenu</c> non.
|
||||||
|
/// </summary>
|
||||||
|
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<string, Dictionary<string, string>> Table =
|
||||||
|
new Dictionary<string, Dictionary<string, string>>
|
||||||
|
{
|
||||||
|
["FR"] = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[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<string, string>
|
||||||
|
{
|
||||||
|
[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<string, string>
|
||||||
|
{
|
||||||
|
[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<string, string>
|
||||||
|
{
|
||||||
|
[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.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le nom d'une langue <b>dans cette langue</b>. C'est la seule façon de nommer un
|
||||||
|
/// choix de langue : « Dutch » ne dit rien à quelqu'un qui cherche « Nederlands ».
|
||||||
|
/// </summary>
|
||||||
|
static readonly Dictionary<string, string> Names = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Le nom à afficher pour une langue, son code à défaut.</summary>
|
||||||
|
public static string NameOf(string language)
|
||||||
|
{
|
||||||
|
var code = Normalize(language);
|
||||||
|
return Names.TryGetValue(code, out var name) ? name : code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L'export écrit les langues tantôt en <c>fr</c>, tantôt en <c>FR</c> — et les
|
||||||
|
/// traductions d'un même contenu ne suivent pas toujours la casse du champ
|
||||||
|
/// <c>languages</c> de la configuration. Tout passe par ici.
|
||||||
|
/// </summary>
|
||||||
|
public static string Normalize(string language) =>
|
||||||
|
string.IsNullOrEmpty(language) ? Fallback : language.Trim().ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ PlayerSettings:
|
|||||||
useOnDemandResources: 0
|
useOnDemandResources: 0
|
||||||
accelerometerFrequency: 60
|
accelerometerFrequency: 60
|
||||||
companyName: UNOV
|
companyName: UNOV
|
||||||
productName: MyInfoMateVR
|
productName: MyInfoMate
|
||||||
defaultCursor: {fileID: 0}
|
defaultCursor: {fileID: 0}
|
||||||
cursorHotspot: {x: 0, y: 0}
|
cursorHotspot: {x: 0, y: 0}
|
||||||
m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1}
|
m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1}
|
||||||
@ -396,92 +396,116 @@ PlayerSettings:
|
|||||||
m_SubKind: App Store
|
m_SubKind: App Store
|
||||||
- m_BuildTarget: Android
|
- m_BuildTarget: Android
|
||||||
m_Icons:
|
m_Icons:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
|
||||||
m_Width: 432
|
m_Width: 432
|
||||||
m_Height: 432
|
m_Height: 432
|
||||||
m_Kind: 2
|
m_Kind: 2
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
|
||||||
m_Width: 324
|
m_Width: 324
|
||||||
m_Height: 324
|
m_Height: 324
|
||||||
m_Kind: 2
|
m_Kind: 2
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
|
||||||
m_Width: 216
|
m_Width: 216
|
||||||
m_Height: 216
|
m_Height: 216
|
||||||
m_Kind: 2
|
m_Kind: 2
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
|
||||||
m_Width: 162
|
m_Width: 162
|
||||||
m_Height: 162
|
m_Height: 162
|
||||||
m_Kind: 2
|
m_Kind: 2
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
|
||||||
m_Width: 108
|
m_Width: 108
|
||||||
m_Height: 108
|
m_Height: 108
|
||||||
m_Kind: 2
|
m_Kind: 2
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
|
||||||
m_Width: 81
|
m_Width: 81
|
||||||
m_Height: 81
|
m_Height: 81
|
||||||
m_Kind: 2
|
m_Kind: 2
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 192
|
m_Width: 192
|
||||||
m_Height: 192
|
m_Height: 192
|
||||||
m_Kind: 1
|
m_Kind: 1
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 144
|
m_Width: 144
|
||||||
m_Height: 144
|
m_Height: 144
|
||||||
m_Kind: 1
|
m_Kind: 1
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 96
|
m_Width: 96
|
||||||
m_Height: 96
|
m_Height: 96
|
||||||
m_Kind: 1
|
m_Kind: 1
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 72
|
m_Width: 72
|
||||||
m_Height: 72
|
m_Height: 72
|
||||||
m_Kind: 1
|
m_Kind: 1
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 48
|
m_Width: 48
|
||||||
m_Height: 48
|
m_Height: 48
|
||||||
m_Kind: 1
|
m_Kind: 1
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 36
|
m_Width: 36
|
||||||
m_Height: 36
|
m_Height: 36
|
||||||
m_Kind: 1
|
m_Kind: 1
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 192
|
m_Width: 192
|
||||||
m_Height: 192
|
m_Height: 192
|
||||||
m_Kind: 0
|
m_Kind: 0
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 144
|
m_Width: 144
|
||||||
m_Height: 144
|
m_Height: 144
|
||||||
m_Kind: 0
|
m_Kind: 0
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 96
|
m_Width: 96
|
||||||
m_Height: 96
|
m_Height: 96
|
||||||
m_Kind: 0
|
m_Kind: 0
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 72
|
m_Width: 72
|
||||||
m_Height: 72
|
m_Height: 72
|
||||||
m_Kind: 0
|
m_Kind: 0
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 48
|
m_Width: 48
|
||||||
m_Height: 48
|
m_Height: 48
|
||||||
m_Kind: 0
|
m_Kind: 0
|
||||||
m_SubKind:
|
m_SubKind:
|
||||||
- m_Textures: []
|
- m_Textures:
|
||||||
|
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
|
||||||
m_Width: 36
|
m_Width: 36
|
||||||
m_Height: 36
|
m_Height: 36
|
||||||
m_Kind: 0
|
m_Kind: 0
|
||||||
|
|||||||