Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
Premier commit du cinquième front. Trois parties :

- `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205),
  un APK unique pour tous les clients. Menu flottant à sélection au regard,
  appairage, chargement de scène GLB, POI, cache de contenu, télémétrie.
- `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec
  les pièges rencontrés consignés dans son README.
- `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript,
  three.js), partagé avec les autres fronts.
- `docs/` : état des lieux, setup Unity, décisions d'architecture et plan
  d'exécution en 9 étapes.

La scène est décrite par un `scene.json` poussé par `adb push` : l'app le
préfère à celui embarqué dans l'APK.

Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par
Git LFS dès ce premier commit — les y faire entrer après coup demanderait de
réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake
(`Library/`, `.utmp/`, Burst debug) sont ignorés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:26:07 +02:00

118 lines
4.3 KiB
C#

using System;
using UnityEngine;
using UnityEngine.XR;
namespace MyInfoMate.Vr.Kiosk
{
/// <summary>
/// Le mode borne — item <b>E10</b> du lot XR-4.
///
/// <b>Ce qui sépare une démo d'une borne</b> : une démo, quelqu'un la lance et la
/// range ; une borne tourne 8 h par jour, passe de main en main, et personne ne la
/// surveille. Le seul état acceptable pour le visiteur suivant est <b>le menu, devant
/// lui, comme si l'app venait de démarrer</b> — pas la galerie que le précédent avait
/// laissée ouverte, pas un menu dans son dos.
///
/// Deux déclencheurs, et le second est le vrai :
/// <list type="bullet">
/// <item><b>Le casque est reposé</b> — le signal le plus fiable qui existe : la
/// visite est finie, tout de suite, sans attendre.</item>
/// <item><b>Plus rien ne bouge</b> pendant un moment — le repli, pour le casque
/// posé sur une table sans que personne ne l'ait retiré de la tête.</item>
/// </list>
///
/// <b>Sans SDK Meta.</b> La présence du porteur se lit par <c>CommonUsages.userPresence</c>
/// d'Unity XR, alimenté par OpenXR : pas besoin d'un <c>OVRManager</c> dans la scène,
/// donc rien à configurer et rien à oublier de configurer.
/// </summary>
public class KioskSession : MonoBehaviour
{
/// <summary>
/// 90 s : assez long pour qu'un visiteur qui lit un panneau ne soit pas renvoyé
/// au menu, assez court pour que le suivant ne trouve pas l'écran du précédent.
/// </summary>
[SerializeField] float idleSeconds = 90f;
/// <summary>Au-delà, la tête a bougé : quelqu'un est là.</summary>
const float MovementThresholdDegrees = 3f;
Transform _head;
Quaternion _lastRotation;
float _idle;
bool _wasWorn = true;
/// <summary>La visite est finie — revenir au menu et repartir de zéro.</summary>
public event Action Ended;
public static KioskSession Attach(GameObject host, Transform head)
{
var session = host.AddComponent<KioskSession>();
session._head = head;
session._lastRotation = head != null ? head.rotation : Quaternion.identity;
// Une borne ne doit pas s'éteindre toute seule pendant qu'un visiteur lit.
Screen.sleepTimeout = SleepTimeout.NeverSleep;
return session;
}
/// <summary>
/// À appeler sur toute action délibérée. Le mouvement de tête suffit à détecter
/// une présence, mais pas à prouver l'attention : quelqu'un qui vise et choisit
/// est actif même s'il bouge à peine.
/// </summary>
public void NotifyActivity() => _idle = 0f;
void Update()
{
if (IsWorn())
{
if (!_wasWorn)
{
// Le casque vient d'être remis : c'est un nouveau visiteur, même si
// le précédent l'a reposé il y a dix secondes.
_wasWorn = true;
_idle = 0f;
Ended?.Invoke();
}
}
else if (_wasWorn)
{
_wasWorn = false;
Ended?.Invoke();
return;
}
if (!_wasWorn) return;
if (_head != null)
{
if (Quaternion.Angle(_head.rotation, _lastRotation) > MovementThresholdDegrees)
{
_lastRotation = _head.rotation;
_idle = 0f;
}
}
_idle += Time.deltaTime;
if (_idle < idleSeconds) return;
_idle = 0f;
Ended?.Invoke();
}
/// <summary>
/// Vrai quand le casque est porté. Un runtime qui ne sait pas répondre — le
/// simulateur, l'éditeur — renvoie « porté » : sur un poste de développement, une
/// borne qui se réinitialise toutes les 90 s serait intenable.
/// </summary>
static bool IsWorn()
{
var device = InputDevices.GetDeviceAtXRNode(XRNode.Head);
if (!device.isValid) return true;
return !device.TryGetFeatureValue(CommonUsages.userPresence, out var present) || present;
}
}
}