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();
}
}