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>
This commit is contained in:
Thomas Fransolet 2026-09-17 11:37:06 +02:00
parent 722ba7c323
commit c70c572aed
84 changed files with 2603 additions and 275 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

View 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:

View File

@ -35,12 +35,25 @@ namespace MyInfoMate.Vr.Boot
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
/// <summary>
/// La langue de repli : celle qu'on prend quand la visite n'en propose qu'une, ou
/// qu'elle n'en déclare aucune. Dès qu'il y a un choix, c'est le visiteur qui
/// tranche au début de sa visite — voir <see cref="ChooseLanguageAsync"/>.
/// </summary>
[SerializeField] string language = "FR";
[SerializeField] bool forgetPairing;
Transform _head;
FloatingMenu _menu;
LanguagePicker _picker;
/// <summary>
/// La langue de la visite <b>en cours</b>. Nulle tant que le visiteur n'a pas
/// choisi, et remise à null à chaque nouvelle visite : elle n'est jamais écrite
/// sur le disque, donc un casque qu'on rallume repose la question.
/// </summary>
string _language;
Telemetry _telemetry;
ConfigurationExport _export;
SceneMessage _message;
@ -77,9 +90,10 @@ namespace MyInfoMate.Vr.Boot
_client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var client = _client;
var content = new ContentService(client, pairing.ConfigurationId);
_deviceId = pairing.DeviceId;
_content = new ContentService(client, pairing.ConfigurationId, _deviceId);
var loaded = await content.LoadAsync();
var loaded = await _content.LoadAsync();
if (!loaded.Ok)
{
Show(loaded.Error);
@ -95,27 +109,112 @@ namespace MyInfoMate.Vr.Boot
if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged;
BuildMenu(loaded.Export);
await _menu.LoadImagesAsync(loaded.Export, client);
await ApplyBackdrop(loaded.Export, client);
// E4 — le reste de la visite part sur le disque pendant que le visiteur
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
_ = ContentPreloader.RunAsync(loaded.Export, client);
await ApplyAsync(loaded.Export);
if (!loaded.FromCache) return;
// Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne,
// et ne se voit que s'il apporte quelque chose.
var fresh = await content.RefreshAsync();
await RefreshContentAsync();
}
/// <summary>
/// Pose un contenu : le menu, ses vignettes, le décor, puis le téléchargement du
/// reste. Les trois chemins qui changent de contenu — démarrage, rafraîchissement,
/// changement de configuration — passent tous par ici, sinon l'un des quatre gestes
/// finit par manquer à l'un d'eux.
/// </summary>
async Task ApplyAsync(ConfigurationExport export)
{
_export = export;
await ApplyBackdrop(export, _client);
// E4 — le reste de la visite part sur le disque pendant que le visiteur
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
_ = ContentPreloader.RunAsync(export, _client);
// Avant le menu, et pas après : le menu est déjà écrit dans une langue, et
// le construire pour le reconstruire aussitôt ferait rejouer sa cascade sous
// les yeux du visiteur.
if (_language == null) await ChooseLanguageAsync(export);
await BuildMenuAsync(export);
}
async Task BuildMenuAsync(ConfigurationExport export)
{
BuildMenu(export);
await _menu.LoadImagesAsync(export, _client);
}
/// <summary>
/// Demande sa langue au visiteur, et ne rend la main qu'une fois qu'il a répondu.
///
/// <b>Le choix vit le temps d'une visite.</b> Sur une borne, la langue n'est pas un
/// réglage de l'appareil : c'est une propriété de la personne qui a le casque sur
/// la tête. Rien n'est persisté, et <see cref="StartNewVisit"/> repose la question
/// au suivant.
///
/// Une visite qui n'existe que dans une langue ne demande rien : un choix à une
/// possibilité est un écran de plus avant le contenu, pas un service.
/// </summary>
async Task ChooseLanguageAsync(ConfigurationExport export)
{
var languages = export.AvailableLanguages();
if (languages.Count <= 1)
{
SetLanguage(languages.Count == 1 ? languages[0] : language);
return;
}
if (_menu != null) _menu.gameObject.SetActive(false);
var choice = new TaskCompletionSource<string>();
_picker = LanguagePicker.Create(_head, languages);
_picker.Chosen += chosen =>
{
_kiosk?.NotifyActivity();
choice.TrySetResult(chosen);
};
SetLanguage(await choice.Task);
Destroy(_picker.gameObject);
_picker = null;
if (_menu != null) _menu.gameObject.SetActive(true);
}
void SetLanguage(string chosen)
{
_language = Ui.Strings.Normalize(chosen);
// La télémétrie est ouverte avant le choix : sans ça, toute la visite serait
// comptée dans la langue de repli du casque.
if (_telemetry != null) _telemetry.Language = _language;
}
/// <summary>
/// Va voir si le contenu a changé, et ne reconstruit que s'il a changé —
/// <c>RefreshAsync</c> rend <c>null</c> quand l'export est identique à celui en
/// cache, et quand le serveur n'a pas répondu.
///
/// ⚠️ À n'appeler qu'aux moments où <b>personne ne regarde le menu</b> : au
/// démarrage, et entre deux visites. Un menu qui se reconstruit sous les yeux d'un
/// visiteur rejoue la cascade d'apparition et déplace ce qu'il visait.
/// </summary>
async Task RefreshContentAsync()
{
if (_content == null) return;
var fresh = await _content.RefreshAsync();
if (fresh == null || _menu == null) return;
Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit.");
BuildMenu(fresh);
await _menu.LoadImagesAsync(fresh, client);
await ApplyBackdrop(fresh, client);
_ = ContentPreloader.RunAsync(fresh, client);
await ApplyAsync(fresh);
}
void BuildMenu(ConfigurationExport export)
@ -131,7 +230,7 @@ namespace MyInfoMate.Vr.Boot
_kiosk.Ended += StartNewVisit;
}
_menu.Build(export, language);
_menu.Build(export, _language);
}
/// <summary>
@ -139,12 +238,31 @@ namespace MyInfoMate.Vr.Boot
/// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de
/// statistiques — sinon une journée de borne compterait pour un seul visiteur.
/// </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();
_menu?.Recenter();
_telemetry?.StartNewSession();
Debug.Log("[Kiosk] Nouvelle visite.");
// La langue appartient au visiteur, pas au casque : le suivant rechoisit.
_language = null;
if (_export != null)
{
await ChooseLanguageAsync(_export);
await BuildMenuAsync(_export);
}
// Le seul moment où reconstruire le menu ne dérange personne. Sans ça, un
// casque allumé toute la journée montre le contenu qu'il avait au démarrage :
// le battement de FleetReporter ne voit qu'un changement de configuration,
// pas une modification de son contenu.
_ = RefreshContentAsync();
}
async void OnSectionChosen(string sectionId)
@ -155,7 +273,7 @@ namespace MyInfoMate.Vr.Boot
var section = _export?.Sections?.Find(s => s.Id == sectionId);
var title = section != null
? ConfigurationExport.Translate(section.Title, language) ?? section.Label
? ConfigurationExport.Translate(section.Title, _language) ?? section.Label
: sectionId;
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
@ -169,7 +287,7 @@ namespace MyInfoMate.Vr.Boot
{
OpenSection(sectionId);
_skybox = SkyboxView.Create(_head);
_skybox = SkyboxView.Create(_head, _language);
_skybox.Closed += CloseSection;
_skybox.Interacted += () => _kiosk?.NotifyActivity();
@ -183,14 +301,14 @@ namespace MyInfoMate.Vr.Boot
{
OpenSection(sectionId);
_model = Scene3DView.Create(_head);
_model = Scene3DView.Create(_head, _language);
_model.Closed += CloseSection;
_model.Interacted += () => _kiosk?.NotifyActivity();
if (!await _model.LoadAsync(section, _export, language))
if (!await _model.LoadAsync(section, _export, _language))
{
CloseSection();
Show($"{title}\n\nCette scène 3D n'a pas pu être chargée.");
Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.Scene3DFailed, _language)}");
}
return;
}
@ -200,17 +318,17 @@ namespace MyInfoMate.Vr.Boot
// Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés.
// Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit
// comme une panne.
Show($"{title}\n\nCe type de contenu n'est pas encore affichable dans le casque.");
Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.NotSupported, _language)}");
return;
}
OpenSection(sectionId);
_view = PagedView.Create(_head);
_view = PagedView.Create(_head, _language);
_view.Closed += CloseSection;
_view.Interacted += () => _kiosk?.NotifyActivity();
await SectionPages.FillAsync(_view, section, _client, language);
await SectionPages.FillAsync(_view, section, _client, _language);
}
/// <summary>
@ -225,13 +343,18 @@ namespace MyInfoMate.Vr.Boot
/// ⚠️ On ferme ce qui est ouvert d'abord. Un visiteur qui regardait une galerie
/// au moment du changement doit revenir au menu — pas rester dans un contenu qui
/// n'appartient plus à sa visite.
///
/// ⚠️ Le <c>deviceId</c> repart avec : c'est lui qui désigne le lien de ce casque,
/// donc son agencement — bento, ordre des sections, sections masquées pour le seul
/// canal VR. Sans lui, le casque bascule sur la configuration nue et perd sa mise
/// en page jusqu'au prochain démarrage.
/// </summary>
async void OnConfigurationChanged(string configurationId)
{
CloseSection();
var content = new ContentService(_client, configurationId);
var loaded = await content.LoadAsync();
_content = new ContentService(_client, configurationId, _deviceId);
var loaded = await _content.LoadAsync();
if (!loaded.Ok)
{
@ -239,14 +362,13 @@ namespace MyInfoMate.Vr.Boot
return;
}
BuildMenu(loaded.Export);
// Avant l'application : recentrer ne dépend que du menu déjà en place, et
// attendre le chargement des vignettes le laisserait le temps d'une requête
// dans le dos du visiteur.
_menu?.Recenter();
_telemetry?.StartNewSession();
await _menu.LoadImagesAsync(loaded.Export, _client);
await ApplyBackdrop(loaded.Export, _client);
_ = ContentPreloader.RunAsync(loaded.Export, _client);
await ApplyAsync(loaded.Export);
}
/// <summary>
@ -311,6 +433,14 @@ namespace MyInfoMate.Vr.Boot
ImmersiveBackdrop _backdrop;
Scene3DView _model;
ApiClient _client;
ContentService _content;
/// <summary>
/// Ce casque, gardé pour toute la vie de l'app : il désigne le lien de
/// configuration, donc l'agencement propre au canal VR. Tout
/// <see cref="ContentService"/> construit ici doit le recevoir.
/// </summary>
string _deviceId;
Kiosk.KioskSession _kiosk;
string _openedSectionId;
float _openedAt;

