Featured image of post 麦田物语开发日记(十)

麦田物语开发日记(十)

构建对话系统

构建对话系统

构建对话UI的canvas,并完成内容显示

创建过程中需要注意如果要保证对话框内容主要是通过

image-20230208172306556

以下三个组件来控制

创建对话数据实现对话逻辑

该部分的主要实现方式是构建一个专门记录对话信息的数据结构,然后可以用SO文件的方式来存储,也可以直接挂载到某个人物身上。当与人物触发对话的时候,就通过事件系统,不断将对话数据传递到对话的Canvas中,显示对应数据。

  • 对话数据结构构建

可以在每一个对话部分都构建一个事件,当对话完成后,可以直接调用事件,创造更多的内容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[System.Serializable]
public class DialoguePiece
{
    [Header("对话详情")] public Sprite faceImage;
    public bool onLeft;     /*人物头像的位置*/
    public string characterName;
    [TextArea] public string dialogueText;
    public bool hasToPause;     /* 是否需要停止,即还有下一句话 */
    [HideInInspector]public bool isDone;         /* 对话是否完成 */
    // public UnityEvent afterDialogueEvent;         /*对话完成后的事件*/ 
}

然后为每个NPC挂载操作对话数据的内容,然后使用栈来控制对话数据,如果数据需要被展示,则通过事件通知的方法,告诉DialogueUI显示对话内容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
[RequireComponent(typeof(BoxCollider2D))]
[RequireComponent(typeof(NpcMovement))]
public class DialogueController : MonoBehaviour
{
    public List<DialoguePiece> dialoguePieces = new List<DialoguePiece>();
    private NpcMovement NpcMovement => GetComponent<NpcMovement>();
    public UnityEvent onFinishEvent;

    private Stack<DialoguePiece> _dialoguePieceStack = new Stack<DialoguePiece>();
    private bool _canTalk;
    private bool _isTalking;        /* 防止生成过多协程 */
    private GameObject _uiSign;


    private void Awake()
    {
        _uiSign = transform.GetChild(1).gameObject;
        FillDialogueStack();
        
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (other.CompareTag("Player"))
        {
            _canTalk = !NpcMovement.isMoving && NpcMovement.interactable;
        }
    }

    private void OnTriggerExit2D(Collider2D other)
    {
        if (other.CompareTag("Player"))
        {
            _canTalk = false;
        }
    }

    private void Update()
    {
        _uiSign.SetActive(_canTalk);
        if (Input.GetKeyDown(KeyCode.Space) && _canTalk && !_isTalking)
        {
            StartCoroutine(DialogueRoutine());
        }
    }


    /// <summary>
    /// 填充对话
    /// </summary>
    private void FillDialogueStack()
    {
        /* 由于是入栈,因此采用倒序的方式 */
        for (int i = dialoguePieces.Count - 1; i >= 0; i--)
        {
            dialoguePieces[i].isDone = false;
            _dialoguePieceStack.Push(dialoguePieces[i]);
        }
    }

    private IEnumerator DialogueRoutine()
    {
        _isTalking = true;
        if (_dialoguePieceStack != null && _dialoguePieceStack.Count > 0)
        {
            if (_dialoguePieceStack.TryPop(out DialoguePiece result))
            {
                /* 将结果发送到UI界面更新UI */
                EventHandler.CallShowDialogueEvent(result);
                yield return new WaitUntil(() => result.isDone);        /* 直到点击空格,才能显示后续内容 */
                _isTalking = false;
            }
        }
        else
        {
            EventHandler.CallShowDialogueEvent(null);
            FillDialogueStack();
            _isTalking = false;
            onFinishEvent?.Invoke();
        }
    }
}
  • 为UI显示内容
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
public class DialogueUI : MonoBehaviour
{
    public GameObject dialogueBox;
    public Text dialogueText;
    public Image faceRight, faceLeft;
    public TextMeshProUGUI nameRight, nameLeft;
    public GameObject continueBox;

    private void Awake()
    {
        dialogueBox.SetActive(false);
        continueBox.SetActive(false);
    }

    private void OnEnable()
    {
        EventHandler.ShowDialogueEvent += OnShowDialogueEvent;
    }

    private void OnDisable()
    {
        EventHandler.ShowDialogueEvent -= OnShowDialogueEvent;
        
    }

    #region 注册事件

    private void OnShowDialogueEvent(DialoguePiece piece)
    {
        StartCoroutine(ShowDialogue(piece));
    }

    #endregion

    #region 显示对话窗口

