第十章 游戏剧情(Game Plot)
在大部分的RPG中,故事剧情是非常重要的。例如某些播放某些过场动画,人物台词等文字叙述的显示。这些可以推动整个游戏流程。
在Unity商店中,有一些剧情类的插件。我们编写的这个可以配合那些插件使用。
文章目录
七 text命令执行器与文本界面 (text Command Exeuctor and UI Text Panel)
我们接下来进行的是text
命令,但写命令之前,我们要先有UI能够显示我们的文本。
关于UIManager
等问题,第六章的示例已经很好了,你也可以查看本章的源码,或者使用自己的UIManager
。两者大同小异,不再阐述。
1 界面结构 (Panel Structure)
我们在这里规定,文本界面有三个显示固定文本窗口,用它们来显示文本:
-
对话用屏幕上方;
-
对话用屏幕下方;
-
过场用屏幕下方(比对话用稍大)。
具体位置如 图 10.1 显示:
- 图 10.1:UI Text Panel
我们的对话文本窗口包含:
-
背景(Background);
-
头像(Profile);
-
文本背景(BackgroundInset);
-
文本(Text);
-
文本图标(Icon)(按键提示符);
而我们的过场用文本窗口包含:
-
文本背景(BackgroundInset);
-
文本(Text);
-
文本图标(Icon)(按键提示符);
它们是雷同的,所以我们可以建立一个子UI,能省掉我们很多事。
2 子UI文本窗口(Sub UI Text Window)
首先要明确的是,子窗口的作用:用来显示文本。
而显示方式有多种,一般情况下:
-
逐字显示;
-
逐行显示;
-
直接全部显示。
它们各有各的用处,主要是看游戏的需求。
根据结构,我们先创建一个UIBehaviour
(它在UnityEngine.EventSystems
命名空间下):
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace DR.Book.SRPG_Dev.UI
{
[AddComponentMenu("SRPG/UI/Text Window")]
public class SubUITextWindow : UIBehaviour
{
#region UI Fields
[SerializeField]
private Image m_ImgBackground;
[SerializeField]
private Image m_ImgProfile;
[SerializeField]
private Image m_ImgBackgroundInset;
[SerializeField]
private Text m_TxtText;
[SerializeField]
private Image m_ImgIcon;
#endregion
#region UI Properties
// 省略UI属性
#endregion
/// TODO 其它内容
}
}
2.1 字段与属性(Fields and Properties)
我们这里选择 逐字写入 ,我们就需要一个写入速度,我们称它为写入间隔。而写入的过程,我们使用Coroutine
(当然,你也可以使用Update()
)。
所以我们需要的字段:
-
写入间隔;
-
需要写入的文本;
-
是否正在写入文本。
创建字段与属性:
#region Fields
[SerializeField]
private float m_WordInterval = 0.05f;
private string m_Text = string.Empty;
private Coroutine m_WritingCoroutine = null;
#endregion
#region Properties
/// <summary>
/// 写入时每个字符的时间间隔
/// </summary>
public float wordInterval
{
get { return m_WordInterval; }
set { m_WordInterval = Mathf.Max(0f, value); }
}
/// <summary>
/// 是否在写入状态
/// </summary>
public bool isWriting
{
get { return m_WritingCoroutine != null; }
}
/// <summary>
/// 获取应写入的完整文本
/// </summary>
public string text
{
get { return m_Text; }
}
#endregion
2.2 写入完成事件(Write Text Done Event)
我们在完成写入文本时,最好可以进行通知(例如通知我们的UITextPanel
),这使用事件是一个好方法。
创建写入完成事件:
#region Unity Event
/// <summary>
/// 当文本写入完成时:
/// Args:
/// SubUITextWindow textWindow; // 写入的窗口
/// </summary>
[Serializable]
public class TextWriteDoneEvent : UnityEvent<SubUITextWindow> { }
[Space, SerializeField]
private TextWriteDoneEvent m_TextWriteDoneEvent = new TextWriteDoneEvent();
/// <summary>
/// 当文本写入完成时:
/// Args:
/// SubUITextWindow textWindow; // 写入的窗口
/// </summary>
public TextWriteDoneEvent textWriteDone
{
get
{
if (m_TextWriteDoneEvent == null)
{
m_TextWriteDoneEvent = new TextWriteDoneEvent();
}
return m_TextWriteDoneEvent;
}
set { m_TextWriteDoneEvent = value; }
}
protected void OnTextWriteDone()
{
textWriteDone.Invoke(this);
DisplayIcon(true);
}
#endregion
其中DisplayIcon(true);
是显示文本图标,我们稍后补充。
2.3 异步写入文本(Write Text Async)
我们在写入开始之前:
-
如果正在写入,需要停止正在写入的内容;(这应该不会发生,如果发生也许是一个错误)
-
保存完整的文本;
-
隐藏文本图标;
-
清空
m_TxtText
组件的文本;
做完这些,我们就可以开始写入文本了,写入方法为IEnumerator WritingText()
。
创建方法:
/// <summary>
/// 逐字写入文本
/// </summary>
/// <param name="text"></param>
public void WriteTextAsync(string text)
{
if (isWriting)
{
StopCoroutine(m_WritingCoroutine);
m_WritingCoroutine = null;
}
m_Text = text;
DisplayIcon(false);
// Text组件不存在
if (txtText == null)
{
OnTextWriteDone();
return;
}
// 没有要写入的内容
txtText.text = string.Empty;
if (string.IsNullOrEmpty(text))
{
OnTextWriteDone();
return;
}
m_WritingCoroutine = StartCoroutine(WritingText());
}
WritingText
是具体的写入过程,我们直接进行循环写入就可以了:
private IEnumerator WritingText()
{
int index = 0; // 正在写入的下标
string curText = string.Empty; // 当前Text组件文本
while (txtText.text != m_Text)
{
yield return new WaitForSeconds(wordInterval);
curText += m_Text[index++];
txtText.text = curText;
}
OnTextWriteDone();
m_WritingCoroutine = null;
}
Tips:关于富文本(About Rich Text)
上面的方法没有对富文本处理,如果使用富文本,在逐字加载时会出现问题。
比如你想替换某些单词的颜色,使用<color=red>Text</color>
,在逐字加载时:
<
<c
<co
- …
这很不友好。
如果你对富文本的处理感兴趣,查看源码。源码包含了富文本的处理,使用的是正则表达式。
Unity支持的富文本格式:
<color=red>字体红色</color>
:改变字体颜色;<b>加粗</b>
:加粗;<i>斜体</i>
:斜体;<size=40>字体大小</size>
:改变字体大小。
这样,我们就可以写入文本了。不过你还要知道,在一般游戏中,对话时按下按键后,可以不用等待,然后完全显示文本。
这还需要我们有一个方法:
/// <summary>
/// 如果正在写入,立即写入文本
/// </summary>
public void WriteText()
{
if (isWriting)
{
StopCoroutine(m_WritingCoroutine);
m_WritingCoroutine = null;
txtText.text = m_Text;
OnTextWriteDone();
}
}
2.4 直接写入文本(Write Text Direct)
这个方法在SRPG或RPG中比较少见,而在J-AVG中比较常见。
-
在多数RPG与SRPG中,一般都是某些操作直接跳过剧情(比如手柄select按键,键盘esc等),并不会直接显示文本;
-
在J-AVG中,存在快进模式,对话显示一帧后直接跳过。
创建方法:
/// <summary>
/// 立即写入文本
/// </summary>
/// <param name="text"></param>
public void WriteText(string text)
{
if (isWriting)
{
StopCoroutine(m_WritingCoroutine);
m_WritingCoroutine = null;
}
SetText(text);
OnTextWriteDone();
}
2.5 显示与隐藏(Show and Hide)
这个不用多说,我们需要显示与隐藏的有:
-
窗口
-
按键提示符
创建方法:
public void Display(bool open)
{
if (gameObject.activeSelf != open)
{
gameObject.SetActive(open);
}
}
public void DisplayIcon(bool show)
{
if (imgIcon != null && imgIcon.enabled != show)
{
imgIcon.enabled = show;
}
}
2.6 Unity回调(Unity Callback)
正常境况下,打开窗口就应该开始写入文本,并且隐藏了光标。
但你在调试时有可能希望先打开UI,而且可能你在Inspector面板设置了Text的文本。
所以在打开窗口时,不要忘记初始化m_Text
和隐藏我们的文本图标。
#region Unity Callback
protected override void Awake()
{
if (txtText != null)
{
m_Text = txtText.text;
}
}
protected override void OnEnable()
{
DisplayIcon(false);
}
#endregion
2.7 按键提示符(Key Press Prompt)
按键提示符可以有多种方式:
-
比如文本最后的三个
.
的数量不断变化; -
比如某些图标动画。
我这里只是让图标可以闪烁。而闪烁的方式也有许多:
-
GameObject
的activeSelf
,根据计时器(或动画)切换true
和false
; -
Image
的enable
,根据计时器(或动画)切换true
和false
; -
Image
的color
,根据计时器(或动画)切换源颜色和透明色; -
还可以利用shader。
由于之前我在显示和隐藏时使用了enable
,所以这里我使用color
。
using UnityEngine;
using UnityEngine.UI;
namespace DR.Book.SRPG_Dev
{
public class Twinkle2D : MonoBehaviour
{
public Image m_Image;
public float m_Interval = 0.5f;
private Color m_OldColor;
private bool m_Hide = false;
private float m_Timer = 0f;
private void OnEnable()
{
if (m_Image != null)
{
m_OldColor = m_Image.color;
}
}
private void OnDisable()
{
m_Hide = false;
m_Timer = 0f;
if (m_Image != null)
{
m_Image.color = m_OldColor;
}
}
private void Update()
{
if (m_Image == null || !m_Image.enabled)
{
return;
}
m_Timer += Time.deltaTime;
if (m_Timer >= m_Interval)
{
m_Timer -= m_Interval;
m_Image.color = m_Hide ? Color.clear : m_OldColor;
m_Hide = !m_Hide;
}
}
}
}
在按键提示符上挂载此组件。
3 文本界面(UI Text Panel)
关于文本界面,我们需要这几个窗口。
private SubUITextWindow m_TopTextWindow;
private SubUITextWindow m_BottomTextWindow;
private SubUITextWindow m_GlobalTextWindow;
public bool isWriting
{
get { return m_TopTextWindow.isWriting
|| m_BottomTextWindow.isWriting
|| m_GlobalTextWindow.isWriting; }
}
#region Get Writing Window
public SubUITextWindow GetWritingWindow()
{
if (m_TopTextWindow.isWriting)
{
return m_TopTextWindow;
}
if (m_BottomTextWindow.isWriting)
{
return m_BottomTextWindow;
}
if (m_GlobalTextWindow.isWriting)
{
return m_GlobalTextWindow;
}
return null;
}
#endregion
写入文本的工作是子窗口完成的,我们在主面板中,只需要调用它们。
不过你要注意,只有两个对话窗口可以同时显示。
-
显示上窗口或下窗口时,全局窗口隐藏;
-
显示全局窗口时,上窗口和下窗口隐藏。
/// <summary>
/// 立即写入文本
/// </summary>
public void WriteTextImmediately()
{
SubUITextWindow window = GetWritingWindow();
if (window == null)
{
return;
}
window.WriteText();
}
/// <summary>
/// 写入文本
/// </summary>
/// <param name="position"></param>
/// <param name="text"></param>
/// <param name="async"></param>
public void WriteText(string position, string text, bool async)
{
SubUITextWindow textWindow;
// 我这里用的字符串,你可以用Enum,这取决于你的文本执行器
switch (position)
{
case "top":
m_GlobalTextWindow.Display(false);
textWindow = m_TopTextWindow;
break;
case "bottom":
m_GlobalTextWindow.Display(false);
textWindow = m_BottomTextWindow;
break;
default:
m_TopTextWindow.Display(false);
m_BottomTextWindow.Display(false);
textWindow = m_GlobalTextWindow;
break;
}
textWindow.Display(true);
if (async)
{
textWindow.WriteTextAsync(text);
}
else
{
textWindow.WriteText(text);
}
}
最后,写一个隐藏按键提示符的方法:
/// <summary>
/// 隐藏按键提示符
/// </summary>
public void HideIcon()
{
m_TopTextWindow.DisplayIcon(false);
m_BottomTextWindow.DisplayIcon(false);
m_GlobalTextWindow.DisplayIcon(false);
}
到现在为止,我们已经完成了文本界面的编写,我们之后来编写如何使用text
命令与其UI配合。
4 text命令(text Command)
我们需要解析的命令如下:
text top // 上对话文本窗口
1 // 第一行 TextInfoConfig id == 1
2; // 第二行 TextInfoConfig id == 2
var customId = 2;
text bottom // 下对话文本窗口
customId; // 第一行 TextInfoConfig id == customId
calc customId += 1;
text global // 全局文本窗口
1 // 第一行 TextInfoConfig id == 1
customId; // 第二行 TextInfoConfig id == customId
我们需要的参数为:
-
top
,bottom
和global
:使用哪个窗口; -
1
,2
和customId
:文本在 TextInfoConfig 中的Id,如果是变量就先取值; -
是否异步:这个参数在脚本中没有显示,它是从游戏设置中读取。
创建执行器类与参数类:
using System.Text;
namespace DR.Book.SRPG_Dev.ScriptManagement
{
using DR.Book.SRPG_Dev.Models;
using DR.Book.SRPG_Dev.UI;
public class TextExecutor : ScenarioContentExecutor<TextExecutor.TextArgs>
{
public struct TextArgs
{
public string position;
public string text;
public bool async;
}
public override string code
{
get { return "text"; }
}
// TODO ParseArgs and Run
}
}
4.1 ParseArgs
我们转换参数已经做过很多,不再过多的说明。
创建方法:
public override bool ParseArgs(IScenarioContent content, ref TextArgs args, out string error)
{
// text position text0 text1 ...;
if (content.length < 3)
{
error = GetLengthErrorString();
return false;
}
string position = content[1].ToLower();
if (position != "top" && position != "bottom" && position != "global")
{
error = string.Format(
"{0} ParseArgs error: position must be one of [top, bottom, global].",
typeName,
content[1]);
return false;
}
args.position = position;
TextInfoConfig config = TextInfoConfig.Get<TextInfoConfig>();
StringBuilder builder = new StringBuilder();
int index = 2;
while (index < content.length)
{
// 可能是个变量
int id = -1;
if (!ParseOrGetVarValue(content[index], ref id, out error))
{
return false;
}
TextInfo info = config[id];
if (info == null)
{
error = string.Format(
"{0} ParseArgs error: text id `{1}` was not found.",
typeName,
content[index]);
return false;
}
builder.AppendLine(info.text);
index++;
}
args.text = builder.ToString();
/// 从游戏设置中读取
/// 最常见的形式是类似J-AVG快进的形式
args.async = true;
error = null;
return true;
}
4.2 Run
在运行时,我们需要获取UI,使用UI来完成写入文本。
protected override ScenarioActionStatus Run(IGameAction gameAction, IScenarioContent content, TextArgs args, out string error)
{
UITextPanel panel = UIManager.views.OpenView<UITextPanel>(UINames.k_UITextPanel, false);
panel.WriteText(args.position, args.text, args.async);
error = null;
// 如果是快进模式,要等待一帧,否则有可能看不到界面,连闪屏都没有。
return args.async ? ScenarioActionStatus.WaitWriteTextDone : ScenarioActionStatus.NextFrame;
}
5 输入处理(Input)
我们还没有做输入处理,当我们按键时:
-
正在写入:立即显示文本;
-
写入完成:继续下一条命令。
这在ScenarioAction
中完成:
public override void OnMouseLButtonDown(Vector3 mousePosition)
{
if (status == ScenarioActionStatus.WaitWriteTextDone)
{
WriteTextDone();
return;
}
}
public void WriteTextDone()
{
if (status == ScenarioActionStatus.WaitWriteTextDone)
{
UITextPanel panel = UIManager.views.GetView<UITextPanel>(UINames.k_UITextPanel);
if (panel.isWriting)
{
panel.WriteTextImmediately();
}
else
{
panel.HideIcon();
status = ScenarioActionStatus.Continue;
}
}
}
至此,我们的text
命令就全部完成了。
不过我们没有使用到SubUITextWindow
中的写入完成事件textWriteDone
,保留它是因为,将来你可能需要别的操作。例如源码中,在UITextPanel
中使用MessageCenter
发送一个写入完成消息,这个消息在源码中没有用到,只是展示了可以这么做;你也可以在每次写入完成时播放一个声音,提示玩家。