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