Thomas Fransolet c70c572aed 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>
2026-09-17 11:37:06 +02:00

223 lines
9.2 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Le menu flottant — <b>le hub de l'app</b>, item <b>E5</b> du lot XR-4.
///
/// C'est le rendu de la <c>SectionMenu</c> du CMS, et le seul type de section dont
/// le plan dit qu'il est « nécessaire » : tout le reste s'atteint depuis lui.
///
/// <b>Disposition en arc, pas en grille plate.</b> Une grille plate oblige à tourner
/// les yeux vers ses bords, qui sont alors vus de biais ; un arc centré sur le
/// visiteur présente chaque panneau de face, à distance constante. C'est le même
/// principe qu'un pupitre incurvé.
/// </summary>
public class FloatingMenu : MonoBehaviour
{
/// <summary>2,2 m : au-delà le texte devient petit, en deçà l'œil doit converger.</summary>
const float RadiusMeters = 2.2f;
const float HeightMeters = 1.45f;
/// <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 : 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>();
AimSelector _selector;
/// <summary>L'id de la section choisie.</summary>
public event Action<string> SectionChosen;
public static FloatingMenu Create(Transform head)
{
var root = new GameObject("FloatingMenu");
var menu = root.AddComponent<FloatingMenu>();
menu._selector = root.AddComponent<AimSelector>();
menu._selector.Configure(head);
menu._selector.Selected += menu.OnSelected;
menu._head = head;
menu.Recenter();
return menu;
}
Transform _head;
/// <summary>
/// Pose le menu devant le visiteur. Appelé au démarrage et à chaque nouvelle
/// visite (mode borne) : le visiteur suivant n'arrive pas forcément orienté comme
/// le précédent, et un menu resté dans son dos est un menu introuvable.
///
/// Entre deux recentrages, le menu <b>ne suit pas la tête</b> : un menu qui suit
/// est impossible à viser, et donne le mal de cœur.
/// </summary>
public void Recenter()
{
if (_head == null) return;
var forward = _head.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
transform.position = new Vector3(_head.position.x, 0f, _head.position.z);
transform.rotation = Quaternion.LookRotation(forward.normalized);
}
public void Build(ConfigurationExport export, string language)
{
foreach (var panel in _panels) Destroy(panel.gameObject);
_panels.Clear();
var sections = export.RootSections();
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 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(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é.
/// </summary>
public async Task LoadImagesAsync(ConfigurationExport export, ApiClient client)
{
var sections = export.RootSections();
for (var i = 0; i < sections.Count && i < _panels.Count; i++)
{
var source = sections[i].ImageSource;
if (string.IsNullOrEmpty(source)) continue;
var path = await ContentCache.MediaPathAsync(client, source);
if (path == null || _panels[i] == null) continue;
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);
}
}
/// <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 angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
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,
height,
Mathf.Cos(radians) * RadiusMeters);
// Chaque panneau pivote vers le centre de l'arc — donc vers le visiteur.
panel.localRotation = Quaternion.Euler(0f, angle, 0f);
}
void OnSelected(IAimTarget target)
{
if (target is MenuItemPanel panel) SectionChosen?.Invoke(panel.Id);
}
}
}