using System; using UnityEngine; namespace MyInfoMate.Vr.Menu { /// /// Visée et sélection — item E5 du lot XR-4. Trois moyens de désigner un /// panneau, dans cet ordre de priorité : manette, main, tête. /// /// ⚠️ « Au regard » ne veut pas dire eye tracking. Le Quest 2 n'a pas de suivi /// oculaire — seul le Quest Pro en a. Ce qu'on suit ici est la direction de la /// tête (head gaze) : le visiteur vise avec son nez, pas avec ses yeux. C'est le /// seul mode qui marche sur tous les casques, donc le repli garanti. /// /// Pourquoi les trois. Une borne publique ne distribue pas de manettes — le /// visiteur met le casque et c'est tout : il faut que ça marche sans rien dans les /// mains. Mais en démo ou en installation surveillée, la manette est plus rapide et /// les mains plus naturelles. Aucun des trois ne suffit seul. /// /// La temporisation ne s'applique qu'à la tête, où il n'y a rien pour valider : /// c'est le seul garde-fou contre le « Midas touch », où tout ce qu'on regarde se /// déclenche, y compris ce qu'on ne fait que lire. La manette (gâchette) et la main /// (pincement) ont un geste explicite, donc pas d'attente : imposer 1,2 s à quelqu'un /// qui vient d'appuyer serait absurde. /// /// Dépendance : OVRInput et OVRHand viennent du Meta XR Core /// SDK, déjà installé (E1) — pas de l'Interaction SDK, qui lui n'est pas configuré. /// Les mains ne sont détectées que si un OVRHand est présent dans la scène /// (building block Hand Tracking) ; sinon on retombe sur les deux autres, sans /// erreur. /// public class AimSelector : MonoBehaviour { /// /// 1,2 s à la tête : assez long pour qu'un regard qui balaie ne déclenche rien, /// assez court pour qu'on n'ait pas l'impression d'attendre. C'est le réglage qui /// décide si le menu est agréable ou pénible — à confirmer au premier essai réel. /// [SerializeField] float dwellSeconds = 1.2f; [SerializeField] float maxDistanceMeters = 10f; /// Sous ce seuil, un pincement n'en est pas un. const float PinchThreshold = 0.7f; public enum AimSource { Head, Controller, Hand } Transform _head; IAimTarget _current; float _dwell; LineRenderer _ray; OVRHand[] _hands = Array.Empty(); public AimSource Source { get; private set; } = AimSource.Head; public event Action Selected; public void Configure(Transform head) { _head = head; // Le hand tracking n'est pas toujours dans la scène : son absence est un cas // nominal, pas une erreur. _hands = FindObjectsByType(FindObjectsSortMode.None); BuildRay(); } void Update() { if (_head == null) return; if (!TryAim(out var ray, out var triggered)) { Clear(); return; } DrawRay(ray); var target = Probe(ray); if (target != _current) { _current?.OnAimExit(); _current = target; _dwell = 0f; _current?.OnAimEnter(); } if (_current == null) return; // Un geste explicite déclenche tout de suite ; sans geste, c'est le temps // passé sur la cible qui vaut validation. if (triggered) { Fire(); return; } if (Source != AimSource.Head) return; _dwell += Time.deltaTime; _current.OnAimProgress(Mathf.Clamp01(_dwell / dwellSeconds)); if (_dwell >= dwellSeconds) Fire(); } /// /// La source active. L'ordre n'est pas un goût : une manette en main est un choix /// délibéré du visiteur, une main levée aussi ; la tête ne veut rien dire par /// elle-même, donc elle passe en dernier. /// bool TryAim(out Ray ray, out bool triggered) { var controller = OVRInput.GetActiveController(); if (controller == OVRInput.Controller.RTouch || controller == OVRInput.Controller.LTouch) { var position = OVRInput.GetLocalControllerPosition(controller); var rotation = OVRInput.GetLocalControllerRotation(controller); // Les poses des manettes sont locales au rig ; la tête vit dans le même // repère, donc son parent donne la transformation à appliquer. var rig = _head.parent != null ? _head.parent : _head; Source = AimSource.Controller; ray = new Ray(rig.TransformPoint(position), rig.rotation * rotation * Vector3.forward); triggered = OVRInput.GetDown(OVRInput.Button.PrimaryIndexTrigger, controller); return true; } foreach (var hand in _hands) { if (hand == null || !hand.IsTracked || !hand.IsPointerPoseValid) continue; if (hand.IsSystemGestureInProgress) continue; Source = AimSource.Hand; ray = new Ray(hand.PointerPose.position, hand.PointerPose.forward); // Le front montant, pas l'état : un pincement maintenu ne doit pas // déclencher à chaque image. triggered = hand.GetFingerPinchStrength(OVRHand.HandFinger.Index) > PinchThreshold && !_wasPinching; _wasPinching = hand.GetFingerPinchStrength(OVRHand.HandFinger.Index) > PinchThreshold; return true; } _wasPinching = false; Source = AimSource.Head; ray = new Ray(_head.position, _head.forward); triggered = false; return true; } bool _wasPinching; void Fire() { var selected = _current; _current = null; _dwell = 0f; selected.OnAimExit(); MenuFeedback.Select(); Selected?.Invoke(selected); } void Clear() { _current?.OnAimExit(); _current = null; _dwell = 0f; if (_ray != null) _ray.enabled = false; } IAimTarget Probe(Ray ray) => Physics.Raycast(ray, out var hit, maxDistanceMeters) ? hit.collider.GetComponentInParent() : null; void BuildRay() { var line = new GameObject("AimRay"); line.transform.SetParent(transform, false); _ray = line.AddComponent(); _ray.useWorldSpace = true; _ray.positionCount = 2; _ray.widthMultiplier = 0.004f; _ray.material = new Material(Shader.Find("Universal Render Pipeline/Unlit")); _ray.material.color = new Color(0.4f, 0.8f, 1f, 0.5f); _ray.enabled = false; } /// /// Le rayon ne se dessine que pour la manette et la main. À la tête, un trait /// parti du milieu du visage est fixe au centre du champ de vision : il gêne en /// permanence sans rien apprendre, puisque l'anneau du panneau dit déjà où on vise. /// void DrawRay(Ray ray) { if (_ray == null) return; if (Source == AimSource.Head) { _ray.enabled = false; return; } _ray.enabled = true; _ray.SetPosition(0, ray.origin); _ray.SetPosition(1, ray.origin + ray.direction * 2.5f); } } /// Ce qu'un objet doit savoir faire pour être visable. public interface IAimTarget { void OnAimEnter(); void OnAimProgress(float ratio); void OnAimExit(); } }