傳回總目錄
第十章 遊戲劇情(Game Plot)
在大部分的RPG中,故事劇情是非常重要的。例如某些播放某些過場動畫,人物台詞等文字叙述的顯示。這些可以推動整個遊戲流程。
在Unity商店中,有一些劇情類的插件。我們編寫的這個可以配合那些插件使用。
文章目錄
- 第十章 遊戲劇情(Game Plot)
-
- 三 文本劇本 (Text Script)
-
- 1 常量字元串(Constant String)
- 2 字段與屬性(Fields and Properties)
- 3 構造器(Constructor)
- 4 格式化文本(Format Text)
-
- 4.1 格式化指令(Format Command)
- 4.2 格式化所有指令(Format Commands of Text)
- 5 重建文本(Recreate Text)
三 文本劇本 (Text Script)
之前,我們已經介紹了關于劇本的内容。這一節我們來建立劇本類。
在這之前,我們規定将格式化方法(相當于非常縮水的詞法分析器(Lexical Analyzer),沒有按字元讀取分析,甚至都不完整)也放入之中。當然,你也可以隻儲存每條指令的字元串,在運作時再格式化。
關于詞法分析器(Lexical Analyzer)
- 百度百科
- Wikipedia
關于更複雜的文法分析器(Syntactic Analyzer)與詞法分析器(Lexical Analyzer)
如果你對它們感興趣,可以看一看“yacc”,“lex”,“bison”等分析器。
我們在上一節已經建立好了劇本類:
namespace DR.Book.SRPG_Dev.ScriptManagement
{
/// <summary>
/// 劇本(腳本)
/// </summary>
public class TxtScript : IScenario
{
/// <summary>
/// 劇本的一條指令
/// </summary>
public class Command : IScenarioContent
{
// 省略
}
// TODO 劇本
}
}
在這一節中,我們來添加内容進去。
大緻需要的程式集為:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions; // 可能需要正規表達式,如果你熟悉它的話
1 常量字元串(Constant String)
我們的劇本規定了:
- 每一條語句以
分割;";"
- 語句參數之間使用
來分割;[" ", "\t", "\n"]
- 注釋使用
;"//"
- 辨別符字首為
。"#"
則在
TxtScript
中建立:
#region Const/Static
/// <summary>
/// 用于指令的分割符
/// </summary>
public const string k_CommandSeparator = ";";
/// <summary>
/// 空格
/// </summary>
public const string k_Space = " ";
/// <summary>
/// 分隔符
/// </summary>
public const string k_Separator = "\t";
/// <summary>
/// 換行符
/// </summary>
public const string k_NewLine = "\n";
/// <summary>
/// 注釋字首
/// </summary>
public const string k_CommentingPrefix = "//";
/// <summary>
/// 預設劇本辨別字首
/// </summary>
public const string k_DefaultFlagMark = "#";
#endregion
注意:我們在之前的Config中,換行符使用的是
Enviroment.Newline
,它與
"\n"
是有差別的。它的取值取決于你的平台,在Windows下,它表示
"\r\n"
,而在Unix Like系統下,它表示
"\n"
。在程式設計時選取
"\n"
是更好的選擇。Config中使用的并不多,你也可以進行更改。
2 字段與屬性(Fields and Properties)
我們的字段與屬性幾乎完全是按照
IScenario
來寫的,隻是我在這裡加了額外變量:
-
,它用于存儲在格式化之前的文本,預留它可能會有用;m_Buffer
-
,它用于存儲目前的劇情辨別符,你可能已經将m_FlagMark
作為了注釋,那麼它也要修改。#
-
,它用于存儲目前注釋字首,有可能你更喜歡用m_CommentingPrefix
表示注釋。#
字段(Fields):
#region Fields
private string m_Name;
private string m_Buffer;
private string m_FlagMark = k_DefaultFlagMark;
private string m_CommentingPrefix = k_CommentingPrefix;
private string m_Error = string.Empty;
private readonly List<Command> m_Commands = new List<Command>();
#endregion
屬性(Properties):
#region Properties
/// <summary>
/// 劇本名(可能為null)
/// </summary>
public string name
{
get { return m_Name; }
private set { m_Name = value; }
}
/// <summary>
/// 劇本的原始副本
/// </summary>
public string buffer
{
get { return m_Buffer; }
private set { m_Buffer = value; }
}
/// <summary>
/// 用作劇本辨別的符号
/// </summary>
public string flagMark
{
get { return m_FlagMark; }
set { m_FlagMark = value; }
}
/// <summary>
/// 注釋
/// </summary>
public string commentingPrefix
{
get { return m_CommentingPrefix; }
set { m_CommentingPrefix = value; }
}
/// <summary>
/// 錯誤
/// </summary>
public string formatError
{
get { return m_Error; }
protected set { m_Error = value; }
}
/// <summary>
/// 是否讀取過劇本文本
/// </summary>
public bool isLoaded
{
get { return !string.IsNullOrEmpty(m_Buffer); }
}
/// <summary>
/// 内容(動作)
/// </summary>
protected List<Command> commands
{
get { return m_Commands; }
}
/// <summary>
/// 指令數量
/// </summary>
public int contentCount
{
get { return m_Commands.Count; }
}
public IScenarioContent GetContent(int index)
{
return m_Commands[index];
}
#endregion
其中,
isLoaded
我使用了
!string.IsNullOrEmpty(m_Buffer)
,這是因為我規定了如果讀取成功,将會儲存進
m_Buffer
,否則
m_Buffer
不會有值。
由于我們儲存了原始副本(
m_Buffer
),是以我們順便更改一下
ToString()
方法:
public override string ToString()
{
if (!isLoaded)
{
return base.ToString();
}
return buffer;
}
3 構造器(Constructor)
在構造器上,除了預設構造器,我們還應該可以更改“劇情辨別符”與“注釋字首”,因為每個人的習慣是不同的。
這些符号的使用習慣因人而異,我在這裡隻是可以更改兩個,你也可以添加你需要修改的參數。
比如,習慣Python的程式員可能更喜歡用
"#"
表示注釋,而指令分割使用
"\n"
。
建立構造器:
#region Constructor
public TxtScript()
{
}
public TxtScript(string flagMark, string commentingPrefix)
{
/// 防止 flagMark 有空格,
/// 它可以有特殊字元,但不推薦含有特殊符号,
/// 你也可以使用 Trim() 去除兩邊的特殊字元,
/// 或使用 Regex.Replace(flagMark, @"\s", "") 去除所有特殊字元。
/// \s 是正規表達式的比對符,包含任何空白的字元([" ", "\f", "\n", "\t", "\v"...])
/// 這些限定不是必須的,也許你就喜歡有空格也說不定,
/// 你在建立語言規則之前,應該考慮這些。
if (flagMark != null)
{
flagMark = flagMark.Replace(" ", "");
}
// 防止 commentingPrefix 有空格。
if (commentingPrefix != null)
{
commentingPrefix = commentingPrefix.Replace(" ", "");
}
if (!string.IsNullOrEmpty(flagMark))
{
m_FlagMark = flagMark;
}
if (!string.IsNullOrEmpty(commentingPrefix))
{
m_CommentingPrefix = commentingPrefix;
}
}
#endregion
4 格式化文本(Format Text)
在我們的接口中,有格式化文本的方法
bool Load(string fileName, string scriptText);
。
這要求我們将文本字元串(String)格式化成指令(Command),而傳回是否成功。
- 在格式化之前,我們先重置參數;
- 格式化成功之後,再指派參數;
- 格式化方法單獨建立一個函數。
通過接口建立方法:
public bool Load(string fileName, string scriptText)
{
string script = Regex.Unescape(scriptText).Trim();
if (string.IsNullOrEmpty(script))
{
formatError = "TxtScript Load -> `scriptText` is null or empty";
return false;
}
name = string.Empty;
buffer = string.Empty;
formatError = null;
commands.Clear();
bool loaded = FormatScriptCommands(script);
if (loaded)
{
name = fileName;
buffer = script;
}
return loaded;
}
好了,這樣這個方法就确定了。
而
protected virtual bool FormatScriptCommands(string script)
是具體的格式化方法:
/// <summary>
/// 按内容格式化劇本,不含行号與注釋
/// </summary>
/// <param name="script"></param>
/// <returns></returns>
protected virtual bool FormatScriptCommands(string script)
{
// TODO 格式化
return true;
}
按照以下步驟進行格式化:
- 其一,按照
分割劇本字元串;[";"]
- 其二,按照
分割指令字元串,生成指令,并将指令添加到劇本中;[" ","\t","\n"]
在進行代碼的編寫之前,我們先要注意一些問題:指令存在注釋,而且如果指令有多行,注釋可能穿插其中。
例如:
keycode arg0
arg1 // 注釋1 注釋 注釋
arg2;
注釋在指令中間,這就導緻我們不能直接分割,而需要分步。
我們先來準備子方法,第二步的格式化指令
FormatCommand
。
4.1 格式化指令(Format Command)
經過分析,我們要解決的主要問題是注釋。而解決它是要:
- 先按
分割指令字元串;["\n"]
- 去除每行注釋;
- 再按
分割每行字元串;[" ", "\t"]
- 生成指令,并添加到劇本。
這樣,我們方法的參數就可以确定了。
還有一種情況需要注意,一條指令隻有
;
或隻有注釋,而沒有其它内容。是以我們的傳回值有三種情況:
- 格式化成功,有内容;
- 格式化失敗;
- 格式化成功,但沒有内容。
為了這個結果,我們可以建立一個枚舉:
namespace DR.Book.SRPG_Dev.ScriptManagement
{
/// <summary>
/// 格式化結果
/// </summary>
public enum FormatContentResult
{
/// <summary>
/// 成功
/// </summary>
Succeed,
/// <summary>
/// 失敗
/// </summary>
Failure,
/// <summary>
/// 隻有注釋
/// </summary>
Commenting
}
}
了解它們後,我們進行編寫。
我們先來建立方法:
/// <summary>
/// 格式化每一條内容
/// </summary>
/// <param name="index">下标,不是行号</param>
/// <param name="commandText"></param>
/// <param name="separators"></param>
/// <param name="newLineSeparator"></param>
/// <param name="command"></param>
/// <returns></returns>
protected virtual FormatContentResult FormatCommand(
int index,
string commandText,
string[] separators,
string[] newLineSeparator,
out Command command)
{
ScenarioContentType type = ScenarioContentType.Action;
List<string> arguments = new List<string>();
// TODO 具體實作
command = new Command(index, type, arguments.ToArray());
return FormatContentResult.Succeed;
}
然後按照以下步驟填充:
- 其一,以
分割指令字元串:["\n"]
// 按 ["\n"] 分割每一條内容 string[] lines = commandText.Split(newLineSeparator, StringSplitOptions.RemoveEmptyEntries); for (int li = 0; li < lines.Length; li++) { // TODO 對每一行格式化 }
- 其二,格式化每一行:
- 首先,去除每一行的注釋:
string line = lines[li].Trim(); // 删除每行注釋 int commentingIndex = line.IndexOf(commentingPrefix); if (commentingIndex != -1) { line = line.Substring(0, commentingIndex).TrimEnd(); } // 如果每行為空,則下一行 if (string.IsNullOrEmpty(line)) { continue; }
- 然後,以
分割每行,并判斷是否是“劇情辨別符”:[" ","\t"]
// 按 [" ","\t"] 分割每行 string[] lineValues = line.Split(separators, StringSplitOptions.RemoveEmptyEntries); // 如果是辨別,如果arguments.Count不是0,則不是第一個參數 if (lineValues[0].StartsWith(flagMark) && arguments.Count == 0) { type = ScenarioContentType.Flag; }
- 最後,添加參數:
// 添加内容 for (int vi = 0; vi < lineValues.Length; vi++) { string value = lineValues[vi].Trim(); if (!string.IsNullOrEmpty(value)) { arguments.Add(value); } }
- 首先,去除每一行的注釋:
- 其三,判斷傳回值
// 隻有注釋 if (arguments.Count == 0) { command = null; return FormatContentResult.Commenting; } 如果辨別參數大于1,則文法錯誤。 也可以不在這裡判斷,而在具體運作代碼時判斷。 //if (type == ScenarioContentType.Flag && arguments.Count > 1) //{ // command = null; // formatError = string.Format("TxtScript FormatError -> syntactic error: {0}", commandText); // return FormatContentResult.Failure; //} command = new Command(index, type, arguments.ToArray()); return FormatContentResult.Succeed;
4.2 格式化所有指令(Format Commands of Text)
我們之前,已經建立了格式化文本的方法:
/// <summary>
/// 按内容格式化劇本,不含行号與注釋
/// </summary>
/// <param name="script"></param>
/// <returns></returns>
protected virtual bool FormatScriptCommands(string script)
{
// TODO 格式化
return true;
}
而現在,我們要進行填充:
- 其一,按照
分割字元串;[";"]
// 以 [";"] 分割文本,并删除空白。 string[] commandTexts = script.Split( new string[] { k_CommandSeparator }, StringSplitOptions.RemoveEmptyEntries);
- 其二,在生成指令之前,我們先來準備指令分隔符;
// 分割劇本每個動作的分隔符: [" ","\t","\n"] string[] separators = new string[] { k_Space, k_Separator }; string[] newLineSeparator = new string[] { k_NewLine };
- 其三,循環生成指令:
for (int i = 0; i < commandTexts.Length; i++) { // 删除左右空格和左右各種特殊轉義符 string commandText = commandTexts[i].Trim(); // 如果為空,下一個動作 if (string.IsNullOrEmpty(commandText)) { continue; } // 格式化每一次動作,生成指令 Command command; FormatContentResult formatResult = FormatCommand( i, // 不是行号,是下标 commandText, separators, newLineSeparator, out command); // 成功添加 if (formatResult == FormatContentResult.Succeed) { commands.Add(command); } // 失敗傳回 else if (formatResult == FormatContentResult.Failure) { return false; } // 隻有注釋,下一動作 else { continue; } }
5 重建文本(Recreate Text)
這是一個幫助方法,可以重新生成一個文本。
/// <summary>
/// 重建立立文本,
/// if `commandSeparator` == null: Enviroment.NewLine.
/// 如果你需要每條指令貼在一起,傳入 `string.Empty`.
/// </summary>
/// <param name="commandSeparator"></param>
/// <param name="withCommenting"></param>
/// <returns></returns>
public string RecreateText(string commandSeparator)
{
if (commandSeparator == null)
{
commandSeparator = Environment.NewLine;
}
string[] texts = commands.Select(cmd => cmd.ToString()).ToArray();
return string.Join(commandSeparator, texts);
}