An easy to use Unity 3D library for creating illustrated Interactive Fiction games and more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

582 lines
20 KiB

// This code is part of the Fungus library (http://fungusgames.com) maintained by Chris Gregan (http://twitter.com/gofungus).
// It is released for free under the MIT open source license (https://github.com/snozbot/fungus/blob/master/LICENSE)
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Text;
using Ideafixxxer.CsvParser;
namespace Fungus
{
/// <summary>
/// Multi-language localization support.
/// </summary>
public class Localization : MonoBehaviour, ISubstitutionHandler
{
/// <summary>
/// Temp storage for a single item of standard text and its localizations.
/// </summary>
protected class TextItem
{
public string description = "";
public string standardText = "";
public Dictionary<string, string> localizedStrings = new Dictionary<string, string>();
}
[Tooltip("Language to use at startup, usually defined by a two letter language code (e.g DE = German)")]
[SerializeField] protected string activeLanguage = "";
[Tooltip("CSV file containing localization data which can be easily edited in a spreadsheet tool")]
[SerializeField] protected TextAsset localizationFile;
protected Dictionary<string, ILocalizable> localizeableObjects = new Dictionary<string, ILocalizable>();
protected string notificationText = "";
protected bool initialized;
protected static Dictionary<string, string> localizedStrings = new Dictionary<string, string>();
#if UNITY_5_4_OR_NEWER
protected virtual void Awake()
{
UnityEngine.SceneManagement.SceneManager.activeSceneChanged += (A, B) => {
LevelWasLoaded();
};
}
#else
public virtual void OnLevelWasLoaded(int level)
{
LevelWasLoaded();
}
#endif
protected virtual void LevelWasLoaded()
{
// Check if a language has been selected using the Set Language command in a previous scene.
if (SetLanguage.mostRecentLanguage != "")
{
// This language will be used when Start() is called
activeLanguage = SetLanguage.mostRecentLanguage;
}
}
protected virtual void OnEnable()
{
StringSubstituter.RegisterHandler(this);
}
protected virtual void OnDisable()
{
StringSubstituter.UnregisterHandler(this);
}
protected virtual void Start()
{
Init();
}
/// <summary>
/// String subsitution can happen during the Start of another component, so we
/// may need to call Init() from other methods.
/// </summary>
protected virtual void Init()
{
if (initialized)
{
return;
}
CacheLocalizeableObjects();
if (localizationFile != null &&
localizationFile.text.Length > 0)
{
SetActiveLanguage(activeLanguage);
}
initialized = true;
}
// Build a cache of all the localizeable objects in the scene
protected virtual void CacheLocalizeableObjects()
{
UnityEngine.Object[] objects = Resources.FindObjectsOfTypeAll(typeof(Component));
for (int i = 0; i < objects.Length; i++)
{
var o = objects[i];
ILocalizable localizable = o as ILocalizable;
if (localizable != null)
{
localizeableObjects[localizable.GetStringId()] = localizable;
}
}
}
/// <summary>
/// Builds a dictionary of localizable text items in the scene.
/// </summary>
protected Dictionary<string, TextItem> FindTextItems()
{
Dictionary<string, TextItem> textItems = new Dictionary<string, TextItem>();
// Add localizable commands in same order as command list to make it
// easier to localise / edit standard text.
var flowcharts = GameObject.FindObjectsOfType<Flowchart>();
for (int i = 0; i < flowcharts.Length; i++)
{
var flowchart = flowcharts[i];
var blocks = flowchart.GetComponents<Block>();
for (int j = 0; j < blocks.Length; j++)
{
var block = blocks[j];
var commandList = block.CommandList;
for (int k = 0; k < commandList.Count; k++)
{
var command = commandList[k];
ILocalizable localizable = command as ILocalizable;
if (localizable != null)
{
TextItem textItem = new TextItem();
textItem.standardText = localizable.GetStandardText();
textItem.description = localizable.GetDescription();
textItems[localizable.GetStringId()] = textItem;
}
}
}
}
// Add everything else that's localizable (including inactive objects)
UnityEngine.Object[] objects = Resources.FindObjectsOfTypeAll(typeof(Component));
for (int i = 0; i < objects.Length; i++)
{
var o = objects[i];
ILocalizable localizable = o as ILocalizable;
if (localizable != null)
{
string stringId = localizable.GetStringId();
if (textItems.ContainsKey(stringId))
{
// Already added
continue;
}
TextItem textItem = new TextItem();
textItem.standardText = localizable.GetStandardText();
textItem.description = localizable.GetDescription();
textItems[stringId] = textItem;
}
}
return textItems;
}
/// <summary>
/// Adds localized strings from CSV file data to a dictionary of text items in the scene.
/// </summary>
protected virtual void AddCSVDataItems(Dictionary<string, TextItem> textItems, string csvData)
{
CsvParser csvParser = new CsvParser();
string[][] csvTable = csvParser.Parse(csvData);
if (csvTable.Length <= 1)
{
// No data rows in file
return;
}
// Parse header row
string[] columnNames = csvTable[0];
for (int i = 1; i < csvTable.Length; ++i)
{
string[] fields = csvTable[i];
if (fields.Length < 3)
{
// No standard text or localized string fields present
continue;
}
string stringId = fields[0];
if (!textItems.ContainsKey(stringId))
{
if (stringId.StartsWith("CHARACTER.") ||
stringId.StartsWith("SAY.") ||
stringId.StartsWith("MENU.") ||
stringId.StartsWith("WRITE.") ||
stringId.StartsWith("SETTEXT."))
{
// If it's a 'built-in' type this probably means that item has been deleted from its flowchart,
// so there's no need to add a text item for it.
continue;
}
// Key not found. Assume it's a custom string that we want to retain, so add a text item for it.
TextItem newTextItem = new TextItem();
newTextItem.description = CSVSupport.Unescape(fields[1]);
newTextItem.standardText = CSVSupport.Unescape(fields[2]);
textItems[stringId] = newTextItem;
}
TextItem textItem = textItems[stringId];
for (int j = 3; j < fields.Length; ++j)
{
if (j >= columnNames.Length)
{
continue;
}
string languageCode = columnNames[j];
string languageEntry = CSVSupport.Unescape(fields[j]);
if (languageEntry.Length > 0)
{
textItem.localizedStrings[languageCode] = languageEntry;
}
}
}
}
#region Public members
/// <summary>
/// Looks up the specified string in the localized strings table.
/// For this to work, a localization file and active language must have been set previously.
/// Return null if the string is not found.
/// </summary>
public static string GetLocalizedString(string stringId)
{
if (localizedStrings == null)
{
return null;
}
if (localizedStrings.ContainsKey(stringId))
{
return localizedStrings[stringId];
}
return null;
}
/// <summary>
/// Language to use at startup, usually defined by a two letter language code (e.g DE = German).
/// </summary>
public virtual string ActiveLanguage { get { return activeLanguage; } }
/// <summary>
/// CSV file containing localization data which can be easily edited in a spreadsheet tool.
/// </summary>
public virtual TextAsset LocalizationFile { get { return localizationFile; } set { localizationFile = value; } }
/// <summary>
/// Stores any notification message from export / import methods.
/// </summary>
public virtual string NotificationText { get { return notificationText; } set { notificationText = value; } }
/// <summary>
/// Clears the cache of localizeable objects.
/// </summary>
public virtual void ClearLocalizeableCache()
{
localizeableObjects.Clear();
}
/// <summary>
/// Convert all text items and localized strings to an easy to edit CSV format.
/// </summary>
public virtual string GetCSVData()
{
// Collect all the text items present in the scene
Dictionary<string, TextItem> textItems = FindTextItems();
// Update text items with localization data from CSV file
if (localizationFile != null &&
localizationFile.text.Length > 0)
{
AddCSVDataItems(textItems, localizationFile.text);
}
// Build CSV header row and a list of the language codes currently in use
string csvHeader = "Key,Description,Standard";
var languageCodes = new List<string>();
var values = textItems.Values;
foreach (var textItem in values)
{
foreach (string languageCode in textItem.localizedStrings.Keys)
{
if (!languageCodes.Contains(languageCode))
{
languageCodes.Add(languageCode);
csvHeader += "," + languageCode;
}
}
}
// Build the CSV file using collected text items
int rowCount = 0;
string csvData = csvHeader + "\n";
var keys = textItems.Keys;
foreach (var stringId in keys)
{
TextItem textItem = textItems[stringId];
string row = CSVSupport.Escape(stringId);
row += "," + CSVSupport.Escape(textItem.description);
row += "," + CSVSupport.Escape(textItem.standardText);
for (int i = 0; i < languageCodes.Count; i++)
{
var languageCode = languageCodes[i];
if (textItem.localizedStrings.ContainsKey(languageCode))
{
row += "," + CSVSupport.Escape(textItem.localizedStrings[languageCode]);
}
else
{
row += ",";
// Empty field
}
}
csvData += row + "\n";
rowCount++;
}
notificationText = "Exported " + rowCount + " localization text items.";
return csvData;
}
/// <summary>
/// Scan a localization CSV file and copies the strings for the specified language code
/// into the text properties of the appropriate scene objects.
/// </summary>
public virtual void SetActiveLanguage(string languageCode, bool forceUpdateSceneText = false)
{
if (!Application.isPlaying)
{
// This function should only ever be called when the game is playing (not in editor).
return;
}
if (localizationFile == null)
{
// No localization file set
return;
}
localizedStrings.Clear();
CsvParser csvParser = new CsvParser();
string[][] csvTable = csvParser.Parse(localizationFile.text);
if (csvTable.Length <= 1)
{
// No data rows in file
return;
}
// Parse header row
string[] columnNames = csvTable[0];
if (columnNames.Length < 3)
{
// No languages defined in CSV file
return;
}
// First assume standard text column and then look for a matching language column
int languageIndex = 2;
for (int i = 3; i < columnNames.Length; ++i)
{
if (columnNames[i] == languageCode)
{
languageIndex = i;
break;
}
}
if (languageIndex == 2)
{
// Using standard text column
// Add all strings to the localized strings dict, but don't replace standard text in the scene.
// This allows string substitution to work for both standard and localized text strings.
for (int i = 1; i < csvTable.Length; ++i)
{
string[] fields = csvTable[i];
if (fields.Length < 3)
{
continue;
}
localizedStrings[fields[0]] = fields[languageIndex];
}
// Early out unless we've been told to force the scene text to update.
// This happens when the Set Language command is used to reset back to the standard language.
if (!forceUpdateSceneText)
{
return;
}
}
// Using a localized language text column
// 1. Add all localized text to the localized strings dict
// 2. Update all scene text properties with localized versions
for (int i = 1; i < csvTable.Length; ++i)
{
string[] fields = csvTable[i];
if (fields.Length < languageIndex + 1)
{
continue;
}
string stringId = fields[0];
string languageEntry = CSVSupport.Unescape(fields[languageIndex]);
if (languageEntry.Length > 0)
{
localizedStrings[stringId] = languageEntry;
PopulateTextProperty(stringId, languageEntry);
}
}
}
/// <summary>
/// Populates the text property of a single scene object with a new text value.
/// </summary>
public virtual bool PopulateTextProperty(string stringId, string newText)
{
// Ensure that all localizeable objects have been cached
if (localizeableObjects.Count == 0)
{
CacheLocalizeableObjects();
}
ILocalizable localizable = null;
localizeableObjects.TryGetValue(stringId, out localizable);
if (localizable != null)
{
localizable.SetStandardText(newText);
return true;
}
return false;
}
/// <summary>
/// Returns all standard text for localizeable text in the scene using an
/// easy to edit custom text format.
/// </summary>
public virtual string GetStandardText()
{
// Collect all the text items present in the scene
Dictionary<string, TextItem> textItems = FindTextItems();
string textData = "";
int rowCount = 0;
var keys = textItems.Keys;
foreach (var stringId in keys)
{
TextItem languageItem = textItems[stringId];
textData += "#" + stringId + "\n";
textData += languageItem.standardText.Trim() + "\n\n";
rowCount++;
}
notificationText = "Exported " + rowCount + " standard text items.";
return textData;
}
/// <summary>
/// Sets standard text on scene objects by parsing a text data file.
/// </summary>
public virtual void SetStandardText(string textData)
{
var lines = textData.Split('\n');
int updatedCount = 0;
string stringId = "";
string buffer = "";
for (int i = 0; i < lines.Length; i++)
{
// Check for string id line
var line = lines[i];
if (line.StartsWith("#"))
{
if (stringId.Length > 0)
{
// Write buffered text to the appropriate text property
if (PopulateTextProperty(stringId, buffer.Trim()))
{
updatedCount++;
}
}
// Set the string id for the follow text lines
stringId = line.Substring(1, line.Length - 1);
buffer = "";
}
else
{
buffer += line + "\n";
}
}
// Handle last buffered entry
if (stringId.Length > 0)
{
if (PopulateTextProperty(stringId, buffer.Trim()))
{
updatedCount++;
}
}
notificationText = "Updated " + updatedCount + " standard text items.";
}
#endregion
#region StringSubstituter.ISubstitutionHandler imlpementation
public virtual bool SubstituteStrings(StringBuilder input)
{
// This method could be called from the Start method of another component, so we
// may need to initilize the localization system.
Init();
// Instantiate the regular expression object.
Regex r = new Regex("{\\$.*?}");
bool modified = false;
// Match the regular expression pattern against a text string.
var results = r.Matches(input.ToString());
for (int i = 0; i < results.Count; i++)
{
Match match = results[i];
string key = match.Value.Substring(2, match.Value.Length - 3);
// Next look for matching localized string
string localizedString = Localization.GetLocalizedString(key);
if (localizedString != null)
{
input.Replace(match.Value, localizedString);
modified = true;
}
}
return modified;
}
#endregion
}
}