using System.Collections; using UnityEngine; using UnityEngine.Networking; using MyInfoMate.Vr.Manifest; namespace MyInfoMate.Vr.Scene { /// /// Un point d'intérêt posé dans la scène : un repère visible, visé au regard, qui /// joue son commentaire audio dans la langue courante. /// /// Le déclenchement se fait au regard soutenu, pas à la gâchette. Deux /// raisons, et la seconde est la vraie : le projet vise deux plugins XR (Meta XR /// SDK et OpenXR) dont les API de manettes diffèrent, et un visiteur de musée qui /// n'a jamais tenu une manette Quest ne sait pas laquelle presser. S7 pourra /// ajouter la gâchette en raccourci — pas la remplacer. /// public class HotspotInstance : MonoBehaviour { public const float DwellSeconds = 1.2f; const float MarkerRadius = 0.09f; SceneHotspot _hotspot; SceneManifest _manifest; Transform _visitorHead; AudioSource _audio; Renderer _marker; float _gazeSeconds; string _language; AudioClip _clip; string _loadedClipAssetId; public SceneHotspot Hotspot => _hotspot; public void Configure(SceneHotspot hotspot, SceneManifest manifest, Transform visitorHead, string language) { _hotspot = hotspot; _manifest = manifest; _visitorHead = visitorHead; _language = language; hotspot.Transform.ApplyTo(transform); BuildMarker(); _audio = gameObject.AddComponent(); // Spatialisé : un commentaire qui sort de l'objet dont il parle situe le // point sans qu'on ait à l'indiquer. Un son plat le laisse invisible. _audio.spatialBlend = 1f; _audio.rolloffMode = AudioRolloffMode.Linear; _audio.maxDistance = 8f; _audio.playOnAwake = false; } void BuildMarker() { var sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere); sphere.name = "Marker"; sphere.transform.SetParent(transform, false); sphere.transform.localScale = Vector3.one * (MarkerRadius * 2f); _marker = sphere.GetComponent(); _marker.material = UnlitMaterial(IdleColor); // Un collider plus large que le repère : viser une bille de 9 cm à trois // mètres au regard est nettement plus dur que ça n'en a l'air. var collider = sphere.GetComponent(); collider.radius = 1.6f; } static readonly Color IdleColor = new Color(1f, 0.85f, 0.3f, 1f); static readonly Color GazeColor = new Color(1f, 1f, 1f, 1f); /// /// ⚠️ Shader.Find ne trouve un shader dans un build que s'il y est /// embarqué. Les shaders URP utilisés uniquement par du code — jamais par un /// matériau posé dans une scène — sont retirés au build et le rendu /// devient magenta sur le casque alors qu'il est correct dans l'éditeur. /// D'où Always Included Shaders dans Project Settings → Graphics. /// static Material UnlitMaterial(Color color) { var shader = Shader.Find("Universal Render Pipeline/Unlit"); if (shader == null) { Debug.LogError("[Hotspot] Shader URP/Unlit introuvable : il a été retiré " + "du build. Project Settings → Graphics → Always Included Shaders."); shader = Shader.Find("Sprites/Default"); } return new Material(shader) { color = color }; } void Update() { if (_visitorHead == null) return; var looking = IsGazedAt(); _gazeSeconds = looking ? _gazeSeconds + Time.deltaTime : 0f; if (_marker != null) _marker.material.color = Color.Lerp( IdleColor, GazeColor, Mathf.Clamp01(_gazeSeconds / DwellSeconds)); if (looking && _gazeSeconds >= DwellSeconds && !_audio.isPlaying) { _gazeSeconds = 0f; StartCoroutine(PlayCurrentLanguage()); } } bool IsGazedAt() { var ray = new Ray(_visitorHead.position, _visitorHead.forward); return Physics.Raycast(ray, out var hit, 12f) && hit.collider.transform.IsChildOf(transform); } public void SetLanguage(string language) { if (_language == language) return; _language = language; // Un changement de langue pendant la lecture doit couper : laisser finir // la phrase dans l'ancienne langue est le genre de détail qui fait dire // « ça ne marche pas ». if (_audio != null && _audio.isPlaying) _audio.Stop(); } public void StopAudio() { if (_audio != null && _audio.isPlaying) _audio.Stop(); } IEnumerator PlayCurrentLanguage() { var assetId = AudioAssetIdFor(_language); if (string.IsNullOrEmpty(assetId)) { Debug.Log($"[Hotspot] {_hotspot.Id} : aucun audio en « {_language} »"); yield break; } if (_loadedClipAssetId != assetId) { yield return LoadClip(assetId); _loadedClipAssetId = assetId; } if (_clip == null) yield break; _audio.clip = _clip; _audio.Play(); } /// /// Le premier contenu du hotspot qui porte un audio dans cette langue. La /// lecture de la suite de contents viendra avec le panneau de S7 ; en /// S2 on valide qu'un son sort du bon endroit. /// string AudioAssetIdFor(string language) { if (_hotspot.Contents == null) return null; foreach (var content in _hotspot.Contents) { if (string.IsNullOrEmpty(content.AssetId)) continue; var hasText = content.Description != null && content.Description.Exists(t => t.Language == language); if (hasText || _hotspot.Contents.Count == 1) return content.AssetId; } return null; } IEnumerator LoadClip(string assetId) { var url = SceneBuilder.ResolveAssetUrl(_manifest, assetId); if (url == null) { Debug.LogWarning($"[Hotspot] {_hotspot.Id} : asset {assetId} absent du manifeste"); yield break; } // Le disque d'abord. Le préchargement (E4) a normalement déjà déposé ce // fichier ; sans ce détour, un hotspot restait muet dès que le wifi tombait, // alors que le son était sur le casque. var cached = Net.ContentCache.CachedPath(url); var source = cached != null ? "file://" + cached : url; using var request = UnityWebRequestMultimedia.GetAudioClip(source, AudioType.MPEG); yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogWarning($"[Hotspot] audio {assetId} illisible : {request.error}"); yield break; } _clip = DownloadHandlerAudioClip.GetContent(request); } } }