Stories

Detail Return Return

手把手教你在unity中實現一個視覺小説系統(一) - Stories Detail

目前市面上這類功能已經很多了,這邊是本人在遊戲項目開發中的一個過程記錄。

美術素材來自互聯網,如有侵權請聯繫我及時刪除。

視頻封面

本期基本功能

  • 打字機效果,單擊後全部顯示、第二次點擊後出現下一句
  • 分支跳轉:Choice、JumpTo
  • log歷史記錄
  • 自動播放auto與速度調節
  • skip到下一個分歧點
  • speaker高亮(非speaker半透明黑色顯示)、清除立繪等

系統結構設計

預備知識:Scriptable Object

Scriptable Object允許我們在不附加到遊戲對象的情況下存儲大量數據。比如在設計一個揹包系統時,將揹包內物體定義為一個Scriptable Object,含有幾種屬性,如物品名稱、數量、sprite圖等等,不同物體的屬性值不同,都可以在Scirptable Object的Element中進行定義。

舉個例子:揹包中的物體定義

ScriptableObject:ItemData

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中表現應如下:

img

添加圖片註釋,不超過 140 字(可選)

img

添加圖片註釋,不超過 140 字(可選)

對話系統設計

關於視覺小説對話系統的邏輯,我畫了一個簡單示意圖輔助理解:

img

簡單示意圖

感覺還是挺簡單明瞭的吧(擦汗)

那麼給出代碼如下:

DialogueLine.cs

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並添加內容後即可看到效果如下:

img

添加圖片註釋,不超過 140 字(可選)

打字機效果,單擊後全部顯示、第二次點擊後出現下一句

協程的應用

打字機效果主要依賴協程的使用,對於新手小白來説可能比較複雜,簡單來説協程就是一個允許在特定位置暫停和恢復執行,從而實現非搶佔式多任務處理的程序組件。也就是説,協程提供了一個暫停時間和恢復的方法,而且在執行 I/O 或長時間任務時不會阻塞主線程,而是通過掛起讓出控制權,使其他協程可以運行。

舉個栗子:打字機效果需要每0.02秒打出一個字,這裏就需要協程將程序暫停0.02秒後顯示下一個字(即恢復執行)但是在這期間,你不希望遊戲的其他線程被阻塞。

再舉個栗子:你要控制某個物體漸漸地由不透明變成完全透明。那麼這裏就需要協程控制每一幀都將透明度減去你所定義的一個固定值,如果不採用協程的話,程序就會在某一幀kua的一下突然變成全透明。(舉例來源:[知乎@宇亓](https://zhuanlan.zhihu.com/p/1969535460939396859/(1 封私信 / 5 條消息) Unity協程的原理與應用 - 知乎))

直接上打字機功能代碼看一下:

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呢哼)

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編寫函數控制流程。

    // 玩家點擊操作,處理下一個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代碼如下:

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 都設計成獨立的協程循環:

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 模式僅修改了間隔時間與顯示速度:

IEnumerator SkipRoutine()
{
    isSkipping = true;
    isAutoPlaying = false;

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

        yield return new WaitForSeconds(skipSpeed);
    }
} 

狀態切換與中斷機制

在自動播放或跳過時,玩家可能會:

  1. 點擊鼠標取消;
  2. 切換模式;
  3. 進入分支選項。

為了避免邏輯衝突,使用狀態標誌與主動停止函數

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() 等涉及交互的函數中,會立即終止自動/跳過模式

StopAutoMode();
StopSkipMode();
 

這樣確保當出現選項時,玩家不會被“自動播放”跳過選擇機會。

Skip 模式的“快顯”邏輯

在 ShowLineAtIndex() 中,通過判斷 IsFastMode(即 isSkipping)跳過打字機動畫:

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並顯示時調用:

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等

Add a new Comments

Some HTML is okay.