在上一次对话系统的制作全过程中,并没有达到一个即插即用的效果,在github上我也只是发布为了"预发布版本",所以这次就一口气制作完剩下的功能,正式发布第一个版本吧ᕕ(◠ڼ◠)ᕗ!

配置说明
各位可以通过我的github界面来获取对话系统的Unity导出包!我所使用的版本是2022的3.57版本进行制作的,具体的配置说明请查看这个视频。
Unity类似视觉小说的对话系统:配置说明(v1.0.0)
这里是github的下载地址,当然在这里你也可以下载到完整的工程文件以及源代码。复制到浏览器打开,或者点击最下方阅读原文前往。
https://github.com/starmatch9/Galgame-Dialogue-System/releases
按照传统,这里也提供百度网盘的下载链接,当然还是建议直接在github界面进行下载。
https://pan.baidu.com/s/1vw895ejSSSJsbToZrfLSiA?pwd=s6gg ------提取码: s6gg

本文所涉及任何网络资源均不用做商业用途,仅供学习与交流使用。这些资源在制作该系统时的使用十分灵活,可以根据需要自行寻找资源。

资源列表:
- 语音:GPT-Sovits V4推理特化一键包(bilibili):BV1j7TszYE27
GPT-Sovits开发者:@花儿不哭
模型训练者:@红血球AE3803 @白菜工厂1145号员工
- 立绘:白子,优香(bilibili图文):@小沫不是末
- 制作过程中未涉及到的资源,可前往前几期文章中查看出处。
制作过程
承接前面的开发篇章(一二三),最最关键的部分还是按钮功能的制作。在我们加入语音后,自动推进对话的时间间隔就受到了语音长度的影响而不能采用原来的方法。
一、制作历史对话界面:
首先我们在画布中建立一个面板,调节其为半透明的黑色充当背景。新建一个游戏对象用来存放人物的名称和文本内容,并调整合适的位置与大小。

在父物体中添加竖直布局组件。注意不要勾选子力扩展,防止拉伸留白。

由于其布局是根据子物体的实际高度进行计算的,而我们物体的矩形框高度不会随着文本行的变化而变化,所以我们需要编写一个脚本用来实时检查文本是否换行,并根据行数来决定高度的大小。
为了防止高度变化时文字错位,我们把子级对齐以及文字的锚点都设置为上中的位置,轴心设置为(0.5, 1),防止换行偏移。

然后编写以下脚本。值得一提的是,这里我们需要用到"注册布局变化回调"的方法,这类方法在注册完成后,就可以让指定方法在布局改变时调用了。

这时我们的布局效果就达成了。

为我们的items创建空父物体,并添加ScrollRect组件。调整其大小作为滚动矩形的视窗,运动类型选弹性即可,灵敏度调到50。

为了让内容的矩形高度能够实时更新,我们为items物体添加内容大小适配器组件(Content Size Fitter)。注意轴心一定要是(0.5, 1),表示从顶部向下扩展,否则会发生错位。

这样我们就可以通过鼠标滚轮进行完美的滑动了。现在来添加滑动条。需要注意的是,如果想要让滑动条能够实现自动隐藏的功能,那么视口就不可以使用挂载滚动矩形的根物体了,需要新建一个子项作为视口。可以参考官方文档操作。这里对隐藏没有要求,故保持原状。

创建UI对象滚动条,设置为从下到上,调整其大小、位置还有颜色等属性。

在滚动矩形中将竖直滚动条拖入,从而完成链接。现在,我们需要让滚动矩形与遮罩 (Mask) 相结合来创建滚动视图,让其部分可见。至此我们的历史对话的场景布局就大功告成了。

现在我们就要通过脚本控制文字的显示了。在按钮管理器中添加如下方法。

由于不同的对话调用时机不同,我们在之前更新对话的策略模式中依次插入这个方法,如下。

经过测试,现在出现问题了,之前我们编写的矩形高度动态变化并没有如期实现,这是因为布局回调的方法只有在物体激活的状态下才可以被调用!所以当我们在没有按下按钮激活对话历史画板时,不会发生回调。

