Les tuiles suivent maintenant les mêmes colSpan × rowSpan que les autres canaux, sur six colonnes — environ 83° d'ouverture, dans le confort du regard — avec un écart serré assumé : espacées, les tuiles se lisent comme des panneaux sans rapport plutôt que comme une planche. Le pas angulaire se déduit de la largeur de cellule et du rayon de l'arc, plutôt que d'être posé en dur. S'y ajoute le sélecteur de langue avec ses drapeaux, et les libellés de l'interface sortis du code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
215 lines
7.9 KiB
C#
215 lines
7.9 KiB
C#
using System.Collections;
|
|
using UnityEngine;
|
|
using UnityEngine.Networking;
|
|
using MyInfoMate.Vr.Manifest;
|
|
|
|
namespace MyInfoMate.Vr.Scene
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <b>Le déclenchement se fait au regard soutenu, pas à la gâchette.</b> 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.
|
|
/// </summary>
|
|
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<AudioSource>();
|
|
|
|
// 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<Renderer>();
|
|
_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<SphereCollider>();
|
|
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);
|
|
|
|
/// <summary>
|
|
/// ⚠️ <c>Shader.Find</c> 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 <b>retirés au build</b> et le rendu
|
|
/// devient magenta sur le casque alors qu'il est correct dans l'éditeur.
|
|
/// D'où <c>Always Included Shaders</c> dans Project Settings → Graphics.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Le premier contenu du hotspot qui porte un audio dans cette langue. La
|
|
/// lecture de la suite de <c>contents</c> viendra avec le panneau de S7 ; en
|
|
/// S2 on valide qu'un son sort du bon endroit.
|
|
/// </summary>
|
|
string AudioAssetIdFor(string language)
|
|
{
|
|
if (_hotspot.Contents == null) return null;
|
|
|
|
foreach (var content in _hotspot.Contents)
|
|
{
|
|
if (string.IsNullOrEmpty(content.AssetId)) continue;
|
|
|
|
// Sans OrdinalIgnoreCase, un export qui écrit « fr » ne répond pas à un
|
|
// choix normalisé en « FR » : le hotspot resterait muet sans rien dire.
|
|
var hasText = content.Description != null
|
|
&& content.Description.Exists(
|
|
t => string.Equals(t.Language, language,
|
|
System.StringComparison.OrdinalIgnoreCase));
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|