using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
///
/// Le menu flottant — le hub de l'app, item E5 du lot XR-4.
///
/// C'est le rendu de la SectionMenu du CMS, et le seul type de section dont
/// le plan dit qu'il est « nécessaire » : tout le reste s'atteint depuis lui.
///
/// Disposition en arc, pas en grille plate. 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é.
///
public class FloatingMenu : MonoBehaviour
{
/// 2,2 m : au-delà le texte devient petit, en deçà l'œil doit converger.
const float RadiusMeters = 2.2f;
const float HeightMeters = 1.45f;
/// Écart angulaire entre deux panneaux, assez large pour que le regard tranche.
const float StepDegrees = 16f;
///
/// 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.
///
const int Columns = 6;
/// Écart vertical entre deux rangées.
const float RowGapMeters = 0.06f;
///
/// Écart horizontal, déduit de l'arc : un pas de à
/// mesure plus qu'une cellule, la différence est le gap.
/// Le calculer évite qu'un panneau double déborde sur son voisin.
///
static float ColumnGapMeters =>
RadiusMeters * StepDegrees * Mathf.Deg2Rad - MenuItemPanel.WidthMeters;
readonly List _panels = new List();
AimSelector _selector;
/// L'id de la section choisie.
public event Action SectionChosen;
public static FloatingMenu Create(Transform head)
{
var root = new GameObject("FloatingMenu");
var menu = root.AddComponent();
menu._selector = root.AddComponent();
menu._selector.Configure(head);
menu._selector.Selected += menu.OnSelected;
menu._head = head;
menu.Recenter();
return menu;
}
Transform _head;
///
/// 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 ne suit pas la tête : un menu qui suit
/// est impossible à viser, et donne le mal de cœur.
///
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(sections.Count);
foreach (var section in sections)
{
items.Add(new BentoLayout.Item(
section.Id,
section.GridColSpan ?? 1,
section.GridRowSpan ?? 1));
}
var layout = BentoLayout.Compute(items, Columns);
// Un placement par item, dans l'ordre des items — donc dans celui des
// sections. C'est ce qui garde `_panels[i]` apparié à `sections[i]`, dont
// LoadImagesAsync dépend.
var placements = layout.Placements;
// La cascade doit suivre l'ordre de lecture de la grille, pas celui de la
// liste : avec des spans, une section placée plus loin dans la liste peut
// occuper une cellule plus haut à gauche, et les deux ordres divergent.
var readingRank = new int[placements.Count];
var byReading = new List(placements.Count);
for (var i = 0; i < placements.Count; i++) byReading.Add(i);
byReading.Sort((a, b) => placements[a].Row != placements[b].Row
? placements[a].Row.CompareTo(placements[b].Row)
: placements[a].Col.CompareTo(placements[b].Col));
for (var r = 0; r < byReading.Count; r++) readingRank[byReading[r]] = r;
for (var i = 0; i < sections.Count && i < placements.Count; i++)
{
var title = ConfigurationExport.Translate(sections[i].Title, language)
?? sections[i].Label;
var 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) * ColumnGapMeters;
static float CellsHeight(int rowSpan) =>
rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * RowGapMeters;
///
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
/// le premier octet d'image ne soit téléchargé.
///
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);
}
}
///
/// Mappe une cellule de la grille sur l'arc : la colonne devient un angle, la
/// rangée une hauteur. La grille est centrée sur , pas sur le
/// nombre d'items — une rangée incomplète reste alignée sur celle du dessus, comme
/// dans l'aperçu du manager.
///
void Place(Transform panel, BentoLayout.Placement placement)
{
var angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
var rowStep = MenuItemPanel.HeightMeters + RowGapMeters;
// 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);
}
}
}