手把手教你在unity中实现一个视觉小说系统(一)

目前市面上这类功能已经很多了,这边是本人在游戏项目开发中的一个过程记录。

美术素材来自互联网,如有侵权请联系我及时删除。

本期基本功能

  • 打字机效果,单击后全部显示、第二次点击后出现下一句
  • 分支跳转:Choice、JumpTo
  • log历史记录
  • 自动播放auto与速度调节
  • skip到下一个分歧点
  • speaker高亮(非speaker半透明黑色显示)、清除立绘等

系统结构设计

预备知识:Scriptable Object

Scriptable Object允许我们在不附加到游戏对象的情况下存储大量数据。比如在设计一个背包系统时,将背包内物体定义为一个Scriptable Object,含有几种属性,如物品名称、数量、sprite图等等,不同物体的属性值不同,都可以在Scirptable Object的Element中进行定义。

举个例子:背包中的物体定义

ScriptableObject:ItemData

c# 复制代码
using UnityEngine;

//ItemType 枚举
public enum ItemType
{
    Consumable,   // 消耗品(药水、食物等)
    Equipment,    // 装备(武器、防具等)
    Material,     // 材料(合成素材等)
    QuestItem,    // 任务物品
    Other         // 其他
} 

[CreateAssetMenu(fileName = "NewItem", menuName = "Inventory/Item Data", order = 1)]
public class ItemData : ScriptableObject
{
    [Header("基本信息")]
    public string itemName;          // 物品名称
    [TextArea(2, 4)] public string description;  // 物品描述
    public Sprite icon;              // 图标
    public ItemType itemType;        // 物品类型
    public int maxStack = 99;        // 最大堆叠数量

    [Header("物品参数")]
    public int value;                // 物品价值(卖出价或使用效果强度等)
    public GameObject worldPrefab;   // 掉落到场景中的实体预制体(可选)

    /// <summary>
    /// 使用物品的逻辑,可由继承类重写
    /// </summary>
    public virtual void Use()
    {
        Debug.Log($"使用物品:{itemName}");
    }
} 

通过上面两段代码即可定义一个背包物品,在创建的时候只需要在Unity的 Assets 文件夹中右键 → Create → Inventory → Item Data即可。

在Unity中表现应如下:

添加图片注释,不超过 140 字(可选)

添加图片注释,不超过 140 字(可选)

对话系统设计

关于视觉小说对话系统的逻辑,我画了一个简单示意图辅助理解:

简单示意图

感觉还是挺简单明了的吧(擦汗)

那么给出代码如下:

DialogueLine.cs

c# 复制代码
using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public enum LineAction
{
    None,
    WaitForClick,
    AutoAdvance,
    Choice,
    JumpTo
}

/// <summary>
/// 立绘显示位置(左、右、中)
/// </summary>
public enum PortraitPosition
{
    None,
    Left,
    Right,
    Center
}

[Serializable]
public class DialogueLine
{
    [Header("基本信息")]
    public string id; // 用于跳转或保存
    public string speaker; // 角色名
    [TextArea(2, 6)] public string text;

    [Header("立绘与表现")]
    public Sprite portrait; // 立绘图像
    public string expression; // 表情 tag,可用于动态换表情
    public PortraitPosition portraitPosition = PortraitPosition.Left; // 立绘位置
    public bool clearPortrait = false; // 是否清空该侧立绘
    public bool keepOtherDim = true; // 是否让非说话侧立绘变暗(半黑)

    [Header("音效与动作")]
    public AudioClip sfx; // 播放音效
    public LineAction action = LineAction.WaitForClick;
    public float autoDelay = 2f; // 自动前进延迟(仅当 AutoAdvance 时)

    [Header("分支控制")]
    public List<Choice> choices; // 如果 action == Choice
    public string jumpTargetId; // 如果 action == JumpTo

    [Header("表现控制")]
    public bool highlightSpeaker = true; // 当前说话者是否高亮
}

/// <summary>
/// 对话选项
/// </summary>
[Serializable]
public class Choice
{
    public string text; // 选择文本
    public string targetLineId; // 选择后跳转到的 line id
    public string setFlag; // 可选:选择会设置的变量
}