那么我们就要换个思路了,可以在historyUpdate方法中不直接在未激活的画板中添加对象,而是新建一个List链表来存放Name和Content的数据(新建一个HistoryItem类),当对象激活后再依次生成物体。

还有要注意的是,将注册回调方法的时机从Start改为Awake。因为Start是在物体激活时才会被调用的,如果脚本的执行顺序不对,可能仍然无法正常执行高度改变的函数。而Awake则是无论激活与否,只要物体实例化就会调用。

这样一来,我们显示对话历史的功能就完全做好了!记得加一个退出按钮。

二、制作游戏设置界面:
我们先确定好设置界面有什么,基础的话得有调节分辨率和是否全屏的选项,退出游戏。然后就......没了吧?倒是有很多个性化选项,比如文字跳出速度,文字颜色什么的,但这些可以放在后面。
那就先来设计一下UI吧。

把选项的头部和应该有的按钮都做出来,放在合适的位置。

然后新建一个SettingButtons的脚本,用来管理各个按钮的方法。代码的逻辑都还算简单,如下所示。
cs
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
//记住,这个脚本挂载在"Setting"对象上
public class SettingButtons : MonoBehaviour
{
//分辨率列表
List<string> resolutions = new List<string>();
//显示模式列表
List<string> displays = new List<string>();
public TMP_Text currentDisplay;
public TMP_Text currentResolution;
private void Awake()
{
if (Screen.fullScreen)
{
currentDisplay.text = "全 屏";
}
else
{
currentDisplay.text = "窗 口";
}
if(Screen.height == 1440 || Screen.height == 1080 || Screen.height == 720)
{
currentResolution.text = Screen.height * 16 / 9 + "*" + Screen.height;
}
//初始化分辨率列表
resolutions.Add("1280*720");
resolutions.Add("1920*1080");
resolutions.Add("2560*1440");
//初始化显示模式列表
displays.Add("窗 口");
displays.Add("全 屏");
}
public void LeftDisplaySelection()
{
//得到正确的索引
int selectionIndex = 0;
for (int i = 0; i < displays.Count; i++)
{
if (displays[i] == currentDisplay.text)
{
selectionIndex = i;
break;
}
}
//索引减一后更改数字
int targetIndex = selectionIndex - 1;
if(targetIndex >= 0 && targetIndex < displays.Count)
{
currentDisplay.text = displays[targetIndex];
}
}
public void RightDisplaySelection()
{
int selectionIndex = 0;
for (int i = 0; i < displays.Count; i++)
{
if (displays[i] == currentDisplay.text)
{
selectionIndex = i;
break;
}
}
//索引加一后更改数字
int targetIndex = selectionIndex + 1;
if (targetIndex >= 0 && targetIndex < displays.Count)
{
currentDisplay.text = displays[targetIndex];
}
}
public void LeftResolutionSelection()
{
//得到正确的索引
int selectionIndex = 0;
for (int i = 0; i < resolutions.Count; i++)
{
if (resolutions[i] == currentResolution.text)
{
selectionIndex = i;
break;
}
}
//索引减一后更改数字
int targetIndex = selectionIndex - 1;
if (targetIndex >= 0 && targetIndex < resolutions.Count)
{
currentResolution.text = resolutions[targetIndex];
}
}
public void RightResolutionSelection()
{
int selectionIndex = 0;
for (int i = 0; i < resolutions.Count; i++)
{
if (resolutions[i] == currentResolution.text)
{
selectionIndex = i;
break;
}
}
int targetIndex = selectionIndex + 1;
if (targetIndex >= 0 && targetIndex < resolutions.Count)
{
currentResolution.text = resolutions[targetIndex];
}
}
public void ExitGame()
{
Application.Quit();
}
public void SaveChange()
{
if(currentResolution.text == "1280*720")
{
Screen.SetResolution(1280, 720, true);
}
else if(currentResolution.text == "1920*1080")
{
Screen.SetResolution(1920, 1080, true);
}
else if(currentResolution.text == "2560*1440")
{
Screen.SetResolution(2569, 1440, true);
}
if(currentDisplay.text == "窗 口")
{
Screen.fullScreen = false;
}
else if(currentDisplay.text == "全 屏")
{
Screen.fullScreen = true;
}
}
}
然后将这个脚本挂载在Setting这个根对象上,让其方法与按钮点击事件一一对应,这样一来,我们的修改分辨率、设置全屏的功能也就处理完成辣!