View 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;
}
}
}

View File

@ -25,11 +25,30 @@ namespace MyInfoMate.Vr.Menu
const float HeightMeters = 1.45f;
/// <summary>Écart angulaire entre deux panneaux, assez large pour que le regard tranche.</summary>
const float StepDegrees = 16f;
/// <summary>
/// É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>
const int MaxPerRow = 5;
/// <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>
/// 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>();
@ -82,23 +101,64 @@ namespace MyInfoMate.Vr.Menu
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)
?? sections[i].Label;
var panel = MenuItemPanel.Create(sections[i].Id, title, transform);
Place(panel.transform, i, sections.Count);
var placement = placements[i];
var panel = MenuItemPanel.Create(
sections[i].Id,
title,
transform,
CellsWidth(placement.ColSpan),
CellsHeight(placement.RowSpan));
Place(panel.transform, placement);
// La cascade : 40 ms d'écart entre deux panneaux. Assez pour qu'on voie
// le menu se construire, assez peu pour que le dernier soit là avant
// qu'on ait fini de tourner la tête.
panel.PlayAppear(i * 0.04f);
panel.PlayAppear(readingRank[i] * 0.04f);
_panels.Add(panel);
}
}
static float CellsWidth(int colSpan) =>
colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * GapMeters;
static float CellsHeight(int rowSpan) =>
rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * GapMeters;
/// <summary>
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
/// le premier octet d'image ne soit téléchargé.
@ -118,19 +178,32 @@ namespace MyInfoMate.Vr.Menu
var texture = new Texture2D(2, 2);
if (texture.LoadImage(File.ReadAllBytes(path)))
_panels[i].SetImage(texture);
// La vignette est composée dans la texture du panneau : la source ne sert
// plus, et une photo de plein format gardée par tuile finirait par tenir
// plus de place que tout le reste du menu.
Destroy(texture);
}
}
void Place(Transform panel, int index, int total)
/// <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 inRow = index % MaxPerRow;
var countInRow = Mathf.Min(total - row * MaxPerRow, MaxPerRow);
var angle = (inRow - (countInRow - 1) / 2f) * StepDegrees;
var angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
var height = HeightMeters - row * (MenuItemPanel.HeightMeters + 0.06f);
var rowStep = MenuItemPanel.HeightMeters + GapMeters;
// Le panneau est centré sur les rangées qu'il couvre, sinon un panneau double
// déborderait vers le haut au lieu de descendre.
var height = HeightMeters
- placement.Row * rowStep
- (placement.RowSpan - 1) * rowStep / 2f;
panel.localPosition = new Vector3(
Mathf.Sin(radians) * RadiusMeters,

View 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);
}
}
}

View File

