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>
211 lines
7.5 KiB
C#
211 lines
7.5 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;
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|