Skip to content

Commit

Permalink
Read localized versions of quest sources
Browse files Browse the repository at this point in the history
Seeks localized quest sources from StreamingAssets/Text/Quests. Filename format should be QuestName-LOC.txt, e.g. "M0B00Y16-LOC.txt".
Must contain valid header for quest display name and a QRC: section. Should not contain a QBN: section, but this will not break execution. Anything after QBN: is simply ignored.
Using a custom parser for localized text files rather than modify core quest parser. This duplicates some logic, but has the benefit of not introducing regressions and allows localization parser to evolve independently of core quest parser.
It's also still possible to add localized quest text using Internal_Quests.csv. Internally all these keys are loaded to Internal_Quests string table and read from there when expanding messages at runtime.
This should be more user-friendly for translators than one big CSV file.
  • Loading branch information
Interkarma committed Apr 26, 2023
1 parent 23f66c2 commit c1e0869
Show file tree
Hide file tree
Showing 5 changed files with 377 additions and 0 deletions.
135 changes: 135 additions & 0 deletions Assets/Scripts/Game/Questing/Parser.cs
Expand Up @@ -161,6 +161,141 @@ public Quest Parse(string[] source, int factionId, bool partialParse = false)
return quest;
}

/// <summary>
/// Special cut-down parser to read localized quest source containing only QRC text part.
/// Only reads lines related to quest display name and messages.
/// Does not tokenize message text. Messages are returned in a dictionary format suitable to store in string tables.
/// Keeping this process separate from standard QRC parser to avoid introducing additional complexity or regressions.
/// </summary>
/// <param name="lines">List of text lines from localized quest file.</param>
/// <param name="displayName">Localized display name out.</param>
/// <param name="messages">Localized message dictionary out.</param>
/// <returns>True if successful.</returns>
public bool ParseLocalized(List<string> lines, out string displayName, out Dictionary<int, string> messages)
{
const string parseIdError = "Could not parse localized quest message ID '{0}' to an int. Expected message ID value.";

displayName = string.Empty;
messages = new Dictionary<int, string>();

// Must have a valid lines array
if (lines == null || lines.Count == 0)
{
Debug.LogErrorFormat("ParseLocalized() lines input is null or empty.");
return false;
}

bool inQRC = false;
bool inQBN = false;
const string idCol = "id";
Table staticMessagesTable = QuestMachine.Instance.StaticMessagesTable;
for (int i = 0; i < lines.Count; i++)
{
// Trim white space from either end of source line data
string text = lines[i].Trim();

// Skip empty lines and comments
if (string.IsNullOrEmpty(text) || text.StartsWith("-", comparison))
continue;

// Handle expected header values
if (text.StartsWith("quest:", comparison)) // Accepted but ignored
continue;
else if (text.StartsWith("displayname:", comparison))
{
displayName = GetFieldStringValue(text);
continue;
}
else if (text.StartsWith("qrc:", comparison))
inQRC = true;
else if (text.StartsWith("qbn:", comparison))
inQBN = true;

// Don't try to read messages until QRC: section starts
// Everything else that follows in file must be message text.
if (!inQRC)
continue;

// Ignore everything after QBN: section
// This really shouldn't be in localized quest sources at all, but can simply ignore rather than breaking execution
// It might appear if translator just copies and renames core quest sources for some reason
if (inQBN)
continue;

// Check for start of message block
// Begins with field Message: (or fixed message type)
// Ignores any lines that cannot be split
string[] parts = SplitField(lines[i]);
if (parts == null || parts.Length == 0)
continue;

// Read message lines
if (staticMessagesTable.HasValue(parts[0]))
{
// Read ID of message
int messageID = 0;

if (parts[1].StartsWith("[") && parts[1].EndsWith("]"))
{
// Fixed message types use ID from table
messageID = staticMessagesTable.GetInt(idCol, parts[0]);
if (messageID == -1)
throw new Exception(string.Format(parseIdError, staticMessagesTable.GetInt(idCol, parts[0])));
}
else
{
// Other messages use ID from message block header
if (!int.TryParse(parts[1], out messageID))
throw new Exception(string.Format(parseIdError, parts[1]));
}

// Keep reading message lines until empty line is found, indicating end of block
string messageLines = string.Empty;
while (true)
{
// Check for end of lines
// This handles a case where final message block isn't terminated an empty line causing an overflow
if (i + 1 >= lines.Count)
break;

// Read line
string textLine = lines[++i].TrimEnd('\r');
if (string.IsNullOrEmpty(textLine))
{
// Sometimes quest author will forget single space in front of an empty message line
// Peek ahead to see if next line is really a new message header
// Otherwise just treat this as a line break in message (add a single ' ' character)
if (!PeekMessageEnd(lines, i))
{
messageLines += " " + "\n";
continue;
}
else
{
break;
}
}
else
{
messageLines += textLine + "\n";
}
}

// Trim trailing newline
messageLines = messageLines.TrimEnd('\n');

// Store message ID and lines to output dictionary
messages.Add(messageID, messageLines);
}
else
{
continue;
}
}

return true;
}

