using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; 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 DefaultTileSizeMeters = 1000f; private const float DefaultTileSizeX = 1000f; private const float DefaultTileSizeY = 1000f; private const float DefaultOverlapX = 0.5f; private const float DefaultOverlapY = 0.5f; 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]"; internal struct TileKeyConfig { public float TileSizeX; public float TileSizeY; public float OverlapX; public float OverlapY; public static TileKeyConfig Default => new TileKeyConfig { TileSizeX = DefaultTileSizeX, TileSizeY = DefaultTileSizeY, OverlapX = DefaultOverlapX, OverlapY = DefaultOverlapY }; } internal struct TileRecord { public string TileId; public string TileKey; public int XKey; public int YKey; public double Xmin; public double Ymin; public double GlobalMin; public double GlobalMax; public double TileMin; public double TileMax; } internal struct BuildRequest { public string TileIndexCsvPath; public string TilePrefabsDir; public string OutputRoot; public BuildTarget Target; public BuildTargetGroup TargetGroup; public float TileSizeMeters; public TileKeyConfig TileKeyConfig; public List SelectedTiles; public bool OverwriteExisting; public bool Verbose; } [MenuItem("Tools/Geo Tiles/Build (Android)")] public static void BuildAndroid() { Debug.LogWarning("[GeoTileAddressablesBuilder] Legacy build. Use Tools/Geo Tiles/Addressables Build Window for selection builds."); BuildForTarget(BuildTarget.Android, BuildTargetGroup.Android); } [MenuItem("Tools/Geo Tiles/Build (Linux)")] public static void BuildLinux() { Debug.LogWarning("[GeoTileAddressablesBuilder] Legacy build. Use Tools/Geo Tiles/Addressables Build Window for selection builds."); BuildForTarget(BuildTarget.StandaloneLinux64, BuildTargetGroup.Standalone); } private static void BuildForTarget(BuildTarget target, BuildTargetGroup group) { BuildSelectedTiles(new BuildRequest { TileIndexCsvPath = TileIndexCsvPath, TilePrefabsDir = TilePrefabsDir, OutputRoot = "ServerData/TileBundles", Target = target, TargetGroup = group, TileSizeMeters = DefaultTileSizeMeters, TileKeyConfig = TileKeyConfig.Default, SelectedTiles = null, OverwriteExisting = false, Verbose = false }); } internal static bool BuildSelectedTiles(BuildRequest request) { var tileIndexCsvPath = string.IsNullOrWhiteSpace(request.TileIndexCsvPath) ? TileIndexCsvPath : request.TileIndexCsvPath; var tilePrefabsDir = string.IsNullOrWhiteSpace(request.TilePrefabsDir) ? TilePrefabsDir : request.TilePrefabsDir; var outputRoot = string.IsNullOrWhiteSpace(request.OutputRoot) ? "ServerData/TileBundles" : request.OutputRoot; var tileSizeMeters = request.TileSizeMeters > 0f ? request.TileSizeMeters : DefaultTileSizeMeters; var tileKeyConfig = request.TileKeyConfig; if (tileKeyConfig.TileSizeX <= 0f && tileKeyConfig.TileSizeY <= 0f && tileKeyConfig.OverlapX == 0f && tileKeyConfig.OverlapY == 0f) tileKeyConfig = TileKeyConfig.Default; if (tileKeyConfig.TileSizeX <= 0f) tileKeyConfig.TileSizeX = DefaultTileSizeX; if (tileKeyConfig.TileSizeY <= 0f) tileKeyConfig.TileSizeY = DefaultTileSizeY; if (!Directory.Exists(tilePrefabsDir)) { Debug.LogError($"[GeoTileAddressablesBuilder] Prefab directory missing: {tilePrefabsDir}"); return false; } if (!File.Exists(tileIndexCsvPath)) { Debug.LogError($"[GeoTileAddressablesBuilder] CSV missing: {tileIndexCsvPath}"); return false; } var tiles = request.SelectedTiles ?? ParseTilesCsv(tileIndexCsvPath, tileKeyConfig); if (tiles.Count == 0) { Debug.LogError("[GeoTileAddressablesBuilder] No tiles selected for build."); return false; } var settings = AddressableAssetSettingsDefaultObject.GetSettings(true); if (settings == null) { Debug.LogError("[GeoTileAddressablesBuilder] Addressables settings not found."); return false; } var buildPathValue = BuildPathFromOutputRoot(outputRoot); EnsureProfileVariable(settings, BuildPathVariable, buildPathValue); EnsureProfileVariable(settings, LoadPathVariable, LoadPathValue); EnsureRemoteCatalogPaths(settings); var groupAsset = GetOrCreateGroup(settings); ConfigureGroup(settings, groupAsset); var assignedTileIds = AssignPrefabs(settings, groupAsset, tilePrefabsDir, tiles, true); var filteredTiles = tiles .Where(tile => !string.IsNullOrWhiteSpace(tile.TileId) && assignedTileIds.Contains(tile.TileId)) .ToList(); if (filteredTiles.Count == 0) { Debug.LogError("[GeoTileAddressablesBuilder] No matching prefabs found for selected tiles."); return false; } settings.SetDirty(AddressableAssetSettings.ModificationEvent.BatchModification, null, true); AssetDatabase.SaveAssets(); var previousTarget = EditorUserBuildSettings.activeBuildTarget; if (previousTarget != request.Target) { if (!EditorUserBuildSettings.SwitchActiveBuildTarget(request.TargetGroup, request.Target)) { Debug.LogError($"[GeoTileAddressablesBuilder] Failed to switch build target to {request.Target}."); return false; } } var outputPath = GetRemoteCatalogBuildPath(settings, request.Target); if (request.OverwriteExisting && Directory.Exists(outputPath)) Directory.Delete(outputPath, true); AddressableAssetSettings.BuildPlayerContent(); outputPath = GetRemoteCatalogBuildPath(settings, request.Target); if (!Directory.Exists(outputPath)) { Debug.LogError($"[GeoTileAddressablesBuilder] Build output not found: {outputPath}"); return false; } var (catalogFile, catalogHashFile) = FindCatalogFiles(outputPath); if (string.IsNullOrEmpty(catalogFile)) { Debug.LogError($"[GeoTileAddressablesBuilder] Catalog file not found in: {outputPath}"); return false; } var manifest = BuildManifest(request.Target.ToString(), catalogFile, catalogHashFile, filteredTiles, tileSizeMeters); var manifestPath = Path.Combine(outputPath, ManifestFileName); File.WriteAllText(manifestPath, JsonUtility.ToJson(manifest, true)); AssetDatabase.Refresh(); if (request.Verbose) Debug.Log($"[GeoTileAddressablesBuilder] Built {filteredTiles.Count} tiles (from {tiles.Count} selected). Output={outputPath}"); return true; } private static HashSet AssignPrefabs(AddressableAssetSettings settings, AddressableAssetGroup group, string prefabsDir, List tiles, bool removeUnselected) { var tileById = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var tile in tiles) { if (!string.IsNullOrWhiteSpace(tile.TileId)) tileById[tile.TileId] = tile; } var assignedTileIds = new HashSet(StringComparer.OrdinalIgnoreCase); var selectedGuids = new HashSet(); var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { prefabsDir }); foreach (var guid in prefabGuids) { var path = AssetDatabase.GUIDToAssetPath(guid).Replace("\\", "/"); var parentDir = Path.GetDirectoryName(path)?.Replace("\\", "/"); if (!string.Equals(parentDir, prefabsDir, StringComparison.OrdinalIgnoreCase)) continue; var tileId = Path.GetFileNameWithoutExtension(path); if (!tileById.TryGetValue(tileId, out var tile)) continue; var entry = settings.CreateOrMoveEntry(guid, group, false, false); entry.address = tile.TileKey; entry.SetLabel(TileLabel, true, true); selectedGuids.Add(guid); assignedTileIds.Add(tileId); } if (!removeUnselected) return assignedTileIds; var entries = group.entries.ToList(); foreach (var entry in entries) { if (entry == null || string.IsNullOrWhiteSpace(entry.AssetPath)) continue; var entryPath = entry.AssetPath.Replace("\\", "/"); var entryDir = Path.GetDirectoryName(entryPath)?.Replace("\\", "/"); if (!string.Equals(entryDir, prefabsDir, StringComparison.OrdinalIgnoreCase)) continue; if (!selectedGuids.Contains(entry.guid)) group.RemoveAssetEntry(entry); } return assignedTileIds; } 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, List tiles, float tileSizeMeters) { if (tiles.Count == 0) throw new InvalidOperationException("No tiles selected for TileManifest."); 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 { tileKey = tile.TileKey, tileId = tile.TileId, offsetX = (float)(tile.Xmin - minX), offsetZ = (float)(tile.Ymin - minY), baseY = (float)tile.TileMin, tileMin = (float)tile.TileMin, tileMax = (float)tile.TileMax }; } 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 static string BuildPathFromOutputRoot(string outputRoot) { if (string.IsNullOrWhiteSpace(outputRoot)) return BuildPathValue; var normalized = outputRoot.Replace("\\", "/").TrimEnd('/'); if (normalized.IndexOf("[BuildTarget]", StringComparison.OrdinalIgnoreCase) >= 0) return normalized; return $"{normalized}/[BuildTarget]"; } internal static List ParseTilesCsv(string csvPath, TileKeyConfig tileKeyConfig) { var tiles = new List(); var ci = CultureInfo.InvariantCulture; var lines = File.ReadAllLines(csvPath); 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"]; int IDX_TMIN = headerMap.TryGetValue("tile_min", out var idxTileMin) ? idxTileMin : -1; int IDX_TMAX = headerMap.TryGetValue("tile_max", out var idxTileMax) ? idxTileMax : -1; int IDX_TILE_KEY = headerMap.TryGetValue("tile_key", out var idxTileKey) ? idxTileKey : -1; 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 (IDX_TILE_KEY >= 0) maxIdx = Math.Max(maxIdx, IDX_TILE_KEY); if (IDX_TMIN >= 0) maxIdx = Math.Max(maxIdx, IDX_TMIN); if (IDX_TMAX >= 0) maxIdx = Math.Max(maxIdx, IDX_TMAX); if (parts.Length <= maxIdx) continue; try { var xmin = double.Parse(parts[IDX_XMIN], ci); var ymin = double.Parse(parts[IDX_YMIN], ci); var tileKeyRaw = IDX_TILE_KEY >= 0 ? parts[IDX_TILE_KEY].Trim() : ""; var tileKey = ResolveTileKey(tileKeyRaw, xmin, ymin, tileKeyConfig, out var xKey, out var yKey); var globalMin = double.Parse(parts[IDX_GMIN], ci); var globalMax = double.Parse(parts[IDX_GMAX], ci); double tileMin = globalMin; double tileMax = globalMax; if (IDX_TMIN >= 0 && IDX_TMIN < parts.Length && double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out var parsedTileMin)) tileMin = parsedTileMin; if (IDX_TMAX >= 0 && IDX_TMAX < parts.Length && double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out var parsedTileMax)) tileMax = parsedTileMax; tiles.Add(new TileRecord { TileId = parts[IDX_TILE].Trim(), TileKey = tileKey, XKey = xKey, YKey = yKey, Xmin = xmin, Ymin = ymin, GlobalMin = globalMin, GlobalMax = globalMax, TileMin = tileMin, TileMax = tileMax }); } catch (Exception e) { Debug.LogWarning($"[GeoTileAddressablesBuilder] Parse error line {i + 1}: {e.Message}"); } } return tiles; } private static string ResolveTileKey(string tileKeyRaw, double xmin, double ymin, TileKeyConfig tileKeyConfig, out int xKey, out int yKey) { if (!string.IsNullOrWhiteSpace(tileKeyRaw) && TryParseTileKey(tileKeyRaw, out xKey, out yKey)) return tileKeyRaw; float sizeX = tileKeyConfig.TileSizeX <= 0f ? 1f : tileKeyConfig.TileSizeX; float sizeY = tileKeyConfig.TileSizeY <= 0f ? 1f : tileKeyConfig.TileSizeY; xKey = (int)Math.Floor((xmin + tileKeyConfig.OverlapX) / sizeX); yKey = (int)Math.Floor((ymin + tileKeyConfig.OverlapY) / sizeY); return $"{xKey}_{yKey}"; } private static bool TryParseTileKey(string tileKey, out int xKey, out int yKey) { xKey = 0; yKey = 0; if (string.IsNullOrWhiteSpace(tileKey)) return false; var parts = tileKey.Split('_'); if (parts.Length != 2) return false; return int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out xKey) && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out yKey); } 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; } }