@ -1,3 +1,4 @@
using System.Collections.Generic;
using MyInfoMate.Vr.Ui;
using UnityEngine;
@ -20,15 +21,44 @@ namespace MyInfoMate.Vr.Menu
/// d'une texture générée une fois et partagée par tous les panneaux : un rectangle
/// arrondi en alpha, avec un bord plus clair. C'est la seule façon d'avoir une
/// matière sans payer une deuxième compilation de variantes.
///
/// <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>
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 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 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>
const float HoverScale = 1.07f;
@ -44,9 +74,30 @@ namespace MyInfoMate.Vr.Menu
/// </summary>
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;
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;
bool _baseScaleKnown;
@ -58,28 +109,57 @@ namespace MyInfoMate.Vr.Menu
float _appearElapsed;
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}");
root.transform.SetParent(parent, false);
var panel = root.AddComponent<MenuItemPanel>();
panel.Build(id, title);
panel._width = widthMeters;
panel._height = heightMeters;
panel.Build(id, title, Style.Card);
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;
_style = style;
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
background.name = "Background";
background.transform.SetParent(transform, false);
background.transform.localScale = new Vector3(WidthMeters, HeightMeters, 1f);
background.transform.localScale = new Vector3(_width, _height, 1f);
_background = background.GetComponent<Renderer>().material =
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_background.mainTexture = PanelTexture;
_background.mainTexture = PanelTextureFor(_width, _height);
Transparent(_background);
_background.color = Idle;
@ -96,9 +176,31 @@ namespace MyInfoMate.Vr.Menu
var box = background.AddComponent<BoxCollider>();
box.size = new Vector3(1f, 1f, 0.02f);
var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
label.transform.localPosition = new Vector3(0f, -HeightMeters * 0.30f, -0.01f);
label.Text = Label.Wrap(title, 22);
var size = style == Style.Action ? ActionTextMeters : CardTextMeters;
_title = Label.Create(transform, "Title", Label.Anchor.MiddleCenter, size, Color.white);
// La coupure suit la largeur réelle : sur un panneau double, 22 caractères
// laisseraient une colonne de texte au milieu d'un panneau vide.
var columns = Mathf.Max(8, Mathf.RoundToInt(22f * _width * CardTextMeters
/ (WidthMeters * size)));
_title.Text = Label.Wrap(title, columns);
if (style == Style.Action)
{
// Le texte laisse sa place au chevron plutôt que de le chevaucher — et
// reprend tout le panneau quand il n'y en a pas : un choix de langue n'a
// pas de « précédent » à désigner.
_title.transform.localPosition = new Vector3(
_chevron ? ChevronWidthMeters * 0.5f : 0f, 0f, -0.01f);
if (_chevron) BuildChevron();
}
else
{
// Sans photo, le titre est tout ce qu'il y a : il occupe le panneau.
// Avec, il descend au-dessus du bord bas (voir SetImage).
_title.transform.localPosition = new Vector3(0f, 0f, -0.01f);
}
BuildProgressRing();
}
@ -128,7 +230,10 @@ namespace MyInfoMate.Vr.Menu
_hover = Mathf.MoveTowards(
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
var color = Color.Lerp(Idle, Hover, _hover);
var color = Color.Lerp(
_composed != null ? PhotoIdle : Idle,
_composed != null ? PhotoHover : Hover,
_hover);
if (_appearing)
{
@ -139,7 +244,7 @@ namespace MyInfoMate.Vr.Menu
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
_appear = _appearElapsed <= 0f ? 0f : Overshoot(t);
// La couleur est toujours recalculée depuis Idle/Hover, jamais modifiée
// La couleur est toujours recalculée depuis les deux teintes, jamais modifiée
// sur place : une opacité multipliée image après image tomberait à zéro.
color.a *= Mathf.Clamp01(_appearElapsed / (AppearSeconds * 0.6f));
@ -174,31 +279,147 @@ namespace MyInfoMate.Vr.Menu
/// L'image de la section, une fois téléchargée. Un panneau sans image reste
/// lisible : le titre suffit, et une borne hors ligne au premier démarrage n'a
/// encore aucun média en cache.
///
/// La photo <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>
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);
quad.name = "Image";
quad.transform.SetParent(transform, false);
quad.transform.localPosition = new Vector3(0f, HeightMeters * 0.16f, -0.005f);
quad.transform.localScale = new Vector3(WidthMeters * 0.86f, HeightMeters * 0.5f, 1f);
var color = Sample(source32, source.width, source.height,
cropX + (x + 0.5f) / scale,
cropY + (y + 0.5f) / scale);
// L'image ne doit pas intercepter le regard : c'est le panneau qui est
// la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
Destroy(quad.GetComponent<Collider>());
_image = quad.transform;
// Le dégradé de lisibilité : rien sur les 40 % du haut, puis on descend
// jusqu'à 72 % de noir au bord bas. Sans lui, un titre blanc sur une
// photo claire est illisible — et une tuile sur deux l'est.
var fromTop = 1f - (y + 0.5f) / height;
var shade = Mathf.InverseLerp(0.4f, 1f, fromTop) * 0.72f;
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
// Le liseré éclaircit la photo au lieu de la recouvrir : c'est ce qui
// détache la tuile d'un fond sombre sans faire un cadre blanc.
var lit = distance > -border ? 0.30f : 0f;
var value = Color.Lerp(color * (1f - shade), Color.white, lit);
value.a = Mathf.Clamp01(0.5f - distance / 1.2f);
pixels[y * width + x] = value;
}
var material = new Material(Shader.Find("Universal Render Pipeline/Unlit"))
// Avec mipmaps, contrairement au fond nu : une photo vue de loin, sans
// niveau réduit, scintille dès que la tête bouge — et dans un casque, la
// tête bouge tout le temps.
var texture = new Texture2D(width, height, TextureFormat.RGBA32, true)
{
mainTexture = texture
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Trilinear
};
NoCulling(material);
_image.GetComponent<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);
@ -218,52 +439,67 @@ namespace MyInfoMate.Vr.Menu
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>
/// Le rectangle arrondi, généré une fois pour tous les panneaux. L'alpha porte la
/// forme et l'anticrénelage du bord ; le RGB porte le liseré, plus clair que
/// l'intérieur — le matériau le teinte ensuite, donc un seul dessin sert au repos
/// comme au survol.
/// Le rectangle arrondi d'un panneau. L'alpha porte la forme et l'anticrénelage du
/// bord ; le RGB porte le liseré, plus clair que l'intérieur — le matériau le teinte
/// ensuite, donc un seul dessin sert au repos comme au survol.
///
/// ⚠️ <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>
static Texture2D PanelTexture
static Texture2D PanelTextureFor(float widthMeters, float heightMeters)
{
get
var key = Mathf.RoundToInt(widthMeters * 1000f) * 10000
+ Mathf.RoundToInt(heightMeters * 1000f);
if (PanelTextures.TryGetValue(key, out var cached) && cached != null) return cached;
var width = Mathf.Max(8, Mathf.RoundToInt(widthMeters * PixelsPerMeter));
var height = Mathf.Max(8, Mathf.RoundToInt(heightMeters * PixelsPerMeter));
var radius = CornerRadiusMeters * PixelsPerMeter;
var border = BorderMeters * PixelsPerMeter;
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
if (_panelTexture != null) return _panelTexture;
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
const int width = 256;
const int height = 176;
const float radius = 30f;
const float border = 3f;
var pixels = new Color32[width * height];
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
var pixels = new Color32[width * height];
// distance < 0 dedans, > 0 dehors. 1,2 px de dégradé suffisent à
// effacer l'escalier du bord à 2,20 m.
var alpha = Mathf.Clamp01(0.5f - distance / 1.2f);
var lit = distance > -border ? 1f : 0.72f;
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
// distance < 0 dedans, > 0 dehors. 1,2 px de dégradé suffisent à
// effacer l'escalier du bord à 2,20 m.
var alpha = Mathf.Clamp01(0.5f - distance / 1.2f);
var lit = distance > -border ? 1f : 0.72f;
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
}
texture.SetPixels32(pixels);
texture.Apply();
_panelTexture = texture;
return _panelTexture;
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
}
texture.SetPixels32(pixels);
texture.Apply();
PanelTextures[key] = texture;
return texture;
}
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>

