using System; using System.Collections.Generic; using System.Globalization; using System.IO; using UnityEditor; using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; using UnityEditor.AddressableAssets.Settings.GroupSchemas; using UnityEngine; public static class GeoTileAddressablesBuilder { private const string TilePrefabsDir = "Assets/TilePrefabs"; private const string TileIndexCsvPath = "Assets/GeoData/tile_index.csv"; private const string GroupName = "TilePrefabs"; private const string TileLabel = "tile"; private const string ManifestFileName = "TileManifest.json"; private const float TileSizeMeters = 1000f; private const string BuildPathVariable = "TileBuildPath"; private const string LoadPathVariable = "TileLoadPath"; private const string BuildPathValue = "ServerData/TileBundles/[BuildTarget]"; private const string LoadPathValue = "file://{UnityEngine.Application.persistentDataPath}/TileBundles/[BuildTarget]"; [MenuItem("Tools/Geo Tiles/Build Tile Addressables (Android)")] public static void BuildAndroid() { BuildForTarget(BuildTarget.Android, BuildTargetGroup.Android); } private static void BuildForTarget(BuildTarget target, BuildTargetGroup group) { if (!Directory.Exists(TilePrefabsDir)) { Debug.LogError($"[GeoTileAddressablesBuilder] Prefab directory missing: {TilePrefabsDir}"); return; } if (!File.Exists(TileIndexCsvPath)) { Debug.LogError($"[GeoTileAddressablesBuilder] CSV missing: {TileIndexCsvPath}"); return; } var settings = AddressableAssetSettingsDefaultObject.GetSettings(true); if (settings == null) { Debug.LogError("[GeoTileAddressablesBuilder] Addressables settings not found."); return; } EnsureProfileVariable(settings, BuildPathVariable, BuildPathValue); EnsureProfileVariable(settings, LoadPathVariable, LoadPathValue); EnsureRemoteCatalogPaths(settings); var groupAsset = GetOrCreateGroup(settings); ConfigureGroup(settings, groupAsset); AssignPrefabs(settings, groupAsset); settings.SetDirty(AddressableAssetSettings.ModificationEvent.BatchModification, null, true); AssetDatabase.SaveAssets(); var previousTarget = EditorUserBuildSettings.activeBuildTarget; if (previousTarget != target) { if (!EditorUserBuildSettings.SwitchActiveBuildTarget(group, target)) { Debug.LogError($"[GeoTileAddressablesBuilder] Failed to switch build target to {target}."); return; } } AddressableAssetSettings.BuildPlayerContent(); var outputPath = GetRemoteCatalogBuildPath(settings, target); if (!Directory.Exists(outputPath)) { Debug.LogError($"[GeoTileAddressablesBuilder] Build output not found: {outputPath}"); return; } var (catalogFile, catalogHashFile) = FindCatalogFiles(outputPath); if (string.IsNullOrEmpty(catalogFile)) { Debug.LogError($"[GeoTileAddressablesBuilder] Catalog file not found in: {outputPath}"); return; } var manifest = BuildManifest(target.ToString(), catalogFile, catalogHashFile); var manifestPath = Path.Combine(outputPath, ManifestFileName); File.WriteAllText(manifestPath, JsonUtility.ToJson(manifest, true)); AssetDatabase.Refresh(); Debug.Log($"[GeoTileAddressablesBuilder] DONE. Output={outputPath}"); } private static void AssignPrefabs(AddressableAssetSettings settings, AddressableAssetGroup group) { var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { TilePrefabsDir }); foreach (var guid in prefabGuids) { var path = AssetDatabase.GUIDToAssetPath(guid).Replace("\\", "/"); var parentDir = Path.GetDirectoryName(path)?.Replace("\\", "/"); if (!string.Equals(parentDir, TilePrefabsDir, StringComparison.OrdinalIgnoreCase)) continue; var entry = settings.CreateOrMoveEntry(guid, group, false, false); entry.address = Path.GetFileNameWithoutExtension(path); entry.SetLabel(TileLabel, true, true); } } private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings) { var group = settings.FindGroup(GroupName); if (group != null) return group; group = settings.CreateGroup(GroupName, false, false, false, new List()); group.AddSchema(); group.AddSchema(); return group; } private static void ConfigureGroup(AddressableAssetSettings settings, AddressableAssetGroup group) { var bundleSchema = group.GetSchema(); if (bundleSchema == null) bundleSchema = group.AddSchema(); bundleSchema.BundleMode = BundledAssetGroupSchema.BundlePackingMode.PackSeparately; bundleSchema.BuildPath.SetVariableByName(settings, BuildPathVariable); bundleSchema.LoadPath.SetVariableByName(settings, LoadPathVariable); var updateSchema = group.GetSchema(); if (updateSchema != null) updateSchema.StaticContent = true; } private static void EnsureProfileVariable(AddressableAssetSettings settings, string name, string value) { var profileId = settings.activeProfileId; var current = settings.profileSettings.GetValueByName(profileId, name); if (string.IsNullOrEmpty(current)) settings.profileSettings.CreateValue(name, value); settings.profileSettings.SetValue(profileId, name, value); } private static void EnsureRemoteCatalogPaths(AddressableAssetSettings settings) { settings.BuildRemoteCatalog = true; settings.RemoteCatalogBuildPath.SetVariableByName(settings, BuildPathVariable); settings.RemoteCatalogLoadPath.SetVariableByName(settings, LoadPathVariable); } private static (string catalogFile, string catalogHashFile) FindCatalogFiles(string outputPath) { var catalogFiles = Directory.GetFiles(outputPath, "*catalog*.json"); if (catalogFiles.Length == 0) catalogFiles = Directory.GetFiles(outputPath, "*catalog*.bin"); var catalogFile = catalogFiles.Length > 0 ? Path.GetFileName(catalogFiles[0]) : null; string catalogHash = null; if (!string.IsNullOrEmpty(catalogFile)) { var baseName = Path.GetFileNameWithoutExtension(catalogFile); var hashCandidate = Path.Combine(outputPath, $"{baseName}.hash"); if (File.Exists(hashCandidate)) { catalogHash = Path.GetFileName(hashCandidate); } else { var hashFiles = Directory.GetFiles(outputPath, "*catalog*.hash"); if (hashFiles.Length > 0) catalogHash = Path.GetFileName(hashFiles[0]); } } return (catalogFile, catalogHash); } private static TileManifest BuildManifest(string buildTarget, string catalogFile, string catalogHashFile) { var tiles = ParseTilesCsv(); if (tiles.Count == 0) throw new InvalidOperationException("No tiles parsed from tile_index.csv"); double minX = double.PositiveInfinity; double minY = double.PositiveInfinity; foreach (var tile in tiles) { minX = Math.Min(minX, tile.Xmin); minY = Math.Min(minY, tile.Ymin); } var entries = new TileEntry[tiles.Count]; for (int i = 0; i < tiles.Count; i++) { var tile = tiles[i]; entries[i] = new TileEntry { tileId = tile.TileId, offsetX = (float)(tile.Xmin - minX), offsetZ = (float)(tile.Ymin - minY), baseY = (float)tile.GlobalMin }; } return new TileManifest { buildTarget = buildTarget, catalogFile = catalogFile, catalogHashFile = catalogHashFile, tileSizeMeters = TileSizeMeters, tiles = entries }; } private static string GetRemoteCatalogBuildPath(AddressableAssetSettings settings, BuildTarget target) { var rawPath = settings.RemoteCatalogBuildPath.GetValue(settings); if (string.IsNullOrWhiteSpace(rawPath)) rawPath = BuildPathValue.Replace("[BuildTarget]", target.ToString()); return Path.GetFullPath(rawPath.Replace("[BuildTarget]", target.ToString())); } private struct TileRecord { public string TileId; public double Xmin; public double Ymin; public double GlobalMin; public double GlobalMax; } private static List ParseTilesCsv() { var tiles = new List(); var ci = CultureInfo.InvariantCulture; var lines = File.ReadAllLines(TileIndexCsvPath); if (lines.Length < 2) return tiles; var headerMap = BuildHeaderMap(lines[0]); string[] required = { "tile_id", "xmin", "ymin", "global_min", "global_max" }; if (!HasAll(headerMap, required)) { Debug.LogError("[GeoTileAddressablesBuilder] CSV missing required columns."); return tiles; } int IDX_TILE = headerMap["tile_id"]; int IDX_XMIN = headerMap["xmin"]; int IDX_YMIN = headerMap["ymin"]; int IDX_GMIN = headerMap["global_min"]; int IDX_GMAX = headerMap["global_max"]; for (int i = 1; i < lines.Length; i++) { var line = lines[i].Trim(); if (string.IsNullOrWhiteSpace(line)) continue; var parts = line.Split(','); int maxIdx = Math.Max(IDX_TILE, Math.Max(IDX_XMIN, Math.Max(IDX_YMIN, Math.Max(IDX_GMIN, IDX_GMAX)))); if (parts.Length <= maxIdx) continue; try { tiles.Add(new TileRecord { TileId = parts[IDX_TILE].Trim(), Xmin = double.Parse(parts[IDX_XMIN], ci), Ymin = double.Parse(parts[IDX_YMIN], ci), GlobalMin = double.Parse(parts[IDX_GMIN], ci), GlobalMax = double.Parse(parts[IDX_GMAX], ci) }); } catch (Exception e) { Debug.LogWarning($"[GeoTileAddressablesBuilder] Parse error line {i + 1}: {e.Message}"); } } return tiles; } private static Dictionary BuildHeaderMap(string headerLine) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); var headers = headerLine.Split(','); for (int i = 0; i < headers.Length; i++) map[headers[i].Trim()] = i; return map; } private static bool HasAll(Dictionary map, string[] keys) { foreach (var key in keys) { if (!map.ContainsKey(key)) return false; } return true; } }