    /* 由于要文字一点一点打出来,因此需要使用协程方法 */
    private IEnumerator ShowDialogue(DialoguePiece piece)
    {
        if (piece is not null)
        {
            piece.isDone = false;
            dialogueBox.SetActive(true);
            continueBox.SetActive(false);
            dialogueText.text = String.Empty;

            if (piece.characterName != String.Empty)
            {
                faceLeft.gameObject.SetActive(piece.onLeft);
                faceRight.gameObject.SetActive(!piece.onLeft);
                if (piece.onLeft)
                {
                    faceLeft.sprite = piece.faceImage;
                    nameLeft.text = piece.characterName;
                }
                else
                {
                    faceRight.sprite = piece.faceImage;
                    nameRight.text = piece.characterName;
                }
            }
            else
            {
                faceLeft.gameObject.SetActive(false);
                faceRight.gameObject.SetActive(false);
                nameLeft.gameObject.SetActive(false);
                nameRight.gameObject.SetActive(false);
            }
            /*等当前语句执行结束才能继续下一步*/
            yield return dialogueText.DOText(piece.dialogueText, 1f).WaitForCompletion();
            piece.isDone = true;

            if (piece.hasToPause && piece.isDone)
                continueBox.SetActive(true);
            else
                continueBox.SetActive(true);
        }
        else
        {
            dialogueBox.SetActive(false);
                yield break;
        }
    }
    

    #endregion
}

实现物品交易

以角色背包为基础,设计一个通用背包的UI,该UI以内容完全自适应为基础,即背包的大小会随着内容的大小而改变,因此每次都需要重新生成物品内容的slot.

如果父物体设置了Vertical Layout Group,但是子物体向摆脱父物体的自适应,则需要为子物体设置LayoutElement,然后选择忽略布局。

然后再InventoryUI中设置需要的变量,为通用背包赋值

1
2
3
[Header("通用背包")] [SerializeField] private GameObject baseBag;
public GameObject shopSlotPrefab;
[SerializeField] private List<SlotUI> baseBagSlots;

设置对话结束事件,为用户打开商店做准备,由于通知NPC打开背包是再NPC身上调用的,因此还是需要通过事件的方式来触发打开背包,然后让Inventory来注册背包打开的函数。最后在完成对话后直接调用该事件即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class NpcFunction : MonoBehaviour
{
    public InventoryBag_SO shopData;
    private bool _isOpen;

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Escape) && _isOpen)
        {
            _isOpen = false;
            // 关闭背包
            
        }
    }

    public void OpenShop()
    {
        _isOpen = true;
        EventHandler.CallBaseBagOpenEvent(SlotType.Shop, shopData);
    }
}
  • inventoryUI打开商店事件

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    private void OnBaseBagOpenEvent(SlotType slotType, InventoryBag_SO bagSo)
    {
        GameObject slotPrefab = slotType switch
        {
            SlotType.Shop => shopSlotPrefab,
            _ => null
        };
    
        baseBag.SetActive(true);
    
        for (int i = 0; i < bagSo.inventoryItemList.Count; i++)
        {
            SlotUI slot = Instantiate(slotPrefab, baseBag.transform.GetChild(1)).GetComponent<SlotUI>();
            slot.slotIndex = i;
            baseBagSlots.Add(slot);
        }
        /* 强制更新界面 */
        LayoutRebuilder.ForceRebuildLayoutImmediate(baseBag.GetComponent<RectTransform>());
        /* 更新格子 */
        OnUpdateInventoryUI(InventoryLocation.Shop, bagSo.inventoryItemList);
    
    
    }
    
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 更新界面的方法
case InventoryLocation.Shop:
    for (int i = 0; i < items.Count; i++)
    {
        if (items[i].itemAmount > 0)
        {
            // 获得需要更新的物体的具体信息
            var itemDetails = InventoryManager.Instance.GetItemDetails(items[i].itemID);
            baseBagSlots[i].UpdateSlot(itemDetails, items[i].itemAmount);
        }
        else
        {
            baseBagSlots[i].UpdateEmptySlot();
        }
    }
    break;

强制更新UI界面需要使用

1
LayoutRebuilder.ForceRebuildLayoutImmediate(baseBag.GetComponent<RectTransform>());

实现物品交易结束以及拖拽交易

  • 交易结束