View File

@ -49,6 +49,7 @@ namespace MyInfoMate.Vr.Menu
Ui.Label _text;
MenuItemPanel _previous;
MenuItemPanel _next;
string _language;
int _index;
/// <summary>Le visiteur veut revenir au menu.</summary>
@ -62,10 +63,11 @@ namespace MyInfoMate.Vr.Menu
public int PageCount => _pages.Count;
public static PagedView Create(Transform head)
public static PagedView Create(Transform head, string language)
{
var root = new GameObject("PagedView");
var view = root.AddComponent<PagedView>();
view._language = language;
if (head != null)
{
@ -109,7 +111,11 @@ namespace MyInfoMate.Vr.Menu
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
_next = Command(CommandNext, "▶", ImageWidthMeters * 0.62f, CenterHeight);
Command(CommandBack, "Retour au menu", 0f, CenterHeight - ImageHeightMeters * 0.95f);
var back = MenuItemPanel.CreateAction(
CommandBack, Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
back.transform.localPosition = new Vector3(
0f, CenterHeight - ImageHeightMeters * 0.95f, DistanceMeters - 0.02f);
UpdateCommands();
}

View File

@ -33,7 +33,7 @@ namespace MyInfoMate.Vr.Menu
public event Action Closed;
public event Action Interacted;
public static Scene3DView Create(Transform head)
public static Scene3DView Create(Transform head, string language)
{
var root = new GameObject("Scene3DView");
var view = root.AddComponent<Scene3DView>();
@ -53,9 +53,9 @@ namespace MyInfoMate.Vr.Menu
selector.Configure(head);
selector.Selected += view.OnSelected;
view._back = MenuItemPanel.Create("__back", "Retour au menu", root.transform);
view._back.transform.localPosition = new Vector3(0f, 0.9f, 2.2f);
view._back.transform.localScale = Vector3.one * 0.6f;
view._back = MenuItemPanel.CreateAction(
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, language), root.transform);
view._back.transform.localPosition = new Vector3(0f, 0.85f, 2.2f);
return view;
}

View File

@ -47,7 +47,7 @@ namespace MyInfoMate.Vr.Menu
}
if (view != null && view.PageCount == 0)
view.ShowEmpty("Ce contenu est vide.");
view.ShowEmpty(Ui.Strings.Get(Ui.Strings.EmptyContent, language));
}
static async Task FillSlider(PagedView view,
@ -155,7 +155,7 @@ namespace MyInfoMate.Vr.Menu
}
if (view.PageCount == 0)
view.ShowEmpty("Rien au programme aujourd'hui.");
view.ShowEmpty(Ui.Strings.Get(Ui.Strings.NothingToday, language));
}
static async Task<Texture2D> LoadImage(ApiClient client, string url)

View File

