289 lines
11 KiB
C#
289 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Cthangover.Core.Factories.Impls;
|
|
using Cthangover.Core.Localization;
|
|
using Cthangover.Mods.Dialog;
|
|
using Cthangover.Mods.Dialog.Action.Impls;
|
|
using Cthangover.Core.UI;
|
|
using Cthangover.Mods.Interface.Base.Lists;
|
|
using Cthangover.Core.UI.Dialog;
|
|
using Cthangover.Core.UI.Event;
|
|
using Cthangover.Core.UI.Executable;
|
|
using Cthangover.Core.Scenarios;
|
|
using Cthangover.Core.Scenes;
|
|
using Cthangover.Core.Interactive;
|
|
|
|
namespace Cthangover.Mods.Dialog.Scenarios
|
|
{
|
|
/// <summary>
|
|
/// Parses scenario DSL script text into a <see cref="DialogQueue"/> of dialog actions.
|
|
/// The pipeline works in two phases: first the text is split into lines, then each
|
|
/// non-empty, non-comment line is tokenized and dispatched to the matching
|
|
/// <see cref="IScenarioCommandStrategy"/> via <see cref="ScenarioCommandStrategyFactory"/>.
|
|
/// Special handling exists for <c>:label</c> anchor points, <c>select/option</c>
|
|
/// blocks (accumulated into <see cref="ActionSelect"/>), and <c>-></c> arrow targets.
|
|
/// </summary>
|
|
public static class ScenarioParser
|
|
{
|
|
/// <summary>
|
|
/// Parses a complete scenario script text and returns the resulting dialog queue.
|
|
/// </summary>
|
|
/// <param name="text">Raw scenario DSL content.</param>
|
|
/// <param name="locale">Optional localization provider for <c>key=</c> text lookups.</param>
|
|
public static DialogQueue Parse(string text, ILocalizationProvider locale = null)
|
|
{
|
|
var dlg = new DialogQueue();
|
|
var lines = text.Split('\n');
|
|
ActionSelect pendingSelect = null;
|
|
|
|
for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++)
|
|
{
|
|
var lineNumber = lineIndex + 1;
|
|
var line = lines[lineIndex].Trim();
|
|
if (line.Length == 0 || line.StartsWith('#'))
|
|
{
|
|
FinalizePendingSelect(dlg, ref pendingSelect);
|
|
continue;
|
|
}
|
|
|
|
if (line.StartsWith(':'))
|
|
{
|
|
FinalizePendingSelect(dlg, ref pendingSelect);
|
|
dlg.Point(line.Substring(1)).SourceLine = lineNumber;
|
|
continue;
|
|
}
|
|
|
|
var positional = new List<string>();
|
|
var named = new Dictionary<string, string>();
|
|
var cmd = SplitLine(line, positional, named, out var arrowTarget);
|
|
if (cmd == null) continue;
|
|
|
|
if (cmd == "select")
|
|
{
|
|
FinalizePendingSelect(dlg, ref pendingSelect);
|
|
pendingSelect = new ActionSelect { SourceLine = lineNumber };
|
|
var promptText = positional.Count > 0 ? positional[0] : null;
|
|
|
|
if (named.TryGetValue("key", out var key) && locale != null)
|
|
{
|
|
var loc = locale.Get(key);
|
|
if (loc != null) promptText = loc;
|
|
}
|
|
if (promptText != null)
|
|
pendingSelect.Text = promptText;
|
|
|
|
if (named.TryGetValue("first", out var first))
|
|
pendingSelect.FirstAvatar = first;
|
|
if (named.TryGetValue("second", out var second))
|
|
pendingSelect.SecondAvatar = second;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (cmd == "option")
|
|
{
|
|
if (pendingSelect == null) continue;
|
|
|
|
var optText = positional.Count > 0 ? positional[0] : "";
|
|
|
|
if (named.TryGetValue("key", out var key) && locale != null)
|
|
{
|
|
var loc = locale.Get(key);
|
|
if (loc != null) optText = loc;
|
|
}
|
|
|
|
var goTo = arrowTarget;
|
|
if (string.IsNullOrEmpty(goTo))
|
|
{
|
|
if (named.TryGetValue("goto", out var gt))
|
|
goTo = gt;
|
|
else if (positional.Count > 1 && positional[1].StartsWith(':'))
|
|
goTo = positional[1].TrimStart(':');
|
|
}
|
|
|
|
if (pendingSelect.Variants == null)
|
|
pendingSelect.Variants = new List<SelectVariant>();
|
|
var variant = SelectVariant.New(optText, goTo);
|
|
if (named.TryGetValue("if", out var condition))
|
|
variant.Condition = condition;
|
|
pendingSelect.Variants.Add(variant);
|
|
continue;
|
|
}
|
|
|
|
FinalizePendingSelect(dlg, ref pendingSelect);
|
|
|
|
var strategy = ScenarioCommandStrategyFactory.Get(cmd);
|
|
if (strategy == null)
|
|
continue;
|
|
|
|
var firstNew = dlg.Queue.Count;
|
|
strategy.Execute(dlg, positional, named, arrowTarget, locale);
|
|
for (var i = firstNew; i < dlg.Queue.Count; i++)
|
|
{
|
|
if (dlg.Queue[i] != null && dlg.Queue[i].SourceLine == 0)
|
|
dlg.Queue[i].SourceLine = lineNumber;
|
|
}
|
|
}
|
|
|
|
FinalizePendingSelect(dlg, ref pendingSelect);
|
|
return dlg;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Splits one trimmed, non-empty DSL line the way <see cref="Parse"/> does:
|
|
/// returns the lowercase command word (null for an empty line) and fills
|
|
/// the positional tokens, the <c>name=value</c> pairs (names lowercased)
|
|
/// and the <c>-> :label</c> target. Tools that read scenarios without
|
|
/// running them (the scenario studio) use it to agree with the parser.
|
|
/// </summary>
|
|
public static string SplitLine(string line, List<string> positional, Dictionary<string, string> named, out string arrowTarget)
|
|
{
|
|
arrowTarget = "";
|
|
var tokens = Tokenize(line);
|
|
if (tokens.Count == 0)
|
|
return null;
|
|
|
|
var cmd = tokens[0].ToLowerInvariant();
|
|
var spec = ScenarioCommandStrategyFactory.Get(cmd) as IScenarioCommandSpec;
|
|
for (var i = 1; i < tokens.Count; i++)
|
|
{
|
|
var t = tokens[i];
|
|
if (t == "->")
|
|
{
|
|
if (i + 1 < tokens.Count)
|
|
{
|
|
arrowTarget = tokens[i + 1].TrimStart(':');
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
var eqIdx = t.IndexOf('=');
|
|
if (eqIdx > 0)
|
|
{
|
|
var name = t.Substring(0, eqIdx);
|
|
if (IsValidIdent(name))
|
|
{
|
|
named[spec?.PreserveNamedArgumentCase == true ? name : name.ToLowerInvariant()] = t.Substring(eqIdx + 1);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
positional.Add(t);
|
|
}
|
|
|
|
return cmd;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts YAML-like front-matter metadata from script text. Lines before
|
|
/// a <c>---</c> separator or the first non-key:value line are parsed as
|
|
/// <c>key: value</c> pairs. Returns both the metadata dictionary and the
|
|
/// remaining script content after the metadata block.
|
|
/// </summary>
|
|
public static (Dictionary<string, string> metadata, string remainingText) ParseMetadata(string text)
|
|
{
|
|
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
var lines = text.Split('\n');
|
|
var contentStart = 0;
|
|
var inMetadata = true;
|
|
|
|
for (var i = 0; i < lines.Length; i++)
|
|
{
|
|
var line = lines[i].Trim();
|
|
|
|
if (!inMetadata)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (line == "---")
|
|
{
|
|
contentStart = i + 1;
|
|
break;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(line))
|
|
{
|
|
contentStart = i;
|
|
break;
|
|
}
|
|
|
|
var colonIdx = line.IndexOf(':');
|
|
if (colonIdx > 0)
|
|
{
|
|
var key = line.Substring(0, colonIdx).Trim();
|
|
var val = line.Substring(colonIdx + 1).Trim();
|
|
metadata[key] = val;
|
|
}
|
|
else
|
|
{
|
|
contentStart = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
var remaining = string.Join("\n", lines, contentStart, lines.Length - contentStart);
|
|
return (metadata, remaining);
|
|
}
|
|
|
|
private static void FinalizePendingSelect(DialogQueue dlg, ref ActionSelect pending)
|
|
{
|
|
if (pending == null) return;
|
|
|
|
if (pending.Variants == null || pending.Variants.Count == 0)
|
|
{
|
|
pending = null;
|
|
return;
|
|
}
|
|
|
|
dlg.Queue.Add(pending);
|
|
pending = null;
|
|
}
|
|
|
|
private static List<string> Tokenize(string line)
|
|
{
|
|
var tokens=new List<string>();
|
|
var i=0;
|
|
while(i<line.Length)
|
|
{
|
|
if(char.IsWhiteSpace(line[i])){i++;continue;}
|
|
var token=new System.Text.StringBuilder();
|
|
while(i<line.Length&&!char.IsWhiteSpace(line[i]))
|
|
{
|
|
// Preserve quotes embedded in JSON. Quotes at the start of a value, including name="...",
|
|
// delimit one DSL argument and allow spaces without changing existing JSON commands.
|
|
var quoted=false;
|
|
if(line[i]=='"')
|
|
{
|
|
var prefix=token.ToString();
|
|
quoted=token.Length==0||prefix.EndsWith("=")&&IsValidIdent(prefix.Substring(0,prefix.Length-1));
|
|
}
|
|
if(!quoted){token.Append(line[i++]);continue;}
|
|
i++;
|
|
while(i<line.Length&&line[i]!='"')
|
|
{
|
|
if(line[i]==(char)92&&i+1<line.Length&&line[i+1]=='"'){token.Append('"');i+=2;}
|
|
else token.Append(line[i++]);
|
|
}
|
|
if(i<line.Length)i++;
|
|
}
|
|
tokens.Add(token.ToString());
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
private static bool IsValidIdent(string s)
|
|
{
|
|
if (string.IsNullOrEmpty(s)) return false;
|
|
for (var i = 0; i < s.Length; i++)
|
|
{
|
|
var c = s[i];
|
|
if (!char.IsLetterOrDigit(c) && c != '_')
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
}
|