注册交易结束事件,然后点击关闭的时候调用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// inventory中注册的关闭事件的方法
private void OnBaseBagCloseEvent(SlotType slotType, InventoryBag_SO bagSo)
{
    baseBag.SetActive(false);
    itemTooltip.gameObject.SetActive(false);
    UpdateSlotHighlight(-1);

    foreach (var slot in baseBagSlots)
    {
        Destroy(slot.gameObject);
    }
    baseBagSlots.Clear();
}
  • 使用不同的状态来表示游戏的运行状态(GamePlayPause
1
2
3
4
5
6
public static event Action<GameState> UpdateGameStateEvent;

public static void CallUpdateGameStateEvent(GameState gameState)
{
    UpdateGameStateEvent?.Invoke(gameState);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// player.cs中注册人物移动
private void OnUpdateGameStateEvent(GameState gameState)
{
    switch (gameState)
    {
        case GameState.Gameplay:
            _inputDisable = false;
            break;
        case GameState.Pause:
            _inputDisable = true;
            break;
        
    }
}

然后在不同的需要控制玩家点击和不能移动的地方调用该事件

  • 强制打开Player的背包

保证打开人物背包的时候修改背包的锚点位置即可

1
2
3
4
5
6
7
/* Inventory.cs 中OnBaseBagOpenEvent()打开人物背包 */
if (slotType == SlotType.Shop)
{
    bagUI.GetComponent<RectTransform>().pivot = new(-1, 0.5f);
    bagUI.SetActive(true);
    bagOpened = true;
}
  • 实现拖拽交易

slotUI拖拽的位置设置对应的处理事件,然后触发交易UI,然后根据交易UI的具体内容,和是否贩卖的标志来完成具体的交易内容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// SlotUI.cs
// OnEndDrag()方法中
/* 物品买卖 */
else if (slotType == SlotType.Shop && targetSlot.slotType == SlotType.Bag)
{
    EventHandler.CallShowTradeUIEvent(slotItemDetails, false);
}
else if (slotType == SlotType.Bag && targetSlot.slotType == SlotType.Shop)
{
    EventHandler.CallShowTradeUIEvent(slotItemDetails, true);
}

然后构造交易UI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class TradeUI : MonoBehaviour
{
    public Image itemIcon;
    public Text itemName;
    public InputField tradeAmount;
    public Button submitBtn;
    public Button cancelBtn;


    private ItemDetails _itemDetails;
    private bool _isSellTrade;

    private void Awake()
    {
        cancelBtn.onClick.AddListener(CancelTrade);
    }

    public void SetTradeUI(ItemDetails item, bool isSell)
    {
        this._itemDetails = item;
        itemIcon.sprite = item.itemIcon;
        itemName.text = item.itemName;
        _isSellTrade = isSell;
        tradeAmount.text = string.Empty;
    }


    public void CancelTrade()
    {
        this.gameObject.SetActive(false);
    }

}

注意在此过程中需要随时根据具体情况修改游戏运行状态

  • 实现交易过程

InventoryManager中实现交易的具体过程,即对玩家的金钱和物品内容的处理,然后更新UI,然后在UI界面注册对该方法的执行。

具体的交易执行方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public void TradeItem(ItemDetails item, bool isSell, int tradeAmount)
{
    /* 计算金额 */
    int totalCost = item.itemPrice * tradeAmount;
    int realCost = 0;
    /* 获得背包位置,检测背包是否有当前物品 */
    int itemIndexId = GetItemIndexInBag(item.itemID);    // 如果没有返回-1
    if (isSell)
    {
        // 有足够的物品才卖出
        if (itemIndexId != -1 && playerBag.inventoryItemList[itemIndexId].itemAmount >= tradeAmount)
        {
            RemoveItem(item.itemID, tradeAmount);
            realCost = (int)(totalCost * item.sellPercentage);
            playerCash += realCost;
        }
    }
    else
    {
        if (playerCash >= totalCost && CheckBagCapacity() != -1)
        {
            playerCash -= totalCost;
            AddItemInIndex(item.itemID, itemIndexId , tradeAmount);   
        }
    }
    EventHandler.CallUpdateInventoryUI(InventoryLocation.Player, playerBag.inventoryItemList);
    
}

然后在TradeUI.cs中实现对上述执行方法的调用

1
2
3
4
5
6
7
private void TradeItem()
{
    /* 将文本转化为具体需要执行的数值 */
    var amount = Convert.ToInt32(tradeAmount.text);
    InventoryManager.Instance.TradeItem(_itemDetails, _isSellTrade, amount);

}

该方法需要通过对btn绑定监听器

1
submitBtn.onClick.AddListener(TradeItem);

最后注意更新玩家的金钱即可。

Built with Hugo
Theme Stack designed by Jimmy