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