#endregion

#region Private Methods
Expand Down
93 changes: 93 additions & 0 deletions Assets/Scripts/Game/Questing/QuestMachine.cs
Expand Up @@ -19,6 +19,7 @@
using DaggerfallWorkshop.Game.UserInterfaceWindows;
using DaggerfallWorkshop.Game.Questing.Actions;
using DaggerfallWorkshop.Game.Serialization;
using UnityEngine.Localization.Settings;

namespace DaggerfallWorkshop.Game.Questing
{
Expand Down Expand Up @@ -85,6 +86,8 @@ public class QuestMachine : MonoBehaviour
StaticNPC lastNPCClicked;
Dictionary<int, IQuestAction> factionListeners = new Dictionary<int, IQuestAction>();

Dictionary<string, string> localizedQuestNames = new Dictionary<string, string>();

System.Random internalSeed = new System.Random();

#endregion
Expand Down Expand Up @@ -662,6 +665,9 @@ public Quest ParseQuest(string questName, string[] questSource, int factionId =
Parser parser = new Parser();
Quest quest = parser.Parse(questSource, factionId, partialParse);

// Parse localized version of quest file (if present)
ParseLocalizedQuestText(questName);

return quest;
}
catch (Exception ex)
Expand Down Expand Up @@ -1591,6 +1597,93 @@ bool RemoveQuestSiteLink(ulong questUID)
return false;
}

/// <summary>
/// Seeks a partial quest file from StreamingAssets/Text/Quests.
/// This file must be called "QuestName-LOC.txt", e.g. "M0B00Y16-LOC.txt".
/// Only header and text (QRC) parts of this file will be read. Any logic (QBN) parts will be ignored.
/// Returning false is not a fail state. By default no localized files exist in StreamingAssets/Text/Quests.
/// Check player log for error messages if localized quest text not displayed in-game.
/// </summary>
/// <param name="questName">Standard quest name, do NOT append -LOC here.</param>
/// <returns>True if localized text was loaded, otherwise false.</returns>
bool ParseLocalizedQuestText(string questName)
{
const string localizedFilenameSuffix = "-LOC";
const string fileExtension = ".txt";
const string textFolderName = "Text";
const string questsFolderName = "Quests";

// Do nothing if localized quest has previously been parsed
if (localizedQuestNames.ContainsKey(questName))
return true;

// Compose filename of localized book
string filename = questName;
string fileNoExt = Path.GetFileNameWithoutExtension(filename);
if (!fileNoExt.EndsWith(localizedFilenameSuffix))
filename = fileNoExt + localizedFilenameSuffix + fileExtension;

// TODO: Also seek localized quest file from mods

// Get path to localized quest file and check it exists
string path = Path.Combine(Application.streamingAssetsPath, textFolderName, questsFolderName, filename);
if (!File.Exists(path))
return false;

// Attempt to load file from StreamingAssets/Text/Quests
string[] lines = File.ReadAllLines(path);
if (lines == null || lines.Length == 0)
return false;

// Parse localized quest file
Parser parser = new Parser();
string displayName = string.Empty;
Dictionary<int, string> messages = null;
try
{
if (!parser.ParseLocalized(new List<string>(lines), out displayName, out messages))
return false;
}
catch (Exception ex)
{
Debug.LogErrorFormat("Parsing localized quest `{0}` FAILED!\r\n{1}", filename, ex.Message);
}

// Validate output
if (string.IsNullOrEmpty(displayName))
{
Debug.LogErrorFormat("Localized quest '{0}' has a null or empty DisplayName: value.", filename);
return false;
}
if (messages == null || messages.Count == 0)
{
Debug.LogErrorFormat("Localized quest '{0}' parsed no valid messages. Check source file is a valid format.", filename);
return false;
}

// Get string database
var stringTable = LocalizationSettings.StringDatabase.GetTable(TextManager.Instance.runtimeQuestsStrings);
if (stringTable == null)
{
Debug.LogErrorFormat("ParseLocalizedQuestText() failed to get string table `{0}`.", TextManager.Instance.runtimeQuestsStrings);
return false;
}

// Store localized messages to Internal_Quests string table
foreach(var item in messages)
{
string key = fileNoExt + "." + item.Key.ToString();
var targetEntry = stringTable.GetEntry(key);
if (targetEntry == null)
stringTable.AddEntry(key, item.Value);
}

// Store localized display name
localizedQuestNames.Add(questName, displayName);

return true;
}

#endregion

#region Static Helper Methods
Expand Down
8 changes: 8 additions & 0 deletions Assets/StreamingAssets/Text/Quests.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c1e0869

Please sign in to comment.