/// <summary>
/// 对话序列 ScriptableObject
/// </summary>
[CreateAssetMenu(fileName = "DialogueSequence", menuName = "VN/DialogueSequence")]
public class DialogueSequence : ScriptableObject
{
    public List<DialogueLine> lines = new List<DialogueLine>();
}

在unity中新建dialogue Sequence并添加内容后即可看到效果如下:

添加图片注释,不超过 140 字(可选)

打字机效果,单击后全部显示、第二次点击后出现下一句

协程的应用

打字机效果主要依赖协程的使用,对于新手小白来说可能比较复杂,简单来说协程就是一个允许在特定位置暂停和恢复执行,从而实现非抢占式多任务处理的程序组件。也就是说,协程提供了一个暂停时间和恢复的方法,而且在执行 I/O 或长时间任务时不会阻塞主线程,而是通过挂起让出控制权,使其他协程可以运行。

举个栗子:打字机效果需要每0.02秒打出一个字,这里就需要协程将程序暂停0.02秒后显示下一个字(即恢复执行)但是在这期间,你不希望游戏的其他线程被阻塞。

再举个栗子:你要控制某个物体渐渐地由不透明变成完全透明。那么这里就需要协程控制每一帧都将透明度减去你所定义的一个固定值,如果不采用协程的话,程序就会在某一帧kua的一下突然变成全透明。(举例来源:[知乎@宇亓](https://zhuanlan.zhihu.com/p/1969535460939396859/(1 封私信 / 5 条消息) Unity协程的原理与应用 - 知乎))

直接上打字机功能代码看一下:

c# 复制代码
IEnumerator TypeTextCoroutine(string text, System.Action onComplete)
    {
        isTyping = true;
        contentText.text = "";

        for (int i = 0; i < text.Length; i++)
        {
            if (skipTyping)
            {
                contentText.text = text;
                break;
            }
            contentText.text += text[i];
            yield return new WaitForSeconds(typeSpeed);
        }

        skipTyping = false;
        isTyping = false;
        onComplete?.Invoke();
    }

使用协程的时候需要注意,unity中协程函数以IEnumerator为返回值,当函数运行到yield return语句时,协程会被暂停,等待yield return后的函数执行完毕后被唤醒,不阻碍unity继续执行其他逻辑。在这段代码中表现为打印一个字符后,暂停typeSpeed秒后协程被唤醒,继续执行下一个字符输出。

点击切换操作

实现好了打字机效果之后我们看一下如何点一下结束协程显示全部内容,再点一下切换到下一个对话。

首先写一个DialoguePanelClickHandle.cs脚本挂载到对话面板上,使得玩家只有在点击对话面板的时候才触发切换操作(当然不是为了掩饰其他功能bug呢哼)

c# 复制代码
using UnityEngine;
using UnityEngine.EventSystems;

public class DialoguePanelClickHandler : MonoBehaviour, IPointerClickHandler
{
    [SerializeField]private DialogueManager dialogueManager;
    public void Start()
    {
        dialogueManager = GameObject.Find("DialogueSystem").GetComponent<DialogueManager>();
    }

    // 当玩家点击此 UI 面板时触发
    public void OnPointerClick(PointerEventData eventData)
    {
        // 可选:仅当左键点击
        if (eventData.button != PointerEventData.InputButton.Left)
            return;

        // 调用 DialogueManager 的推进函数
        dialogueManager.OnNextClicked();
    }
}
    // 玩家点击操作,处理下一个line
    public void OnNextClicked()
    {
        // 若正在打字,则跳过打字机直接显示全文
        if (isTyping)
        {
            skipTyping = true;
            return;
        }

        AdvanceIndex();
    } 
    void AdvanceIndex()
    {
        currentIndex++;
        if (currentIndex >= currentSequence.lines.Count)
        {
            EndSequence();
        }
        else
        {
            ShowLineAtIndex(currentIndex);
        }
    }

    //显示当前对话line
     void ShowLineAtIndex(int index)
    {
        if (index < 0 || index >= currentSequence.lines.Count)
        {
            EndSequence();
            return;
        }

        var line = currentSequence.lines[index];
        nameText.text = string.IsNullOrEmpty(line.speaker) ? "" : line.speaker;

        // 清理打字机
        if (typingCoroutine != null)
            StopCoroutine(typingCoroutine);

        // --- Skip 模式下立即显示 ---
        if (IsFastMode)
        {
            contentText.text = line.text;
            isTyping = false;
            skipTyping = false;
            nextIndicator.SetActive(true);
            HandlePostLineAction(line);
        }
        else
        {
            typingCoroutine = StartCoroutine(TypeTextCoroutine(line.text, () =>
            {
                nextIndicator.SetActive(true);
                HandlePostLineAction(line);
            }));
        }

        DialogueLogManager.Instance?.AddLog(line.speaker, line.text);
    }

分支跳转:Choice、JumpTo

将是否在当前位置产生分支或跳转写在刚刚的dialogue Sequence里,并通过 Dialogue Manager.cs编写函数控制流程。

c# 复制代码
    // 玩家点击操作,处理下一个line
    public void OnNextClicked()
    {
        // 若正在打字,则跳过打字机直接显示全文
        if (isTyping)
        {
            skipTyping = true;
            return;
        }

        var line = currentSequence.lines[currentIndex];
        if (line.action == LineAction.Choice) return;

        if (line.action == LineAction.JumpTo)
        {
            int targetIndex = FindLineIndexById(line.jumpTargetId);
            if (targetIndex >= 0)
            {
                currentIndex = targetIndex;
                ShowLineAtIndex(currentIndex);
                return;
            }
        }

        AdvanceIndex();
    } 


    // 玩家选择选项后根据line的ID进行跳转
    void OnChoiceSelected(Choice c)
    {

        // 选择后停掉 Auto 和 Skip 模式
        StopAutoMode();
        StopSkipMode();

        if (!string.IsNullOrEmpty(c.setFlag))
        {
            //GameState.Instance.SetFlag(c.setFlag, true);
        }
        int targetIndex = FindLineIndexById(c.targetLineId);
        if (targetIndex >= 0)
            currentIndex = targetIndex;
        else
            currentIndex++;
        ShowLineAtIndex(currentIndex);
    }


    // 通过ID查找Line
    int FindLineIndexById(string id)
    {
        for (int i = 0; i < currentSequence.lines.Count; i++)
        {
            if (currentSequence.lines[i].id == id) return i;
        }
        return -1;
    }

Log历史记录

Log日志功能遵循一个简单原则:

"在每次显示对白后,记录一条日志。"

每一条对白 (DialogueLine) 都包含说话人 speaker 与文本 text,这些信息被统一推送给日志管理器单例。

复制代码
DialogueLogManager.Instance?.AddLog(line.speaker, line.text);

这行代码放在 ShowLineAtIndex() 的最后,确保:

  • 无论是普通对白、自动播放或跳过模式;
  • 只要对白被显示,就一定会被记录。

Log数据结构的主要思路是创建一个List<List >的嵌套结构,举个栗子大概是下面这样:

List 按理来说这个格子应该和右边合并,但我没找到合并键
Speaker Name 1 吃了吗您?
Speaker Name 2 没吃呢。
Speaker Name 1 人是铁饭是钢,一顿不吃饿得慌
Speaker Name 1 快去吃饭吧
Speaker Name 2 好的好的

完整的DialogueLogManager.cs代码如下:

c# 复制代码
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEngine.UI;


public class DialogueLogManager : MonoBehaviour
{
    public static DialogueLogManager Instance;

    [Header("UI References")]
    public GameObject logPanel; // 整个历史记录面板
    public Transform logContent; // ScrollView 的 Content
    public GameObject logEntryPrefab; // 每条记录的Text或自定义项

    [SerializeField]private List<List<string>> logEntries = new List<List<string>>();
    private bool isVisible = false;

    void Awake()
    {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);
    }

    /// <summary>
    /// 添加一条历史记录(通常由 DialogueManager 调用)
    /// </summary>
    public void AddLog(string speaker, string text)
    {
        List<string> entry=new List<string>();
        if (string.IsNullOrEmpty(speaker))
        {
            entry.Add("");
            entry.Add(text);
        }
        else {
            entry.Add(speaker);
            entry.Add(text);
        }
        
        logEntries.Add(entry);

        // 同步显示到 UI(如果 logPanel 当前打开)
        if (isVisible)
            CreateEntryUI(entry);
    }

    /// <summary>
    /// 打开历史面板
    /// </summary>
    public void ShowLog()
    {
        if (isVisible) return;
        isVisible = true;
        logPanel.SetActive(true);
        RefreshLogUI();
    }

    /// <summary>
    /// 关闭历史面板
    /// </summary>
    public void HideLog()
    {
        isVisible = false;
        logPanel.SetActive(false);
    }

    /// <summary>
    /// 重新绘制所有历史记录
    /// </summary>
    void RefreshLogUI()
    {
        foreach (Transform child in logContent)
            Destroy(child.gameObject);

        foreach (var entry in logEntries)
            CreateEntryUI(entry);
    }

    void CreateEntryUI(List<string> entry)
    {
        string entryName = entry[0];
        string entryText = entry[1];
        var go = Instantiate(logEntryPrefab, logContent);
        var textName = go.transform.Find("LogNameText").GetComponent<TextMeshProUGUI>();
        var textText = go.transform.Find("LogText").GetComponent<TextMeshProUGUI>();
        if (textName != null || textText != null) { 
            textName.text = entryName;
            textText.text = entryText;
        }
    }

    /// <summary>
    /// 清除所有记录(如新章节)
    /// </summary>
    public void ClearLog()
    {
        logEntries.Clear();
        foreach (Transform child in logContent)
            Destroy(child.gameObject);
    }
}

自动播放与Auto调节&Skip到下一个分歧点

  • Auto 模式:玩家启用后,对话将自动进行,无需点击"下一句",系统会在每句对白播放完后自动延迟几秒并进入下一句。
  • Skip 模式:玩家启用后,对话以极快速度(通常不带打字机动画)连续播放,常用于"跳过已读文本"。

这两种功能的实现都要考虑协程逻辑控制、打字机状态管理、分支处理与玩家中断行为

协程驱动的核心思路

在 Unity 中,"连续执行 + 可中断"的逻辑非常适合用 Coroutine(协程) 来实现。 DialogueManager 的实现将 Auto 与 Skip 都设计成独立的协程循环:

c# 复制代码
IEnumerator AutoPlayRoutine()
{
    isAutoPlaying = true;
    isSkipping = false;

    while (isAutoPlaying && currentSequence != null)
    {
        if (!isTyping && !choicePanel.activeSelf)
            OnNextClicked();

        yield return new WaitForSeconds(autoDelay);
    }
}
 
  • isTyping:防止在打字机效果播放时提前跳句;
  • choicePanel.activeSelf:有选项分支时暂停自动播放;
  • WaitForSeconds(autoDelay):控制每句对白之间的间隔。

Skip 模式仅修改了间隔时间与显示速度:

c# 复制代码
IEnumerator SkipRoutine()
{
    isSkipping = true;
    isAutoPlaying = false;

    while (isSkipping && currentSequence != null)
    {
        if (!isTyping && !choicePanel.activeSelf)
            OnNextClicked();

        yield return new WaitForSeconds(skipSpeed);
    }
} 

状态切换与中断机制

在自动播放或跳过时,玩家可能会:

  1. 点击鼠标取消;
  2. 切换模式;
  3. 进入分支选项。

为了避免逻辑冲突,使用状态标志与主动停止函数

c# 复制代码
public void StopAutoMode()
{
    if (autoRoutine != null)
    {
        StopCoroutine(autoRoutine);
        autoRoutine = null;
    }
    isAutoPlaying = false;
}

public void StopSkipMode()
{
    if (autoRoutine != null)
    {
        StopCoroutine(autoRoutine);
        autoRoutine = null;
    }
    isSkipping = false;
}

在 ShowChoices() 和 OnChoiceSelected() 等涉及交互的函数中,会立即终止自动/跳过模式

c# 复制代码
StopAutoMode();
StopSkipMode();
 

这样确保当出现选项时,玩家不会被"自动播放"跳过选择机会。

Skip 模式的"快显"逻辑

在 ShowLineAtIndex() 中,通过判断 IsFastMode(即 isSkipping)跳过打字机动画:

c# 复制代码
if (IsFastMode)
{
    contentText.text = line.text; // 直接显示全文
    nextIndicator.SetActive(true);
    HandlePostLineAction(line);
}
else
{
    typingCoroutine = StartCoroutine(TypeTextCoroutine(...));
}
 

speaker高亮(非speaker半透明黑色显示)、清除立绘

这一部分的实现思路是将当前speaker是否高亮、是否需要清除当前立绘或清除全部立绘写入Dialogue Sequecnce中,也即前文提到的那个Scriptable Object,而具体的操作逻辑则写入Dialoguage Manager.cs中,实现函数如下,该函数在读取下一line并显示时调用:

c# 复制代码
void UpdatePortraits(DialogueLine line)
    {
        // 1️ 清空立绘逻辑
        if (line.clearPortrait)
        {
            if (line.portraitPosition == PortraitPosition.Left && leftPortrait != null)
                leftPortrait.gameObject.SetActive(false);
            else if (line.portraitPosition == PortraitPosition.Right && rightPortrait != null)
                rightPortrait.gameObject.SetActive(false);
            else if (line.portraitPosition == PortraitPosition.Center && centerPortrait != null)
                centerPortrait.gameObject.SetActive(false);
            else
            {
                leftPortrait.gameObject.SetActive(false);
                rightPortrait.gameObject.SetActive(false);
                centerPortrait.gameObject.SetActive(false);
            }
            return;
        }

        // 2️ 更新当前侧立绘图像
        if (line.portrait != null)
        {
            switch (line.portraitPosition)
            {
                case PortraitPosition.Left:
                    if (leftPortrait != null)
                    {
                        leftPortrait.sprite = line.portrait;
                        leftPortrait.gameObject.SetActive(true);
                    }
                    else leftPortrait.gameObject.SetActive(false);
                    break;
                case PortraitPosition.Right:
                    if (rightPortrait != null)
                    {
                        rightPortrait.sprite = line.portrait;
                        rightPortrait.gameObject.SetActive(true);
                    }
                    else rightPortrait.gameObject.SetActive(false);
                    break;
                case PortraitPosition.Center:
                    if (centerPortrait != null)
                    {
                        centerPortrait.sprite = line.portrait;
                        centerPortrait.gameObject.SetActive(true);
                    }
                    else centerPortrait.gameObject.SetActive(false);
                    break;
            }
        }

        // 3️ 亮暗处理
        if (line.keepOtherDim)
        {
            // 当前说话方亮
            Image activePortrait = null;
            if (line.portraitPosition == PortraitPosition.Left) activePortrait = leftPortrait;
            else if (line.portraitPosition == PortraitPosition.Right) activePortrait = rightPortrait;
            else if (line.portraitPosition == PortraitPosition.Center) activePortrait = centerPortrait;

            if (activePortrait != null) activePortrait.color = normalColor;

            // 其他侧变暗(存在的才处理)
            if (leftPortrait != null && leftPortrait.gameObject.activeSelf && activePortrait != leftPortrait)
                leftPortrait.color = dimColor;
            if (rightPortrait != null && rightPortrait.gameObject.activeSelf && activePortrait != rightPortrait)
                rightPortrait.color = dimColor;
            if (centerPortrait != null && centerPortrait.gameObject.activeSelf && activePortrait != centerPortrait)
                centerPortrait.color = dimColor;
        }
    }

下一期改进方向

  • 自动播放可调速度
  • 仅跳过已读文本,未读文本不可skip
  • 游戏设置界面(音量、auto速度等)
  • 支持跨Dialogue Sequence与跨场景切换
  • 支持添加mini game等
相关推荐
在路上看风景12 小时前
15. 纹理尺寸是4的倍数
unity
AT~15 小时前
unity 使用Socket和protobuf实现网络连接
unity·游戏引擎
怣疯knight21 小时前
Cocos creator判断节点是否能用的方法
unity·cocos2d
tealcwu21 小时前
Google Play的Keystore不可用时的解决方法
unity
呼呼突突1 天前
Unity使用TouchSocket的RPC
unity·rpc·游戏引擎
qq 180809512 天前
从零构建一个多目标多传感器融合跟踪器
unity
平行云2 天前
实时云渲染支持在网页上运行UE5开发的3A大作Lyra项目
unity·云原生·ue5·webgl·虚拟现实·实时云渲染·像素流送
鹏飞于天2 天前
Shader compiler initialization error: Failed to read D3DCompiler DLL file
unity
wonder135792 天前
UGUI重建流程和优化
unity·游戏开发·ugui
那个村的李富贵2 天前
Unity打包Webgl后 本地运行测试
unity·webgl