This commit is contained in:
SoulliesOfficial
2026-06-09 11:21:59 -04:00
parent 7c60c40d6b
commit 021e76efe7
493 changed files with 50500 additions and 2211 deletions

View File

@@ -0,0 +1,17 @@
#nullable enable
namespace Yarn.Unity.Editor
{
internal static class AssetStoreSamplesInstaller
{
internal const string assetStoreURL = "https://assetstore.unity.com/packages/slug/319418";
internal static void InstallSamples()
{
if (YarnPackageImporter.IsSamplesPackageInstalled)
{
return;
}
UnityEngine.Application.OpenURL(assetStoreURL);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cd9cd1021d3794a8bb88cc658221e712
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,44 @@
/*
Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.
*/
using System.Collections.Generic;
namespace Yarn.Unity.Editor
{
// A simple class lets us use a delegate as an IEqualityComparer from
// https://stackoverflow.com/a/4607559
internal static class Compare
{
public static IEqualityComparer<T> By<T>(System.Func<T, T, bool> comparison)
{
return new DelegateComparer<T>(comparison);
}
private class DelegateComparer<T> : EqualityComparer<T>
{
private readonly System.Func<T, T, bool> comparison;
public DelegateComparer(System.Func<T, T, bool> identitySelector)
{
this.comparison = identitySelector;
}
public override bool Equals(T x, T y)
{
return comparison(x, y);
}
public override int GetHashCode(T obj)
{
// Force LINQ to never refer to the hash of an object by
// returning a constant for all values. This is inefficient
// because LINQ can't use an internal comparator, but we're
// already looking to use a delegate to do a more
// fine-grained test anyway, so we want to ensure that it's
// called.
return 0;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ff913f9ce0558401e853febebd2ebc0a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,64 @@
/*
Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.
*/
#nullable enable
namespace Yarn.Unity.Editor
{
using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using Yarn.Unity;
/// <summary>
/// Adds a menu item to the menu bar for creating an instance of the
/// dialogue runner prefab.
/// </summary>
public static class CreateDialogueRunnerMenuItem
{
const string DialogueRunnerPrefabGUID = "52571f68872914e24837210513edea1d";
/// <summary>
/// Instantiates the Dialogue System prefab in the currently active scene,
/// and returns the created <see cref="DialogueRunner"/>.
/// </summary>
/// <returns>A newly created <see cref="DialogueRunner"/>.</returns>
/// <exception cref="System.InvalidOperationException">Thrown when the
/// Dialogue System prefab cannot be found in the Yarn Spinner
/// package.</exception>
[MenuItem("GameObject/Yarn Spinner/Dialogue System", priority = 11)]
public static DialogueRunner CreateDialogueRunner()
{
string assetPath = AssetDatabase.GUIDToAssetPath(DialogueRunnerPrefabGUID);
var prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
if (prefabAsset == null)
{
throw new System.InvalidOperationException(
$"Can't create a new Dialogue System: Can't find the prefab to create a Dialogue System from."
);
}
var instantiatedPrefab = (GameObject)PrefabUtility.InstantiatePrefab(prefabAsset);
#if UNITY_2023_1_OR_NEWER
var eventSystems = Object.FindObjectsByType<EventSystem>(FindObjectsSortMode.None);
#else
var eventSystems = Object.FindObjectsOfType<EventSystem>();
#endif
if (eventSystems.Length > 1)
{
// At least one other event system is present in the scene. Turn off
// the one that came with the prefab - it's not needed.
var instantiatedEventSystem = instantiatedPrefab.GetComponentInChildren<EventSystem>();
instantiatedEventSystem.gameObject.SetActive(false);
}
return instantiatedPrefab.GetComponent<DialogueRunner>();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 82e9906f6762e445d8082f495e7c1061
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,186 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
#if USE_UNITY_LOCALIZATION
using UnityEngine.Localization;
#endif
#nullable enable
namespace Yarn.Unity.Editor
{
public abstract class PackageSetupStep
{
public abstract string PerformStepButtonLabel { get; }
public abstract string Description { get; }
public abstract bool NeedsSetup { get; }
public abstract void RunSetup();
}
public class CustomPackageSetupStep : PackageSetupStep
{
public override string Description { get; }
public override string PerformStepButtonLabel { get; }
public override bool NeedsSetup => this.NeedsSetupAction();
public override void RunSetup() => this.RunSetupAction();
private System.Func<bool> NeedsSetupAction { get; }
private System.Action RunSetupAction { get; }
public CustomPackageSetupStep(string description,
string performStepButtonLabel,
System.Func<bool> needsSetup,
System.Action runSetup)
{
Description = description;
NeedsSetupAction = needsSetup;
RunSetupAction = runSetup;
PerformStepButtonLabel = performStepButtonLabel;
}
}
#if USE_UNITY_LOCALIZATION
public class UnityLocalizationSetupStep : PackageSetupStep
{
public static IEnumerable<string> SampleLocaleIdentifiers => new[] { "en", "es", "pt-BR", "de", "zh-Hans" };
public static IDictionary<string, string> SampleLocaleFallbacks => new Dictionary<string, string> { { "es", "en" } };
public override string Description =>
"Unity Localization is installed, but your project doesn't have a " +
"Localization Settings asset, and/or it lacks Locale assets that this sample needs.";
public override string PerformStepButtonLabel => "Create Localization Assets";
public override bool NeedsSetup
{
get
{
// Do we have settings?
var settings = UnityEditor.Localization.LocalizationEditorSettings.ActiveLocalizationSettings;
if (settings == null)
{
return true;
}
// Do we have the appropriate locales?
foreach (var identifier in SampleLocaleIdentifiers)
{
// we now have a valid settings, but we don't know if it
// has english locale support
var localeID = new UnityEngine.Localization.LocaleIdentifier(identifier);
if (UnityEngine.Localization.Settings.LocalizationSettings.AvailableLocales.GetLocale(localeID) == null)
{
return true;
}
}
return false;
}
}
public string DestinationPath { get; }
public UnityLocalizationSetupStep(string destinationPath = "Assets/Localization")
{
this.DestinationPath = destinationPath;
}
public override void RunSetup()
{
// First, we need to make sure the folder we're working in exists
if (Directory.Exists(this.DestinationPath) == false)
{
var pieces = this.DestinationPath.Split('/', System.StringSplitOptions.RemoveEmptyEntries);
var parent = string.Join('/', pieces.Take(pieces.Length - 1));
AssetDatabase.CreateFolder(parent, pieces.Last());
}
// Do we already have a LocalizationSettings asset? If not, create
// one and set it up.
var settings = UnityEditor.Localization.LocalizationEditorSettings.ActiveLocalizationSettings;
if (settings == null)
{
// Create localization settings
settings = ScriptableObject.CreateInstance<UnityEngine.Localization.Settings.LocalizationSettings>();
settings.name = "Test Localization Settings";
AssetDatabase.CreateAsset(settings, DestinationPath + "/Localization Settings.asset");
// setting this new settings object to be the global settings
// for the project
UnityEditor.Localization.LocalizationEditorSettings.ActiveLocalizationSettings = settings;
}
foreach (var identifier in SampleLocaleIdentifiers)
{
// we now have a valid settings, but we don't know if it has
// the locales we need
var localeID = new LocaleIdentifier(identifier);
if (UnityEngine.Localization.Settings.LocalizationSettings.AvailableLocales.GetLocale(localeID) == null)
{
// we need to make the asset and add it to the settings
// and on disk
var locale = Locale.CreateLocale(localeID);
AssetDatabase.CreateAsset(locale, DestinationPath + "/Locale " + identifier + ".asset");
UnityEditor.Localization.LocalizationEditorSettings.AddLocale(locale);
}
}
// Finally, ensure that the locales have their fallbacks
// configured correctly
foreach (var (fromLocaleID, toLocaleID) in SampleLocaleFallbacks)
{
var fromLocale = UnityEditor.Localization.LocalizationEditorSettings.GetLocale(fromLocaleID);
var toLocale = UnityEditor.Localization.LocalizationEditorSettings.GetLocale(toLocaleID);
var fallbackMetadata = fromLocale.Metadata.GetMetadata<UnityEngine.Localization.Metadata.FallbackLocale>();
if (fallbackMetadata == null)
{
fallbackMetadata = new UnityEngine.Localization.Metadata.FallbackLocale();
fromLocale.Metadata.AddMetadata(fallbackMetadata);
}
fallbackMetadata.Locale = toLocale;
}
AssetDatabase.SaveAssets();
// Find all table collections, and make sure they (and their
// contents) are known to the addressable system (which might
// have just been installed, so no assets have any addresses)
var allTableCollectionGUIDs = AssetDatabase.FindAssets("t:LocalizationTableCollection");
foreach (var guid in allTableCollectionGUIDs)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var localizationCollection = AssetDatabase.LoadAssetAtPath<UnityEditor.Localization.LocalizationTableCollection>(path);
// Make sure the table collection's assets are all
// addressable
localizationCollection.RefreshAddressables();
// If this is an asset table collection, make sure that
// every asset in all of its tables is addressable
if (localizationCollection is UnityEditor.Localization.AssetTableCollection assetTableCollection)
{
foreach (var table in assetTableCollection.AssetTables)
{
var allEntries = new Dictionary<long, UnityEngine.Localization.Tables.AssetTableEntry>(table);
foreach (var entry in allEntries)
{
var assetPath = AssetDatabase.GUIDToAssetPath(entry.Value.Guid);
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
assetTableCollection.AddAssetToTable(table, entry.Key, asset);
}
}
}
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
}
#endif
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 91c165b2af7cd4bdcbf269890f2a49c0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,15 @@
using UnityEditor;
using UnityEngine;
namespace Yarn.Unity.Editor
{
[CustomPropertyDrawer(typeof(InterfaceContainer<>), true)]
public class InterfaceContainerDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
var targetProp = property.FindPropertyRelative(nameof(InterfaceContainer<UnityEngine.Object>.targetObject));
EditorGUI.ObjectField(position, targetProp, new UnityEngine.GUIContent(property.displayName));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ea37ee3989123c4c88a0c4a218d74bc

View File

@@ -0,0 +1,17 @@
#nullable enable
namespace Yarn.Unity.Editor
{
internal static class ItchSamplesInstaller
{
internal const string itchURL = "https://yarnspinner.itch.io/yarn-spinner";
internal static void InstallSamples()
{
if (YarnPackageImporter.IsSamplesPackageInstalled)
{
return;
}
UnityEngine.Application.OpenURL(itchURL);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0c21c80267e2f4204b7d5e261e616faa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
#nullable enable
namespace Yarn.Unity.Editor
{
internal static class ManualSamplesInstaller
{
private const string manualInstallURL = "https://docs.yarnspinner.dev/next/yarn-spinner-for-game-engines/unity/installation-and-setup#installing-the-samples";
internal static void InstallSamples()
{
if (YarnPackageImporter.IsSamplesPackageInstalled)
{
return;
}
UnityEngine.Application.OpenURL(manualInstallURL);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23020a8d4675148c9ad05af13ab1202b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,606 @@
/*
Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.
*/
/*
* Copyright (c) 2013 Calvin Rien
*
* Based on the JSON parser by Patrick van Bergen
* http://techblog.procurios.nl/k/618/news/view/14605/14863/How-do-I-write-my-own-parser-for-JSON.html
*
* Simplified it so that it doesn't throw exceptions
* and can be used in Unity iPhone with maximum code stripping.
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
// Nullability disabled due to third-party code
#nullable disable
namespace Yarn.Unity.Editor
{
/// <summary>
/// This class encodes and decodes JSON strings.
/// Spec. details, see http://www.json.org/
///
/// JSON uses Arrays and Objects. These correspond here to the datatypes IList and IDictionary.
/// All numbers are parsed to doubles.
/// </summary>
public static class Json
{
/// <summary>
/// Parses the string json into a value
/// </summary>
/// <param name="json">A JSON string.</param>
/// <returns>An List&lt;object&gt;, a Dictionary&lt;string, object&gt;, a double, an integer,a string, null, true, or false</returns>
public static object Deserialize(string json)
{
// save the string for debug information
if (json == null)
{
return null;
}
return Parser.Parse(json);
}
sealed class Parser : IDisposable
{
const string WORD_BREAK = "{}[],:\"";
public static bool IsWordBreak(char c)
{
return Char.IsWhiteSpace(c) || WORD_BREAK.IndexOf(c) != -1;
}
enum TOKEN
{
NONE,
CURLY_OPEN,
CURLY_CLOSE,
SQUARED_OPEN,
SQUARED_CLOSE,
COLON,
COMMA,
STRING,
NUMBER,
TRUE,
FALSE,
NULL
};
StringReader json;
Parser(string jsonString)
{
json = new StringReader(jsonString);
}
public static object Parse(string jsonString)
{
using (var instance = new Parser(jsonString))
{
return instance.ParseValue();
}
}
public void Dispose()
{
json.Dispose();
json = null;
}
Dictionary<string, object> ParseObject()
{
Dictionary<string, object> table = new Dictionary<string, object>();
// ditch opening brace
json.Read();
// {
while (true)
{
switch (NextToken)
{
case TOKEN.NONE:
return null;
case TOKEN.COMMA:
continue;
case TOKEN.CURLY_CLOSE:
return table;
default:
// name
string name = ParseString();
if (name == null)
{
return null;
}
// :
if (NextToken != TOKEN.COLON)
{
return null;
}
// ditch the colon
json.Read();
// value
table[name] = ParseValue();
break;
}
}
}
List<object> ParseArray()
{
List<object> array = new List<object>();
// ditch opening bracket
json.Read();
// [
var parsing = true;
while (parsing)
{
TOKEN nextToken = NextToken;
switch (nextToken)
{
case TOKEN.NONE:
return null;
case TOKEN.COMMA:
continue;
case TOKEN.SQUARED_CLOSE:
parsing = false;
break;
default:
object value = ParseByToken(nextToken);
array.Add(value);
break;
}
}
return array;
}
object ParseValue()
{
TOKEN nextToken = NextToken;
return ParseByToken(nextToken);
}
object ParseByToken(TOKEN token)
{
switch (token)
{
case TOKEN.STRING:
return ParseString();
case TOKEN.NUMBER:
return ParseNumber();
case TOKEN.CURLY_OPEN:
return ParseObject();
case TOKEN.SQUARED_OPEN:
return ParseArray();
case TOKEN.TRUE:
return true;
case TOKEN.FALSE:
return false;
case TOKEN.NULL:
return null;
default:
return null;
}
}
string ParseString()
{
StringBuilder s = new StringBuilder();
char c;
// ditch opening quote
json.Read();
bool parsing = true;
while (parsing)
{
if (json.Peek() == -1)
{
parsing = false;
break;
}
c = NextChar;
switch (c)
{
case '"':
parsing = false;
break;
case '\\':
if (json.Peek() == -1)
{
parsing = false;
break;
}
c = NextChar;
switch (c)
{
case '"':
case '\\':
case '/':
s.Append(c);
break;
case 'b':
s.Append('\b');
break;
case 'f':
s.Append('\f');
break;
case 'n':
s.Append('\n');
break;
case 'r':
s.Append('\r');
break;
case 't':
s.Append('\t');
break;
case 'u':
var hex = new char[4];
for (int i = 0; i < 4; i++)
{
hex[i] = NextChar;
}
s.Append((char)Convert.ToInt32(new string(hex), 16));
break;
}
break;
default:
s.Append(c);
break;
}
}
return s.ToString();
}
object ParseNumber()
{
string number = NextWord;
if (number.IndexOf('.') == -1)
{
long parsedInt;
Int64.TryParse(number, out parsedInt);
return parsedInt;
}
double parsedDouble;
Double.TryParse(number, out parsedDouble);
return parsedDouble;
}
void EatWhitespace()
{
while (Char.IsWhiteSpace(PeekChar))
{
json.Read();
if (json.Peek() == -1)
{
break;
}
}
}
char PeekChar
{
get
{
return Convert.ToChar(json.Peek());
}
}
char NextChar
{
get
{
return Convert.ToChar(json.Read());
}
}
string NextWord
{
get
{
StringBuilder word = new StringBuilder();
while (!IsWordBreak(PeekChar))
{
word.Append(NextChar);
if (json.Peek() == -1)
{
break;
}
}
return word.ToString();
}
}
TOKEN NextToken
{
get
{
EatWhitespace();
if (json.Peek() == -1)
{
return TOKEN.NONE;
}
switch (PeekChar)
{
case '{':
return TOKEN.CURLY_OPEN;
case '}':
json.Read();
return TOKEN.CURLY_CLOSE;
case '[':
return TOKEN.SQUARED_OPEN;
case ']':
json.Read();
return TOKEN.SQUARED_CLOSE;
case ',':
json.Read();
return TOKEN.COMMA;
case '"':
return TOKEN.STRING;
case ':':
return TOKEN.COLON;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case '-':
return TOKEN.NUMBER;
}
switch (NextWord)
{
case "false":
return TOKEN.FALSE;
case "true":
return TOKEN.TRUE;
case "null":
return TOKEN.NULL;
}
return TOKEN.NONE;
}
}
}
/// <summary>
/// Converts a IDictionary / IList object or a simple type (string, int, etc.) into a JSON string
/// </summary>
/// <param name="json">A Dictionary&lt;string, object&gt; / List&lt;object&gt;</param>
/// <returns>A JSON encoded string, or null if object 'json' is not serializable</returns>
public static string Serialize(object obj)
{
return Serializer.Serialize(obj);
}
sealed class Serializer
{
StringBuilder builder;
Serializer()
{
builder = new StringBuilder();
}
public static string Serialize(object obj)
{
var instance = new Serializer();
instance.SerializeValue(obj);
return instance.builder.ToString();
}
void SerializeValue(object value)
{
IList asList;
IDictionary asDict;
string asStr;
if (value == null)
{
builder.Append("null");
}
else if ((asStr = value as string) != null)
{
SerializeString(asStr);
}
else if (value is bool)
{
builder.Append((bool)value ? "true" : "false");
}
else if ((asList = value as IList) != null)
{
SerializeArray(asList);
}
else if ((asDict = value as IDictionary) != null)
{
SerializeObject(asDict);
}
else if (value is char)
{
SerializeString(new string((char)value, 1));
}
else
{
SerializeOther(value);
}
}
void SerializeObject(IDictionary obj)
{
bool first = true;
builder.Append('{');
foreach (object e in obj.Keys)
{
if (!first)
{
builder.Append(',');
}
SerializeString(e.ToString());
builder.Append(':');
SerializeValue(obj[e]);
first = false;
}
builder.Append('}');
}
void SerializeArray(IList anArray)
{
builder.Append('[');
bool first = true;
foreach (object obj in anArray)
{
if (!first)
{
builder.Append(',');
}
SerializeValue(obj);
first = false;
}
builder.Append(']');
}
void SerializeString(string str)
{
builder.Append('\"');
char[] charArray = str.ToCharArray();
foreach (var c in charArray)
{
switch (c)
{
case '"':
builder.Append("\\\"");
break;
case '\\':
builder.Append("\\\\");
break;
case '\b':
builder.Append("\\b");
break;
case '\f':
builder.Append("\\f");
break;
case '\n':
builder.Append("\\n");
break;
case '\r':
builder.Append("\\r");
break;
case '\t':
builder.Append("\\t");
break;
default:
int codepoint = Convert.ToInt32(c);
if ((codepoint >= 32) && (codepoint <= 126))
{
builder.Append(c);
}
else
{
builder.Append("\\u");
builder.Append(codepoint.ToString("x4"));
}
break;
}
}
builder.Append('\"');
}
void SerializeOther(object value)
{
// NOTE: decimals lose precision during serialization.
// They always have, I'm just letting you know.
// Previously floats and doubles lost precision too.
if (value is float)
{
builder.Append(((float)value).ToString("R"));
}
else if (value is int
|| value is uint
|| value is long
|| value is sbyte
|| value is byte
|| value is short
|| value is ushort
|| value is ulong)
{
builder.Append(value);
}
else if (value is double
|| value is decimal)
{
builder.Append(Convert.ToDouble(value).ToString("R"));
}
else
{
SerializeString(value.ToString());
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3b4adfe2cc33c4714a7fad372d96d61e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,108 @@
using UnityEditor.PackageManager;
using UnityEditor.PackageManager.Requests;
#nullable enable
namespace Yarn.Unity.Editor
{
internal static class UPMSamplesInstaller
{
private const string samplesPackageURL = "https://github.com/YarnSpinnerTool/YarnSpinner-Unity-Samples.git#current";
private static AddRequest? installationRequest;
private static void MonitorInstallation()
{
if (installationRequest == null)
{
return;
}
if (!installationRequest.IsCompleted)
{
return;
}
if (installationRequest.Status == StatusCode.Failure)
{
// it failed, log the error but don't clean up the installation
// request as we need the error it has for determining the
// status
UnityEngine.Debug.LogError(installationRequest.Error);
}
else
{
// we succeeded, so we wipe out the request
installationRequest = null;
}
// we remove ourselves from the update loop
UnityEditor.EditorApplication.update -= MonitorInstallation;
}
internal static void InstallSamples()
{
switch (Status)
{
case YarnPackageImporter.SamplesPackageStatus.Installed:
{
// we already have it, ignoring this
break;
}
case YarnPackageImporter.SamplesPackageStatus.NotInstalled:
{
// it's not installed so we need to request it
installationRequest = Client.Add(samplesPackageURL);
UnityEditor.EditorApplication.update += MonitorInstallation;
break;
}
case YarnPackageImporter.SamplesPackageStatus.Installing:
{
// its in progress so just wait, jeez
break;
}
case YarnPackageImporter.SamplesPackageStatus.FailedToInstall:
{
// it failed but that's fine, we can just go again!
installationRequest = Client.Add(samplesPackageURL);
UnityEditor.EditorApplication.update += MonitorInstallation;
break;
}
}
}
internal static YarnPackageImporter.SamplesPackageStatus Status
{
get
{
// ok so first things first if the package is installed we can
// say that
if (YarnPackageImporter.IsSamplesPackageInstalled)
{
return YarnPackageImporter.SamplesPackageStatus.Installed;
}
// we aren't installed but we could be one of:
// - installing in progress
// - failed while attempting an install
// - not even attempted to install it
if (installationRequest != null)
{
// we might be in the process of installing
if (installationRequest.Status == StatusCode.InProgress)
{
return YarnPackageImporter.SamplesPackageStatus.Installing;
}
// at this point we must have had a failure, so we report
// that
if (installationRequest.Status == StatusCode.Failure)
{
return YarnPackageImporter.SamplesPackageStatus.FailedToInstall;
}
}
// at this point we simply aren't installed
return YarnPackageImporter.SamplesPackageStatus.NotInstalled;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fc1506e515b6046b1939407847cb96e5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,326 @@
/*
Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.
*/
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
#nullable enable
namespace Yarn.Unity.Editor
{
/// <summary>
/// Contains utility methods for working with Yarn Spinner content in
/// the Unity Editor.
/// </summary>
public static class YarnEditorUtility
{
// GUID for editor assets. (Doing it like this means that we don't
// have to worry about where the assets are on disk, if the user
// has moved Yarn Spinner around.)
const string DocumentIconTextureGUID = "0ed312066ea6f40f6af965f21c818b34";
const string ProjectIconTextureGUID = "f6a533d9225cd40ea9ded31d4f686e3b";
const string LocalizationIconTextureGUID = "2cbba4ddd142149b0a38697070990deb";
const string YarnScriptTemplateFileGUID = "4f4ca4a46020a454f80e2ac78eda5aa1";
const string DialoguePresenterTemplateFileGUID = "4a168359cda6140c0bddcd5955a326e4";
/// <summary>
/// Returns a <see cref="Texture2D"/> that can be used to represent
/// Yarn files.
/// </summary>
/// <returns>A texture to use in the Unity editor for Yarn
/// files.</returns>
public static Texture2D GetYarnDocumentIconTexture()
{
string textureAssetPath = AssetDatabase.GUIDToAssetPath(DocumentIconTextureGUID);
return AssetDatabase.LoadAssetAtPath<Texture2D>(textureAssetPath);
}
/// <summary>
/// Returns a <see cref="Texture2D"/> that can be used to represent
/// Yarn project files.
/// </summary>
/// <returns>A texture to use in the Unity editor for Yarn project
/// files.</returns>
public static Texture2D GetYarnProjectIconTexture()
{
string textureAssetPath = AssetDatabase.GUIDToAssetPath(ProjectIconTextureGUID);
return AssetDatabase.LoadAssetAtPath<Texture2D>(textureAssetPath);
}
/// <summary>
/// Returns a <see cref="Texture2D"/> that can be used to represent
/// built-in Localization objects.
/// </summary>
/// <returns>A texture to use in the Unity editor for Yarn built-in
/// Localization files.</returns>
public static Texture2D GetLocalizationIconTexture()
{
string textureAssetPath = AssetDatabase.GUIDToAssetPath(LocalizationIconTextureGUID);
return AssetDatabase.LoadAssetAtPath<Texture2D>(textureAssetPath);
}
/// <summary>
/// Returns the path to a text file that can be used as the basis
/// for newly created Yarn scripts.
/// </summary>
/// <returns>A path to a file to use in the Unity editor for
/// creating new Yarn scripts.</returns>
/// <throws cref="FileNotFoundException">Thrown if the template
/// text file cannot be found.</throws>
public static string GetTemplateYarnScriptPath()
{
var path = AssetDatabase.GUIDToAssetPath(YarnScriptTemplateFileGUID);
if (string.IsNullOrEmpty(path))
{
throw new System.IO.FileNotFoundException($"Template file for new Yarn scripts couldn't be found. Have the .meta files for Yarn Spinner been modified or deleted? Try re-importing the Yarn Spinner package to fix this error.");
}
return path;
}
/// <summary>
/// Returns the path to a text file that can be used as the basis
/// for newly created C# Dialogue Presenter scripts.
/// </summary>
/// <returns>A path to a file to use in the Unity editor for
/// creating new C# Dialogue Presenter.</returns>
/// <throws cref="FileNotFoundException">Thrown if the template
/// text file cannot be found.</throws>
public static string GetTemplateDialoguePresenterPath()
{
var path = AssetDatabase.GUIDToAssetPath(DialoguePresenterTemplateFileGUID);
if (string.IsNullOrEmpty(path))
{
throw new System.IO.FileNotFoundException($"Template file for Dialogue View scripts couldn't be found. Have the .meta files for Yarn Spinner been modified or deleted? Try re-importing the Yarn Spinner package to fix this error.");
}
return path;
}
/// <summary>
/// Begins the interactive process of creating a new Yarn file in
/// the Editor.
/// </summary>
[MenuItem("Assets/Create/Yarn Spinner/Yarn Script", false, 10)]
public static void CreateYarnAsset()
{
// This method call is undocumented, but public. It's defined
// in ProjectWindowUtil, and used by other parts of the editor
// to create other kinds of assets (scripts, textures, etc).
ProjectWindowUtil.StartNameEditingIfProjectWindowExists(
default,
ScriptableObject.CreateInstance<DoCreateYarnScriptAsset>(),
"NewYarnScript.yarn",
GetYarnDocumentIconTexture(),
GetTemplateYarnScriptPath());
}
/// <summary>
/// Creates a new Yarn Project asset in the current folder, and begins
/// interactively renaming it.
/// </summary>
[MenuItem("Assets/Create/Yarn Spinner/Yarn Project", false, 101)]
public static void CreateYarnProject()
{
// This method call is undocumented, but public. It's defined
// in ProjectWindowUtil, and used by other parts of the editor
// to create other kinds of assets (scripts, textures, etc).
ProjectWindowUtil.StartNameEditingIfProjectWindowExists(
default,
ScriptableObject.CreateInstance<DoCreateYarnProjectAsset>(),
"NewProject.yarnproject",
GetYarnProjectIconTexture(),
GetTemplateYarnScriptPath());
}
/// <summary>
/// Creates a new C# script asset containing a template Dialogue Presenter in
/// the current folder, and begins interactively renaming it.
/// </summary>
[MenuItem("Assets/Create/Yarn Spinner/Dialogue Presenter Script", false, 111)]
[MenuItem("Assets/Create/Scripting/Yarn Spinner/Dialogue Presenter Script", false, 101)]
public static void CreateDialoguePresenterScript()
{
// This method call is undocumented, but public. It's defined
// in ProjectWindowUtil, and used by other parts of the editor
// to create other kinds of assets (scripts, textures, etc).
ProjectWindowUtil.StartNameEditingIfProjectWindowExists(
default,
ScriptableObject.CreateInstance<DoCreateYarnScriptAsset>(),
"NewDialoguePresenter.cs",
null,
GetTemplateDialoguePresenterPath());
}
/// <summary>
/// Writes a Yarn Project to <paramref name="path"/>.
/// </summary>
/// <param name="path">The path at which to write the file.</param>
/// <param name="project">The Yarn Project to write to disk.</param>
public static Object CreateYarnProject(string path, Compiler.Project project)
{
var text = project.GetJson();
File.WriteAllText(path, text);
AssetDatabase.ImportAsset(path);
return AssetDatabase.LoadAssetAtPath<Object>(path);
}
/// <summary>
/// Creates a new Yarn script at the given path, using the default
/// template.
/// </summary>
/// <param name="path">The path at which to create the
/// script.</param>
public static Object CreateYarnAsset(string path)
{
return CreateScriptAssetFromTemplate(path, GetTemplateYarnScriptPath());
}
private static Object CreateScriptAssetFromTemplate(string pathName, string resourceFile)
{
// Read the contents of the template file
string templateContent;
try
{
templateContent = File.ReadAllText(resourceFile);
}
catch
{
Debug.LogError("Failed to find template file. Creating an empty file instead.");
templateContent = "";
}
// The file name is the name of the file, sans extension.
string fileName = Path.GetFileNameWithoutExtension(pathName);
// Replace any spaces with underscores - these aren't allowed
// in node names
fileName = fileName.Replace(" ", "_");
// Replace the placeholder with the file name
templateContent = templateContent.Replace("#SCRIPTNAME#", fileName);
// Respect the user's line endings preferences for this new
// text asset
string unixLineEndings = "\n";
string windowsLineEndings = "\r\n";
string lineEndings;
switch (EditorSettings.lineEndingsForNewScripts)
{
case LineEndingsMode.OSNative:
// OS native = use Windows if we're on Windows, else
// Unix
var isWindows = Application.platform == RuntimePlatform.WindowsEditor;
lineEndings = isWindows ? windowsLineEndings : unixLineEndings;
break;
case LineEndingsMode.Windows:
// Windows = use Windows endings
lineEndings = windowsLineEndings;
break;
case LineEndingsMode.Unix:
default:
// Unix or anything else = use Unix endings
lineEndings = unixLineEndings;
break;
}
// Replace every line ending in the template (this way we don't
// need to keep track of which line ending the asset was last
// saved in)
templateContent = System.Text.RegularExpressions.Regex.Replace(templateContent, @"\r\n?|\n", lineEndings);
// Write it all out to disk as UTF-8
var fullPath = Path.GetFullPath(pathName);
File.WriteAllText(fullPath, templateContent, System.Text.Encoding.UTF8);
// Force Unity to notice the new asset (this will also compile
// the new, empty Yarn script)
AssetDatabase.ImportAsset(pathName);
// We don't hugely care about the details of the object anyway
// (we just wanted to ensure that it's imported as at least an
// asset), so we'll return it as an Object here.
return AssetDatabase.LoadAssetAtPath<Object>(pathName);
}
private static void CreateYarnScriptAsset(string pathName, string resourceFile)
{
// Produce the asset.
Object o = CreateScriptAssetFromTemplate(pathName, resourceFile);
// Reveal it on disk.
ProjectWindowUtil.ShowCreatedAsset(o);
}
private static void CreateYarnProjectAsset(string pathName)
{
// Produce the asset.
var project = YarnProjectUtility.CreateDefaultYarnProject();
var json = project.GetJson();
// Write it all out to disk as UTF-8
var fullPath = Path.GetFullPath(pathName);
File.WriteAllText(fullPath, json, System.Text.Encoding.UTF8);
// Force Unity to notice the new asset.
AssetDatabase.ImportAsset(pathName);
Object o = AssetDatabase.LoadAssetAtPath<Object>(pathName);
// Reveal it on disk.
ProjectWindowUtil.ShowCreatedAsset(o);
}
// A handler that receives a callback after the user finishes
// naming a new file.
#if UNITY_6000_4_OR_NEWER
private class DoCreateYarnScriptAsset : UnityEditor.ProjectWindowCallback.AssetCreationEndAction
{
public override void Action(EntityId entityId, string pathName, string resourceFile) => CreateYarnScriptAsset(pathName, resourceFile);
}
#else
private class DoCreateYarnScriptAsset : UnityEditor.ProjectWindowCallback.EndNameEditAction
{
public override void Action(int instanceId, string pathName, string resourceFile) => CreateYarnScriptAsset(pathName, resourceFile);
}
#endif
// A handler that receives a callback after the user finishes naming a
// new file. The user just finished typing (and didn't cancel it by
// pressing escape or anything.) Commit the action by generating the
// file on disk.
#if UNITY_6000_4_OR_NEWER
private class DoCreateYarnProjectAsset : UnityEditor.ProjectWindowCallback.AssetCreationEndAction
{
public override void Action(EntityId entityId, string pathName, string resourceFile) => CreateYarnProjectAsset(pathName);
}
#else
private class DoCreateYarnProjectAsset : UnityEditor.ProjectWindowCallback.EndNameEditAction
{
public override void Action(int instanceId, string pathName, string resourceFile) => CreateYarnProjectAsset(pathName);
}
#endif
/// <summary>
/// Get all assets of a given type.
/// </summary>
/// <typeparam name="T">AssetImporter type to search for. Should be convertible from AssetImporter.</typeparam>
/// <param name="filterQuery">Asset query (see <see cref="AssetDatabase.FindAssets(string)"/> documentation for formatting).</param>
/// <param name="converter">Custom type caster.</param>
/// <returns>Enumerable of all assets of a given type.</returns>
public static IEnumerable<T> GetAllAssetsOf<T>(string filterQuery, System.Func<AssetImporter, T>? converter = null) where T : class
=> AssetDatabase.FindAssets(filterQuery)
.Select(AssetDatabase.GUIDToAssetPath)
.Select(AssetImporter.GetAtPath)
.Select(importer => converter?.Invoke(importer) ?? importer as T)
.Where(source => source != null)!;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9d9a462ef0480497085e70c75081cf1b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
namespace Yarn.Unity.Editor
{
public static partial class YarnPackageImporter
{
// what install approach do we follow?
public const InstallApproach InstallationApproach = InstallApproach.Manual;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0e75395ea6a314c868fb875f5f16fab0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,250 @@
/*
Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor.PackageManager;
using UnityEditor.PackageManager.UI;
#nullable enable
namespace Yarn.Unity.Editor
{
public class YarnPackageImporterException : Exception
{
public YarnPackageImporterException() { }
public YarnPackageImporterException(string message)
: base(message) { }
public YarnPackageImporterException(string message, Exception inner)
: base(message, inner) { }
}
public static partial class YarnPackageImporter
{
public enum SamplesPackageStatus
{
Installed, NotInstalled, Installing, FailedToInstall
}
private const string yarnSpinnerPackageName = "dev.yarnspinner.unity";
private const string samplesPackageName = "dev.yarnspinner.unity.samples";
public enum InstallApproach
{
Itch, AssetStore, Manual
}
// What is the status of the samples package?
public static SamplesPackageStatus Status
{
get
{
// if we have the samples we don't really care about HOW, so
// just return that
if (IsSamplesPackageInstalled)
{
return SamplesPackageStatus.Installed;
}
// each approach has their own approach for this, at this stage
// only UPM can really do anything. so in that case we bounce
// out to it, and for all others they are not installed later on
// this will ideally change
#pragma warning disable 162
switch (InstallationApproach)
{
case InstallApproach.Manual:
{
return UPMSamplesInstaller.Status;
}
}
return SamplesPackageStatus.NotInstalled;
#pragma warning restore
}
}
// install the samples package if not installed
[UnityEditor.MenuItem("Window/Yarn Spinner/Install Samples Package", false)]
internal static void InstallSamples()
{
#pragma warning disable 162
switch (InstallationApproach)
{
// there are two variants here
case InstallApproach.Manual:
{
if (IsYarnSpinnerPackageInstalled)
{
// if the yarn spinner package is installed as a
// package we want to also install the samples this
// way
UPMSamplesInstaller.InstallSamples();
}
else
{
// otherwise it's a fully manually vendored version
// of YS
ManualSamplesInstaller.InstallSamples();
}
break;
}
case InstallApproach.Itch:
{
ItchSamplesInstaller.InstallSamples();
break;
}
case InstallApproach.AssetStore:
{
AssetStoreSamplesInstaller.InstallSamples();
break;
}
}
#pragma warning restore
}
// open the samples up if they are installed
private static void ShowSamples()
{
if (IsSamplesPackageInstalled)
{
Window.Open(samplesPackageName);
}
}
#if UNITY_2022_3_33_OR_NEWER
static PackageInfo? GetInstalledPackageInfo(string packageName)
{
return PackageInfo.FindForPackageName(packageName);
}
#else
// prior to 2022.3.33f1 they didn't have a good way to get a specific selected package
// so instead what we do is run through every installed package and see if it has the same name
// if it does we return that
// otherwise we return null.
// In my testing this hasn't caused any issues but I am sure there are gaps I have missed
// which I think is an acceptable tradeoff considering the age of <.33
// and the failure state is that instead of opening the samples we open the docs
// which feels ok to me as a fallback.
static PackageInfo? GetInstalledPackageInfo(string packageName)
{
var allPackages = PackageInfo.GetAllRegisteredPackages();
foreach (var package in allPackages)
{
if (package.name.ToLower() == packageName.ToLower())
{
return package;
}
}
return null;
}
#endif
static IEnumerable<Sample> GetSamplesForInstalledPackage(PackageInfo package)
{
return Sample.FindByPackage(package.name, package.version);
}
private static void InstallPackageSamples(PackageInfo package)
{
IEnumerable<Sample> samples = GetSamplesForInstalledPackage(package);
InstallPackageSamples(samples);
}
private static void InstallPackageSamples(IEnumerable<Sample> samples)
{
List<Sample> failedSamples = new();
foreach (Sample sample in samples)
{
if (!sample.isImported && !sample.Import())
{
failedSamples.Add(sample);
}
}
if (failedSamples.Any())
{
throw new YarnPackageImporterException($"{failedSamples.Count} samples failed to install.");
}
}
// MARK: Yarn Spinner Package
public static PackageInfo? YarnSpinnerPackageInfo
{
get
{
return GetInstalledPackageInfo(yarnSpinnerPackageName);
}
}
public static string? YarnSpinnerPackageVersion
{
get
{
PackageInfo? yarnspinnerPackage = YarnSpinnerPackageInfo;
return yarnspinnerPackage?.version;
}
}
public static bool IsYarnSpinnerPackageInstalled
{
get
{
return YarnSpinnerPackageInfo != null;
}
}
// MARK: Samples Package
public static PackageInfo? SamplesPackageInfo
{
get
{
return GetInstalledPackageInfo(samplesPackageName);
}
}
public static string? SamplesPackageVersion
{
get
{
PackageInfo? samplesPackage = SamplesPackageInfo;
return samplesPackage?.version;
}
}
public static bool IsSamplesPackageInstalled
{
get
{
return SamplesPackageInfo != null;
}
}
[UnityEditor.MenuItem("Window/Yarn Spinner/Install Samples Package", true)]
public static bool InstallSamplesPackageValidation()
{
return !IsSamplesPackageInstalled;
}
public static IEnumerable<Sample> GetSamplesPackageSamples()
{
if (SamplesPackageInfo != null)
{
return GetSamplesForInstalledPackage(SamplesPackageInfo);
}
return new List<Sample>();
}
public static void OpenSamplesUI()
{
if (!IsSamplesPackageInstalled)
{
return;
}
ShowSamples();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ec4788e44f8ba48539f44859c5fe921d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,732 @@
/*
Yarn Spinner is licensed to you under the terms found in the file LICENSE.md.
*/
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEngine;
using Yarn.Unity;
#if USE_ADDRESSABLES
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
#endif
#if !UNITY_6000_4_OR_NEWER
// EntityId was introduced in Unity 6.4 to replace integer-based instance IDs.
// We'll use a type alias so that versions earlier than that can keep working
// with the older APIs.
using EntityId = System.Int32;
#endif
#nullable enable
namespace Yarn.Unity.Editor
{
/// <summary>
/// Contains methods for performing high-level operations on Yarn projects,
/// and their associated localization files.
/// </summary>
internal static class YarnProjectUtility
{
/// <summary>
/// Creates a new .yarnproject asset in the same directory as the Yarn
/// script represented by <paramref name="initialSourceAsset"/>, and
/// configures the script's importer to use the new Yarn Project.
/// </summary>
/// <param name="initialSourceAsset">An importer for an existing Yarn
/// script.</param>
/// <returns>The path to the created asset, relative to the Unity
/// project root.</returns>
internal static string CreateYarnProject(YarnImporter initialSourceAsset)
{
// Figure out where on disk this asset is
var path = initialSourceAsset.assetPath;
var directory = Path.GetDirectoryName(path);
// Figure out a new, unique path for the localization we're creating
var databaseFileName = $"Project.yarnproject";
var destinationPath = Path.Combine(directory, databaseFileName);
destinationPath = AssetDatabase.GenerateUniqueAssetPath(destinationPath);
// Create the program
var newProject = YarnProjectUtility.CreateDefaultYarnProject();
newProject.SaveToFile(destinationPath);
AssetDatabase.ImportAsset(destinationPath);
AssetDatabase.SaveAssets();
return destinationPath;
}
/// <summary>
/// Creates a Unity tweaked default Yarn Project.
/// </summary>
/// <remarks>
/// This is just a default Yarn Project with the exclusion file pattern
/// set up to ignore ~ folders.
/// </remarks>
/// <returns>A Unity default Yarn Project</returns>
internal static Yarn.Compiler.Project CreateDefaultYarnProject()
{
// Create the program
var newProject = new Yarn.Compiler.Project();
// Follow Unity's behaviour - exclude any content in a folder whose
// name ends with a tilde
// and also ignoring anything that is inside a sample folder
newProject.ExcludeFilePatterns = new[] { "**/*~/*", "./Samples/Yarn Spinner*/*" };
return newProject;
}
/// <summary>
/// Updates every localization .CSV file associated with this
/// .yarnproject file.
/// </summary>
/// <remarks>
/// This method updates each localization file by performing the
/// following operations:
/// <list type="bullet">
/// <item>Inserts new entries if they're present in the base
/// localization and not in the translated localization</item>
///
/// <item>Removes entries if they're present in the translated
/// localization and not in the base localization</item>
///
/// <item>Detects if a line in the base localization has changed its
/// Lock value from when the translated localization was created, and
/// update its Comment</item></list>
/// </remarks>
/// <param name="yarnProjectImporter">An importer for an existing Yarn
/// script.</param>
/// <returns>The path to the created asset, relative to the Unity
/// project root.</returns>
internal static void UpdateLocalizationCSVs(YarnProjectImporter yarnProjectImporter)
{
if (yarnProjectImporter.CanGenerateStringsTable == false)
{
Debug.LogError($"Can't update localization CSVs for Yarn Project \"{yarnProjectImporter.name}\" because not every line has a tag.");
return;
}
var importData = yarnProjectImporter.ImportData;
if (importData == null)
{
Debug.LogError($"Can't update localization CSVs for Yarn Project \"{yarnProjectImporter.name}\" because it failed to compile.");
return;
}
var job = yarnProjectImporter.GetCompilationJob();
job.CompilationType = Compiler.CompilationJob.Type.StringsOnly;
var result = Compiler.Compiler.Compile(job);
var baseLocalizationStrings = yarnProjectImporter.GenerateStringsTable(result);
if (baseLocalizationStrings == null)
{
Debug.LogError($"Can't update localization CSVs for Yarn Project \"{yarnProjectImporter.name}\" because it failed to compile.");
return;
}
var localizations = importData.localizations;
var modifiedFiles = new List<TextAsset>();
try
{
AssetDatabase.StartAssetEditing();
foreach (var loc in localizations)
{
if (loc.languageID == importData.baseLanguageName)
{
// This is the base language - no strings file to
// update.
continue;
}
if (loc.stringsFile == null)
{
Debug.LogWarning($"Can't update localization for {loc.languageID} because it doesn't have a strings file.", yarnProjectImporter);
continue;
}
var fileWasChanged = UpdateLocalizationFile(baseLocalizationStrings, loc.languageID, loc.stringsFile);
if (fileWasChanged)
{
modifiedFiles.Add(loc.stringsFile);
}
}
if (modifiedFiles.Count > 0)
{
Debug.Log($"Updated the following files: {string.Join(", ", modifiedFiles.Select(f => AssetDatabase.GetAssetPath(f)))}");
}
else
{
Debug.Log($"No files needed updating.");
}
}
finally
{
AssetDatabase.StopAssetEditing();
}
}
internal static void UpdateAssetAddresses(YarnProjectImporter importer)
{
#if USE_ADDRESSABLES
var job = importer.GetCompilationJob();
job.CompilationType = Compiler.CompilationJob.Type.StringsOnly;
var result = Compiler.Compiler.Compile(job);
var lineIDs = importer.GenerateStringsTable(result).Select(s => s.ID);
if (importer.ImportData == null)
{
throw new System.InvalidOperationException($"Can't update asset addresses: importer has no {nameof(importer.ImportData)}");
}
// Get a map of language IDs to (lineID, asset path) pairs
var languageToAssets = importer
// Get the languages-to-source-assets map
.ImportData.localizations
// Get the asset folder for them
.Select(l => new { l.languageID, l.assetsFolder })
// Only consider those that have an asset folder
.Where(f => f.assetsFolder != null)
// Get the path for the asset folder
.Select(f => new { f.languageID, path = AssetDatabase.GetAssetPath(f.assetsFolder) })
// Use that to get the assets inside these folders
.Select(f => new { f.languageID, assetPaths = FindAssetPathsForLineIDs(lineIDs, f.path, typeof(UnityEngine.Object)) });
var addressableAssetSettings = AddressableAssetSettingsDefaultObject.Settings;
foreach (var languageToAsset in languageToAssets)
{
var assets = languageToAsset.assetPaths
.Select(pair => new { LineID = pair.Key, GUID = AssetDatabase.AssetPathToGUID(pair.Value) });
foreach (var asset in assets)
{
// Find the existing entry for this asset, if it has one.
AddressableAssetEntry entry = addressableAssetSettings.FindAssetEntry(asset.GUID);
if (entry == null)
{
// This asset didn't have an entry. Create one in the
// default group.
entry = addressableAssetSettings.CreateOrMoveEntry(asset.GUID, addressableAssetSettings.DefaultGroup);
}
// Update the entry's address.
entry.SetAddress(Localization.GetAddressForLine(asset.LineID, languageToAsset.languageID));
}
}
#else
throw new System.NotSupportedException($"A method that requires the Addressable Assets package was called, but USE_ADDRESSABLES was not defined. Please either install Addressable Assets, or if you have already, add it to this project's compiler definitions.");
#endif
}
internal static Dictionary<string, string> FindAssetPathsForLineIDs(IEnumerable<string> lineIDs, string assetsFolderPath, System.Type assetType)
{
// Find _all_ files in this director that are not .meta files and
// whose main asset is equal to (or derived from) assetType
var allFiles = Directory.EnumerateFiles(assetsFolderPath, "*", SearchOption.AllDirectories)
.Where(path => path.EndsWith(".meta") == false)
.Where(path => assetType.IsAssignableFrom(AssetDatabase.GetMainAssetTypeAtPath(path)));
// Match files with those whose filenames contain a line ID
// If a direct file match is found prefer that
Dictionary<string, string> assets = new();
foreach (var lineID in lineIDs)
{
var lineIDWithoutPrefix = lineID.Replace("line:", "").ToLowerInvariant();
var candidates = new List<string>();
foreach (var asset in allFiles)
{
var file = Path.GetFileNameWithoutExtension(asset).ToLowerInvariant();
if (file == lineIDWithoutPrefix)
{
assets[lineID] = asset;
break;
}
if (file.Contains(lineIDWithoutPrefix))
{
candidates.Add(asset);
}
}
var count = candidates.Count();
if (count > 0)
{
assets[lineID] = candidates.FirstOrDefault();
if (count > 1)
{
Debug.LogWarning($"Discovered {count} candidates for {lineID}. Selecting one.\nCandidates:\n" + string.Join("\n", candidates));
}
}
}
return assets;
}
/// <summary>
/// Verifies the TextAsset referred to by <paramref
/// name="destinationLocalizationAsset"/>, and updates it if necessary.
/// </summary>
/// <param name="baseLocalizationStrings">A collection of <see
/// cref="StringTableEntry"/></param>
/// <param name="language">The language that <paramref
/// name="destinationLocalizationAsset"/> provides strings
/// for.false</param>
/// <param name="destinationLocalizationAsset">A TextAsset containing
/// localized strings in CSV format.</param>
/// <returns>Whether <paramref name="destinationLocalizationAsset"/> was
/// modified.</returns>
private static bool UpdateLocalizationFile(IEnumerable<StringTableEntry> baseLocalizationStrings, string language, TextAsset destinationLocalizationAsset)
{
var translatedStrings = StringTableEntry.ParseFromCSV(destinationLocalizationAsset.text);
// Convert both enumerables to dictionaries, for easier lookup
var baseDictionary = baseLocalizationStrings.ToDictionary(entry => entry.ID);
var translatedDictionary = translatedStrings.ToDictionary(entry => entry.ID);
// The list of line IDs present in each localisation
var baseIDs = baseLocalizationStrings.Select(entry => entry.ID);
var translatedIDs = translatedStrings.Select(entry => entry.ID);
// The list of line IDs that are ONLY present in each localisation
var onlyInBaseIDs = baseIDs.Except(translatedIDs);
var onlyInTranslatedIDs = translatedIDs.Except(baseIDs);
// Tracks if the translated localisation needed modifications
// (either new lines added, old lines removed, or changed lines
// flagged)
var modificationsNeeded = false;
// Remove every entry whose ID is only present in the translated
// set. This entry has been removed from the base localization.
foreach (var id in onlyInTranslatedIDs.ToList())
{
translatedDictionary.Remove(id);
modificationsNeeded = true;
}
// Conversely, for every entry that is only present in the base
// localisation, we need to create a new entry for it.
foreach (var id in onlyInBaseIDs)
{
StringTableEntry baseEntry = baseDictionary[id];
var newEntry = new StringTableEntry(baseEntry)
{
// Empty this text, so that it's apparent that a translated
// version needs to be provided.
Text = string.Empty,
Language = language,
};
translatedDictionary.Add(id, newEntry);
modificationsNeeded = true;
}
// Finally, we need to check for any entries in the translated
// localisation that:
// 1. have the same line ID as one in the base, but
// 2. have a different Lock (the hash of the text), which indicates
// that the base text has changed.
// First, get the list of IDs that are in both base and translated,
// and then filter this list to any where the lock values differ
var outOfDateLockIDs = baseDictionary.Keys
.Intersect(translatedDictionary.Keys)
.Where(id => baseDictionary[id].Lock != translatedDictionary[id].Lock);
// Now loop over all of these, and update our translated dictionary
// to include a note that it needs attention
foreach (var id in outOfDateLockIDs)
{
// Get the translated entry as it currently exists
var entry = translatedDictionary[id];
// Include a note that this entry is out of date
entry.Text = $"(NEEDS UPDATE) {entry.Text}";
// Update the lock to match the new one
entry.Lock = baseDictionary[id].Lock;
// Put this modified entry back in the table
translatedDictionary[id] = entry;
modificationsNeeded = true;
}
// We're all done!
if (modificationsNeeded == false)
{
// No changes needed to be done to the translated string table
// entries. Stop here.
return false;
}
// We need to produce a replacement CSV file for the translated
// entries.
var outputStringEntries = translatedDictionary.Values
.OrderBy(entry => entry.File)
.ThenBy(entry => int.Parse(entry.LineNumber));
var outputCSV = StringTableEntry.CreateCSV(outputStringEntries);
// Write out the replacement text to this existing file, replacing
// its existing contents
var outputFile = AssetDatabase.GetAssetPath(destinationLocalizationAsset);
File.WriteAllText(outputFile, outputCSV, System.Text.Encoding.UTF8);
// Tell the asset database that the file needs to be reimported
AssetDatabase.ImportAsset(outputFile);
// Signal that the file was changed
return true;
}
private static (List<string> AllExistingTags, List<string> ProjectImplicitTags) ExtantLineTags(YarnProjectImporter importer)
{
// First, gather all existing line tags across ALL yarn projects, so
// that we don't accidentally overwrite an existing one. Do this by
// finding all yarn projects, and get the string tags inside them.
// By doing it in this way we get the same implicit tags from the
// project as the importer would normally do, letting us then do a
// direct comparision for them.
var allYarnProjects =
// get all yarn projects across the entire project
AssetDatabase.FindAssets($"t:{nameof(YarnProject)}")
// Get the path for each asset's GUID
.Select(guid => AssetDatabase.GUIDToAssetPath(guid))
// Get the importer for each asset at this path
.Select(path => AssetImporter.GetAtPath(path))
// Ensure it's a YarnProjectImporter
.OfType<YarnProjectImporter>()
// Ensure that its import data is present
.Where(i => i.ImportData != null)
// get the project out, and also flag if it is the project for
// THIS importer
.Select(i => (Project: i.GetProject()!, IsThisImporter: i == importer))
// remove any nulls just in case any are found
.Where(p => p.Project != null);
#if YARNSPINNER_DEBUG
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
#endif
var allExistingTags = new List<string>();
var projectImplicitTags = new List<string>();
// Compile all of these, and get whatever existing string tags they
// had. Do each in isolation so that we can continue even if a
// project contains a parse error.
foreach (var (Project, IsThisImporter) in allYarnProjects)
{
var project = Project;
var compilationJob = Yarn.Compiler.CompilationJob.CreateFromFiles(project.SourceFiles);
compilationJob.CompilationType = Yarn.Compiler.CompilationJob.Type.StringsOnly;
var result = Yarn.Compiler.Compiler.Compile(compilationJob);
bool containsErrors = result.Diagnostics.Any(d => d.Severity == Compiler.Diagnostic.DiagnosticSeverity.Error);
if (containsErrors)
{
Debug.LogWarning($"{project} has errors so cannot be scanned for tagging.");
continue;
}
allExistingTags.AddRange(result.StringTable.Where(i => i.Value.isImplicitTag == false).Select(i => i.Key));
// we add the implicit lines IDs only for this project
if (IsThisImporter)
{
projectImplicitTags.AddRange(result.StringTable.Where(i => i.Value.isImplicitTag == true).Select(i => i.Key));
}
}
#if YARNSPINNER_DEBUG
stopwatch.Stop();
Debug.Log($"Checked {allYarnProjects.Count()} yarn files for line tags in {stopwatch.ElapsedMilliseconds}ms");
#endif
return (allExistingTags, projectImplicitTags);
}
public static void AddLineTagsToFilesInYarnProject(YarnProjectImporter importer)
{
var (AllExistingTags, ProjectImplicitTags) = YarnProjectUtility.ExtantLineTags(importer);
#if USE_UNITY_LOCALIZATION
// if we are using Unity localisation we need to first remove the
// implicit tags for this project from the strings table
if (importer.UseUnityLocalisationSystem && importer.UnityLocalisationStringTableCollection != null)
{
foreach (var implicitTag in ProjectImplicitTags)
{
importer.UnityLocalisationStringTableCollection.RemoveEntry(implicitTag);
}
}
#endif
if (importer.ImportData == null)
{
Debug.LogError($"Can't add line tags to {importer.assetPath}, because it failed to compile.");
return;
}
var modifiedFiles = new List<string>();
try
{
AssetDatabase.StartAssetEditing();
foreach (var script in importer.ImportData.yarnFiles)
{
var assetPath = AssetDatabase.GetAssetPath(script);
var contents = File.ReadAllText(assetPath);
// Produce a version of this file that contains line tags
// added where they're needed.
var tagged = Yarn.Compiler.Utility.TagLines(contents, AllExistingTags ?? new List<string>());
var taggedVersion = tagged.Item1;
// if the file has an error it returns null we want to bail
// out then otherwise we'd wipe the yarn file
if (taggedVersion == null)
{
continue;
}
// If this produced a modified version of the file, write it
// out and re-import it.
if (contents != taggedVersion)
{
modifiedFiles.Add(Path.GetFileNameWithoutExtension(assetPath));
File.WriteAllText(assetPath, taggedVersion, System.Text.Encoding.UTF8);
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.Default);
AllExistingTags = tagged.Item2 as List<string>;
}
}
}
catch (System.Exception e)
{
Debug.LogError($"Encountered an error when updating scripts: {e}");
return;
}
finally
{
AssetDatabase.StopAssetEditing();
}
// Report on the work we did.
if (modifiedFiles.Count > 0)
{
Debug.Log($"Updated the following files: {string.Join(", ", modifiedFiles)}");
}
else
{
Debug.Log("No files needed updating.");
}
}
/// <summary>
/// Writes a .csv file to disk at the path indicated by <paramref
/// name="destination"/>, containing all of the lines found in the
/// scripts referred to by <paramref name="yarnProjectImporter"/>.
/// </summary>
/// <param name="yarnProjectImporter">The YarnProjectImporter to extract
/// strings from.</param>
/// <param name="destination">The path to write the file to.</param>
/// <returns><see langword="true"/> if the file was written
/// successfully, <see langword="false"/> otherwise.</returns>
/// <exception cref="CsvHelper.CsvHelperException">Thrown when an error
/// is encountered when generating the CSV data.</exception>
/// <exception cref="IOException">Thrown when an error is encountered
/// when writing the data to disk.</exception>
internal static bool WriteStringsFile(string destination, YarnProjectImporter yarnProjectImporter)
{
// Perform a strings-only compilation to get a full strings table,
// and generate the CSV.
var job = yarnProjectImporter.GetCompilationJob();
job.CompilationType = Compiler.CompilationJob.Type.StringsOnly;
var result = Compiler.Compiler.Compile(job);
if (result.ContainsErrors)
{
// The project contains errors. Bail out.
return false;
}
var stringTable = yarnProjectImporter.GenerateStringsTable(result);
// If there was an error, bail out here
if (stringTable == null)
{
return false;
}
// Convert the string tables to CSV...
var outputCSV = StringTableEntry.CreateCSV(stringTable);
// ...and write it to disk.
File.WriteAllText(destination, outputCSV);
return true;
}
/// <summary>
/// Writes a .csv file to disk at the path indicated by <paramref
/// name="destination"/>, containing all of the lines found in the
/// scripts referred to by <paramref name="yarnProjectImporter"/> that
/// contain any metadata associated with them.
/// </summary>
/// <param name="yarnProjectImporter">The YarnProjectImporter to extract
/// strings from.</param>
/// <param name="destination">The path to write the file to.</param>
/// <returns><see langword="true"/> if the file was written
/// successfully, <see langword="false"/> otherwise.</returns>
/// <exception cref="CsvHelper.CsvHelperException">Thrown when an error
/// is encountered when generating the CSV data.</exception>
/// <exception cref="IOException">Thrown when an error is encountered
/// when writing the data to disk.</exception>
internal static bool WriteMetadataFile(string destination, YarnProjectImporter yarnProjectImporter)
{
var lineMetadataEntries = yarnProjectImporter.GenerateLineMetadataEntries();
// If there was an error, bail out here.
if (lineMetadataEntries == null)
{
return false;
}
var outputCSV = LineMetadataTableEntry.CreateCSV(lineMetadataEntries);
File.WriteAllText(destination, outputCSV);
return true;
}
/// <summary>
/// Upgrades an old-style Yarn Project to JSON.
/// </summary>
/// <remarks>
/// This method copies the text of the project to a new file adjacent to
/// the project, and replaces the text of the project with a new empty
/// JSON project.
/// </remarks>
/// <param name="importer">A YarnProjectImporter that represents the
/// project that needs to be upgraded.</param>
internal static void UpgradeYarnProject(YarnProjectImporter importer)
{
// We need to copy out the variable declarations from the old Yarn
// project before we replace it.
// Get the current text of the old project
var existingText = File.ReadAllText(importer.assetPath);
// Does the existing text contain anything besides the default?
var defaultProjectPattern = new System.Text.RegularExpressions.Regex(@"^title:.*?\n---[\n\s]*===[\n\s]*$", System.Text.RegularExpressions.RegexOptions.Multiline);
if (defaultProjectPattern.IsMatch(existingText))
{
// The project contains no content, so there's no need to copy
// it out.
}
else
{
// Create a unique path to store our variables
var newFilePath = Path.GetDirectoryName(importer.assetPath) + "/Variables.yarn";
newFilePath = AssetDatabase.GenerateUniqueAssetPath(newFilePath);
// Write it out to the new file
File.WriteAllText(newFilePath, existingText);
}
// Next, replace the existing project with a new one!
var newProject = YarnProjectUtility.CreateDefaultYarnProject();
File.WriteAllText(importer.assetPath, newProject.GetJson());
// Finally, import the assets we've touched.
AssetDatabase.Refresh();
}
[OnOpenAsset(OnOpenAssetAttributeMode.Execute)]
public static bool OnOpenAsset(EntityId instanceID)
{
// temporarily disabling the obsolete warning for the GetAssetPath call
// but only for Unity <6.4, otherwise we want the warning
// because this code needs to exist across multiple unity versions
// and it's only an actual concern on 6.4+ we can disable it when earlier
#if !UNITY_6000_4_OR_NEWER
#pragma warning disable 0618
#endif
var path = AssetDatabase.GetAssetPath(instanceID);
#if !UNITY_6000_4_OR_NEWER
#pragma warning restore 0618
#endif
var project = AssetDatabase.LoadAssetAtPath<YarnProject>(path);
if (project == null)
{
return false;
}
var importer = AssetImporter.GetAtPath(path) as Yarn.Unity.Editor.YarnProjectImporter;
if (importer == null)
{
return false;
}
var yp = Yarn.Compiler.Project.LoadFromFile(path);
var files = yp.SourceFiles;
if (files.Any())
{
UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(System.IO.Path.GetDirectoryName(files.First()), 0);
}
return true;
}
[OnOpenAsset(OnOpenAssetAttributeMode.Validate)]
public static bool OnValidateAsset(EntityId instanceID)
{
// temporarily disabling the obsolete warning for the GetAssetPath call
// but only for Unity <6.4, otherwise we want the warning
// because this code needs to exist across multiple unity versions
// and it's only an actual concern on 6.4+ we can disable it when earlier
#if !UNITY_6000_4_OR_NEWER
#pragma warning disable 0618
#endif
var path = AssetDatabase.GetAssetPath(instanceID);
#if !UNITY_6000_4_OR_NEWER
#pragma warning restore 0618
#endif
var project = AssetDatabase.LoadAssetAtPath<YarnProject>(path);
if (project == null)
{
return false;
}
return true;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9c10d39382c214ee88bd4fd359feb40a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: