一个对话场景,使用CSV文件,根据当前索引加载对应的台词和角色图像,角色立绘动态显示,并支持玩家通过点击选项选择剧情分支并改变角色状态。

台词文件

打开Excel,添加台词,例:

id标志人物内容跳转效果目标
0对话角色A早上好1
1对话角色B2
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退场角色A12
12END
13对话角色A你不饿是吧14
14退场角色A15
15END

保存后导出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;
 }

更新角色立绘使用双重字典实现检索:

  1. roleNameToSO字典关联角色名与ScriptableObject数据
  2. 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);
  }
 }

更新布局

  1. 过滤非活跃角色,构建activeCharacters列表
  2. 计算约束后的总宽度:Mathf.Min((count - 1) * spacing, maxWidth)
  3. 推导单位宽度:totalWidth / characterCount
  4. 确定起始偏移:-unitWidth * (count-1)/2实现居中
  5. 迭代设置位置时保留原有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;
  }
 }

生成选项按钮:

  1. 解析选项文本时使用Regex.Replace清除特殊字符
  2. 动态绑定点击事件传递跳转ID和效果参数
  3. 通过buttonGroup父子关系管理实现批量销毁
  4. 效果参数采用@符号分隔格式,支持多类型效果组合
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}");
}
}


笔记参考: https://www.bilibili.com/video/BV1v5411D79x/

THE END