一、插件下载或购买
官方购买地址:Conversa Dialogue System | 可视化脚本 | Unity Asset Store
百度网盘地址:提取码:syq1
此插件没有官方案例,插件作者也明确回复说后期不会出教程,所以此教程根据插件demo案例进行学习总结,所以有问题欢迎大家留言指正。
二、Demo介绍
导入插件后 可以在Conversa—>Demo文件下找到官方的实例场景。运行可以看到效果,我们接下来就根据官方demo教程来逐步分解学习
三、Lineardialogue节点(线性人物对话)
学习使用Linear dialogue节点和其Actor、Message子节点完成一次简单的对话。
需要实现的效果如下:
1.新建Canversation资源文件
双击打开可以看到内部有一个名为Start的bookmark标签节点,这个节点代表一个功能模块的入口,通此节点进行一段话的开始。
2.创建LinearDialogue节点
LinearDialogue 线性对话节点是用来按照顺序执行对话的。
LinearDialogue 线性对话节点内部只能创建简单的Actor和Message。交叉使用可以完成人物和对话的结合
3.创建人物属性Actor资源文件
我们使用Actor profile节点,需要创建并配置人物资源。这里我们创建一个player(玩家)和Merchant(商人)两个人物资源。
player玩家
Merchant商人
4.制作线性人物对话
5.制作UI界面
制作一个播放对话的按钮和展示人物对话的界面
按钮:
人物对话的界面
6.脚本控制对话
ConversationControllerTest.cs
using Conversa.Runtime;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine;
using Conversa.Runtime.Events;
using Conversa.Runtime.Interfaces;
using Conversa.Demo.Scripts;
//对话控制器
public class ConversationControllerTest : MonoBehaviour
{
ConversationRunner runner; //控制谈话执行
[SerializeField] Conversation conversation;//对话类
[SerializeField] UIControllerTest uiController; //ui控制器
[Header("按钮")]
[SerializeField] Button restartConversationButton; //重启对话按钮
void Start()
{
runner = new ConversationRunner(conversation);//实例化
runner.OnConversationEvent.AddListener(HandleConversationEvent); //所有谈话事件监听
//添加按钮监听
restartConversationButton.onClick.AddListener(HandleRestartConversation);
}
void Update()
{
}
#region 处理会话事件
private void HandleConversationEvent(IConversationEvent e)
{
Debug.Log(e.ToString());
switch (e)
{
case MessageEvent messageEvent:
break;
case ChoiceEvent choiceEvent:
break;
case ActorMessageEvent actorMessageEvent:
//Debug.Log("actorMessage节点调用");
//使用LinearDialogue节点将Actor和Message结合所以是actorMessageEvent事件
HandleActorMessageEvent(actorMessageEvent);
break;
case ActorChoiceEvent actorChoiceEvent:
break;
case UserEvent userEvent:
break;
case EndEvent _:
HandleEnd();
break;
}
}
#endregion
#region 事件触发函数
//Actor Message触发
private void HandleActorMessageEvent(ActorMessageEvent evt)
{
//Debug.Log("3.触发Actor Message节点事件");
var actorDisplayName = evt.Actor == null ? "" : evt.Actor.DisplayName;//获取人物名称
//判断evt.Actor是否是带头像的Actor
if (evt.Actor is AvatarActor avatarActor)
uiController.ShowMessage(actorDisplayName, evt.Message, avatarActor.Avatar, evt.Advance); //执行有头像的方法
else
uiController.ShowMessage(actorDisplayName, evt.Message, null, evt.Advance);//执行没有头像的方法
}
//HandleEnd触发
private void HandleEnd()
{
Debug.Log("没有事件触发了,隐藏ui");
uiController.Hide();
}
#endregion
#region 按钮监听
private void HandleRestartConversation()
{
Debug.Log("开始谈话");
runner.Begin();//执行start节点
}
#endregion
}
UIControllerTest.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine;
using System;
//UI控制器
public class UIControllerTest : MonoBehaviour
{
[Header("信息面板")]
[SerializeField] private GameObject messageWindow; //信息ui窗口
[SerializeField] private Image avatarImage;//人物头像
[SerializeField] private Text actorNameText;//人物名称
[SerializeField] private Text messageText; //文字信息
[SerializeField] private Button nextMessageButton;//下一条信息按钮
#region 显示信息面板
/// <summary>
/// 显示信息面板
/// </summary>
/// <param name="actor">人物名称</param>
/// <param name="message">要说的话</param>
/// <param name="avatar">人物头像</param>
/// <param name="onContinue">下一步要执行的行为</param>
public void ShowMessage(string actor, string message, Sprite avatar, Action onContinue)
{
//显示信息面板
messageWindow.SetActive(true);
//更新头像 人物名称和要说的话
UpdateImage(avatar);
actorNameText.text = actor;
messageText.text = message;
//下一步按钮监听
nextMessageButton.enabled = true;
nextMessageButton.onClick.RemoveAllListeners();
nextMessageButton.onClick.AddListener(() => onContinue());
}
//更新人物头像图片
private void UpdateImage(Sprite sprite)
{
avatarImage.enabled = sprite != null;//如果为空不执行
avatarImage.sprite = sprite;//更换图片
}
#endregion
public void Hide()
{
messageWindow.SetActive(false);
}
}
将以上脚本拖入ConversationControllerTest并进行如下设置
四、 Choice选择节点
我们上一节进入商店之后就要出现多种选择项,根据不同选择项我们可以进行不同的对话和行为的操作。使用Choice节点实现选择项。
不过这里我们先根据选项创建出对应的按钮,至于按钮点击后的操作行为后面在详细解说
实现效果:
1.添加并设置Choice选择节点
2.设置choice选择UI面板
选项面板中的按钮是根据choice节点的选项自动生成的,这里我们直接使用Conversa—>Prefabs文件下的Choice option button预设体即可
3.脚本修改
添加choiceEvent需要触发的事件函数
ConversationControllerTest.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine;
using System;
using Conversa.Runtime.Events;
//UI控制器
public class UIControllerTest : MonoBehaviour
{
[Header("信息面板")]
[SerializeField] private GameObject messageWindow; //信息ui窗口
[SerializeField] private Image avatarImage;//人物头像
[SerializeField] private Text actorNameText;//人物名称
[SerializeField] private Text messageText; //文字信息
[SerializeField] private Button nextMessageButton;//下一条信息按钮
[Header("选择面板")]
[SerializeField] private GameObject choiceWindow; //对话选择ui
[SerializeField] private GameObject choiceOptionButtonPrefab;//选择单选按钮预设体
#region 显示信息面板
/// <summary>
/// 显示信息面板
/// </summary>
/// <param name="actor">人物名称</param>
/// <param name="message">要说的话</param>
/// <param name="avatar">人物头像</param>
/// <param name="onContinue">下一步要执行的行为</param>
public void ShowMessage(string actor, string message, Sprite avatar, Action onContinue)
{
//显示信息面板
choiceWindow.SetActive(false);
messageWindow.SetActive(true);
//更新头像 人物名称和要说的话
UpdateImage(avatar);
actorNameText.text = actor;
messageText.text = message;
//下一步按钮监听
nextMessageButton.enabled = true;
nextMessageButton.onClick.RemoveAllListeners();
nextMessageButton.onClick.AddListener(() => onContinue());
}
//更新人物头像图片
private void UpdateImage(Sprite sprite)
{
avatarImage.enabled = sprite != null;//如果为空不执行
avatarImage.sprite = sprite;//更换图片
}
#endregion
#region 显示选择面板
/// <summary>
/// 显示选择面板
/// </summary>
/// <param name="actor">人物名称</param>
/// <param name="message">要说的话</param>
/// <param name="avatar">人物头像</param>
/// <param name="options">选项</param>
public void ShowChoice(string actor, string message, Sprite avatar, List<Option> options)
{
//显示选择面板
messageWindow.SetActive(true);
choiceWindow.SetActive(true);
//更新头像 人物名称和要说的话
UpdateImage(avatar);
actorNameText.text = actor;
messageText.text = message;
nextMessageButton.enabled = false; //下一步按钮禁用
//清除选择面板的所有子物体
foreach (Transform child in choiceWindow.transform)
Destroy(child.gameObject);
//设置列表的每一个选项
options.ForEach(option =>
{
//创建选项物体 设置信息和点击事件
var instance = Instantiate(choiceOptionButtonPrefab, Vector3.zero, Quaternion.identity);
instance.transform.SetParent(choiceWindow.transform);
instance.GetComponentInChildren<Text>().text = option.Message;
instance.GetComponent<Button>().onClick.AddListener(() => option.Advance());
});
}
#endregion
public void Hide()
{
messageWindow.SetActive(false);
choiceWindow.SetActive(false);
}
}
UIControllerTest.cs
using Conversa.Runtime;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine;
using Conversa.Runtime.Events;
using Conversa.Runtime.Interfaces;
using Conversa.Demo.Scripts;
//对话控制器
public class ConversationControllerTest : MonoBehaviour
{
ConversationRunner runner; //控制谈话执行
[SerializeField] Conversation conversation;//对话类
[SerializeField] UIControllerTest uiController; //ui控制器
[Header("按钮")]
[SerializeField] Button restartConversationButton; //重启对话按钮
void Start()
{
runner = new ConversationRunner(conversation);//实例化
runner.OnConversationEvent.AddListener(HandleConversationEvent); //所有谈话事件监听
//添加按钮监听
restartConversationButton.onClick.AddListener(HandleRestartConversation);
}
void Update()
{
}
#region 处理会话事件
private void HandleConversationEvent(IConversationEvent e)
{
Debug.Log(e.ToString());
switch (e)
{
case MessageEvent messageEvent:
break;
case ChoiceEvent choiceEvent:
break;
case ActorMessageEvent actorMessageEvent:
//使用LinearDialogue节点将Actor和Message结合所以是actorMessageEvent事件
HandleActorMessageEvent(actorMessageEvent);
break;
case ActorChoiceEvent actorChoiceEvent:
HandleActorChoiceEvent(actorChoiceEvent);
break;
case UserEvent userEvent:
break;
case EndEvent _:
HandleEnd();
break;
}
}
#endregion
#region 事件触发函数
//Actor Message触发
private void HandleActorMessageEvent(ActorMessageEvent evt)
{
//Debug.Log("3.触发Actor Message节点事件");
var actorDisplayName = evt.Actor == null ? "" : evt.Actor.DisplayName;//获取人物名称
//判断evt.Actor是否是带头像的Actor
if (evt.Actor is AvatarActor avatarActor)
uiController.ShowMessage(actorDisplayName, evt.Message, avatarActor.Avatar, evt.Advance); //执行有头像的方法
else
uiController.ShowMessage(actorDisplayName, evt.Message, null, evt.Advance);//执行没有头像的方法
}
//Actor Choice事件
private void HandleActorChoiceEvent(ActorChoiceEvent evt)
{
//Debug.Log("4.触发Actor Choice事件");
var actorDisplayName = evt.Actor == null ? "" : evt.Actor.DisplayName;
if (evt.Actor is AvatarActor avatarActor)
uiController.ShowChoice(actorDisplayName, evt.Message, avatarActor.Avatar, evt.Options);
else
uiController.ShowChoice(actorDisplayName, evt.Message, null, evt.Options);
}
//HandleEnd触发
private void HandleEnd()
{
Debug.Log("没有事件触发了,隐藏ui");
uiController.Hide();
}
#endregion
#region 按钮监听
private void HandleRestartConversation()
{
Debug.Log("开始谈话");
runner.Begin();//执行start节点
}
#endregion
}
脚本属性进行如下设置
五、“检查钱包”对话模块
购买食物对话模块会牵扯到其他模块,所以这里先从“检查钱包”模块制作。所有的对话模块开始和跳跃都需要用到bookmark标签节点。
例如:我们上方选择节点有四个选项,我们单独制作这四个选项的功能就需要使用bookmark标签节点进行两者的连接。
检查钱包对话模块效果:
1.创建名为“检查钱包”的bookmark标签节点。
2.创建float属性用于记录钱包中钱的数量
3.使用Parse分析节点将文本语言和“当前金币”整合
message节点无法将要写的文本语言和创建的属性节点进行结合,所以需要使用Parse分析节点进行组合。
我们最后输入“我有${0}”其中{}是占位符,0代表使用Parse分析节点的第0个参数(当前金币)
4.使用Advancedmessage高级信息节点整合人物属性和Parse分析节点输出的文本信息
上一步我们把要说的对话整合了,如果使用message你会发现此节点没有办法接收整合信息。所以这里我们采用Advancedmessage高级信息节点。
5.使用跳转节点跳转到“选择”标签节点
对话模块结束后重新跳转到choice选择节点,在第一步我们知道了bookmark标签节点的作用。这是我们使用此节点完成对话模块的跳转
先给创建“选择”标签节点
节点跳转
这样就可以完成一个闭环, “检查钱包”模块结束后跳转到“选择标签”,而“选择标签”指向“Choice”选择节点。
6.最后给 “检查钱包”模块打个组
将“检查钱包”模块中的所有节点进行组合并注释,这样方便移动操作和理解此模块的作用。
但是使用组需要注意一些问题,可以查看注意选项进行详细了解。
六、“购买食物”对话模块
“购买食物”对话模块较为复杂,分为购买成功和购买失败两个功能分支。
1.创建属性“食物价格”并使用CompareNumber进行数值对比
CompareNumber“比较节点”的GreatOrEqual代表A>=B时输出true。然后使用Brabch分支将两个进行分割
2. 使用Brabch分支节点创建True逻辑对话
True代表食物购买成功需要进行如下几步操作:
- 神秘商人进行购买成功的对话提示
- 计算“当前金币”减去“食物价格”后所生的金币数量。
- 将计算值赋值给“当前金币”属性。
- 创建名为“购买了食物”的bool属性并修改为true,用于判断防止下次重复购买。
- 创建名为“更新所有金币价格”的Event属性,用于通知脚本修改场景中UI文字价格
- 最后跳转到“Start”标签节点
①使用Message节点设置购买成功的对话提示
②使用Subtract节点计算购买后的金币价格
③使用Setproperty修改“当前金币”属性
④ 创建名为“购买了食物”的bool属性并修改为true
购买成功后使用一个bool值属性存储,用于判断防止下次重复购买。
⑤创建名为“更新所有金币价格”的Event属性
后面场景中会添加食物价格、我的金币和钱包金币三个uiText,通过调用此事件来动态修改场景中的uiText值
⑥最后跳转到“Start”标签节点
七、False分支的 “购买失败”对话模块
- 添加“购买失败”bookMark节点
- 计算“食物价格”减去“当前金币”的差价
- 使用Parse节点将文本语言和差价整合
- 使用AdvancedMessage节点将Parse节点语言与Actor整合
- 最后跳转到“选择”标签
1. 购买失败分支图
2.“购买食物”添加跳转“购买失败”和“购买食物”标签
八、“搜索口袋”对话模块
九、 设置choice选择节点所有选项的跳转标签
十、最终UI界面布局
更新保存点:用于保存当前对话执行的位置
从保存点加载:从上一次保存的对话位置处开始执行
十一、最终脚本
ConversationControllerTest.cs
using Conversa.Runtime;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine;
using Conversa.Runtime.Events;
using Conversa.Runtime.Interfaces;
using Conversa.Demo.Scripts;
//对话控制器
public class ConversationControllerTest : MonoBehaviour
{
ConversationRunner runner; //控制谈话执行
private string savepointGuid = string.Empty; //保存点指南
[SerializeField] Conversation conversation;//对话类
[SerializeField] UIControllerTest uiController; //ui控制器
[Header("按钮")]
[SerializeField] Button restartConversationButton; //重启对话按钮
[SerializeField] private Button updateSavepointButton; //更新保存点按钮
[SerializeField] private Button loadSavepointButton; //加载保存点按钮
[Header("食物、当前金币、钱包")]
[SerializeField] float foodMoney = 100;
[SerializeField] float currentMoney = 20;
[SerializeField] float walletMoney = 40;
[Header("食物、当前金币、钱包文本")]
[SerializeField] Text foodMoneyTxt;
[SerializeField] Text currentMoneyTxt;
[SerializeField] Text walletMoneyTxt;
void Start()
{
runner = new ConversationRunner(conversation);//实例化
runner.OnConversationEvent.AddListener(HandleConversationEvent); //所有谈话事件监听
//添加按钮监听
restartConversationButton.onClick.AddListener(HandleRestartConversation);
updateSavepointButton.onClick.AddListener(HandleUpdateSavepoint);
loadSavepointButton.onClick.AddListener(HandleLoadSavepoint);
updateSavepointButton.interactable = false;//更新保存点按钮默认不执行
SetUIMoneyNumber();
}
void Update()
{
}
#region 处理会话事件
private void HandleConversationEvent(IConversationEvent e)
{
Debug.Log(e.ToString());
switch (e)
{
case MessageEvent messageEvent:
HandleMessage(messageEvent);
break;
case ChoiceEvent choiceEvent:
HandleChoice(choiceEvent);
break;
case ActorMessageEvent actorMessageEvent:
//使用LinearDialogue节点将Actor和Message结合所以是actorMessageEvent事件
HandleActorMessageEvent(actorMessageEvent);
break;
case ActorChoiceEvent actorChoiceEvent:
HandleActorChoiceEvent(actorChoiceEvent);
break;
case UserEvent userEvent:
HandleUserEvent(userEvent);
break;
case EndEvent _:
HandleEnd();
break;
}
}
#endregion
#region 事件触发函数
//Message事件
//private void HandleMessage(MessageEvent e) => uiController.ShowMessage(e.Actor, e.Message, null, () => e.Advance());
private void HandleMessage(MessageEvent e)
{
//Debug.Log("1.触发Message节点事件");
uiController.ShowMessage(e.Actor, e.Message, null, () => e.Advance());
}
//Choice事件
//private void HandleChoice(ChoiceEvent e) => uiController.ShowChoice(e.Actor, e.Message, null, e.Options);
private void HandleChoice(ChoiceEvent e)
{
//Debug.Log("2.触发Choice节点事件");
uiController.ShowChoice(e.Actor, e.Message, null, e.Options);
}
//Actor Message触发
private void HandleActorMessageEvent(ActorMessageEvent evt)
{
//Debug.Log("3.触发Actor Message节点事件");
var actorDisplayName = evt.Actor == null ? "" : evt.Actor.DisplayName;//获取人物名称
//判断evt.Actor是否是带头像的Actor
if (evt.Actor is AvatarActor avatarActor)
uiController.ShowMessage(actorDisplayName, evt.Message, avatarActor.Avatar, evt.Advance); //执行有头像的方法
else
uiController.ShowMessage(actorDisplayName, evt.Message, null, evt.Advance);//执行没有头像的方法
}
//Actor Choice事件
private void HandleActorChoiceEvent(ActorChoiceEvent evt)
{
//Debug.Log("4.触发Actor Choice事件");
var actorDisplayName = evt.Actor == null ? "" : evt.Actor.DisplayName;
if (evt.Actor is AvatarActor avatarActor)
uiController.ShowChoice(actorDisplayName, evt.Message, avatarActor.Avatar, evt.Options);
else
uiController.ShowChoice(actorDisplayName, evt.Message, null, evt.Options);
}
//User Event触发
private void HandleUserEvent(UserEvent userEvent)
{
if (userEvent.Name == "更新所有金币价格")
{
Debug.Log("更新所有金币价格");
SetUIMoneyNumber();
}
}
//HandleEnd触发
private void HandleEnd()
{
Debug.Log("没有事件触发了,隐藏ui");
uiController.Hide();
updateSavepointButton.interactable = false;
}
#endregion
#region 按钮监听
private void HandleRestartConversation()
{
Debug.Log("开始谈话");
runner.Begin();//执行start节点
updateSavepointButton.interactable = true;
}
private void HandleUpdateSavepoint()
{
//保存当前节点名称。
savepointGuid = runner.CurrentNodeGuid;
}
private void HandleLoadSavepoint()
{
//设置从指定节点开始执行
runner.BeginByGuid(savepointGuid);
}
#endregion
#region 修改ui界面金币数量
public void SetUIMoneyNumber()
{
//修改指定属性的方法
//runner.SetProperty("食物价格", foodMoney);
//runner.SetProperty("当前金币", currentMoney);
//runner.SetProperty("钱包金币", walletMoney);
//设置uiText
foodMoneyTxt.text = runner.GetProperty<float>("食物价格").ToString();
currentMoneyTxt.text = runner.GetProperty<float>("当前金币").ToString();
walletMoneyTxt.text = runner.GetProperty<float>("钱包金币").ToString();
Debug.Log($"食物价格:{runner.GetProperty<float>("食物价格")},当前金币:{runner.GetProperty<float>("当前金币")},钱包金币:{runner.GetProperty<float>("钱包金币")}");
}
#endregion
}
UIControllerTest.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine;
using System;
using Conversa.Runtime.Events;
//UI控制器
public class UIControllerTest : MonoBehaviour
{
[Header("信息面板")]
[SerializeField] private GameObject messageWindow; //信息ui窗口
[SerializeField] private Image avatarImage;//人物头像
[SerializeField] private Text actorNameText;//人物名称
[SerializeField] private Text messageText; //文字信息
[SerializeField] private Button nextMessageButton;//下一条信息按钮
[Header("选择面板")]
[SerializeField] private GameObject choiceWindow; //对话选择ui
[SerializeField] private GameObject choiceOptionButtonPrefab;//选择单选按钮预设体
#region 显示信息面板
/// <summary>
/// 显示信息面板
/// </summary>
/// <param name="actor">人物名称</param>
/// <param name="message">要说的话</param>
/// <param name="avatar">人物头像</param>
/// <param name="onContinue">下一步要执行的行为</param>
public void ShowMessage(string actor, string message, Sprite avatar, Action onContinue)
{
//显示信息面板
choiceWindow.SetActive(false);
messageWindow.SetActive(true);
//更新头像 人物名称和要说的话
UpdateImage(avatar);
actorNameText.text = actor;
messageText.text = message;
//下一步按钮监听
nextMessageButton.enabled = true;
nextMessageButton.onClick.RemoveAllListeners();
nextMessageButton.onClick.AddListener(() => onContinue());
}
//更新人物头像图片
private void UpdateImage(Sprite sprite)
{
avatarImage.enabled = sprite != null;//如果为空不执行
avatarImage.sprite = sprite;//更换图片
}
#endregion
#region 显示选择面板
/// <summary>
/// 显示选择面板
/// </summary>
/// <param name="actor">人物名称</param>
/// <param name="message">要说的话</param>
/// <param name="avatar">人物头像</param>
/// <param name="options">选项</param>
public void ShowChoice(string actor, string message, Sprite avatar, List<Option> options)
{
//显示选择面板
messageWindow.SetActive(true);
choiceWindow.SetActive(true);
//更新头像 人物名称和要说的话
UpdateImage(avatar);
actorNameText.text = actor;
messageText.text = message;
nextMessageButton.enabled = false; //下一步按钮禁用
//清除选择面板的所有子物体
foreach (Transform child in choiceWindow.transform)
Destroy(child.gameObject);
//设置列表的每一个选项
options.ForEach(option =>
{
//创建选项物体 设置信息和点击事件
var instance = Instantiate(choiceOptionButtonPrefab, Vector3.zero, Quaternion.identity);
instance.transform.SetParent(choiceWindow.transform);
instance.GetComponentInChildren<Text>().text = option.Message;
instance.GetComponent<Button>().onClick.AddListener(() => option.Advance());
});
}
#endregion
public void Hide()
{
messageWindow.SetActive(false);
choiceWindow.SetActive(false);
}
}
十四、 注意事项
1.修改节点后必须点击Save按钮,快捷键Ctrl+S无用
无保存标识。
2.节点无法进行复制,只能创建
目前版本我们设置好节点模块后,想要复制一份新的进行更改是不允许的,只能一个个重新创建连接。如果复制Conversation数据资源文件的话是可行的。
复制资源Conversation数据资源文件
3.GroupNodes无法删除,并且节点也无法分离
GroupNodes将多个节点包围成组合后,就无法解除组合。并且组合后的节点也无法单独脱离出来。(至少目前没有找到解除的方式,可能作者还没加)。
4. 快速定位Start区域
评论区有小伙伴在做完对话后发现找不到设置的对话了,或者说你拖拽区域找不到对话时可以按下F按键进行快速定位。
5.将节点从区域中分离出来
我们经常会对对话节点进行group 打组,已经打好组的节点我们发现无论怎么拖动都无法拖出来,这种情况我们在选中拖动节点前可以按下shift按键拖拽出来