@ -39,15 +39,17 @@ namespace MyInfoMate.Vr.Menu
Texture2D _image;
MenuItemPanel _back;
Transform _head;
string _language;
public event Action Closed;
public event Action Interacted;
public static SkyboxView Create(Transform head)
public static SkyboxView Create(Transform head, string language)
{
var root = new GameObject("SkyboxView");
var view = root.AddComponent<SkyboxView>();
view._head = head;
view._language = language;
if (head != null)
{
@ -75,8 +77,8 @@ namespace MyInfoMate.Vr.Menu
if (_skybox == null) return;
// Le seul élément posé dans la scène : de quoi ressortir.
_back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform);
_back.transform.localScale = Vector3.one * 0.6f;
_back = MenuItemPanel.CreateAction(
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
PlaceBack();
}

View File

@ -14,15 +14,24 @@ namespace MyInfoMate.Vr.Net
/// est le DTO de l'import/export du back-office, déjà stable, et c'est aussi celui que
/// mymuseum-visitapp télécharge pour une visite hors ligne.
///
/// ⚠️ <b>L'export ne rend qu'une langue à la fois.</b> <c>language</c> traverse jusqu'à
/// <c>Section.GetReferencedResourceIds(language)</c> côté serveur : changer de langue
/// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la
/// question ouverte du lot XR-3 ; en attendant, on recharge.
/// <b>L'export porte toutes les langues à la fois</b>, et c'est ce qui permet au
/// visiteur de choisir la sienne au début de sa visite sans aucun appel réseau : le
/// paramètre <c>language</c> du serveur ne filtre que les <b>ressources</b>, jamais
/// les textes. Le détail de l'arbitrage est sur <see cref="FetchAsync"/>.
/// </summary>
public class ConfigurationExport
{
public string Id;
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>();
/// <summary>
@ -129,6 +138,15 @@ namespace MyInfoMate.Vr.Net
public bool IsSubSection;
public string ParentId;
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 List<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
@ -249,16 +267,50 @@ namespace MyInfoMate.Vr.Net
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)
{
if (translations == null || translations.Count == 0) return null;
foreach (var t in translations)
if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase))
return PlainText(t.Value);
return Find(translations, language)
?? Find(translations, Ui.Strings.Fallback)
?? First(translations);
}
return PlainText(translations[0].Value);
static string Find(List<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>
@ -285,6 +337,37 @@ namespace MyInfoMate.Vr.Net
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>
public List<SectionSummary> RootSections()
{

View File

@ -24,10 +24,19 @@ namespace MyInfoMate.Vr.Net
readonly ApiClient _client;
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;
_configurationId = configurationId;
_deviceId = deviceId;
}
public class Loaded
@ -68,6 +77,10 @@ namespace MyInfoMate.Vr.Net
return await FetchAsync();
}
string ExportPath => string.IsNullOrEmpty(_deviceId)
? $"/api/configuration/{_configurationId}/export"
: $"/api/configuration/{_configurationId}/export?deviceId={_deviceId}";
/// <summary>
/// Rafraîchit le cache sans bloquer l'affichage. Renvoie le contenu neuf, ou null
/// si rien n'a changé ou si le serveur n'a pas répondu — dans les deux cas
@ -76,8 +89,7 @@ namespace MyInfoMate.Vr.Net
public async Task<ConfigurationExport> RefreshAsync()
{
var before = ContentCache.ReadExport(_configurationId);
var raw = await _client.GetRawAsync(
$"/api/configuration/{_configurationId}/export");
var raw = await _client.GetRawAsync(ExportPath);
if (!raw.Ok)
{
@ -97,8 +109,7 @@ namespace MyInfoMate.Vr.Net
async Task<Loaded> FetchAsync()
{
var raw = await _client.GetRawAsync(
$"/api/configuration/{_configurationId}/export");
var raw = await _client.GetRawAsync(ExportPath);
if (!raw.Ok) return new Loaded { Error = raw.Error };

View File

@ -29,7 +29,14 @@ namespace MyInfoMate.Vr.Net
readonly ApiClient _client;
readonly string _instanceId;
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>
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
@ -43,7 +50,7 @@ namespace MyInfoMate.Vr.Net
_client = client;
_instanceId = instanceId;
_configurationId = configurationId;
_language = language;
Language = language;
}
public void StartNewSession() => SessionId = NewSessionId();
@ -69,7 +76,7 @@ namespace MyInfoMate.Vr.Net
sessionId = SessionId,
eventType,
appType = AppTypeVr,
language = _language,
language = Language,
durationSeconds,
metadata,
timestamp = DateTime.UtcNow

View File

@ -171,8 +171,12 @@ namespace MyInfoMate.Vr.Scene
{
if (string.IsNullOrEmpty(content.AssetId)) continue;
// Sans OrdinalIgnoreCase, un export qui écrit « fr » ne répond pas à un
// choix normalisé en « FR » : le hotspot resterait muet sans rien dire.
var hasText = content.Description != null
&& content.Description.Exists(t => t.Language == language);
&& content.Description.Exists(
t => string.Equals(t.Language, language,
System.StringComparison.OrdinalIgnoreCase));
if (hasText || _hotspot.Contents.Count == 1) return content.AssetId;
}

View File

@ -1,4 +1,4 @@
using TMPro;
using TMPro;
using UnityEngine;
namespace MyInfoMate.Vr.Ui
@ -63,6 +63,15 @@ namespace MyInfoMate.Vr.Ui
// Sans rect assez large, TMP tronque au lieu d'afficher.
tmp.rectTransform.sizeDelta = new Vector2(4f, 2f);
// ⚠️ Le pivot, sinon le texte part en l'air. TMP aligne dans son rect, et
// ce rect est centré sur l'origine : un texte aligné en haut d'un rect de
// 2 m s'affiche donc 1 m au-dessus du point où on a posé le Label. En
// remontant le pivot, le haut du rect est l'origine — c'est ce que fait
// TextMesh en UpperCenter, sur lequel les positions des appelants sont
// calées.
tmp.rectTransform.pivot = new Vector2(
0.5f, anchor == Anchor.TopCenter ? 1f : 0.5f);
_tmp = tmp;
return;
}

View 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();
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3078ca055dd32e34d87eb6ecdcf06e42
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

View 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:

View File

@ -1035,7 +1035,7 @@ MonoBehaviour:
_monoscopic: 0
_sharpenType: 0
_disableRecording: 0
_enableDynamicResolution: 0
_enableDynamicResolution: 1
minDynamicResolutionScale: 1
maxDynamicResolutionScale: 1
quest2MinDynamicResolutionScale: 0.7

View File

@ -35,12 +35,25 @@ namespace MyInfoMate.Vr.Boot
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
/// <summary>
/// La langue de repli : celle qu'on prend quand la visite n'en propose qu'une, ou
/// qu'elle n'en déclare aucune. Dès qu'il y a un choix, c'est le visiteur qui
/// tranche au début de sa visite — voir <see cref="ChooseLanguageAsync"/>.
/// </summary>
[SerializeField] string language = "FR";
[SerializeField] bool forgetPairing;
Transform _head;
FloatingMenu _menu;
LanguagePicker _picker;
/// <summary>
/// La langue de la visite <b>en cours</b>. Nulle tant que le visiteur n'a pas
/// choisi, et remise à null à chaque nouvelle visite : elle n'est jamais écrite
/// sur le disque, donc un casque qu'on rallume repose la question.
/// </summary>
string _language;
Telemetry _telemetry;
ConfigurationExport _export;
SceneMessage _message;
@ -113,13 +126,75 @@ namespace MyInfoMate.Vr.Boot
/// </summary>
async Task ApplyAsync(ConfigurationExport export)
{
BuildMenu(export);
await _menu.LoadImagesAsync(export, _client);
_export = export;
await ApplyBackdrop(export, _client);
// E4 — le reste de la visite part sur le disque pendant que le visiteur
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
_ = ContentPreloader.RunAsync(export, _client);
// Avant le menu, et pas après : le menu est déjà écrit dans une langue, et
// le construire pour le reconstruire aussitôt ferait rejouer sa cascade sous
// les yeux du visiteur.
if (_language == null) await ChooseLanguageAsync(export);
await BuildMenuAsync(export);
}
async Task BuildMenuAsync(ConfigurationExport export)
{
BuildMenu(export);
await _menu.LoadImagesAsync(export, _client);
}
/// <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>
@ -155,7 +230,7 @@ namespace MyInfoMate.Vr.Boot
_kiosk.Ended += StartNewVisit;
}
_menu.Build(export, language);
_menu.Build(export, _language);
}
/// <summary>
@ -163,13 +238,26 @@ namespace MyInfoMate.Vr.Boot
/// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de
/// statistiques — sinon une journée de borne compterait pour un seul visiteur.
/// </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();
_menu?.Recenter();
_telemetry?.StartNewSession();
Debug.Log("[Kiosk] Nouvelle visite.");
// La langue appartient au visiteur, pas au casque : le suivant rechoisit.
_language = null;
if (_export != null)
{
await ChooseLanguageAsync(_export);
await BuildMenuAsync(_export);
}
// Le seul moment où reconstruire le menu ne dérange personne. Sans ça, un
// casque allumé toute la journée montre le contenu qu'il avait au démarrage :
// le battement de FleetReporter ne voit qu'un changement de configuration,
@ -185,7 +273,7 @@ namespace MyInfoMate.Vr.Boot
var section = _export?.Sections?.Find(s => s.Id == sectionId);
var title = section != null
? ConfigurationExport.Translate(section.Title, language) ?? section.Label
? ConfigurationExport.Translate(section.Title, _language) ?? section.Label
: sectionId;
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
@ -199,7 +287,7 @@ namespace MyInfoMate.Vr.Boot
{
OpenSection(sectionId);
_skybox = SkyboxView.Create(_head);
_skybox = SkyboxView.Create(_head, _language);
_skybox.Closed += CloseSection;
_skybox.Interacted += () => _kiosk?.NotifyActivity();
@ -213,14 +301,14 @@ namespace MyInfoMate.Vr.Boot
{
OpenSection(sectionId);
_model = Scene3DView.Create(_head);
_model = Scene3DView.Create(_head, _language);
_model.Closed += CloseSection;
_model.Interacted += () => _kiosk?.NotifyActivity();
if (!await _model.LoadAsync(section, _export, language))
if (!await _model.LoadAsync(section, _export, _language))
{
CloseSection();
Show($"{title}\n\nCette scène 3D n'a pas pu être chargée.");
Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.Scene3DFailed, _language)}");
}
return;
}
@ -230,17 +318,17 @@ namespace MyInfoMate.Vr.Boot
// Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés.
// Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit
// comme une panne.
Show($"{title}\n\nCe type de contenu n'est pas encore affichable dans le casque.");
Show($"{title}\n\n{Ui.Strings.Get(Ui.Strings.NotSupported, _language)}");
return;
}
OpenSection(sectionId);
_view = PagedView.Create(_head);
_view = PagedView.Create(_head, _language);
_view.Closed += CloseSection;
_view.Interacted += () => _kiosk?.NotifyActivity();
await SectionPages.FillAsync(_view, section, _client, language);
await SectionPages.FillAsync(_view, section, _client, _language);
}
/// <summary>

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3aae3324a55246341918e0f85a7657c1

