# NodeBasedSkillEditor **Repository Path**: antonieo/node-based-skill-editor ## Basic Information - **Project Name**: NodeBasedSkillEditor - **Description**: 基于WPF实现的节点图编辑器 - **Primary Language**: C# - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 6 - **Forks**: 4 - **Created**: 2021-10-28 - **Last Updated**: 2025-07-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 介绍 WildFire技能节点图编辑器 ## ToDo - 文件->明文保存 - VM_Port的Id和LocalId尝试改为对BindPortData的成员引用 - 在菜单栏查看操作历史队列,并可以通过点选跳转到对应历史状态 - 打开新Dag之前,上一个工作中的Dag未保存的,应弹窗提问是否保存之前的Dag - nodeInspector: field grid -> dockPanel - Open Recent - Help -> about licence ## Runtime 架构 在RuntimeDag中,每一个Responser Node都要注册一个对应的监听事件类型。每当事件发生时,GameEventManager记录并管理对应的GameEvent,GameEvent基于事件类型检查监听情况,若存在对应的监听者(Responser Node),便会创建一个Executor,执行起点就是此Responser Node,执行信息、过程的维护都由Executor负责 之所以这样设计是因为,Dag中可能存在延时、跨帧执行的行为,并且Dag允许多个分支流程走向同一个路径,这就可能出现执行流在时间、路径上的重叠。 为此,我把RT_DAG作为纯粹的不可变逻辑数据,RT_Node是函数式、无状态的,执行相关的状态信息都由Executor来保管,这样多路径重叠的、时间错峰的各个执行过程就能相互独立。 ### 执行过程 可执行的RT_Node通过FlowPort串联起来,Executor从起点RT_Node开始前向逐个执行,直到末尾后开始倒退执行(基于ExecutedStack倒退),直到倒退执行至起点结束。 由于存在流程控制类的节点如Sequencer(逐个分支执行),Cooldown(冷却后才能执行之后的节点),Wait(等候),PathSwitch(基于Bool切换),PathChooser(基于整数Index选择)等,因此,执行一个节点后,下一步的执行方向是由RT_Node执行后的返回信息来决定的,Executor只负责按照返回信息照章办事 RT_Node提供ForewardExecute()、UpdateExecute()、BackwardExecute() 3种执行接口,表示3种不同的执行方向调用 调用这3个接口后都会返回(NextExeState, NextNode),EDagExeState表示下一步的执行方向(Foreward,Update,Backward),NextNode表示下一个要执行的节点。 一般的可执行节点在ForewardExecute()后,如果需要继续Update以完成节点功能(存在跨帧的任务),则返回(Update, this);如果不需要Update,就检查是否存在前向连接的节点,是则返回(Foreward, NextNode),否则返回(Backward, null)开始倒退执行。 然而对于流程控制型节点,会通过返回信息来控制Executor下一步的执行方向。 Executor保管的特征信息主要包括:执行起点,执行状态,当前节点,路径栈(用于背向执行路径),节点的状态信息(仅部分的特殊节点有) ## 基于指令栈的操作历史 需要实现的Undo/Redo操作 - 新增节点 - 删除节点(复数) - 建立连接 - 删除连接(复数) - 移动节点(复数) - 添加端口组成员 - 删除端口组成员(删除已建立的连接,恢复时需要正确恢复被删除端口和连接) - 修改节点的某端口数据 - 粘贴剪贴板中的dag数据 ### 多路径错峰执行的问题 技能DAG可能有多个起点形成多条路径,所以存在多个Executor并相互重复走其他Executor的路径的情况,加上可能存在节点可能有延时执行或持续执行一段时间的情况,此时会发起CreateUpdateTask任务到Executor的情况,所以需要如下设计 每一个起点对应一个Executor,Executor在DAG上依序执行。节点完全函数化,本身不保存执行状态,由Executor记录执行状态,DAGRuntime每次会update所有Executor。 节点的接口分成3个部分,Execute,Update,ReturnExcute,由节点的函数执行过程返回告知Executor下一步要进行的任务,任务类型包括Execute下一个节点,CreateUpdateTask挂起要求持续更新,Update执行更新过程,BackExecute前一个节点。 其中,CreateUpdateTask时提交UpdateTaskState类型数据给Executor,让Executor在下次调用此节点Update时传入 --- ## 端口的类型 从BasePort派生出2个端口大类,FlowPort和ValuePort。 FlowPort是流程端口,多入单出,用于控制主运行流程。FlowPort端口是类型固定的端口。 ValuePort是数值端口,单入多出,用于表示数据的流动方向。ValuePort的类型可以是固定的,也可以是可变得(配合TypeGroup定义可变的类型种类),他们遵守以下规则 - 固定类型ValuePort端口,在声明时必须是ValuePort派生之下的某个具体类型,且不可属于任何TypeGroup - 可变类型ValuePort端口,在声明时必须是ValuePort类型本身,且必然属于某个TypeGroup - 同属一个TypeGroup的端口必须始终保持类型一致 - 作为数据源头的输出端口,必须是固定类型的ValuePort 只有类型相同的端口可以建立连接,要让两个当前类型不同的端口相连,那么其中之一必须是可变类型的端口,且可以转变成另一方的类型。如果双方都可以变成对方的类型时,那么以出发端口的类型为准。 可变类型端口可以变成TypeGroup中允许的类型中的任意一种,但是也有例外情况,会导致端口类型不可变,这涉及到了类型传导的问题。 ### 类型的传导 以当一个可变类型端口发生类型变化时,同TypeGroup所有端口类型都随之改变,而相连的其他节点的端口也必须保持类型一致,所以类型就传导形成了一个网络,通过端口的连接,串联起了多个节点的TypeGroup和固定类型端口,他们始终要保持当前类型一致。 当类型网络中连接了固定类型端口时,那么整个网络内的端口类型都因此被固定不可变。 当类型网络中没有连接固定类型端口时,那么整个网络内的端口类型可变,可变的种类是所有串联的TypeGroup的类型交集 ### 枚举类型的问题 注意:下文中,Enum表示枚举类本身,而enum表示一个具体的枚举数据类型 枚举比较特殊,不同于int/float之类的具体数据类型,Enum和Class/Struct是同一层次的抽象,且Enum本身是抽象类,一个EnumPort\的T类型不可能是Enum的实例,而只能是一个具体的enum类型。但是,任意具体的enum类型之间、以及它们和Enum类本身都是不等的。 这导致通过给TypeGroup.AllowedTypes中增加Enum,用于表示支持变成任意enum类型时,AllowedTypes里的Enum本身不能直接匹配任何的具体enum类型,而当CurrentDataType是enum类型时,必然是具体的enum类型,进而出现CurrentDataType不在AllowedTypes中的情况。 这是因为,具体的enum类型必然是由某个的EnumPort\提供(即使该EnumPort\断出网络,由于类型不会退化,所以在连接其他类型网络导致类型发生变化之前,CurrentDataType依然会保持着这个具体的enum类型)。同时,TypeSpreadMap.AllowedTypes是类型连接网络中所有TypeGroup.AllowedTypes的交集,因此它可能包含Enum,但往往不包含具体的enum类型(除非你在TypeGroups声明里手动添加,而这样穷举Enum类型的做法不符合泛化的设计)。 为此,TypeSpreadMapLinkResult需要特殊处理Enum类型的兼容性,在处理之前,对于2个TypeSpreadMap(下称map1和map2),先明确几个前提: - 2个map的CurrentDataType相同、类型都不可变的情况已经在前面的流程排除。 - 若map1是具体的enum类型且不可变,而map2支持Enum且可变,此时validTypes有可能为空,但不妨碍map2可以变成map1的类型,反之亦然。 - validTypes是2个map的AllowedTypes的交集,如果其中包含Enum类,那就表示双方都可以变成任意enum类型。 - 倘若ValidTypes中只有Enum类,导致两个map的端口被迫兼容切换为Enum类,这将使端口类型退化为ValuePort,这和其他诸如int/float之类是不同的。 - 任一map的CurrentDataType.IsEnum时,表示其为具体的enum类型,将优先尝试让另一侧兼容变幻为当前的类型 由上得出结论: 只有任一map的CurrentDataType为enum类型时,才需要进行此处理 --- ## 端口的上下文 在编辑器中的DAG是抽象的逻辑演绎,具体的数据要在运行时中才存在。所以在编辑器模式下,DAG的数组接口并没有具体的数据,而上下文的作用就是为了保证将来在运行时中,数据的流动是合理的。 端口与端口要建立连接时,除了基本的输入输出型检查、数据类型检查、拓扑无环检查外,还需要上下文兼容 节点的每一个端口都可以有对应的上下文,上下文是一个uint,通常为一个Node的Id或者一个Port的GlobalId,甚至没有上下文(ContextId为0),这取决于端口的ContextMode属性配置。 对于2个单值端口,建立连接时并不要求上下文兼容,同时,1个单值端口和1个数组端口不能对接。因此,上下文问题可以简化为2个数组端口连接时才需要处理的问题。 由上可知 - 单值端口可以忽略上下文 - 数组端口有上下文,且仅限2个数组端口上下文一致、或其中之一没有上下文时可以连接 ### 上下文的产生、传递、隔绝 通过对端口的Context属性配置,以控制上下文的产生、传递、隔绝行为,ContextMode分为3种配置情况:Relay/Node/Isolate,结合端口作为输入还是输出,有6种情况 | | Relay | Node | Isolate | |:----:| :----: | :----: | :----: | | 输入 |自身无上下文,传递上下文 |自身无上下文,传递上下文 |自身无上下文,不传递上下文 | | 输出 |自身无上下文,传递上下文 |以Node为上下文,输出上下文 |以自身为上下文,输出上下文 | 在一个上下文字典(ContextTopology)中记录了所有的上下文拓扑图,Key=上下文ID,Value=此ID上下文的结构图,分别为源头端口组,和被连接传播了上下文的端口组,端口上下文的传播关系由LinkManager的端口连接关系求解而得。与此同时,配合一张倒查表用于快速查询某个端口对应的上下文ID ### 上下文的解除 当断开一对数组端口的连接时,会导致由此连接传播上下文可能不再与源头相连,进而导致上下文的解除。此过程通过对对应的上下文连通关系重新求解来刷新 ### 上下文的关键路径切换 当两个拥有不同上下文的端口检查是否可连接时(outPort -> inPort),检查新连接在建立时是否会取代老连接,进而使某一侧port的上下文拓扑整体切换上下文环境。具体思路如下: - 由于拥有上下文的端口必然是ValuePort,且ValuePort的input必然是单入的,所以inPort也是单入的 - 因此如果inPort原本没有连接,那么表示inPort可以通过其他拓扑路径连接到上下文源头,因此此次连接不成立 - 如果inPort原本有连接,那么新连接的建立会导致inPort的旧连接断开,此时会预判在忽略inPort的旧连接的情况下,inPort的上下文环境是否会解除,如果会解除,那么新连接就可以取代就连接,并且传播新的上下文环境