三、自动播放修正:
前面说了,我们的原来的自动播放完全就是固定间隔时间,没办法兼顾文字和音声的结束与否。所以我们需要尝试新的方法进行自动播放。
限制我们自动播放的时间间隔的目前只有两个:打字机协程和角色音声的播放。如果有音声的话,优先通过音声判断是否推进对话,因为音声完了代表人物的对话说完了嘛。如果没有音声,再通过打字机协程判断,在协程结束后等待特定的时间。

在打字机协程的最后加入一个置空语句,因为协程结束之后不会自动置空,我们自动播放的判断可以通过协程是否为空来作为依据。

但是这种情况下,如果获取对话管理器的对话索引,实际上是将要跳转的角色索引,将无法满足我们检测音频是否为空的需要。
我们可以在音频管理器中添加名为"当前音频"的变量,用来存放当前的音频片段。

这样一来,我们自动播放的方法只需要通过currentClip这个变量操作即可。具体的脚本如下。
cs
public void ButtonAuto()
{
isAuto = !isAuto;
if (isAuto)
{
autoStart();
}
else
{
autoEnd();
}
}
IEnumerator AutoPlay()
{
while (true)
{
if (audioSource.clip != null)
{
//事件回调,2018+版本用,这个可以在播放结束后跳出
yield return new WaitWhile(() => audioSource.isPlaying);
}
//如果这段对话的音频是空的,就按打字机协程来
if (audioManager.currentClip == null)
{
//如果协程在运行,就在代码块里循环
float timeAll = 0f;
while (dialogueManager.typeTextCoroutine != null)
{
yield return null;
timeAll += Time.deltaTime;
}
//打字机结束后,时停时长
Debug.Log(timeAll * 6);
//让阅读时间和字的长度大致成正比
yield return new WaitForSeconds(timeAll * 6);
}
//音频播放结束后等待时间的长度
yield return new WaitForSeconds(1f);
dialogueManager.BoxClick();
}
}
//协程强制开始时的动作
void autoStart()
{
if (autoPlayCoroutine != null)
{
StopCoroutine(autoPlayCoroutine);
autoPlayCoroutine = null;
}
//在开始协程前重新清空
autoPlayCoroutine = StartCoroutine(AutoPlay());
buttonImage.color = runningColor;
}
void autoEnd()
{
if (autoPlayCoroutine != null)
{
StopCoroutine(autoPlayCoroutine);
autoPlayCoroutine = null;
}
buttonImage.color = currentColor;
}
这样一来,我们的自动播放就可以流畅使用了!但还有个问题,我们希望当对话进入到选项时,自动播放不可以影响到玩家的选择。同时在点击对话历史、设置或是隐藏文本按钮后,自动播放可以停下来,以免影响体验。
在选项对话的推进脚本中写下如下代码。

这样一来,选择的主动权就可以正常地交给玩家了。然后在玩家选择历史对话、设置或者隐藏对话框按钮时,自动执行ButtonAuto方法,来结束自动播放,这是目前最简单的一种操作。

经过我的测试,现在的自动播放功能算是完全没问题了~
小结
这样一来,这个"星星火柴の『galgame对话系统』"的v1.0.0版本就告一段落了,后续的更新消息我会在github中进行同步,如果感兴趣的话可以多多关注ᕕ(◠ڼ◠)ᕗ。

如有补充交流欢迎留言,我们下次再见~
参考列表:
滚动条滚动面板制作(bilibili):BV1fS4y1x7fc