View File

@ -25,28 +25,31 @@ namespace MyInfoMate.Vr.Menu
const float HeightMeters = 1.45f;
/// <summary>Écart angulaire entre deux panneaux, assez large pour que le regard tranche.</summary>
const float StepDegrees = 16f;
/// <summary>
/// É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>
/// Colonnes de la grille bento. Au-delà on passe à une rangée de plus plutôt que
/// d'encercler le visiteur : 6 × 16° font 80° d'ouverture, dans les ±60° de confort
/// une fois compté que le regard balaie. Doit rester aligné sur `kVrMenuColumns`
/// côté manager-app, qui dessine l'aperçu de cette même grille.
/// d'encercler le visiteur : six colonnes font environ 83° d'ouverture, dans les
/// ±60° de confort une fois compté que le regard balaie. Doit rester aligné sur
/// `kVrMenuColumns` côté manager-app, qui dessine l'aperçu de cette même grille.
/// </summary>
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>();
AimSelector _selector;
@ -151,10 +154,10 @@ namespace MyInfoMate.Vr.Menu
}
static float CellsWidth(int colSpan) =>
colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * ColumnGapMeters;
colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * GapMeters;
static float CellsHeight(int rowSpan) =>
rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * RowGapMeters;
rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * GapMeters;
/// <summary>
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
@ -175,6 +178,11 @@ namespace MyInfoMate.Vr.Menu
var texture = new Texture2D(2, 2);
if (texture.LoadImage(File.ReadAllBytes(path)))
_panels[i].SetImage(texture);
// La vignette est composée dans la texture du panneau : la source ne sert
// plus, et une photo de plein format gardée par tuile finirait par tenir
// plus de place que tout le reste du menu.
Destroy(texture);
}
}
@ -189,7 +197,7 @@ namespace MyInfoMate.Vr.Menu
var angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
var rowStep = MenuItemPanel.HeightMeters + RowGapMeters;
var rowStep = MenuItemPanel.HeightMeters + GapMeters;
// Le panneau est centré sur les rangées qu'il couvre, sinon un panneau double
// déborderait vers le haut au lieu de descendre.

View 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);
}
}
}

View File

