using System.Collections.Generic; using System.Threading.Tasks; using MyInfoMate.Vr.Manifest; using UnityEngine; namespace MyInfoMate.Vr.Scene { /// /// Construit une scène à partir d'un manifeste, et remplace à ce titre /// S1Bootstrap dont les chemins étaient en dur. /// /// C'est ici que la règle du §1 de la conception devient vraie : un rebuild /// seulement pour une fonctionnalité. Changer une position, ajouter un objet, /// déplacer un hotspot — tout ça se fait dans le JSON, sans recompiler. /// /// Le builder ne lit ni ne valide le manifeste : le /// fait avant, et un manifeste refusé n'arrive jamais jusqu'ici. /// public class SceneBuilder : MonoBehaviour { public Transform Root { get; private set; } public NavigationBounds Bounds { get; private set; } readonly List _hotspots = new List(); readonly List _personas = new List(); SceneManifest _manifest; string _language; public IReadOnlyList Hotspots => _hotspots; public async Task BuildAsync(SceneManifest manifest, Transform visitorHead, Transform visitorRig, string language) { _manifest = manifest; _language = language ?? manifest.DefaultLanguage; var watch = System.Diagnostics.Stopwatch.StartNew(); Root = new GameObject($"Scene:{manifest.SceneId}").transform; Root.SetParent(transform, false); var worldLoaded = await LoadWorld(); await LoadObjects(); await LoadPersonas(visitorHead); BuildHotspots(visitorHead); SetUpBounds(visitorHead, visitorRig); PlaceVisitor(visitorRig); // Le chiffre que S1 doit rapporter (§« ce que cette étape mesure »), mais // mesuré ici sur la scène réelle plutôt que sur un GLB isolé : c'est // celui-là qui décide si l'écran de progression est un détail ou un sujet. Debug.Log($"[SceneBuilder] Scène {manifest.SceneId} v{manifest.Version} " + $"construite en {watch.ElapsedMilliseconds} ms " + $"({_manifest.Objects.Count} objets, {_personas.Count} personnages, " + $"{_hotspots.Count} hotspots, budget " + $"{manifest.Budget?.TotalBytes ?? 0} octets)"); return worldLoaded; } async Task LoadWorld() { var url = ResolveAssetUrl(_manifest, _manifest.World.AssetId); if (url == null) { Debug.LogError($"[SceneBuilder] décor {_manifest.World.AssetId} absent du manifeste"); return false; } var holder = new GameObject("World"); holder.transform.SetParent(Root, false); _manifest.World.Transform.ApplyTo(holder.transform); return await GltfLoader.LoadAsync(url, holder.transform, "Model") != null; } async Task LoadObjects() { var objectsRoot = new GameObject("Objects").transform; objectsRoot.SetParent(Root, false); foreach (var sceneObject in _manifest.Objects) { var url = ResolveAssetUrl(_manifest, sceneObject.AssetId); if (url == null) { // Un asset manquant n'interrompt pas la scène : sur site, une // amphore absente vaut mieux qu'une salle noire. Debug.LogWarning($"[SceneBuilder] objet {sceneObject.Id} : " + $"asset {sceneObject.AssetId} absent du manifeste"); continue; } var holder = new GameObject($"Object:{sceneObject.Id}"); holder.transform.SetParent(objectsRoot, false); sceneObject.Transform.ApplyTo(holder.transform); await GltfLoader.LoadAsync(url, holder.transform, "Model"); } } async Task LoadPersonas(Transform visitorHead) { var personasRoot = new GameObject("Personas").transform; personasRoot.SetParent(Root, false); foreach (var persona in _manifest.Personas) { var url = ResolveAssetUrl(_manifest, persona.AssetId); if (url == null) { Debug.LogWarning($"[SceneBuilder] persona {persona.Id} : " + $"asset {persona.AssetId} absent du manifeste"); continue; } var holder = new GameObject($"Persona:{persona.Id}"); holder.transform.SetParent(personasRoot, false); persona.Transform.ApplyTo(holder.transform); var instance = holder.AddComponent(); // L'échelle du manifeste s'applique au porteur ; le regard, lui, ne // vise que si le personnage a été demandé comme tel. if (!await instance.LoadAsync(url, persona.GazeAtVisitor ? visitorHead : null)) continue; _personas.Add(instance); } } void BuildHotspots(Transform visitorHead) { var hotspotsRoot = new GameObject("Hotspots").transform; hotspotsRoot.SetParent(Root, false); foreach (var hotspot in _manifest.Hotspots) { var holder = new GameObject($"Hotspot:{hotspot.Id}"); holder.transform.SetParent(hotspotsRoot, false); var instance = holder.AddComponent(); instance.Configure(hotspot, _manifest, visitorHead, _language); _hotspots.Add(instance); } } void SetUpBounds(Transform visitorHead, Transform visitorRig) { Bounds = new GameObject("NavigationBounds").AddComponent(); Bounds.transform.SetParent(Root, false); Bounds.Configure(visitorRig, visitorHead, _manifest.Navigation.ClampedRadiusMeters, _manifest.Navigation.ShowBoundary); } /// /// L'origine du manifeste est le point de spawn, au sol (§2.1). Un /// spawn non identitaire est donc l'exception, pas la règle — mais il /// existe, et il doit déplacer le visiteur, pas le décor : déplacer le décor /// désaligne le garde-fou natif du casque. /// void PlaceVisitor(Transform visitorRig) { if (visitorRig == null) return; visitorRig.localPosition = _manifest.Navigation.Spawn.UnityPosition; visitorRig.localRotation = _manifest.Navigation.Spawn.UnityRotation; } public void SetLanguage(string language) { _language = language; foreach (var hotspot in _hotspots) hotspot.SetLanguage(language); } public void StopAllAudio() { foreach (var hotspot in _hotspots) hotspot.StopAudio(); } /// /// Résolution de l'URL d'un asset, et le seul endroit qui la fait. /// /// Le manifeste servi par le serveur porte des URL absolues. Mais la règle 2 /// de la décision D3 impose qu'un manifeste dont les URL ont été réécrites en /// chemins locaux soit lisible tel quel — c'est ce qui rend l'offline /// possible, et c'est ce qu'utilise le scene.json de S2 avec ses noms /// de fichiers nus. Une URL sans schéma est donc un fichier de /// StreamingAssets aujourd'hui, du cache disque en S6, sans changer d'appelant. /// public static string ResolveAssetUrl(SceneManifest manifest, string assetId) { var asset = manifest.FindAsset(assetId); if (asset == null || string.IsNullOrEmpty(asset.Url)) return null; return asset.Url.Contains("://") ? asset.Url : GltfLoader.StreamingAssetsUrl(asset.Url); } } }