一个对话场景,使用CSV文件,根据当前索引加载对应的台词和角色图像,角色立绘动态显示,并支持玩家通过点击选项选择剧情分支并改变角色状态。
台词文件
打开Excel,添加台词,例:
| id | 标志 | 人物 | 内容 | 跳转 | 效果 | 目标 |
|---|---|---|---|---|---|---|
| 0 | 对话 | 角色A | 早上好 | 1 | ||
| 1 | 对话 | 角色B | 好 | 2 | ||
| 2 | 对话 | 角色A | 吃了吗您 | 3 | ||
| 3 | 选项 | 吃过了 | 6 | |||
| 4 | 选项 | 还没吃 | 7 | |||
| 5 | 选项 | 你猜 | 14 | 好感度减@1 | 角色B | |
| 6 | 对话 | 角色A | 那一起出去走走吧 | 10 | ||
| 7 | 对话 | 角色A | 那我们一起去吃吧 | 8 | ||
| 8 | 选项 | 走吧 | 13 | 体力值加@1 | 角色A | |
| 9 | 选项 | 我不饿 | 13 | |||
| 10 | 对话 | 角色B | 好吧 | 11 | ||
| 11 | 退场 | 角色A | 12 | |||
| 12 | END | |||||
| 13 | 对话 | 角色A | 你不饿是吧 | 14 | ||
| 14 | 退场 | 角色A | 15 | |||
| 15 | END |
保存后导出csv文件
Code
- 创建两个立绘的对象
- 导入ttf字体后,右键字体文件创建SDF
- 创建对话UI的TextMeshPro,分别显示角色名、台词文本内容
- 在UI中创建ButtonGroup空对象,用来存储选项按钮,并添加Grid Layout Group设置参数
- 创建CharacterGroup空对象用来存放玩家立绘
创建CharacterDataSO文件用来存储数据,为每一个角色创建SO文件
using UnityEngine;
[CreateAssetMenu(fileName = "CharacterDataSO", menuName = "SO/Dialog/CharacterDataSO")]
public class CharacterDataSO : ScriptableObject
{
public string characterName;
public Sprite characterImage;
public int characterLike;
public int characterStrength;
}
创建DialogManager脚本并挂载同名对象
对所需内容进行拖拽赋值:
- 台词的csv文件
- 对话框的两个TextMeshPro
- 角色预制体
- 角色的父级CharacterGroup
- 设置间隔宽度和最大宽度
- 加入对应的SO文件
- 选项的预制体
- 选项的父级ButtonGroup
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEngine.UI;
using System.Text.RegularExpressions;
public class DialogManager : MonoBehaviour
{
// 台词文件
public TextAsset dialogDataFile;
// 角色名字、台词
[Header("对话框")]
public TMP_Text nameText;
public TMP_Text contentText;
// 角色立绘
[Header("角色容器")]
public GameObject CharacterPrefab;
public Transform CharacterGroup;
// 角色间距
public float characterSpacing = 10f;
public float maxTotalWidth = 800f; // 防止角色过多时超出屏幕
// 角色名字和SO的映射关系
private Dictionary<string, GameObject> characterInstances = new Dictionary<string, GameObject>();
private Dictionary<string, CharacterDataSO> roleNameToSO = new Dictionary<string, CharacterDataSO>();
public List<CharacterDataSO> characters = new List<CharacterDataSO>();
// 选项的按钮预制体和父节点位置
[Header("选项")]
public GameObject buttonPrefab;
public Transform buttonGroup;
// 当前对话,索引,每行内容
[Header("对话内容")]
public int currentDialogIndex = 0;
public bool isAllowNextLine = true;
public string[] dialogLines;
读取CSV格式的台词文件,通过Split('\n')将文本按行分割存储到dialogLines数组中。为后续处理提供数据
public void ReadCSVLine(TextAsset csvFile)
{
dialogLines = csvFile.text.Split('\n');
}
处理跳转下一行的核心方法,通过遍历dialogLines数组寻找与当前currentDialogIndex索引匹配的行。采用int.TryParse进行安全解析避免格式错误导致的崩溃。通过currentDialogIndex 赋值实现非线性跳转,支持分支剧情设计。
- 对话:触发UI更新和角色显示
- 选项:禁用自动推进并生成交互按钮
- 退场:执行角色隐藏逻辑
- END:终止对话流程
public void ShowNextRow()
{
// 跳过标题行和空行
for (int i = 0; i < dialogLines.Length; i++)
{
string[] row = dialogLines[i].Split(',');
// 使用 TryParse 避免异常
if (int.TryParse(row[0], out int lineID) && lineID == currentDialogIndex && row[1] == "对话")
{
UpdateDialog(row[2], row[3]);
UpdateImage(row[2]);
currentDialogIndex = int.Parse(row[4]);
break;
}
else if (lineID == currentDialogIndex && row[1] == "选项")
{
isAllowNextLine = false;
GenerateOptionButton(i);
}
else if (lineID == currentDialogIndex && row[1] == "退场")
{
HandleExit(row[2]);
}
else if (lineID == currentDialogIndex && row[1] == "END")
{
Debug.Log("剧情结束");
isAllowNextLine = false;
}
}
}
更新台词
public void UpdateDialog(string RoleName, string Content)
{
nameText.text = RoleName;
contentText.text = Content;
}
更新角色立绘使用双重字典实现检索:
- roleNameToSO字典关联角色名与ScriptableObject数据
- characterInstances字典管理已实例化的角色对象,并在指定对象内创建新角色。通过SpriteRenderer组件动态更换立绘。
public void UpdateImage(string RoleName)
{
if (!roleNameToSO.TryGetValue(RoleName, out CharacterDataSO targetSO))
{
Debug.LogWarning($"找不到角色{RoleName}对应的SO文件");
return;
}
// 实例化角色(如果尚未存在)
if (!characterInstances.TryGetValue(RoleName, out GameObject instance))
{
instance = Instantiate(CharacterPrefab, CharacterGroup);
instance.name = RoleName;
characterInstances[RoleName] = instance;
Debug.Log("已创建角色:" + RoleName);
}
instance.SetActive(true); // 启用角色
UpdateCharactersLayout();
// Debug.Log("角色已启用:" + RoleName);
// 设置角色图片
SpriteRenderer renderer = instance.GetComponent<SpriteRenderer>();
if (renderer != null)
{
renderer.sprite = targetSO.characterImage;
}
}
角色退出,将角色禁用
public void HandleExit(string RoleName)
{
if (characterInstances.TryGetValue(RoleName, out GameObject instance))
{
instance.SetActive(false); // 禁用角色
UpdateCharactersLayout();
Debug.Log("角色已退出:" + RoleName);
}
}
更新布局
- 过滤非活跃角色,构建
activeCharacters列表 - 计算约束后的总宽度:
Mathf.Min((count - 1) * spacing, maxWidth) - 推导单位宽度:
totalWidth / characterCount - 确定起始偏移:
-unitWidth * (count-1)/2实现居中 - 迭代设置位置时保留原有Z轴坐标,兼容3D场景,特殊处理单个角色情况直接归零位置,避免浮点计算误差。
private void UpdateCharactersLayout()
{
// 获取所有活跃角色
List<Transform> activeCharacters = new List<Transform>();
foreach (var instance in characterInstances.Values)
{
if (instance.activeSelf)
{
activeCharacters.Add(instance.transform);
}
}
int count = activeCharacters.Count;
if (count == 0) return;
// 动态计算总宽度(基于角色数量)
// float dynamicTotalWidth = (count - 1) * characterSpacing;
float dynamicTotalWidth = Mathf.Min((count - 1) * characterSpacing, maxTotalWidth);
// 新增:根据角色数量调整布局逻辑
if (count > 1)
{
// 计算平均分布的每个单位宽度
float unitWidth = dynamicTotalWidth / count;
float startX = -unitWidth * (count - 1) / 2;
// 设置每个角色的位置
for (int i = 0; i < count; i++)
{
Vector3 newPos = new Vector3(
startX + (i * unitWidth),
0,
activeCharacters[i].localPosition.z
);
activeCharacters[i].localPosition = newPos;
}
}
else
{
// 单个角色时居中显示
activeCharacters[0].localPosition = Vector3.zero;
}
}
生成选项按钮:
- 解析选项文本时使用
Regex.Replace清除特殊字符 - 动态绑定点击事件传递跳转ID和效果参数
- 通过buttonGroup父子关系管理实现批量销毁
- 效果参数采用
@符号分隔格式,支持多类型效果组合
public void GenerateOptionButton(int index)
{
string[] row = dialogLines[index].Split(',');
if (row[1] == "选项")
{
GameObject button = Instantiate(buttonPrefab, buttonGroup);
button.GetComponentInChildren<TMP_Text>().text = row[3];
button.GetComponent<Button>().onClick.AddListener(() =>
{
OnOptionClick(int.Parse(row[4]));
if (row[5] != "")
{
string[] effect = row[5].Split('@');
row[6] = Regex.Replace(row[6], @"[\r\n\s]", "");
OptionEffect(effect[0], int.Parse(effect[1]), row[6]);
}
});
GenerateOptionButton(index + 1);
}
}
退出对话,将所有按钮遍历销毁
private void OnOptionClick(int id)
{
currentDialogIndex = id;
ShowNextRow();
for (int i = 0; i < buttonGroup.childCount; i++)
{
Destroy(buttonGroup.GetChild(i).gameObject);
}
isAllowNextLine = true;
}
效果处理(附加)
public void OptionEffect(string effect, int param, string target)
{
if (!roleNameToSO.TryGetValue(target, out CharacterDataSO targetSO))
{
Debug.LogWarning($"找不到目标角色{target}对应的SO文件");
return;
}
if (effect == "好感度加")
{
targetSO.characterLike += param;
Debug.Log($"{targetSO.characterName}好感度+1,当前好感度:{targetSO.characterLike}");
}
else if (effect == "好感度减")
{
targetSO.characterLike -= param;
Debug.Log($"{targetSO.characterName}好感度-1,当前好感度:{targetSO.characterLike}");
}
else if (effect == "体力值加")
{
targetSO.characterStrength += param;
Debug.Log($"{targetSO.characterName}体力值:{targetSO.characterStrength}");
}
}
。







Comments | NOTHING