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
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
using UnityEngine.Video;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Lecture 360° — item <b>E6</b> du lot XR-4, et <b>la seule chose que le casque
/// fait mieux que tout le reste</b>.
///
/// Une photo ou une vidéo équirectangulaire devient le ciel : le visiteur est
/// <i>dedans</i>, il tourne la tête et le monde suit. C'est ce qu'aucune tablette ne
/// sait faire, et c'est pour ça que le plan (§8) dit que la Video 360 « gagne
/// vraiment » là où un Article flottant est <i>moins</i> bon qu'un écran.
///
/// Les deux cas passent par le même shader de skybox, donc le même rendu : une image
/// devient une texture, une vidéo devient une <c>RenderTexture</c> qu'un
/// <c>VideoPlayer</c> alimente image par image.
///
/// ⚠️ <b>Ce shader doit être dans <i>Always Included Shaders</i></b>, comme
/// <c>Universal Render Pipeline/Unlit</c> : construit au runtime, il n'est référencé
/// par aucune scène et le build l'élimine en silence. Le ciel sort alors magenta sur
/// le casque et correct dans l'éditeur — le piège déjà payé une fois avec glTFast
/// (voir le README de l'overlay).
/// </summary>
public class SkyboxView : MonoBehaviour
{
/// <summary>Résolution de la cible vidéo. 4096×2048 tient sur un Quest 2.</summary>
const int VideoWidth = 4096;
const int VideoHeight = 2048;
Material _skybox;
Material _previousSkybox;
VideoPlayer _player;
RenderTexture _target;
Texture2D _image;
MenuItemPanel _back;
Transform _head;
string _language;
public event Action Closed;
public event Action Interacted;
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)
{
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()
{
_previousSkybox = RenderSettings.skybox;
_skybox = PanoramicSky.CreateMaterial();
if (_skybox == null) return;
// Le seul élément posé dans la scène : de quoi ressortir.
_back = MenuItemPanel.CreateAction(
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
PlaceBack();
}
/// <summary>
/// <b>Le retour ne reste pas affiché.</b> Toute la valeur d'une 360 est d'y être :
/// un panneau planté en permanence dans le décor rappelle qu'on regarde une image,
/// et c'est précisément ce qu'on cherchait à faire oublier.
///
/// Il se montre <see cref="VisibleSeconds"/> secondes au début — assez pour qu'on
/// sache qu'il existe et où le retrouver — puis s'efface. Il revient dès que le
/// visiteur <b>baisse les yeux</b>, le geste qu'on fait naturellement pour chercher
/// une commande, ou dès qu'il touche une manette. Sans ce rappel, quelqu'un en
/// borne resterait coincé dans la photo.
/// </summary>
const float VisibleSeconds = 4f;
/// <summary>Sous cet angle, le visiteur cherche quelque chose plutôt qu'il ne regarde.</summary>
const float LookDownDegrees = 25f;
float _hideAt;
void Update()
{
if (_back == null || _head == null) return;
var wants = LookingDown() || ControllerInUse();
if (wants)
{
if (!_back.gameObject.activeSelf) PlaceBack();
_hideAt = Time.time + VisibleSeconds;
}
_back.gameObject.SetActive(Time.time < _hideAt);
}
bool LookingDown() => Vector3.Angle(_head.forward, Vector3.down) < 90f - LookDownDegrees;
static bool ControllerInUse()
{
var controller = OVRInput.GetActiveController();
return controller == OVRInput.Controller.RTouch
|| controller == OVRInput.Controller.LTouch;
}
/// <summary>
/// Reposé sous le regard courant à chaque réapparition : en 360 le visiteur tourne
/// sur lui-même, et un panneau laissé à sa position de départ serait dans son dos
/// au moment où il le cherche.
/// </summary>
void PlaceBack()
{
if (_back == null) return;
var forward = _head != null ? _head.forward : Vector3.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
forward.Normalize();
_back.transform.position =
(_head != null ? _head.position : Vector3.zero) + forward * 2.2f + Vector3.down * 0.8f;
_back.transform.rotation = Quaternion.LookRotation(forward);
_back.gameObject.SetActive(true);
}
public async Task LoadAsync(ConfigurationExport.Resource resource, ApiClient client)
{
if (_skybox == null) return;
if (resource.Type == ConfigurationExport.ResourceKind.Video360)
await PlayVideo(resource, client);
else
await ShowImage(resource, client);
}
async Task ShowImage(ConfigurationExport.Resource resource, ApiClient client)
{
var texture = await PanoramicSky.LoadEquirectangularAsync(client, resource.Url);
if (texture == null || this == null) return;
_image = texture;
_skybox.SetTexture("_MainTex", texture);
RenderSettings.skybox = _skybox;
// Le panneau de retour se montre au début, puis s'efface.
_hideAt = Time.time + VisibleSeconds;
}
async Task PlayVideo(ConfigurationExport.Resource resource, ApiClient client)
{
// Une vidéo 360 pèse des centaines de Mo : elle est lue depuis le cache
// disque, pas gardée en mémoire.
var path = await ContentCache.MediaPathAsync(client, resource.Url);
if (path == null || this == null) return;
_target = new RenderTexture(VideoWidth, VideoHeight, 0);
_player = gameObject.AddComponent<VideoPlayer>();
_player.source = VideoSource.Url;
_player.url = new Uri(path).AbsoluteUri;
_player.renderMode = VideoRenderMode.RenderTexture;
_player.targetTexture = _target;
_player.isLooping = true;
_player.audioOutputMode = VideoAudioOutputMode.Direct;
_player.errorReceived += (_, message) =>
Debug.LogError($"[Skybox] Lecture vidéo impossible : {message}");
_skybox.SetTexture("_MainTex", _target);
RenderSettings.skybox = _skybox;
_hideAt = Time.time + VisibleSeconds;
_player.Play();
}
void OnSelected(IAimTarget target)
{
if (!(target is MenuItemPanel panel) || panel.Id != "__back") return;
Interacted?.Invoke();
Closed?.Invoke();
}
/// <summary>
/// Le ciel est un réglage <b>global</b> : sans cette remise en état, le menu
/// s'afficherait par-dessus la dernière image 360 vue, et le visiteur suivant
/// hériterait du décor du précédent.
/// </summary>
void OnDestroy()
{
RenderSettings.skybox = _previousSkybox;
if (_player != null) _player.Stop();
if (_target != null) _target.Release();
// Une équirectangulaire pèse des dizaines de Mo même compressée : laissée
// derrière, elle s'accumulerait à chaque 360 ouverte jusqu'à ce que le
// système tue l'app — au bout de plusieurs visites, donc jamais en test court.
if (_image != null) Destroy(_image);
}
}
}