type
status
date
slug
summary
tags
category
icon
password
fullWidth
fullWidth
创建插件
我希望以插件的形式扩展编辑器,因此需要先创建插件。创建项目后,来到
Edit→Plugins,选择Blank,填写基本信息后点击Create Plugin ,等待引擎创建代码。

回到Visual Studio,发现
Plugins目录下已有刚才创建的插件代码,我将插件命名为PlotEditor,发现创建出PlotEditor.uplugin(插件描述文件)。
打开
Source/PlotEditor.Build.cs,在PrivateDependencyModuleNames中加入必要模块依赖:然后右键
.uproject,重新生成工程并编译。自定义资产类型和行为
虚幻的编辑器扩展体系分三层:
目标 | 实现类 | 功能 |
定义资产类型 | UObject 派生类 | 存放数据 |
注册资产到 Content Browser | UFactory+FAssetTypeActions | 支持创建与双击打开 |
自定义编辑器 | FAssetEditorToolkit+Slate Widget | 自定义编辑界面 |
对此,可以先在
/Source/PlotEditor目录下先规划出必要的类:文件 | 作用 |
Private/UPlotEditorEntry.h | 定义资产类型 |
Private/UPlotEditorEntryFactory.h/.cpp | 创建资产的工厂 |
Public/UPlotEditorEntryActions.h/.cpp | 注册到内容浏览器 |
Private/UPlotEditorToolkit.h/.cpp | 自定义资产编辑器 |
Public/PlotyEditorModule.cpp | 插件入口模块(插件生成时已定义,可以改个名) |
接下来一一实现它们。
自定义资产类型
自定义资产类型
UPlotEditorEntry,继承自UObject;保存资产数据。资产工厂实现
资产工厂
UPlotEditorEntryFactory 继承自UFactory,并覆写FactoryCreateNew接口。实现构造函数和
FactoryCreateNew接口。至此为止,已经可以在内容浏览器菜单右键创建
UPlotEditorEntry资产;但是发现双击没反应,这首由于缺少资产行为导致的。

