/// <summary> /// Invoked by the menu system to create a new localization on the /// currently selected objects with a given language. /// </summary> /// <param name="language">The language to use. This must be a /// string.</param> private void CreateLocalizationWithLanguage(object language) { if (!(language is string theLanguage)) { throw new ArgumentException("Expected to receive a string", nameof(language)); } if (serializedObject.isEditingMultipleObjects) { Debug.LogWarning($"Can't create a new {nameof(Localization)} when multiple objects are selected"); return; } LocalizationDatabaseUtility.CreateLocalizationWithLanguage(serializedObject, theLanguage); }
public void YarnImporterUtility_CanCreateLocalizationInLocalizationDatabase() { // Arrange: Import a yarn script and create a localization // database for it string fileName = Path.GetRandomFileName(); string path = Path.Combine("Assets", fileName + ".yarn"); createdFilePaths.Add(path); File.WriteAllText(path, TestYarnScriptSource); AssetDatabase.Refresh(); var importer = AssetImporter.GetAtPath(path) as YarnImporter; var importerSerializedObject = new SerializedObject(importer); YarnImporterUtility.CreateNewLocalizationDatabase(importerSerializedObject); createdFilePaths.Add(AssetDatabase.GetAssetPath(importer.localizationDatabase)); var databaseSerializedObject = new SerializedObject(importer.localizationDatabase); // Act: Create a new localization CSV file for some new language LocalizationDatabaseUtility.CreateLocalizationWithLanguage(databaseSerializedObject, AlternateLocaleCode); YarnImporterUtility.CreateLocalizationForLanguageInProgram(importerSerializedObject, AlternateLocaleCode); foreach (var loc in importer.localizationDatabase.Localizations) { createdFilePaths.Add(AssetDatabase.GetAssetPath(loc)); } foreach (var loc in importer.localizations) { createdFilePaths.Add(AssetDatabase.GetAssetPath(loc.text)); } importer.SaveAndReimport(); // Assert: Verify that it exists, contains the string table // entries we expect, and has the language we expect. var expectedLanguages = new HashSet <string> { importer.baseLanguageID, AlternateLocaleCode }.OrderBy(n => n); var foundLanguages = importer.programContainer.localizations.Select(l => l.languageName).OrderBy(n => n); CollectionAssert.AreEquivalent(expectedLanguages, foundLanguages, $"The locales should be what we expect to see"); }
public void YarnImporterUtility_CanUpdateLocalizedCSVs_WhenBaseScriptChanges() { // Arrange: Import a yarn script and create a localization // database for it, create an alternate localization for it string fileName = Path.GetRandomFileName(); string path = Path.Combine("Assets", fileName + ".yarn"); createdFilePaths.Add(path); File.WriteAllText(path, TestYarnScriptSource); AssetDatabase.Refresh(); var importer = AssetImporter.GetAtPath(path) as YarnImporter; var importerSerializedObject = new SerializedObject(importer); YarnImporterUtility.CreateNewLocalizationDatabase(importerSerializedObject); var localizationDatabaseSerializedObject = new SerializedObject(importer.localizationDatabase); LocalizationDatabaseUtility.CreateLocalizationWithLanguage(localizationDatabaseSerializedObject, AlternateLocaleCode); YarnImporterUtility.CreateLocalizationForLanguageInProgram(importerSerializedObject, AlternateLocaleCode); var unmodifiedBaseStringsTable = StringTableEntry.ParseFromCSV((importerSerializedObject.targetObject as YarnImporter).baseLanguage.text); var unmodifiedLocalizedStringsTable = StringTableEntry.ParseFromCSV((importerSerializedObject.targetObject as YarnImporter).localizations.First(l => l.languageName == AlternateLocaleCode).text.text); // Act: modify the imported script so that lines are added, // changed and deleted, and then update the localized CSV // programmatically File.WriteAllText(path, TestYarnScriptSourceModified); AssetDatabase.Refresh(); YarnImporterUtility.UpdateLocalizationCSVs(importerSerializedObject); var modifiedBaseStringsTable = StringTableEntry.ParseFromCSV((importerSerializedObject.targetObject as YarnImporter).baseLanguage.text); var modifiedLocalizedStringsTable = StringTableEntry.ParseFromCSV((importerSerializedObject.targetObject as YarnImporter).localizations.First(l => l.languageName == AlternateLocaleCode).text.text); // Assert: verify the base language string table contains the // string table entries we expect, verify the localized string // table contains the string table entries we expect System.Func <StringTableEntry, string> CompareIDs = t => t.ID; System.Func <StringTableEntry, string> CompareLocks = t => t.Lock; var tests = new[] {
/// <summary> /// Updates every localization .CSV file associated with this .yarn /// file. /// </summary> /// <remarks> /// This method updates each localization file by performing the /// following operations: /// /// - Inserts new entries if they're present in the base localization /// and not in the translated localization /// /// - Removes entries if they're present in the translated localization /// and not in the base localization /// /// - Detects if a line in the base localization has changed its Lock /// value from when the translated localization was created, and update /// its Comment /// </remarks> /// <param name="serializedObject">A serialized object that represents /// a <see cref="YarnImporter"/>.</param> internal static void UpdateLocalizationCSVs(SerializedObject serializedObject) { var localizationDatabaseProperty = serializedObject.FindProperty("localizationDatabase"); if (serializedObject.isEditingMultipleObjects) { Debug.LogError($"Can't update localization CSVs: multiple objects are being edited."); return; } var baseLocalizationStrings = GetBaseLanguageStringsForSelectedObject(serializedObject); var localizations = (serializedObject.targetObject as YarnImporter).localizations; var modifiedFiles = new List <TextAsset>(); foreach (var loc in localizations) { var fileWasChanged = UpdateLocalizationFile(baseLocalizationStrings, loc.languageName, loc.text); if (fileWasChanged) { modifiedFiles.Add(loc.text); } } if (modifiedFiles.Count > 0) { Debug.Log($"Updated the following files: {string.Join(", ", modifiedFiles.Select(f => f.name))}"); } else { Debug.Log($"No files needed updating."); // Update our corresponding localization database. if (localizationDatabaseProperty.objectReferenceValue is LocalizationDatabase database) { LocalizationDatabaseUtility.UpdateContents(database); } } }
/// <summary> /// Creates a new localization database asset adjacent to one of the /// selected objects, configures all selected objects to use it, and /// ensures that the project's text language list includes this /// program's base language. /// </summary> /// <param name="serializedObject">A serialized object that represents /// a <see cref="YarnImporter"/>.</param> internal static void CreateNewLocalizationDatabase(SerializedObject serializedObject) { if (serializedObject.isEditingMultipleObjects) { throw new System.InvalidOperationException($"Cannot invoke {nameof(CreateNewLocalizationDatabase)} when editing multiple objects"); } var localizationDatabaseProperty = serializedObject.FindProperty("localizationDatabase"); var target = serializedObject.targetObjects[0]; // Figure out where on disk this asset is var path = AssetDatabase.GetAssetPath(target); var directory = Path.GetDirectoryName(path); // Figure out a new, unique path for the localization we're // creating var databaseFileName = $"LocalizationDatabase.asset"; var destinationPath = Path.Combine(directory, databaseFileName); destinationPath = AssetDatabase.GenerateUniqueAssetPath(destinationPath); // Create the asset and set it up var localizationDatabaseAsset = ScriptableObject.CreateInstance <LocalizationDatabase>(); // The list of languages we needed to add to the project's text language // list var languagesAddedToProject = new List <string>(); // Attach all selected programs to this new database foreach (YarnImporter importer in serializedObject.targetObjects) { if (importer.programContainer != null) { localizationDatabaseAsset.AddTrackedProgram(importer.programContainer); // If this database doesn't currently have a localization // for the currently selected program, add one var theLanguage = importer.baseLanguageID; if (localizationDatabaseAsset.HasLocalization(theLanguage) == false) { var localizationPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(databaseFileName)}-{theLanguage}.asset"); localizationPath = AssetDatabase.GenerateUniqueAssetPath(localizationPath); // Create the asset and set it up var localizationAsset = ScriptableObject.CreateInstance <Localization>(); localizationAsset.LocaleCode = theLanguage; AssetDatabase.CreateAsset(localizationAsset, localizationPath); localizationDatabaseAsset.AddLocalization(localizationAsset); } // Add this language to the project's text language list if // we need to, and remember that we did so if (ProjectSettings.TextProjectLanguages.Contains(importer.baseLanguageID) == false) { ProjectSettings.TextProjectLanguages.Add(importer.baseLanguageID); languagesAddedToProject.Add(importer.baseLanguageID); } } } // Log if we needed to update the project text language list if (languagesAddedToProject.Count > 0) { Debug.Log($"The following {(languagesAddedToProject.Count == 1 ? "language was" : "languages were")} added to the project's text language list: {string.Join(", ", languagesAddedToProject)}. To review this list, choose Edit > Project Settings > Yarn Spinner."); } // Populate the database's contents LocalizationDatabaseUtility.UpdateContents(localizationDatabaseAsset); // Save it to disk AssetDatabase.CreateAsset(localizationDatabaseAsset, destinationPath); AssetDatabase.SaveAssets(); AssetDatabase.ImportAsset(destinationPath); // Associate this localization database with the object. localizationDatabaseProperty.objectReferenceValue = localizationDatabaseAsset; serializedObject.ApplyModifiedProperties(); }
public override void OnInspectorGUI() { // If true, at least one of the Localizations in the selected // LocalizationDatabases are null references; in this case, the // Add New Localization button will be disabled (it will be // re-enabled when the empty field is filled) bool anyLocalizationsAreNull = false; if (localizationsProperty.arraySize == 0) { EditorGUILayout.HelpBox($"This {ObjectNames.NicifyVariableName(nameof(LocalizationDatabase)).ToLowerInvariant()} has no {ObjectNames.NicifyVariableName(nameof(Localization)).ToLowerInvariant()}s. Create a new one, or add an existing one.", MessageType.Info); } foreach (SerializedProperty property in localizationsProperty) { // The locale code for this localization ("en-AU") string localeCode = null; // The human-friendly code for this localization ("English // (Australia)") string localeCodeDisplayName = null; // If true, a localization asset is present in this // property bool localizationPresent = false; // If true, the locale code for this localization asset // exists inside the Cultures class's list bool cultureIsValid = false; if (property.objectReferenceValue != null) { localeCode = (property.objectReferenceValue as Localization).LocaleCode; localizationPresent = true; cultureIsValid = Cultures.HasCulture(localeCode); if (cultureIsValid) { localeCodeDisplayName = Cultures.GetCulture(localeCode).DisplayName; } else { localeCodeDisplayName = "Invalid"; } } else { // This property is empty. We'll end up drawing an // empty object field here; record this so that we know // to disable the 'Add Existing' button later. anyLocalizationsAreNull = true; } using (new EditorGUILayout.HorizontalScope()) { // If a localization is present, show the display name // and locale code; otherwise, show null (which will // make the corresponding empty field take up the whole // width) string labelContents; if (localizationPresent) { labelContents = localeCodeDisplayName + $" ({localeCode})"; } else { labelContents = ""; } // Show the property field for this element in the // array EditorGUILayout.PropertyField(property, new GUIContent(labelContents)); // Show a button that removes this element if (GUILayout.Button("-", EditorStyles.miniButton, GUILayout.ExpandWidth(false))) { // Remove the element from the slot in the array... property.DeleteCommand(); // ... and remove the empty slot from the array. property.DeleteCommand(); } } // Is a locale code set that's invalid? if (localeCode == null) { EditorGUILayout.HelpBox($"Drag and drop a {nameof(Localization)} to this field to add it to this localization database.", MessageType.Info); } else { if (cultureIsValid == false) { // A locale code was set, but this locale code // isn't valid. Show a warning. EditorGUILayout.HelpBox($"'{localeCode}' is not a valid locale. This localization's contents won't be available in the game.", MessageType.Warning); } else if (ProjectSettings.TextProjectLanguages.Contains(localeCode) == false) { // The locale is valid, but the project settings // don't include this language. Show a warning (the // user won't be able to select this localization) const string fixButtonLabel = "Add to Language List"; EditorGUILayout.HelpBox($"{localeCodeDisplayName} is not in this project's language list. This localization's contents won't be available in the game.\n\nClick {fixButtonLabel} to fix this issue.", MessageType.Warning); if (GUILayout.Button(fixButtonLabel)) { ProjectSettings.AddNewTextLanguage(localeCode); // This will resolve the error, so we'll // immediately repaint Repaint(); } // Nice little space to visually associate the // 'add' button with the field and reduce confusion EditorGUILayout.Space(); } } } // Show the buttons for adding and creating localizations only // if we're not editing multiple databases. if (serializedObject.isEditingMultipleObjects == false) { // Disable the 'add existing' button if there's already an // empty field. (Clicking this button adds a new empty field, // so we don't want to end up creating multiples.) using (new EditorGUI.DisabledScope(anyLocalizationsAreNull)) { // Show the 'add existing' button, which adds a new // empty field for the user to drop an existing // Localization asset into. if (GUILayout.Button("Add Existing Localisation")) { localizationsProperty.InsertArrayElementAtIndex(localizationsProperty.arraySize); localizationsProperty.GetArrayElementAtIndex(localizationsProperty.arraySize - 1).objectReferenceValue = null; } } // Show the 'create new' button, which displays the menu of // available languages; selecting a language causes a new // localization asset to be created with that language, and // adds it to this database. if (GUILayout.Button("Create New Localization")) { var languageMenu = CreateLanguageMenu(); languageMenu.ShowAsContext(); } } GUILayout.Space(EditorGUIUtility.singleLineHeight); // Show information about the scripts we're pulling data from, // and offer to update the database now if (trackedProgramsProperty.arraySize == 0) { EditorGUILayout.HelpBox("No Yarn scripts currently use this database.\n\nTo make a Yarn script use this database, select one, and set its Localization Database to this file.", MessageType.Info); } else { if (serializedObject.isEditingMultipleObjects == false) { EditorGUILayout.LabelField("Uses lines from:"); EditorGUI.indentLevel += 1; // List every tracked program, but disable it (we don't // change them here, they're changed in the inspector for // the Yarn script.) using (new EditorGUI.DisabledScope(true)) { foreach (SerializedProperty trackedProgramProperty in trackedProgramsProperty) { EditorGUILayout.PropertyField(trackedProgramProperty, new GUIContent()); } } EditorGUI.indentLevel -= 1; EditorGUILayout.Space(); EditorGUILayout.HelpBox("This database will automatically update when the contents of these scripts change. If you modify the .csv files for other translations, modify any locale-specific assets, or if you need to manually update the database, click Update Database.", MessageType.Info); } if (GUILayout.Button("Update Database")) { foreach (LocalizationDatabase target in serializedObject.targetObjects) { LocalizationDatabaseUtility.UpdateContents(target); } } #if ADDRESSABLES // Give a helpful note if addressables are availalbe, but // haven't been set up. (In this circumstance, // Localizations won't be able to store references to the // assets they find.) if (AddressableAssetSettingsDefaultObject.SettingsExists == false) { EditorGUILayout.HelpBox("The Addressable Assets package has been added, but it hasn't been set up yet. Assets associated with lines won't be included in this database.\n\nTo set up Addressable Assets, choose Window > Asset Management > Addressables > Groups.", MessageType.Info); } #endif } serializedObject.ApplyModifiedProperties(); }
// Detects when a YarnProgram has been imported (either created or // modified), and checks to see if its importer is configured to // associate it with a LocalizationDatabase. If it is, the // LocalizationDatabase is updated to include this program in its // TrackedPrograms collection. Finally, the LocalizationDatabase is // made to update its contents. // // We do this in a post-processor rather than in the importer // itself, because assets created in an importer don't actually // "exist" (as far as Unity is concerned) until after the import // process completes, so references to that asset aren't valid // (i.e. it hasn't been assigned a GUID yet). static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) { // Get the list of paths to assets that we've just imported // whose main asset is a YarnProgram. (If there aren't any, // this method has nothing to do.) var importedYarnAssets = importedAssets.Where(path => AssetDatabase.GetMainAssetTypeAtPath(path)?.IsAssignableFrom(typeof(YarnProgram)) ?? false); if (importedYarnAssets.Count() == 0) { return; } // Tracks all databases that are affected by the Yarn scripts // that we just imported var impactedDatabases = new HashSet <LocalizationDatabase>(); foreach (var importedPath in importedYarnAssets) { var importer = AssetImporter.GetAtPath(importedPath) as YarnImporter; // Verify that we have the right kind of importer that we // expect. if (importer == null) { Debug.Log($"{nameof(YarnProgram)} at {importedPath}'s importer is not a {nameof(YarnImporter)}. This probably indicates a problem elsewhere in your setup."); continue; } // Try and get the localization database! var database = importer.localizationDatabase; if (database == null) { // This program has no localization database to // associate with, so nothing to do here. continue; } var trackedPrograms = importer.localizationDatabase.TrackedPrograms; // The database is using data from this program if any of // the programs in its list have a path that match this // asset. (Doing it this way means we don't need to // deserialize the asset at importedPath unless we really // need to.) var databaseIsTrackingThisProgram = trackedPrograms.Select(p => AssetDatabase.GetAssetPath(p)) .Contains(importedPath); if (databaseIsTrackingThisProgram == false) { // The YarnProgram wants the database to have it in its // TrackedPrograms list, but it's not there. We need to // add it. // First, import the program, so we can add the // reference. var program = AssetDatabase.LoadAssetAtPath <YarnProgram>(importedPath); if (program == null) { // The program failed to be loaded at this path. It // probably failed to compile, so we can't add it // to the localized database. continue; } // Add this program to the list. importer.localizationDatabase.AddTrackedProgram(program); } // This database needs to be updated. impactedDatabases.Add(importer.localizationDatabase); } if (impactedDatabases.Count > 0) { foreach (var db in impactedDatabases) { // Make the database update its contents LocalizationDatabaseUtility.UpdateContents(db); } // Save any changed localization databases. (This will trigger // this method to be called again, but none of the paths that // we just worked with will have changed, so it won't trigger a // recursion.) AssetDatabase.SaveAssets(); } }