using System; using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; public class GeoTileAddressablesLoader : MonoBehaviour { [Header("References")] [SerializeField] private Transform player; [SerializeField] private Transform tilesParent; [Header("Paths")] [SerializeField] private string tileBundleFolderName = "TileBundles"; [SerializeField] private string manifestFileName = "TileManifest.json"; [SerializeField] private string buildTargetFolderOverride = ""; [Header("Streaming")] [SerializeField] private float loadRadiusMeters = 1500f; [SerializeField] private float unloadRadiusMeters = 2000f; [SerializeField] private float updateInterval = 0.5f; [SerializeField] private int maxConcurrentLoads = 2; [SerializeField] private bool verboseLogging = false; private TileManifest manifest; private readonly Dictionary tiles = new Dictionary(); private readonly Dictionary loaded = new Dictionary(); private readonly HashSet loading = new HashSet(); private readonly HashSet queued = new HashSet(); private readonly Queue loadQueue = new Queue(); private bool initialized; private float nextUpdateTime; private void OnValidate() { if (unloadRadiusMeters < loadRadiusMeters) unloadRadiusMeters = loadRadiusMeters; if (maxConcurrentLoads < 1) maxConcurrentLoads = 1; if (updateInterval < 0.05f) updateInterval = 0.05f; } private void Awake() { if (tilesParent == null) { var parent = new GameObject("Geo_Tile_Addressables"); tilesParent = parent.transform; } } private void Start() { if (player == null && Camera.main != null) player = Camera.main.transform; StartCoroutine(Initialize()); } private IEnumerator Initialize() { var buildTargetFolder = string.IsNullOrWhiteSpace(buildTargetFolderOverride) ? GetBuildTargetFolderName() : buildTargetFolderOverride; var basePath = Path.Combine(Application.persistentDataPath, tileBundleFolderName, buildTargetFolder); var manifestPath = Path.Combine(basePath, manifestFileName); Log($"Initializing loader. BasePath={basePath}"); Log($"ManifestPath={manifestPath}"); if (!File.Exists(manifestPath)) { Debug.LogError($"[GeoTileAddressablesLoader] Manifest not found: {manifestPath}"); yield break; } manifest = JsonUtility.FromJson(File.ReadAllText(manifestPath)); if (manifest == null || manifest.tiles == null || manifest.tiles.Length == 0) { Debug.LogError("[GeoTileAddressablesLoader] Manifest is empty or invalid."); yield break; } tiles.Clear(); foreach (var tile in manifest.tiles) { if (!string.IsNullOrWhiteSpace(tile.tileId)) tiles[tile.tileId] = tile; } Log($"Manifest loaded. Tiles={tiles.Count} CatalogFile={manifest.catalogFile}"); var catalogPath = Path.Combine(basePath, manifest.catalogFile); if (!File.Exists(catalogPath)) { Debug.LogError($"[GeoTileAddressablesLoader] Catalog not found: {catalogPath}"); yield break; } Log($"Loading catalog: {catalogPath}"); var handle = Addressables.LoadContentCatalogAsync(ToFileUri(catalogPath), true); yield return handle; if (handle.Status != AsyncOperationStatus.Succeeded) { Debug.LogError($"[GeoTileAddressablesLoader] Catalog load failed: {handle.OperationException}"); yield break; } Log("Catalog loaded successfully."); initialized = true; nextUpdateTime = Time.time; } private void Update() { if (!initialized || player == null) return; if (Time.time < nextUpdateTime) return; nextUpdateTime = Time.time + updateInterval; UpdateTileSet(); ProcessQueue(); } private void UpdateTileSet() { var playerPos = player.position; var tileSize = manifest.tileSizeMeters; foreach (var kvp in tiles) { var tile = kvp.Value; var tileCenter = new Vector3( tile.offsetX + tileSize * 0.5f, tile.baseY, tile.offsetZ + tileSize * 0.5f); var distance = Vector2.Distance( new Vector2(playerPos.x, playerPos.z), new Vector2(tileCenter.x, tileCenter.z)); if (distance <= loadRadiusMeters) { EnqueueTileLoad(tile.tileId); } else if (distance >= unloadRadiusMeters) { UnloadTile(tile.tileId); } } } private void EnqueueTileLoad(string tileId) { if (loaded.ContainsKey(tileId) || loading.Contains(tileId) || queued.Contains(tileId)) return; loadQueue.Enqueue(tileId); queued.Add(tileId); } private void ProcessQueue() { while (loading.Count < maxConcurrentLoads && loadQueue.Count > 0) { var tileId = loadQueue.Dequeue(); queued.Remove(tileId); StartLoad(tileId); } } private void StartLoad(string tileId) { if (!tiles.TryGetValue(tileId, out var tile)) return; loading.Add(tileId); Log($"Loading tile {tileId}..."); var handle = Addressables.InstantiateAsync(tileId, tilesParent); handle.Completed += op => { loading.Remove(tileId); if (op.Status != AsyncOperationStatus.Succeeded) { Debug.LogError($"[GeoTileAddressablesLoader] Load failed for {tileId}: {op.OperationException}"); return; } var instance = op.Result; instance.name = tileId; instance.transform.position = new Vector3(tile.offsetX, tile.baseY, tile.offsetZ); loaded[tileId] = instance; Log($"Loaded tile {tileId}. LoadedCount={loaded.Count}"); }; } private void UnloadTile(string tileId) { if (!loaded.TryGetValue(tileId, out var instance)) return; Addressables.ReleaseInstance(instance); loaded.Remove(tileId); Log($"Unloaded tile {tileId}. LoadedCount={loaded.Count}"); } private static string GetBuildTargetFolderName() { switch (Application.platform) { case RuntimePlatform.Android: return "Android"; case RuntimePlatform.WindowsPlayer: case RuntimePlatform.WindowsEditor: return "Windows"; case RuntimePlatform.LinuxPlayer: case RuntimePlatform.LinuxEditor: return "Linux"; case RuntimePlatform.OSXPlayer: case RuntimePlatform.OSXEditor: return "OSX"; default: return "Android"; } } private static string ToFileUri(string path) { var normalized = path.Replace("\\", "/"); return normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) ? normalized : "file://" + normalized; } private void OnDestroy() { foreach (var instance in loaded.Values) Addressables.ReleaseInstance(instance); loaded.Clear(); loading.Clear(); queued.Clear(); loadQueue.Clear(); } private void Log(string message) { if (verboseLogging) Debug.Log($"[GeoTileAddressablesLoader] {message}"); } }