资产行为注册
声明资产行为类
UPlotEditorEntryActions,继承自FAssetTypeActions_Base,覆写其中的某些方法用于自定义资产在内容浏览器中的行为和外观。其中
OpenAssetEditor 是必不可少的,其实现中应打开自定义资产编辑器。实现上述接口,指定编辑器命名、样式、支持的资产等信息。
还需要把资产行为类注册到模块中,模块类
FPlotEditorModule继承自IModuleInterface接口,必须实现两个接口函数:StartupModule:模块启动时调用
ShutdownModule:模块卸载时调用
实现上述接口,可以将
AssetTypeAction的引用保存到缓存中,用于注销:自定义资产编辑器
初始化和Tab注册
上文中我们留下了一个TODO,需要打开自定义资产编辑器,现在来实现它。
首先需要创建一个Toolkit类
FPlotEditorToolkit继承自FAssetEditorToolkit,实现必须要实现的虚函数:GetToolkitFName:编辑器的唯一标识符(内部名称)
GetBaseToolkitName:编辑器的显示名称
GetWorldCentricTabPrefix:世界中心模式下标签页的前缀(嵌入到关卡编辑器时,标签页显示为"Plot:资源名称”)
GetWorldCentricTabColorScale:在世界中心模式下标签页的颜色
覆写
RegisterTabSpawners和UnregisterTabSpawners接口。函数 | 用途 | 调用时机(由引擎自动控制) |
RegisterTabSpawners() | 向 TabManager注册所有可生成的编辑器窗口 | 在调用 InitAssetEditor() 时,UE自动调用 |
UnregisterTabSpawners() | 注销所有 TabSpawners,防止内存泄漏 | 当编辑器关闭时(Toolkit销毁)由引擎自动调用 |
因此可以在
RegisterTabSpawners中创建Tab对应的Slate控件,实现如下。InitAssetEditor函数中会调用RegisterTabSpawners,通过打断点发现调用栈如下:
一切就绪之后,把
OpenAssetEditor方法中的TODO补上。现在双击资产能打开资产编辑器,发现默认打开的Tab名称为”Plot Graph” 。

“窗口 > 工作区”菜单的“Plot Editor”下有此Tab。

同理,可以再创建一个细节面板。细节面板通过虚幻的反射机制渲染
UObject中的各属性。
创建细节面板的具体方法:
图节点定义和渲染
根据经验,AVG游戏中的剧情节点可以分为两类:对话节点和选择节点。对话节点理解为为单线程播片,其呈现内容是固定的;而选择节点会影响剧情走向,后续呈现内容会随着玩家的选择而改变。
因此,需要一个
UPlotNodeBase基类,继承自虚幻原生节点UEdGraphNode,并派生出UPlotNode_Dialog和UPlotNode_Choice两个子类。.cpp实现,Bind方法处理UEdGraphNode和其对应数据的双向绑定关系(后面会说):对话节点只有一个输入节点
PrevPin和一个输出节点NextPin:.cpp实现:PostTransacted在Transactions结束后调用,这里调用数据类的DoTransacted()序列化Json
AutowireNewNode在右键创建节点时调用,根据输入引脚自动连接新节点
AllocateDefaultPins在CreateNewNode时自动调用,这里只需创建默认节点
CreateVisualWidget创建Slate控件
选择节点有一个输入节点
PrevPin和多个输出节点ChoicePins对应不同选项的后续分支。.cpp实现,CreateOutputPinsByOptions根据数据类UPlotData_Choice的Options调整节点引脚数量,简单理解为多退少补,这个方法在后面会用到。除了节点对象,还需要与节点一一对应的数据模型用于序列化成Json。因此针对上述三个节点类,创建对应的数据类
UPlotDataBase、UPlotData_Dialog、UPlotData_Choice。节点数据基类
UPlotDataBase保存共同属性(如节点ID、节点类型)。DoTransacted方法里调用工具类的序列化方法。.cpp实现:子类需要在构造函数中指定
NodeType(这个变量后面有用,比如在反序列化时读取NodeType创建子类);子类的DoTransacted中根据节点的引脚连接更新数据层。UPlotData_Dialog需要知道下一个节点的节点ID,由于剧情内容不影响节点结构,打算后面再加:.cpp实现:UPlotData_Choice需要知道各选项对应的下一个节点的节点ID,这里用一个数组存储,由于选项个数会影响节点结构,先把选项内容加上了:.cpp实现:两种节点对应的Slate控件无需基类,这里覆写
CreateBelowPinControls函数,随便渲染一些文字:
SPlotNode_Dialog实现:必须在构造函数中先赋值
GraphNode,再调用UpdateGraphNode;否则无法绘制节点。SPlotNode_Choice实现:
创建和删除节点
现期望右键或从已有节点的引脚拖出后创建新节点,大概这样:

对此需要创建
UEdGraph专门的Schema类UPlotEditorGraphSchema;创建节点的右键操作需要在FEdGraphSchemaAction中封装和实现;此外还可以覆写CanCreateConnection决定连接规则:UEdGraphSchema定义了图中节点之间如何连接、节点类型、Pin 类型等规则;搞过前端的应该熟悉Schema-Renderer模型。
而实际的创建节点可以在工具类
FPlotEditorToolkit中实现。如果想要创建节点可撤销/恢复,需要把创建过程封装在一个事务里,即FScopedTransaction Transaction :创建Dialog节点:
创建Choice节点:
这里不用
FEdGraphSchemaAction::CreateNode是因为涉及Data和EdGraphNode的绑定,以及一些自定义事务处理,但一些必须要调用的方法和CreateNode里写的是一致,如SetFlags、CreateNewGuid、PostPlacedNewNode、AllocateDefaultPins、SnapToGrid、AutowireNewNode。这两行是因为,在
Modify之后修改数据,就会触发事务系统记录以及PostTransacted回调,回调函数里会进行Json序列化(新创一个节点后肯定是需要保存的)。有添加功能自然也有删除功能,不过删除节点不是写在Schema中,而是写在GraphView中,趁此也把一些其他的命令,如选择变化回调、双击回调、文本提交回调都补全:
当然具体的删除节点的实现还是在工具类
FPlotEditorToolkit中实现,需要修改数据上下文EditorContext中保存的节点Map,移除该节点。说起数据上下文
EditorContext,它通过PlotDataMap保存所有节点的数据,并且管理节点ID递增;中心数据类不应包含过多的逻辑,只是作为数据传递的纽带。序列化和反序列化
工具类
这里不走虚幻资产的序列化方式
需要一个工具类
FJsonSerializationHelper来处理数据的序列化和反序列化,实现引擎资产和Json数据互通。DeserializePlotMap反序列化会根据NodeType创建具体子类。然后再FPlotEditorToolkit中再次封装:
节点和连接初始化
有了反序列化功能,将Json中的节点数据存入
EditorContext的PlotDataMap中,每次打开资产后,需要根据数据创建节点并建立连接关系。对于Dialog节点,其输出节点可能连接另一个Dialog节点的输入节点或另一个Choice节点的输入节点:
对于Choice节点的每个输出节点,都需要尝试寻找后继节点并连接,连接方式同上:
到这里为止 ,这个编辑器的框架已经有了。

节点数据定义
对话节点

AVG游戏中对话的呈现由说话人+内容构成,因此需要在对话节点中声明一个包含说话人+内容的结构体数组,存储一段连续的对话。
至此为止,发现细节面板可以编辑对话内容数组。

修改对话内容数组后,应当立即序列化成Json存盘,因此需要覆写属性变化的回调函数
PostEditChangeProperty ;这里可以创建在节点数据基类UPlotDataBase中,调用序列化方法。选择节点
选择节点
UPlotData_Choice的Options属性由于会动态影响节点引脚个数,在上文中已经写过了。这里只做小优化,在新增数组元素时让新增选项文本呈现”选项x”。
节点渲染
SGraphNode提供了许多虚函数,可以覆写这些虚函数自定义节点样式。CreateTitleWidget:节点标题
CreateTitleRightWidget:标题右侧小区域
CreateNodeContentArea:标题下面的整个主体区域(左右 Pin + 中间内容)
CreateInputSideAddButton:输入Pin列表最下方,比如给输入Pins下面增加”+”按钮
CreateOutputSideAddButton:输出Pin列表最下方,比如给输出Pins下面增加”+”按钮
CreateBelowPinControls:Pin区域下方内容
CreateBelowWidgetControls:整个节点的最底部
CreateAdvancedViewArrow:标题左下角(可折叠箭头位置)
对话节点
对于Dialog节点,我希望实现以下功能:
- 将”说话人+内容”显示在节点上
- 对话内容在节点上和细节面板都能编辑
- 并且节点上提供”+”按钮可以新增对话条目

创建样式宏。
重写
CreateBelowPinControls,渲染节点下方内容。“+”按钮的回调函数中,需要在数据层新增一条对话条目,并序列化保存。
为了避免节点宽度因对话内容变得过长,可以使用
SMultiLineEditableText控件渲染多行可编辑文本;重写CreateNodeContentArea函数,将节点内容包裹在SBox中并指定WidthOverride属性。最后放一下
SPlotNode_Dialog这个Slate控件的头文件。选择节点
对于Choice节点,希望实现以下功能:
- 将”选项内容”显示在节点上
- 选项内容在节点上和细节面板都能编辑
- 并且节点上提供”+”按钮可以新增选项条目

首先,将”选项内容”显示在输出节点边上,需要自己写一个节点专用的
Pin控件SPlotPin_Choice,覆写GetLabelWidget方法,渲染选项内容为可编辑文本,并提供文本提交回调函数作为Slate Widget的参数。.cpp实现:由于
SGraphPin隶属于SGraphNode,因此还需要覆写SGraphNode中和创建引脚有关的方法,来创建自定义引脚类的实例。- 首先是
CtreatePinWidgets,把原生的方法抄过来进行魔改。这里用OutputIndex记录每一个输出引脚在所有输出引脚中的索引,这个变量对于后面从Options中取对应的选项有用。
此外,为了接收
OutpunIndex变量,需要创建函数CreateStantardPinWidgetChoice(覆写需要函数签名相同,因此只能另外创建了),同样抄原生的方法就行,把里面创建Pin的具体方法替换成自己写的CreatePinWidget_Choice。最后
CreatePinWidget_Choice中创建自定义引脚控件SPlotPin_Choice的实例。同样,
SPlotNode_Choice也需要限制节点宽度,并在下方渲染添加选项的按钮。撤销和重做
事务系统
虚幻的事务系统(Transaction System)负责编辑器中所有撤销和重做的机制;通过
FScopedTransaction保存一次操作的记录块,通过Apply()在撤销或重做时恢复对象数据。
具体可以详细看EditorTransaction的源码。
Undo时调用栈:
Redo时调用栈:

因此对于自定义的对象(自定义的Node、Graph、Data)都需要设置RF_Transactional标识,使之可以被事务系统捕捉。

补充一下,对于之前写的在节点面板点”+”按钮添加对话或条目的功能,需要自己创建一个事务块。
这样在节点处添加对话条目也能丝滑撤销了。

选择节点同理,也需要添加事务块。
选项条目的丝滑撤销。

注释节点功能
接下来将实现在节点图中创建注释节点的功能,并借此梳理虚幻的资产序列化机制。

注释节点功能
资产序列化
数据序列化是指将对象转换成可存取的格式(通常是二进制格式或Json);反序列化是指从特定存取格式复原对象。
虚幻中所有的资产(
.uasset和.umap)底层都是UObject,点击保存资产按钮后,通过AssetEditorToolkit::GetSaveableObjects收集需要保存的资产;然后调用FEditorFileUtils::PromptForCheckoutAndSave进行Package保存。只要对象的
Outer链最终指向Package就会被序列化,并且Packge对象只序列化UPROPERTY标记的属性。这个案例中,注释节点是纯虚幻编辑器数据,不参与运行时,因此希望走虚幻原生的uasset序列化机制(当然也可以加入自己写的Json序列化机制,只是有点麻烦);而剧情节点(UPlotNode_Dialog和UPlotNode_Choice)走的是自己写的Json序列化机制。
Outer链
回顾之前的代码,之前在Slate控件的构造函数中创建
UEdGraph,然后指定该Graph的Outer对象为EditorContext。这就出现了一个问题,每次打开资产调用
FPlotEditorToolkit::InitPlotEditor时,EditorContext都会重新创建:因此无法序列化
Graph,更无法序列化里面的注释节点。
现进行如下修改,在资产
UPlotEditorEntry里新增UPROPERTY,保存Graph。然后在节点图Slate Widget构造函数中创建并传入
Graph,设置Graph的Outer对象为资产。由于需要在Graph对象中保存
EditorContext的引用,需要新创建一个变量。现在的
Outer链如下,Graph和其中的Node能被序列化。
由于
Outer链的改变,右键创建新节点的方法相应调整,直接从Graph对象中获取EditorContext。节点创建
注释节点的创建,应当是在选中节点后右键上下文菜单中出现的选项。这个菜单的实现需要覆写Schema中的
GetContextMenuActions函数。值得注意的是,之前覆写的GetGraphContextActions 是空白处的右键菜单,而GetContextMenuActions 是节点上的右键菜单。然后实现
AddComment,里面调用FPlotEditorToolkit的类方法Action_NewComment创建实际的注释节点(和创建剧情节点的方法类似)。最后需要在
FPlotEditorToolkit中补充Action_NewComment函数,实现真正的注释节点创建。截至目前,能成功创建节点,但发现节点文本提交后无法修改。

文本提交
针对上述问题,覆写
SPlotGraphView的OnNodeTextCommit回调函数,把注释节点的NodeComment属性设置成传入的文本。这个解决办法在虚幻论坛中亦有记载Comment text doesn’t update。现在注释文本可以成功修改了。

节点删除
在
SPlotGraphView的DeleteSelectNodes函数中补充删除注释节点的逻辑,因为走的是虚幻原生的序列化,无需进行数据层的修改;直接调用DestroyNode即可。注释节点可以被删除。

自定义细节面板
有两种自定义细节面板的方式,一种是自定义类,第二种是自定义属性类型。自定义类需继承自
IDetailCustomization接口,而自定义属性类型需继承自IPropertyTypeCustomization接口。特性 | 属性定制 | 类定制 |
作用范围 | 特定数据类型 | 整个类 |
复用性 | 高(任何使用该类型的地方) | 低(仅针对特定类) |
控制粒度 | 单个属性 | 整个面板 |
常见用途 | 数据类型的特殊编辑器 | 类的专用工具面板 |
继承影响 | 影响所有子类使用该类型处 | 只影响当前类 |
属性类型定制
讲讲自定义属性类型细节面板显示的方法。从具体业务场景入手:
这个剧情节点中有大量柯罗莎和女子的对话,如果对话内容折叠起来,每个索引都显示”2个成员”(说话人和说话内容),非常不方便查看,我希望把 ”2个成员” 的文本替换成人名。

创建一个自定义属性类型的类
FPlotDialogLineCustomization,继承自IPropertyTypeCustomization接口,必须覆写其中的CustomizeHeader和CustomizeChildren函数,因为在父接口中这两个函数是虚函数。
除此之外,还要在
FPlotEditorModule中注册和注销细节面板定制。在
StartupModule()中增加以下代码注册对结构体FPlotDialogLine的定制,注意结构体名要去掉前缀F。因为在UPROPERTY、USTRUCT、UCLASS的元数据里,反射系统会去掉前缀作为类型名称。在
ShutdownModule()增加以下代码注销定制:实现这两个接口:
我们先用Test Custom Content文本测试下显示是否正常,发现已经能正常显示了:

现在只需要将测试文本替换成Speaker文本即可,通过
GetChildHandle获取成员属性Speaker的属性句柄,再自己封装一个类方法GetSpeakerText获取属性值,赋值给Text控件的构造函数:再次打开对话节点的细节面板,已经奏效了:

类定制
讲讲自定义类的细节面板显示的方法。从具体业务场景入手:
想要新增一个节点信息汇总的Category,展示节点信息。

创建一个自定义类细节面板定制的类
FPlotDataDetailCustomization,继承自IDetailCustomization接口,覆写其中的CustomizeDetails函数。覆写
CustomizeDetails(),在其中新增一个Category,渲染节点信息:同样需要在
StartupModule()中注册:在
ShutdownModule()中注销:- Author:Yuki
- URL:http://shirakoko.xyz/article/avg-plot-editor
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts










