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>
113 lines
4.2 KiB
C#
113 lines
4.2 KiB
C#
using System.Collections;
|
|
using UnityEngine;
|
|
|
|
namespace MyInfoMate.Vr.Menu
|
|
{
|
|
/// <summary>
|
|
/// Le retour au survol et à la sélection : un son court, et une vibration quand il y
|
|
/// a une manette pour la sentir.
|
|
///
|
|
/// <b>Ce que ça règle.</b> En visée à la tête, rien ne confirme qu'on a bien accroché
|
|
/// un panneau avant que l'anneau ne commence à se remplir — et l'anneau est en
|
|
/// périphérie du regard au moment où on l'attend. Un son de 40 ms dit « tu es
|
|
/// dessus » avant même que l'œil ne l'ait vu.
|
|
///
|
|
/// <b>Les sons sont synthétisés</b>, pas chargés. Deux raisons : aucun fichier à
|
|
/// gérer dans un repo qui n'a pas d'assets, et un clic généré est identique sur tous
|
|
/// les casques. Ils sont volontairement discrets — une borne de musée qui claque à
|
|
/// chaque regard devient insupportable en une heure.
|
|
/// </summary>
|
|
public class MenuFeedback : MonoBehaviour
|
|
{
|
|
const int SampleRate = 44100;
|
|
|
|
static MenuFeedback _instance;
|
|
|
|
AudioSource _source;
|
|
AudioClip _hover;
|
|
AudioClip _select;
|
|
|
|
static MenuFeedback Instance
|
|
{
|
|
get
|
|
{
|
|
if (_instance != null) return _instance;
|
|
|
|
var root = new GameObject("MenuFeedback");
|
|
DontDestroyOnLoad(root);
|
|
|
|
_instance = root.AddComponent<MenuFeedback>();
|
|
_instance.Build();
|
|
return _instance;
|
|
}
|
|
}
|
|
|
|
void Build()
|
|
{
|
|
_source = gameObject.AddComponent<AudioSource>();
|
|
_source.playOnAwake = false;
|
|
|
|
// 2D : le retour n'appartient pas à un endroit de la pièce, il appartient au
|
|
// geste. Le spatialiser le ferait venir d'un panneau qu'on est en train de
|
|
// quitter.
|
|
_source.spatialBlend = 0f;
|
|
|
|
_hover = Tone("hover", 880f, 0.045f, 0.18f);
|
|
_select = Tone("select", 1320f, 0.09f, 0.32f, 660f);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Une sinusoïde avec une enveloppe qui décroît. <paramref name="startFrequency"/>
|
|
/// non nul fait un glissando : c'est ce qui distingue « choisi » de « survolé »
|
|
/// sans avoir à monter le volume.
|
|
/// </summary>
|
|
static AudioClip Tone(string name, float frequency, float seconds, float volume,
|
|
float startFrequency = 0f)
|
|
{
|
|
var samples = Mathf.RoundToInt(SampleRate * seconds);
|
|
var data = new float[samples];
|
|
var phase = 0f;
|
|
|
|
for (var i = 0; i < samples; i++)
|
|
{
|
|
var t = i / (float)samples;
|
|
var f = startFrequency > 0f ? Mathf.Lerp(startFrequency, frequency, t) : frequency;
|
|
|
|
phase += 2f * Mathf.PI * f / SampleRate;
|
|
|
|
// Attaque très courte pour éviter le claquement d'un début abrupt, puis
|
|
// décroissance exponentielle.
|
|
var envelope = Mathf.Min(1f, t * 40f) * Mathf.Exp(-5f * t);
|
|
data[i] = Mathf.Sin(phase) * envelope * volume;
|
|
}
|
|
|
|
var clip = AudioClip.Create(name, samples, 1, SampleRate, false);
|
|
clip.SetData(data, 0);
|
|
return clip;
|
|
}
|
|
|
|
public static void Hover() => Instance.Play(Instance._hover, 0.12f, 0.05f);
|
|
|
|
public static void Select() => Instance.Play(Instance._select, 0.5f, 0.12f);
|
|
|
|
void Play(AudioClip clip, float vibrationAmplitude, float vibrationSeconds)
|
|
{
|
|
if (clip != null) _source.PlayOneShot(clip);
|
|
|
|
var controller = OVRInput.GetActiveController();
|
|
if (controller != OVRInput.Controller.RTouch && controller != OVRInput.Controller.LTouch)
|
|
return;
|
|
|
|
StopAllCoroutines();
|
|
StartCoroutine(Vibrate(controller, vibrationAmplitude, vibrationSeconds));
|
|
}
|
|
|
|
IEnumerator Vibrate(OVRInput.Controller controller, float amplitude, float seconds)
|
|
{
|
|
OVRInput.SetControllerVibration(0.4f, amplitude, controller);
|
|
yield return new WaitForSeconds(seconds);
|
|
OVRInput.SetControllerVibration(0f, 0f, controller);
|
|
}
|
|
}
|
|
}
|