@ -1,3 +1,4 @@
using System.Collections.Generic;
using MyInfoMate.Vr.Ui;
using UnityEngine;
@ -20,6 +21,12 @@ namespace MyInfoMate.Vr.Menu
/// d'une texture générée une fois et partagée par tous les panneaux : un rectangle
/// arrondi en alpha, avec un bord plus clair. C'est la seule façon d'avoir une
/// matière sans payer une deuxième compilation de variantes.
///
/// <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>
public class MenuItemPanel : MonoBehaviour, IAimTarget
{
@ -30,9 +37,28 @@ namespace MyInfoMate.Vr.Menu
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 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>
const float HoverScale = 1.07f;
@ -48,9 +74,27 @@ namespace MyInfoMate.Vr.Menu
/// </summary>
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;
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;
@ -79,13 +123,34 @@ namespace MyInfoMate.Vr.Menu
var panel = root.AddComponent<MenuItemPanel>();
panel._width = widthMeters;
panel._height = heightMeters;
panel.Build(id, title);
panel.Build(id, title, Style.Card);
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;
_style = style;
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
background.name = "Background";
@ -94,7 +159,7 @@ namespace MyInfoMate.Vr.Menu
_background = background.GetComponent<Renderer>().material =
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_background.mainTexture = PanelTexture;
_background.mainTexture = PanelTextureFor(_width, _height);
Transparent(_background);
_background.color = Idle;
@ -111,12 +176,31 @@ namespace MyInfoMate.Vr.Menu
var box = background.AddComponent<BoxCollider>();
box.size = new Vector3(1f, 1f, 0.02f);
var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
label.transform.localPosition = new Vector3(0f, -_height * 0.30f, -0.01f);
var size = style == Style.Action ? ActionTextMeters : CardTextMeters;
_title = Label.Create(transform, "Title", Label.Anchor.MiddleCenter, size, Color.white);
// La coupure suit la largeur réelle : sur un panneau double, 22 caractères
// laisseraient une colonne de texte au milieu d'un panneau vide.
label.Text = Label.Wrap(title, Mathf.Max(8, Mathf.RoundToInt(22f * _width / WidthMeters)));
var columns = Mathf.Max(8, Mathf.RoundToInt(22f * _width * CardTextMeters
/ (WidthMeters * size)));
_title.Text = Label.Wrap(title, columns);
if (style == Style.Action)
{
// Le texte laisse sa place au chevron plutôt que de le chevaucher — et
// reprend tout le panneau quand il n'y en a pas : un choix de langue n'a
// pas de « précédent » à désigner.
_title.transform.localPosition = new Vector3(
_chevron ? ChevronWidthMeters * 0.5f : 0f, 0f, -0.01f);
if (_chevron) BuildChevron();
}
else
{
// Sans photo, le titre est tout ce qu'il y a : il occupe le panneau.
// Avec, il descend au-dessus du bord bas (voir SetImage).
_title.transform.localPosition = new Vector3(0f, 0f, -0.01f);
}
BuildProgressRing();
}
@ -146,7 +230,10 @@ namespace MyInfoMate.Vr.Menu
_hover = Mathf.MoveTowards(
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
var color = Color.Lerp(Idle, Hover, _hover);
var color = Color.Lerp(
_composed != null ? PhotoIdle : Idle,
_composed != null ? PhotoHover : Hover,
_hover);
if (_appearing)
{
@ -157,7 +244,7 @@ namespace MyInfoMate.Vr.Menu
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
_appear = _appearElapsed <= 0f ? 0f : Overshoot(t);
// La couleur est toujours recalculée depuis Idle/Hover, jamais modifiée
// La couleur est toujours recalculée depuis les deux teintes, jamais modifiée
// sur place : une opacité multipliée image après image tomberait à zéro.
color.a *= Mathf.Clamp01(_appearElapsed / (AppearSeconds * 0.6f));
@ -192,31 +279,147 @@ namespace MyInfoMate.Vr.Menu
/// L'image de la section, une fois téléchargée. Un panneau sans image reste
/// lisible : le titre suffit, et une borne hors ligne au premier démarrage n'a
/// encore aucun média en cache.
///
/// La photo <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>
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);
quad.name = "Image";
quad.transform.SetParent(transform, false);
quad.transform.localPosition = new Vector3(0f, _height * 0.16f, -0.005f);
quad.transform.localScale = new Vector3(_width * 0.86f, _height * 0.5f, 1f);
var color = Sample(source32, source.width, source.height,
cropX + (x + 0.5f) / scale,
cropY + (y + 0.5f) / scale);
// L'image ne doit pas intercepter le regard : c'est le panneau qui est
// la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
Destroy(quad.GetComponent<Collider>());
_image = quad.transform;
// Le dégradé de lisibilité : rien sur les 40 % du haut, puis on descend
// jusqu'à 72 % de noir au bord bas. Sans lui, un titre blanc sur une
// photo claire est illisible — et une tuile sur deux l'est.
var fromTop = 1f - (y + 0.5f) / height;
var shade = Mathf.InverseLerp(0.4f, 1f, fromTop) * 0.72f;
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
// Le liseré éclaircit la photo au lieu de la recouvrir : c'est ce qui
// détache la tuile d'un fond sombre sans faire un cadre blanc.
var lit = distance > -border ? 0.30f : 0f;
var value = Color.Lerp(color * (1f - shade), Color.white, lit);
value.a = Mathf.Clamp01(0.5f - distance / 1.2f);
pixels[y * width + x] = value;
}
var material = new Material(Shader.Find("Universal Render Pipeline/Unlit"))
// Avec mipmaps, contrairement au fond nu : une photo vue de loin, sans
// niveau réduit, scintille dès que la tête bouge — et dans un casque, la
// tête bouge tout le temps.
var texture = new Texture2D(width, height, TextureFormat.RGBA32, true)
{
mainTexture = texture
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Trilinear
};
NoCulling(material);
_image.GetComponent<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);
@ -236,52 +439,67 @@ namespace MyInfoMate.Vr.Menu
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>
/// Le rectangle arrondi, généré une fois pour tous les panneaux. L'alpha porte la
/// forme et l'anticrénelage du bord ; le RGB porte le liseré, plus clair que
/// l'intérieur — le matériau le teinte ensuite, donc un seul dessin sert au repos
/// comme au survol.
/// Le rectangle arrondi d'un panneau. L'alpha porte la forme et l'anticrénelage du
/// bord ; le RGB porte le liseré, plus clair que l'intérieur — le matériau le teinte
/// ensuite, donc un seul dessin sert au repos comme au survol.
///
/// ⚠️ <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>
static Texture2D PanelTexture
static Texture2D PanelTextureFor(float widthMeters, float heightMeters)
{
get
var key = Mathf.RoundToInt(widthMeters * 1000f) * 10000
+ Mathf.RoundToInt(heightMeters * 1000f);
if (PanelTextures.TryGetValue(key, out var cached) && cached != null) return cached;
var width = Mathf.Max(8, Mathf.RoundToInt(widthMeters * PixelsPerMeter));
var height = Mathf.Max(8, Mathf.RoundToInt(heightMeters * PixelsPerMeter));
var radius = CornerRadiusMeters * PixelsPerMeter;
var border = BorderMeters * PixelsPerMeter;
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
if (_panelTexture != null) return _panelTexture;
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
const int width = 256;
const int height = 176;
const float radius = 30f;
const float border = 3f;
var pixels = new Color32[width * height];
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
var pixels = new Color32[width * height];
// distance < 0 dedans, > 0 dehors. 1,2 px de dégradé suffisent à
// effacer l'escalier du bord à 2,20 m.
var alpha = Mathf.Clamp01(0.5f - distance / 1.2f);
var lit = distance > -border ? 1f : 0.72f;
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
// distance < 0 dedans, > 0 dehors. 1,2 px de dégradé suffisent à
// effacer l'escalier du bord à 2,20 m.
var alpha = Mathf.Clamp01(0.5f - distance / 1.2f);
var lit = distance > -border ? 1f : 0.72f;
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
}
texture.SetPixels32(pixels);
texture.Apply();
_panelTexture = texture;
return _panelTexture;
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
}
texture.SetPixels32(pixels);
texture.Apply();
PanelTextures[key] = texture;
return texture;
}
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>

View File

@ -49,6 +49,7 @@ namespace MyInfoMate.Vr.Menu
Ui.Label _text;
MenuItemPanel _previous;
MenuItemPanel _next;
string _language;
int _index;
/// <summary>Le visiteur veut revenir au menu.</summary>
@ -62,10 +63,11 @@ namespace MyInfoMate.Vr.Menu
public int PageCount => _pages.Count;
public static PagedView Create(Transform head)
public static PagedView Create(Transform head, string language)
{
var root = new GameObject("PagedView");
var view = root.AddComponent<PagedView>();
view._language = language;
if (head != null)
{
@ -109,7 +111,11 @@ namespace MyInfoMate.Vr.Menu
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
_next = Command(CommandNext, "▶", ImageWidthMeters * 0.62f, CenterHeight);
Command(CommandBack, "Retour au menu", 0f, CenterHeight - ImageHeightMeters * 0.95f);
var back = MenuItemPanel.CreateAction(
CommandBack, Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
back.transform.localPosition = new Vector3(
0f, CenterHeight - ImageHeightMeters * 0.95f, DistanceMeters - 0.02f);
UpdateCommands();
}

View File

@ -33,7 +33,7 @@ namespace MyInfoMate.Vr.Menu
public event Action Closed;
public event Action Interacted;
public static Scene3DView Create(Transform head)
public static Scene3DView Create(Transform head, string language)
{
var root = new GameObject("Scene3DView");
var view = root.AddComponent<Scene3DView>();
@ -53,9 +53,9 @@ namespace MyInfoMate.Vr.Menu
selector.Configure(head);
selector.Selected += view.OnSelected;
view._back = MenuItemPanel.Create("__back", "Retour au menu", root.transform);
view._back.transform.localPosition = new Vector3(0f, 0.9f, 2.2f);
view._back.transform.localScale = Vector3.one * 0.6f;
view._back = MenuItemPanel.CreateAction(
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, language), root.transform);
view._back.transform.localPosition = new Vector3(0f, 0.85f, 2.2f);
return view;
}

View File

@ -47,7 +47,7 @@ namespace MyInfoMate.Vr.Menu
}
if (view != null && view.PageCount == 0)
view.ShowEmpty("Ce contenu est vide.");
view.ShowEmpty(Ui.Strings.Get(Ui.Strings.EmptyContent, language));
}
static async Task FillSlider(PagedView view,
@ -155,7 +155,7 @@ namespace MyInfoMate.Vr.Menu
}
if (view.PageCount == 0)
view.ShowEmpty("Rien au programme aujourd'hui.");
view.ShowEmpty(Ui.Strings.Get(Ui.Strings.NothingToday, language));
}
static async Task<Texture2D> LoadImage(ApiClient client, string url)

