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

233 lines
8.4 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Une pile de pages qu'on feuillette — la vue commune du <b>Slider</b>, de la
/// <b>Map</b> et de l'<b>Event</b>, item <b>E8</b> du lot XR-4.
///
/// <b>Pourquoi une seule vue pour trois types.</b> Une galerie de photos, une liste de
/// points d'intérêt et un programme de la journée sont trois contenus différents dans
/// le CMS, mais dans le casque ce sont les mêmes gestes : une chose à la fois, grande,
/// devant soi, et de quoi passer à la suivante. Les trois renderers ne se distinguent
/// que par <b>ce qu'ils mettent dans les pages</b> — c'est le rôle de
/// <see cref="SectionPages"/>.
///
/// Les pages s'ajoutent au fil du chargement, et la première s'affiche dès qu'elle
/// arrive : sur vingt photos, attendre la vingtième ferait passer le chargement pour
/// une panne.
/// </summary>
public class PagedView : MonoBehaviour
{
const string CommandPrevious = "__previous";
const string CommandNext = "__next";
const string CommandBack = "__back";
const float DistanceMeters = 2.5f;
const float ImageWidthMeters = 1.6f;
const float ImageHeightMeters = 0.9f;
const float CenterHeight = 1.5f;
public class Page
{
/// <summary>Le titre de la page. Toujours présent.</summary>
public string Heading;
/// <summary>Description, horaire, adresse… Peut être vide.</summary>
public string Body;
/// <summary>Nulle sur une page sans média — le texte tient tout seul.</summary>
public Texture2D Image;
}
readonly List<Page> _pages = new List<Page>();
Material _imageMaterial;
Transform _imageQuad;
Ui.Label _text;
MenuItemPanel _previous;
MenuItemPanel _next;
string _language;
int _index;
/// <summary>Le visiteur veut revenir au menu.</summary>
public event Action Closed;
/// <summary>
/// N'importe quelle action du visiteur. Sert au mode borne : feuilleter est une
/// activité, même si la tête ne bouge presque pas.
/// </summary>
public event Action Interacted;
public int PageCount => _pages.Count;
public static PagedView Create(Transform head, string language)
{
var root = new GameObject("PagedView");
var view = root.AddComponent<PagedView>();
view._language = language;
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 += view.OnSelected;
view.Build();
return view;
}
void Build()
{
var quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
quad.name = "Image";
quad.transform.SetParent(transform, false);
quad.transform.localPosition = new Vector3(0f, CenterHeight, DistanceMeters);
quad.transform.localScale = new Vector3(ImageWidthMeters, ImageHeightMeters, 1f);
// L'image n'est pas une cible de visée : ce sont les trois commandes qui le
// sont, et un collider plein cadre les masquerait.
Destroy(quad.GetComponent<Collider>());
_imageMaterial = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_imageMaterial.SetFloat("_Cull", 0f);
_imageMaterial.color = new Color(0.08f, 0.10f, 0.13f, 1f);
quad.GetComponent<Renderer>().material = _imageMaterial;
_imageQuad = quad.transform;
_text = Ui.Label.Create(transform, "Text", Ui.Label.Anchor.TopCenter, 0.048f, Color.white);
_text.transform.localPosition = new Vector3(
0f, CenterHeight - ImageHeightMeters * 0.62f, DistanceMeters);
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
_next = Command(CommandNext, "▶", ImageWidthMeters * 0.62f, CenterHeight);
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();
}
MenuItemPanel Command(string id, string label, float x, float y)
{
var panel = MenuItemPanel.Create(id, label, transform);
panel.transform.localPosition = new Vector3(x, y, DistanceMeters - 0.02f);
panel.transform.localScale = Vector3.one * 0.55f;
return panel;
}
public void AddPage(Page page)
{
_pages.Add(page);
if (_pages.Count == 1) Display(0);
else UpdateCommands();
}
/// <summary>Quand il n'y a rien à montrer, le dire — pas laisser un cadre vide.</summary>
public void ShowEmpty(string message)
{
_text.Text = Wrap(message, 46);
UpdateCommands();
}
void Display(int index)
{
if (index < 0 || index >= _pages.Count) return;
_index = index;
var page = _pages[index];
if (page.Image != null)
{
_imageMaterial.mainTexture = page.Image;
_imageMaterial.color = Color.white;
_imageQuad.gameObject.SetActive(true);
}
else
{
// Sans image, le cadre reste — mais sombre : il porte le texte, qui
// devient alors le contenu principal.
_imageMaterial.mainTexture = null;
_imageMaterial.color = new Color(0.08f, 0.10f, 0.13f, 1f);
}
var counter = _pages.Count > 1 ? $"\n{index + 1} / {_pages.Count}" : "";
var body = string.IsNullOrEmpty(page.Body) ? "" : $"\n{page.Body}";
_text.Text = Wrap(page.Heading, 46) + Wrap(body, 46) + counter;
UpdateCommands();
}
/// <summary>
/// Les flèches disparaissent aux extrémités plutôt que de ne rien faire : un
/// bouton présent qui ne réagit pas se lit comme une panne.
/// </summary>
void UpdateCommands()
{
if (_previous != null) _previous.gameObject.SetActive(_index > 0);
if (_next != null) _next.gameObject.SetActive(_index < _pages.Count - 1);
}
void OnSelected(IAimTarget target)
{
if (!(target is MenuItemPanel panel)) return;
Interacted?.Invoke();
switch (panel.Id)
{
case CommandPrevious: Display(_index - 1); break;
case CommandNext: Display(_index + 1); break;
case CommandBack: Closed?.Invoke(); break;
}
}
static string Wrap(string text, int columns)
{
if (string.IsNullOrEmpty(text)) return "";
var wrapped = new System.Text.StringBuilder();
// Les retours à la ligne du contenu sont respectés : c'est la mise en forme
// voulue par le gestionnaire, on ne recolle pas ses paragraphes.
foreach (var paragraph in text.Split('\n'))
{
if (wrapped.Length > 0) wrapped.Append('\n');
var line = 0;
foreach (var word in paragraph.Split(' '))
{
if (line + word.Length > columns)
{
wrapped.Append('\n');
line = 0;
}
else if (line > 0)
{
wrapped.Append(' ');
line++;
}
wrapped.Append(word);
line += word.Length;
}
}
return wrapped.ToString();
}
}
}