// // This file is part of the Tremble package by Tiny Goose. // Copyright (c) 2024-2026 TinyGoose Ltd., All Rights Reserved. // using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using UnityEditor; using UnityEditor.Build; using UnityEngine; using Color = UnityEngine.Color; using Debug = UnityEngine.Debug; using FileMode = System.IO.FileMode; namespace TinyGoose.Tremble.Editor { public static class TrembleSync { // ----------------------------------------------------------------------------------------------------------------------------- // State // ----------------------------------------------------------------------------------------------------------------------------- private static readonly List s_BackgroundTasks = new(256); private static int s_ProgressTask; private static TrembleSyncSettings SyncSettings => TrembleSyncSettings.Get(); internal static async Task PerformSync() { // Check for upgrades! VersionCheck.FetchLatestVersionsInBackground(() => VersionCheck.PromptToUpgradeIfAvailable()); // Upgrade - check material export size is sane UpgradeMaterialExportSettingsIfRequired(); TrembleTimer.BeginSession("Tremble Sync"); try { // Clear cached asset lists UnityObjectFieldConverterStatics.s_CachedAssetLists.Clear(); // Calculate working folders s_ProgressTask = ProgressUtil.Start("Trembling..."); // Create lookups string baseq3 = TrembleConsts.BASEQ3_PATH; string baseq3Old = TrembleConsts.BASEQ3_PATH_OLD; string modelsFolder = TrembleConsts.BASE_MODELS_PATH; string baseMaterialsFolder = TrembleConsts.BASE_MATERIALS_PATH; TrembleTimerScope createLookupTimer = new(TrembleTimer.Context.CreateTypeLookups); PrefabNameLookup prefabNameLookup = new(SyncSettings); MaterialNameLookup materialNameLookup = new(SyncSettings); MapTypeLookup typeLookup = new(SyncSettings); ModTimeDB modTimeDB = new(TrembleConsts.MODTIME_DB_PATH); createLookupTimer.Dispose(); // ----------------------------------------------------------------------------------------------------------------------------- // Gather lists of resources // ----------------------------------------------------------------------------------------------------------------------------- TrembleTimerScope exportEntitiesTimer = new(TrembleTimer.Context.ExportEntities); List allPrefabPaths = prefabNameLookup.AllPrefabPaths; List allBrushTypes = TypeDatabase.GetTypesWithAttribute().ToList(); List allPointTypes = TypeDatabase.GetTypesWithAttribute().ToList(); List allScriptableObjectTypes = new(128); // User supplied a list of materials - use that! List allMaterialGroups = SyncSettings.GetMaterialGroupsOrDefault(); int numMaterials = allMaterialGroups.Sum(mg => mg.Materials.Length); // Collect prefab materials and transparent brushes List prefabMaterialPaths = new(allPrefabPaths.Count * 2); List transparentBrushes = new(allBrushTypes.Count); // Collect assets List assets = new(allPrefabPaths.Count + numMaterials); // ----------------------------------------------------------------------------------------------------------------------------- // Work out steps, create folders // ----------------------------------------------------------------------------------------------------------------------------- int numSteps = numMaterials + allPrefabPaths.Count + allPointTypes.Count + allBrushTypes.Count; if (Directory.Exists(baseq3Old)) { Directory.Delete(baseq3Old, true); } if (Directory.Exists(baseq3)) { Directory.Move(baseq3, baseq3Old); } DirectoryUtil.EmptyAndCreateDirectory(baseq3); DirectoryUtil.CreateAllDirectories(modelsFolder); DirectoryUtil.CreateAllDirectories(baseMaterialsFolder); // ----------------------------------------------------------------------------------------------------------------------------- // Export Prefabs // ----------------------------------------------------------------------------------------------------------------------------- int step = 0; FgdWriter fgdWriter = GatherEntities(ref step, numSteps, prefabNameLookup, materialNameLookup, typeLookup, modTimeDB, modelsFolder, baseMaterialsFolder, prefabMaterialPaths, transparentBrushes, allBrushTypes, allPointTypes, allScriptableObjectTypes, assets); exportEntitiesTimer.Dispose(); // ----------------------------------------------------------------------------------------------------------------------------- // Data Assets // ----------------------------------------------------------------------------------------------------------------------------- using (TrembleTimerScope _ = new(TrembleTimer.Context.GatherDataAssets)) { numSteps += allScriptableObjectTypes.Count; // Add discovered data assets foreach (Type scriptableObjectType in allScriptableObjectTypes) { string[] paths = AssetDatabase .FindAssets($"t:{scriptableObjectType.Name}", new[] { "Assets" }) .Select(AssetDatabase.GUIDToAssetPath) .ToArray(); foreach (string path in paths) { string mapName = Path.GetFileNameWithoutExtension(path); ReportProgress(ref step, numSteps, mapName); assets.Add(new() { MapName = mapName, Path = path, #if ADDRESSABLES_INSTALLED AddressableName = TrembleSyncAddressables.GetExistingEntryForPath(path)?.address ?? mapName, #endif FullTypeName = scriptableObjectType.FullName }); } } } // ----------------------------------------------------------------------------------------------------------------------------- // Materials // ----------------------------------------------------------------------------------------------------------------------------- List allMaterialPaths = allMaterialGroups .SelectMany(mg => mg.Materials .Select(AssetDatabase.GetAssetPath) .Where(path => !path.Contains("M_Clip")) ) .ToList(); using (TrembleTimerScope _ = new(TrembleTimer.Context.ExportMaterials)) { MapTextureSnapshotter textureSnapshotter = new(); textureSnapshotter.Init(SyncSettings); try { numSteps += prefabMaterialPaths.Count; // Material groups foreach (string materialPath in allMaterialPaths) { // Look up material name with group if (!materialNameLookup.TryGetMapNameFromMaterialPath(materialPath, out string mapName)) { mapName = Path.GetFileNameWithoutExtension(materialPath); } string outputPath = Path.Combine(baseMaterialsFolder, mapName + ".png"); string description = Path.GetFileName(materialPath); ReportProgress(ref step, numSteps, description); ProcessMaterial(textureSnapshotter, materialPath, mapName, outputPath, modTimeDB, assets); } // Prefab materials foreach (string materialPath in prefabMaterialPaths) { string mapName = materialNameLookup.GetPrefabNameFromMaterialPath(materialPath); string outputPath = Path.Combine(modelsFolder, mapName + ".png"); string description = Path.GetFileName(materialPath); ReportProgress(ref step, numSteps, description); ProcessMaterial(textureSnapshotter, materialPath, mapName, outputPath, modTimeDB, assets); } } finally { textureSnapshotter.DeInit(); } // ----------------------------------------------------------------------------------------------------------------------------- // "clip" texture // ----------------------------------------------------------------------------------------------------------------------------- if (SyncSettings.ExportClipSkipTextures) { string clipPath = Path.Combine(baseMaterialsFolder, "special", "clip.png"); DirectoryUtil.CreateAllDirectories(clipPath); Texture2D clipTexture = TextureUtil.GenerateClipTexture(Color.white, Color.grey, Color.red); await File.WriteAllBytesAsync(clipPath, clipTexture.EncodeToPNG()); string skipPath = Path.Combine(baseMaterialsFolder, "special", "skip.png"); DirectoryUtil.CreateAllDirectories(clipPath); Texture2D skipTexture = TextureUtil.GenerateSkipTexture(Color.white, Color.grey, Color.blue); await File.WriteAllBytesAsync(skipPath, skipTexture.EncodeToPNG()); } // ----------------------------------------------------------------------------------------------------------------------------- // Missing texture texture // ----------------------------------------------------------------------------------------------------------------------------- { string emptyPath = Path.Combine(baseMaterialsFolder, "__TB_empty.png"); DirectoryUtil.CreateAllDirectories(emptyPath); Texture2D emptyTexture = TextureUtil.GenerateColourTexture(Color.magenta); await File.WriteAllBytesAsync(emptyPath, emptyTexture.EncodeToPNG()); } } // ----------------------------------------------------------------------------------------------------------------------------- // Write configs // ----------------------------------------------------------------------------------------------------------------------------- bool wasNewInstall = false; using (TrembleTimerScope _ = new(TrembleTimer.Context.WriteEntitiesFgdAndGameConfig)) { WriteAllConfigFiles(fgdWriter, transparentBrushes); wasNewInstall = await TrenchBroomUtil.WriteEnginePathIntoTrenchBroomPrefs(); modTimeDB.WriteToFile(); if (assets != null && SyncSettings) { SyncSettings.AssetMetadatas = assets.ToArray(); EditorUtility.SetDirty(SyncSettings); AssetDatabase.SaveAssetIfDirty(SyncSettings); } } // ----------------------------------------------------------------------------------------------------------------------------- // Cleanup backups, if wanted // ----------------------------------------------------------------------------------------------------------------------------- using (TrembleTimerScope _ = new(TrembleTimer.Context.Cleanup)) { if (SyncSettings.AutoDeleteMapBackups) { TrembleMenu.DeleteAutosaves(); } } // ----------------------------------------------------------------------------------------------------------------------------- // Write Addressables data // ----------------------------------------------------------------------------------------------------------------------------- #if ADDRESSABLES_INSTALLED if (SyncSettings.AllowStandaloneImport) { await TrembleSyncAddressables.SetupAddressables( SyncSettings, materialNameLookup, allMaterialPaths, prefabNameLookup, allPrefabPaths, allScriptableObjectTypes); } #endif // ----------------------------------------------------------------------------------------------------------------------------- // Wait for any background tasks // ----------------------------------------------------------------------------------------------------------------------------- await Task.WhenAll(s_BackgroundTasks); s_BackgroundTasks.Clear(); // ----------------------------------------------------------------------------------------------------------------------------- // Open settings if newly installed // ----------------------------------------------------------------------------------------------------------------------------- if (wasNewInstall) { if (TrenchBroomUtil.IsTrenchBroomRunning) { EditorUtility.DisplayDialog( title: "TrenchBroom running!", message: "Hey, your new Tremble game was just installed, but TrenchBroom was running. " + "You will need to quit TrenchBroom and re-open it before editing any maps!", "Oh, Okay!"); } EditorApplication.delayCall += () => { TrembleEditorAPI.SyncToTrenchBroom(); TrembleEditorAPI.ReimportAllMaps(silent: true); try { Assembly assembly = Assembly.GetAssembly(typeof(UnityEditor.Editor)); Type type = assembly.GetType("UnityEditor.LogEntries"); MethodInfo method = type.GetMethod("Clear"); method?.Invoke(new object(), null); } finally { Debug.ClearDeveloperConsole(); } }; } } catch (Exception ex) { Debug.LogError("Project Sync failed!"); Debug.LogException(ex); ProgressUtil.Fail(s_ProgressTask); } finally { ProgressUtil.Succeed(s_ProgressTask); string baseq3Old = Path.Combine(Directory.GetCurrentDirectory(), "Library", "baseq3_old"); if (Directory.Exists(baseq3Old)) { Directory.Delete(baseq3Old, true); } TrembleTimer.EndSession(); } } private static void ReportProgress(ref int step, int numSteps, string assetName) { ProgressUtil.Report(s_ProgressTask, step++, numSteps, $"{assetName} ({step + 1}/{numSteps})"); } // ----------------------------------------------------------------------------------------------------------------------------- // Entity // ----------------------------------------------------------------------------------------------------------------------------- internal static FgdWriter GatherEntities() { List allBrushTypes = TypeDatabase.GetTypesWithAttribute().ToList(); List allPointTypes = TypeDatabase.GetTypesWithAttribute().ToList(); List allScriptableObjectTypes = new(128); int step = 0; int numSteps = allBrushTypes.Count + allPointTypes.Count + allScriptableObjectTypes.Count; // These are not duplicates - they're only used from the entities manual page! MapTypeLookup mapTypeLookup = new(SyncSettings); PrefabNameLookup prefabNameLookup = new(SyncSettings); MaterialNameLookup materialNameLookup = new(SyncSettings); ModTimeDB modTimeDB = new(TrembleConsts.MODTIME_DB_PATH); List prefabMaterialPaths = new(prefabNameLookup.AllPrefabPaths.Count * 2); List transparentBrushes = new(allBrushTypes.Count); List assets = new(prefabNameLookup.AllPrefabPaths.Count * 2); return GatherEntities(ref step, numSteps, prefabNameLookup, materialNameLookup, mapTypeLookup, modTimeDB, TrembleConsts.BASE_MODELS_PATH, TrembleConsts.BASE_MATERIALS_PATH, prefabMaterialPaths, transparentBrushes, allBrushTypes, allPointTypes, allScriptableObjectTypes, assets); } private static FgdWriter GatherEntities(ref int step, int numSteps, PrefabNameLookup prefabNameLookup, MaterialNameLookup materialNameLookup, MapTypeLookup typeLookup, ModTimeDB modTimeDB, string modelsFolder, string baseMaterialsFolder, List prefabMaterialPaths, List transparentBrushes, List allBrushTypes, List allPointTypes, List allScriptableObjectTypes, List assets) { TrembleFieldConverterCollection fieldConverter = new(); FgdWriter fgdWriter = new(TrembleConsts.VERSION_STRING, SyncSettings); DiscoverAndAddBaseClasses(fgdWriter, fieldConverter, allScriptableObjectTypes); { // Prefabs foreach (string prefabPath in prefabNameLookup.AllPrefabPaths) { string rawPrefabName = Path.GetFileNameWithoutExtension(prefabPath); prefabNameLookup.TryGetMapNameFromPrefabPath(prefabPath, out string prefabName); ReportProgress(ref step, numSteps, prefabName); ProcessPrefab(prefabName, rawPrefabName, prefabPath, prefabMaterialPaths, modelsFolder, fgdWriter, fieldConverter, materialNameLookup, modTimeDB, allScriptableObjectTypes, assets); } // Point MonoBehaviours foreach (Type pointType in allPointTypes) { ReportProgress(ref step, numSteps, pointType.Name); ProcessPointEntity(pointType, modelsFolder, fgdWriter, fieldConverter, typeLookup, allScriptableObjectTypes); } // Brush MonoBehaviours foreach (Type brushType in allBrushTypes) { ReportProgress(ref step, numSteps, brushType.Name); ProcessBrushEntity(brushType, transparentBrushes, baseMaterialsFolder, fgdWriter, fieldConverter, typeLookup, allScriptableObjectTypes); } } // Export worldspawn data Type worldspawnScriptType = SyncSettings.WorldspawnScript?.Class ?? typeof(Worldspawn); FgdClass worldspawn = new(FgdClassType.Brush, "worldspawn", "The root Worldspawn entity class"); { AddExposedFieldsToEntity(worldspawnScriptType, worldspawn, fieldConverter, allScriptableObjectTypes); } fgdWriter.WorldspawnClass = worldspawn; return fgdWriter; } // ----------------------------------------------------------------------------------------------------------------------------- // Processing // ----------------------------------------------------------------------------------------------------------------------------- private static bool ProcessMaterial(MapTextureSnapshotter snapshotter, string materialPath, string mapName, string outputPath, ModTimeDB modTimeDB, List assets) { if (materialPath.IsNullOrEmpty()) return false; Material material = AssetDatabase.LoadAssetAtPath(materialPath); if (!material) { //Debug.LogError($"Material {materialPath} not found!"); return false; } try { ulong lastModified = modTimeDB.GetFileModifiedTime(material); ulong nowModified = ModTimeDB.CalcMaterialModifiedTime(material); modTimeDB.SetFileModifiedTime(material, nowModified); // Store metadata about material assets.Add(new() { MapName = mapName, Path = materialPath, FullTypeName = typeof(Material).FullName, #if ADDRESSABLES_INSTALLED AddressableName = TrembleSyncAddressables.GetExistingEntryForPath(materialPath)?.address ?? material.name, #endif }); MaterialRenderData data = SyncSettings.GetMaterialRenderData(material); if (nowModified <= lastModified && !data.IsDirty && TryCopyLastVersion(outputPath)) { // No modifications, old value copied! return true; } DirectoryUtil.CreateAllDirectories(outputPath); snapshotter.SnapshotMaterial(material, outputPath); if (data != null) { data.ClearDirty(); EditorUtility.SetDirty(SyncSettings); } } catch (Exception ex) { Debug.LogError(ex); return false; } return true; } private static bool ProcessPrefab(string prefabName, string unityPrefabName, string prefabPath, List materialsList, string modelsFolder, FgdWriter fgdWriter, TrembleFieldConverterCollection fieldConverter, MaterialNameLookup materialNameLookup, ModTimeDB modTimeDB, List scriptableObjectTypes, List assets) { GameObject prefab = AssetDatabase.LoadAssetAtPath(prefabPath); string modelPath = Path.Combine(modelsFolder, $"{prefabName}.md3"); // Check if we have an up-to-date MD3 for this prefab already bool dontCreateMD3 = false; ulong lastModified = modTimeDB.GetFileModifiedTime(prefab); ulong nowModified = ModTimeDB.CalcModelModifiedTime(prefab); modTimeDB.SetFileModifiedTime(prefab, nowModified); if (nowModified <= lastModified && TryCopyLastVersion(modelPath)) { dontCreateMD3 = true; } foreach (MonoBehaviour mb in prefab.GetComponents()) { if (!mb) { Debug.LogWarning($"Prefab {prefab.name} has a broken/missing MonoBehaviour!"); continue; } Type prefabType = mb.GetType(); if (!prefabType.TryGetCustomAttribute(out PrefabEntityAttribute pea)) continue; if (!pea.IsPrefabIncluded(unityPrefabName)) continue; // Sanity check position + rotation if (prefab.transform.position.sqrMagnitude > 1f) { Debug.LogWarning($"WARNING: Prefab {prefab.name} has an position offset of {prefab.transform.position}. This is probably unintended and will affect how your prefab appears in TrenchBroom! Set the prefab's position to (0, 0, 0) for best results.", prefab); } if (prefab.transform.rotation.eulerAngles.magnitude > 1f) { Debug.LogWarning($"WARNING: Prefab {prefab.name} has a rotation of {prefab.transform.rotation.eulerAngles}. This is probably unintended and will affect how your prefab appears in TrenchBroom! Set the prefab's rotation to (0, 0, 0) for best results.", prefab); } // Write materials used by prefab into material list FindAndAddMaterialsUsedByPrefab(prefab, materialsList); // Write MD3 or use existing metadata bool hasModel = dontCreateMD3 ? MD3Util.HasModel(prefab, out Bounds modelBounds) : MD3Util.SaveMD3(prefab, modelPath, materialNameLookup, SyncSettings.ImportScale, out modelBounds); string overrideSprite = pea.OverrideSprite; if (mb is TrembleSpawnablePrefab tsp && tsp.OverrideSprite != null) { overrideSprite = Path.GetFileNameWithoutExtension(AssetDatabase.GetAssetPath(tsp.OverrideSprite)); } if (overrideSprite != null) { hasModel = false; string srcPngPath = TrembleAssetLoader.FindAssetPath(overrideSprite); string destPngPath = Path.Combine(modelsFolder, $"{overrideSprite}.png"); File.Copy(srcPngPath, destPngPath, true); } string prefabTypeDescription = prefabType == typeof(TrembleSpawnablePrefab) ? $"Tremble Spawnable Prefab '{unityPrefabName}'" : $"[PrefabEntity] class {prefabType.Name}, from prefab '{Path.GetFileNameWithoutExtension(prefabPath)}'"; FgdClass entityClass = new(FgdClassType.Point, prefabName, prefabTypeDescription) { HasModel = hasModel, Sprite = overrideSprite, Box = hasModel ? modelBounds : null, }; entityClass.AddBaseClass(FgdConsts.CLASS_MAP_BASE); entityClass.AddBaseClass(FgdConsts.CLASS_MAP_PREFAB_BASE); entityClass.AddBaseClassInterfaces(prefabType); AddExposedFieldsToEntity(prefabType, entityClass, fieldConverter, scriptableObjectTypes, prefab.GetComponent(prefabType)); fgdWriter.AddClass(entityClass); // Store metadata about prefab assets.Add(new() { MapName = prefabName, Path = prefabPath, FullTypeName = typeof(GameObject).FullName, #if ADDRESSABLES_INSTALLED AddressableName = TrembleSyncAddressables.GetExistingEntryForPath(prefabPath)?.address ?? unityPrefabName, #endif SpawnOffset = MD3Util.GetPrefabUnitySpawnOffset(prefab, SyncSettings.ImportScale) }); return true; } return false; } private static bool TryCopyLastVersion(string outputFile) { string oldVersion = outputFile.Replace("baseq3", "baseq3_old"); if (!File.Exists(oldVersion)) return false; ContinueInBackground(() => { DirectoryUtil.CreateAllDirectories(outputFile); try { File.Copy(oldVersion, outputFile, true); } catch (IOException) { Debug.LogError($"Failed to copy old version of '{Path.GetFileName(outputFile)}'"); } }); return true; } private static bool ProcessPointEntity(Type pointType, string modelsFolder, FgdWriter fgdWriter, TrembleFieldConverterCollection fieldConverter, MapTypeLookup typeLookup, List scriptableObjectTypes) { if (!pointType.IsSubclassOf(typeof(MonoBehaviour))) { Debug.LogError($"[PointEntity] {pointType.Name} is NOT a MonoBehaviour or derived from a type that is a MonoBehaviour. This won't work! Your class should appear as e.g. public class {pointType.Name} : MonoBehaviour"); return false; } typeLookup.TryGetMapNameFromClass(pointType, out string name); pointType.TryGetCustomAttribute(out PointEntityAttribute pea); float size = pea.Size; // Write sprite, if any if (!pea.Sprite.IsNullOrEmpty()) { string spritePath = TrembleAssetLoader.FindAssetPath(pea.Sprite); if (spritePath.IsNullOrEmpty()) { Debug.LogWarning($"Couldn't find sprite '{pea.Sprite}' for Point Entity '{pointType.Name}'!"); } else { Texture2D texture = AssetDatabase.LoadAssetAtPath(spritePath); if (texture) { size = (texture.width * SyncSettings.ImportScale) + 0.2f; } string spriteFilename = Path.GetFileName(spritePath); string outputPath = Path.Combine(modelsFolder, spriteFilename); ContinueInBackground(() => File.Copy(spritePath, outputPath, true)); } } FgdClass entityClass = new(FgdClassType.Point, name, $"[PointEntity] class {pointType.Name} ('{name}')") { Colour = pea.Colour, Box = new Bounds(Vector3.zero, Vector3.one * size), Sprite = pea.Sprite }; entityClass.AddBaseClass(FgdConsts.CLASS_MAP_BASE); entityClass.AddBaseClass(FgdConsts.CLASS_MAP_POINT_BASE); entityClass.AddBaseClassInterfaces(pointType); AddExposedFieldsToEntity(pointType, entityClass, fieldConverter, scriptableObjectTypes); fgdWriter.AddClass(entityClass); return true; } private static bool ProcessBrushEntity(Type brushType, List transparentBrushes, string baseMaterialsFolder, FgdWriter fgdWriter, TrembleFieldConverterCollection fieldConverter, MapTypeLookup typeLookup, List scriptableObjectTypes) { if (!brushType.IsSubclassOf(typeof(MonoBehaviour))) { Debug.LogError($"[BrushEntity] {brushType.Name} is NOT a MonoBehaviour or derived from a type that is a MonoBehaviour. This won't work! Your class should appear as e.g. public class {brushType.Name} : MonoBehaviour"); return false; } typeLookup.TryGetMapNameFromClass(brushType, out string name); brushType.TryGetCustomAttribute(out BrushEntityAttribute bea); // Add to transparent brushes if needed if (bea.BrushType is BrushType.Invisible or BrushType.Trigger) { transparentBrushes.Add(name); } // Write texture, if present if (bea.Colour != null) { string specialPath = Path.Combine(baseMaterialsFolder, "special", $"{name}.png"); DirectoryUtil.CreateAllDirectories(specialPath); if (bea.CheckerStyle == CheckerboardStyle.None) { Texture2D specialTexture = TextureUtil.GenerateColourTexture(bea.Colour.Value); File.WriteAllBytes(specialPath, specialTexture.EncodeToPNG()); } else { bool useDarkCheckerboard = bea.CheckerStyle == CheckerboardStyle.Dark; Color checkerColour = useDarkCheckerboard ? Color.black : Color.white; Texture2D specialTexture = TextureUtil.GenerateCheckerTexture(bea.Colour.Value, checkerColour); File.WriteAllBytes(specialPath, specialTexture.EncodeToPNG()); } } FgdClass entityClass = new(FgdClassType.Brush, name, $"[BrushEntity] class {brushType.Name} ('{name}')"); entityClass.AddBaseClass(FgdConsts.CLASS_MAP_BASE); entityClass.AddBaseClassInterfaces(brushType); AddExposedFieldsToEntity(brushType, entityClass, fieldConverter, scriptableObjectTypes); fgdWriter.AddClass(entityClass); return true; } // ----------------------------------------------------------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------------------------------------------------------- private static void FindAndAddMaterialsUsedByPrefab(GameObject prefab, List intoList) { foreach (Renderer renderer in prefab.GetComponentsInChildren()) { if (renderer is not MeshRenderer and not SkinnedMeshRenderer) continue; IEnumerable usedMaterials = renderer.sharedMaterials .Select(AssetDatabase.GetAssetPath) .Where(p => !p.IsNullOrEmpty()); foreach (string usedMaterial in usedMaterials) { if (intoList.Contains(usedMaterial)) continue; intoList.Add(usedMaterial); } } } private static Component CreateComponentOnTesterObject(Type scriptType) { GameObject tester = new("Tester"); foreach (RequireComponent requiredComponent in scriptType.GetCustomAttributes()) { MaybeAddComponent(requiredComponent.m_Type0); MaybeAddComponent(requiredComponent.m_Type1); MaybeAddComponent(requiredComponent.m_Type2); } Component testerScript = tester.AddComponent(scriptType); return testerScript; void MaybeAddComponent(Type type) { // Null, no need to add! if (type == null) return; // Collider - add a box collider if (type == typeof(Collider)) type = typeof(BoxCollider); tester.AddComponent(type); } } internal static void AddExposedFieldsToEntity(Type scriptType, FgdClass entityClass, TrembleFieldConverterCollection fieldConverter, List scriptableObjectTypes, Component testerComponent = null) { bool addComments = SyncSettings.FgdFormattingStyle == FgdFormattingStyle.VeryVerbose; bool alsoHandleSerializedFields = SyncSettings.SyncSerializedFields; // Create a "tester" - an instance to read the default values, add any pre-requisites Component testerScript = testerComponent ?? CreateComponentOnTesterObject(scriptType); foreach (FieldInfo target in scriptType.GetAllInstanceFieldsIncludingPrivateAndBaseClasses()) { target.GetCustomAttributes(out TrembleAttribute ta, out NoTrembleAttribute nta, out SerializeField sfa); string overrideName = ta?.OverrideName; if (ta == null && !(sfa != null && alsoHandleSerializedFields) && !target.IsPublic) { // If not public, and not [MapSerialized], skip! if (addComments) { entityClass.AddComment($"Skipped {target.FieldType.Name} {target.Name} - missing [Tremble] or [SerializedField]!"); } continue; } if (nta != null) { // Specifically NOT serialised in maps - skip! if (addComments) { entityClass.AddComment($"Skipped {target.FieldType.Name} {target.Name} - saw [NoTremble]."); } continue; } // Special case: Prefabs and Materials bool isMaterialOrPrefabField = target.FieldType == typeof(Material) || target.FieldType == typeof(GameObject); if (isMaterialOrPrefabField && ta == null && !SyncSettings.SyncMaterialsAndPrefabs) { // This is a Material or Prefab but we've turned off the setting if (addComments) { entityClass.AddComment($"Skipped {target.FieldType.Name} {target.Name} - Material or Prefab, but the SyncMaterialsAndPrefabs setting is off."); } continue; } string fieldName = overrideName ?? target.Name.GetFieldNameInMap(SyncSettings.FieldNamingConvention); TrembleFieldConverter converter = fieldConverter.GetConverterForType(target.FieldType); if (target.FieldType.IsSubclassOf(typeof(ScriptableObject))) { scriptableObjectTypes.Add(target.FieldType); } if (converter != null) { // Use a converter object defaultValue = target.GetValue(testerScript); if (defaultValue is Array defaultArray) { defaultValue = defaultArray.Length > 0 ? defaultArray.GetValue(0) : null; } converter.AddFieldToFgd(entityClass, fieldName, defaultValue, target); } else if (ta != null) { // Nothing worked - warning! if (addComments) { entityClass.AddComment($"Skipped {target.FieldType.Name} {target.Name} - no field converter for a {target.FieldType}!"); } Debug.LogWarning($"Saw field '{target.Name}' of type {target.FieldType}, which is not yet supported. Consider creating a TrembleFieldConverter for it!"); } } if (testerScript != testerComponent) { GameObject.DestroyImmediate(testerScript.gameObject); } } private static void DiscoverAndAddBaseClasses(FgdWriter fgd, TrembleFieldConverterCollection fieldConverter, List scriptableObjectTypes) { TypeCache.TypeCollection types = TypeCache.GetTypesDerivedFrom(); foreach (Type baseType in types) { if (!baseType.IsInterface) continue; string baseClassName = baseType.Name.Substring(1); // Remove 'I' string baseClassDesc = $"Base Class from C# Interface '{baseType.Name}'"; FgdClass baseClass = new(FgdClassType.Base, baseClassName, baseClassDesc); foreach (PropertyInfo target in baseType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { target.GetCustomAttributes(out TrembleAttribute ta, out NoTrembleAttribute nta); string overrideName = ta?.OverrideName; if (nta != null) { // Specifically NOT serialised in maps - skip! continue; } // Special case: Prefabs and Materials bool isMaterialOrPrefabField = target.PropertyType == typeof(Material) || target.PropertyType == typeof(GameObject); if (isMaterialOrPrefabField && ta == null && !SyncSettings.SyncMaterialsAndPrefabs) { // This is a Material or Prefab but we've turned off the setting continue; } ITrembleBaseClass defaultBaseType = TrembleBaseClassDefault.CreateDefault(baseType); string fieldName = overrideName ?? target.Name.GetFieldNameInMap(SyncSettings.FieldNamingConvention); TrembleFieldConverter converter = fieldConverter.GetConverterForType(target.PropertyType); if (target.PropertyType.IsSubclassOf(typeof(ScriptableObject))) { scriptableObjectTypes.Add(target.PropertyType); } if (converter != null) { // Use a converter converter.AddFieldToFgd(baseClass, fieldName, target.GetValue(defaultBaseType), target); } else if (ta != null) { // Nothing worked - warning! Debug.LogWarning($"Saw field '{target.Name}' of type {target.PropertyType}, which is not yet supported. Consider creating a TrembleFieldConverter for it!"); } } fgd.AddClass(baseClass); } } // Continue a task on a worker thread. Don't touch Unity API in these callbacks!! // These are awaited right at the end of the process. private static void ContinueInBackground(Action action) { s_BackgroundTasks.Add(Task.Run(action)); } private static void WriteAllConfigFiles(FgdWriter fgd, List invisibleBrushes) { // Write Files string gameFolder = TrenchBroomUtil.GetGameFolder(); DirectoryUtil.EmptyAndCreateDirectory(gameFolder); string gameConfigPath = Path.Combine(gameFolder, "gameconfig.cfg"); string entitiesPath = Path.Combine(gameFolder, "entities.fgd"); string iconPath = Path.Combine(gameFolder, "Icon.png"); // Write entities.fgd using FileStream entityFile = new(entitiesPath, FileMode.Create, FileAccess.Write); using StreamWriter sw = new(entityFile); fgd.WriteFgd(sw, SyncSettings.FgdFormattingStyle is FgdFormattingStyle.HumanReadable or FgdFormattingStyle.VeryVerbose); // Write gameconfig.cfg string version = SyncSettings.TrenchBroomGameConfigVersion; StringBuilder brushes = new(); for (int brushIdx = 0; brushIdx < invisibleBrushes.Count; brushIdx++) { string invisibleBrush = invisibleBrushes[brushIdx]; if (brushIdx > 0) { brushes.AppendLine(","); brushes.Append("\t\t\t"); } brushes.AppendLine("{"); brushes.AppendLine($"\t\t\t\t\"name\": \"{invisibleBrush}\","); brushes.AppendLine($"\t\t\t\t\"attribs\": [ \"transparent\" ],"); brushes.AppendLine($"\t\t\t\t\"match\": \"classname\","); brushes.AppendLine($"\t\t\t\t\"pattern\": \"{invisibleBrush}\","); brushes.AppendLine($"\t\t\t\t\"texture\": \"special/{invisibleBrush}\""); brushes.Append("\t\t\t}"); } string gameConfigFilename = $"gameconfig-{SyncSettings.TrenchBroomGameConfigVersion}.cfg"; string gameConfigTemplatePath = Path.Combine(TrembleConsts.EDITOR_GetTrembleInstallFolder(), "Editor", "Templates", gameConfigFilename); string gameConfigTemplate = File.ReadAllText(gameConfigTemplatePath); string gameConfig = gameConfigTemplate .Replace("$GAME_NAME", TrembleConsts.GAME_NAME) .Replace("$BRUSHES", brushes.ToString()); File.WriteAllText(gameConfigPath, gameConfig); // Copy Icon.png bool copiedIcon = false; Texture2D[] icons = PlayerSettings.GetIcons(NamedBuildTarget.Standalone, IconKind.Any); for (int iconIdx = icons.Length - 1; iconIdx >= 0; iconIdx--) { if (!icons[iconIdx]) continue; string sourceIconPath = Path.GetFullPath(AssetDatabase.GetAssetPath(icons[iconIdx])); ContinueInBackground(() => { File.Copy(sourceIconPath, iconPath, true); File.SetAttributes(iconPath, FileAttributes.Normal); }); copiedIcon = true; break; } if (!copiedIcon) { // No Icon! } } private static void UpgradeMaterialExportSettingsIfRequired() { // Older versions of Tremble encode this as an int, so we need to check that first TrembleSyncSettings syncSettings = TrembleSyncSettings.Get(); MaterialExportSize mes = syncSettings.MaterialExportSize; if ((int)mes <= 8) return; int oldSize = (int)mes; bool hasResolvedSize = false; for (int sizeIdx = 0; sizeIdx < MaterialExportSizeUtils.Names.Length; sizeIdx++) { int newSize = int.Parse(MaterialExportSizeUtils.Names[sizeIdx]); if (oldSize > newSize) continue; mes = (MaterialExportSize)sizeIdx; hasResolvedSize = true; break; } if (!hasResolvedSize) { // No matching size found - use default mes = MaterialExportSizeUtils.GetDefault(); } EditorUtility.DisplayDialog( title: "Tremble Material Upgrade", message: $"This new version of Tremble handles texture sizes differently. Your old size of {oldSize} has " + $"been mapped to {mes}. Please verify this size is what you were expecting.", ok: "Thanks!"); SerializedObject settingsObj = new(syncSettings); SerializedProperty sizeProp = settingsObj.FindBackedProperty(nameof(syncSettings.MaterialExportSize)); sizeProp.enumValueIndex = (int)mes; settingsObj.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(syncSettings); AssetDatabase.SaveAssets(); TrembleEditorAPI.InvalidateMaterialAndPrefabCache(silent: true); TrembleEditorAPI.SyncToTrenchBroom(); } } }