View File

@ -39,15 +39,17 @@ namespace MyInfoMate.Vr.Menu
Texture2D _image;
MenuItemPanel _back;
Transform _head;
string _language;
public event Action Closed;
public event Action Interacted;
public static SkyboxView Create(Transform head)
public static SkyboxView Create(Transform head, string language)
{
var root = new GameObject("SkyboxView");
var view = root.AddComponent<SkyboxView>();
view._head = head;
view._language = language;
if (head != null)
{
@ -75,8 +77,8 @@ namespace MyInfoMate.Vr.Menu
if (_skybox == null) return;
// Le seul élément posé dans la scène : de quoi ressortir.
_back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform);
_back.transform.localScale = Vector3.one * 0.6f;
_back = MenuItemPanel.CreateAction(
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
PlaceBack();
}

View File

@ -14,15 +14,24 @@ namespace MyInfoMate.Vr.Net
/// est le DTO de l'import/export du back-office, déjà stable, et c'est aussi celui que
/// mymuseum-visitapp télécharge pour une visite hors ligne.
///
/// ⚠️ <b>L'export ne rend qu'une langue à la fois.</b> <c>language</c> traverse jusqu'à
/// <c>Section.GetReferencedResourceIds(language)</c> côté serveur : changer de langue
/// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la
/// question ouverte du lot XR-3 ; en attendant, on recharge.
/// <b>L'export porte toutes les langues à la fois</b>, et c'est ce qui permet au
/// visiteur de choisir la sienne au début de sa visite sans aucun appel réseau : le
/// paramètre <c>language</c> du serveur ne filtre que les <b>ressources</b>, jamais
/// les textes. Le détail de l'arbitrage est sur <see cref="FetchAsync"/>.
/// </summary>
public class ConfigurationExport
{
public string Id;
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>();
/// <summary>
@ -258,16 +267,50 @@ namespace MyInfoMate.Vr.Net
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)
{
if (translations == null || translations.Count == 0) return null;
foreach (var t in translations)
if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase))
return PlainText(t.Value);
return Find(translations, language)
?? Find(translations, Ui.Strings.Fallback)
?? First(translations);
}
return PlainText(translations[0].Value);
static string Find(List<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>
@ -294,6 +337,37 @@ namespace MyInfoMate.Vr.Net
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>
public List<SectionSummary> RootSections()
{

View File

@ -29,7 +29,14 @@ namespace MyInfoMate.Vr.Net
readonly ApiClient _client;
readonly string _instanceId;
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>
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
@ -43,7 +50,7 @@ namespace MyInfoMate.Vr.Net
_client = client;
_instanceId = instanceId;
_configurationId = configurationId;
_language = language;
Language = language;
}
public void StartNewSession() => SessionId = NewSessionId();
@ -69,7 +76,7 @@ namespace MyInfoMate.Vr.Net
sessionId = SessionId,
eventType,
appType = AppTypeVr,
language = _language,
language = Language,
durationSeconds,
metadata,
timestamp = DateTime.UtcNow

View File

@ -171,8 +171,12 @@ namespace MyInfoMate.Vr.Scene
{
if (string.IsNullOrEmpty(content.AssetId)) continue;
// Sans OrdinalIgnoreCase, un export qui écrit « fr » ne répond pas à un
// choix normalisé en « FR » : le hotspot resterait muet sans rien dire.
var hasText = content.Description != null
&& content.Description.Exists(t => t.Language == language);
&& content.Description.Exists(
t => string.Equals(t.Language, language,
System.StringComparison.OrdinalIgnoreCase));
if (hasText || _hotspot.Contents.Count == 1) return content.AssetId;
}

View File

@ -1,4 +1,4 @@
using TMPro;
using TMPro;
using UnityEngine;
namespace MyInfoMate.Vr.Ui
@ -63,6 +63,15 @@ namespace MyInfoMate.Vr.Ui
// Sans rect assez large, TMP tronque au lieu d'afficher.
tmp.rectTransform.sizeDelta = new Vector2(4f, 2f);
// ⚠️ Le pivot, sinon le texte part en l'air. TMP aligne dans son rect, et
// ce rect est centré sur l'origine : un texte aligné en haut d'un rect de
// 2 m s'affiche donc 1 m au-dessus du point où on a posé le Label. En
// remontant le pivot, le haut du rect est l'origine — c'est ce que fait
// TextMesh en UpperCenter, sur lequel les positions des appelants sont
// calées.
tmp.rectTransform.pivot = new Vector2(
0.5f, anchor == Anchor.TopCenter ? 1f : 0.5f);
_tmp = tmp;
return;
}

View 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();
}
}

View File

@ -13,7 +13,7 @@ PlayerSettings:
useOnDemandResources: 0
accelerometerFrequency: 60
companyName: UNOV
productName: MyInfoMateVR
productName: MyInfoMate
defaultCursor: {fileID: 0}
cursorHotspot: {x: 0, y: 0}
m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1}
@ -396,92 +396,116 @@ PlayerSettings:
m_SubKind: App Store
- m_BuildTarget: Android
m_Icons:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 432
m_Height: 432
m_Kind: 2
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 324
m_Height: 324
m_Kind: 2
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 216
m_Height: 216
m_Kind: 2
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 162
m_Height: 162
m_Kind: 2
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 108
m_Height: 108
m_Kind: 2
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e52, type: 3}
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e51, type: 3}
m_Width: 81
m_Height: 81
m_Kind: 2
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 192
m_Height: 192
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 144
m_Height: 144
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 96
m_Height: 96
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 72
m_Height: 72
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 48
m_Height: 48
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 36
m_Height: 36
m_Kind: 1
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 192
m_Height: 192
m_Kind: 0
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 144
m_Height: 144
m_Kind: 0
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 96
m_Height: 96
m_Kind: 0
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 72
m_Height: 72
m_Kind: 0
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 48
m_Height: 48
m_Kind: 0
m_SubKind:
- m_Textures: []
- m_Textures:
- {fileID: 2800000, guid: b17a5c0e4d3f4a1c9e2b8d6f0a1c3e50, type: 3}
m_Width: 36
m_Height: 36
m_Kind: 0