使用指南
高阶教程 / 自定义节点完整指南
系统讲解自定义节点的设计流程、模板结构、端口、类型、内联控件、bridge 执行、动态变化、排查和演进规则。
自定义节点适合把项目里反复出现的能力封装成一个稳定节点。它解决的不是“少放几个节点”,而是把一段已经明确的业务语义变成团队可以统一使用、统一命名、统一输入输出的能力。
这篇按真实制作顺序讲:先判断是否应该封装,再创建模板,然后设计节点信息、分类、端口、内联控件、执行逻辑、动态行为,最后处理排查、版本演进和发布前检查。
阅读前提和学习路线
阅读前建议已经完成:
- Hello World:打印第一条消息。
- 节点、引脚和连线。
- 至少做过一个组件工作流,并能保存、编译、挂接和运行。
本页的逻辑顺序是:
判断是否值得封装
-> 设计稳定身份
-> 设计标题、分类和端口
-> 选择控件和选择器
-> 编写执行逻辑
-> 处理动态端口和类型
-> 调试、演进、发布
如果你只是想把两三个节点暂时合成一块,先不要写自定义节点;如果你已经开始关心命名、端口稳定性、团队复用和错误提示,本页才真正有价值。
什么时候应该做自定义节点
先判断你的逻辑是否已经稳定。下面情况适合封装:
| 场景 | 适合原因 | 示例 |
|---|---|---|
| 同一段节点组合复制了多次 | 复制越多,后续修改越容易漏 | 查找角色节点、获取组件、播放动作 |
| 输入输出边界已经明确 | 可以把复杂内部逻辑藏起来 | 输入目标路径和距离,输出实际移动距离 |
| 团队需要统一用法 | 避免每个人搭出不同版本 | 统一播放 UI 音效、统一上报事件 |
| 业务动作有名字 | 节点标题能表达领域语义 | “造成伤害”“打开背包”“刷新任务” |
| 需要减少手写路径或类型 | 可以内置选择器和默认值 | 场景名、节点路径、资源选择 |
下面情况先不要封装:
| 场景 | 原因 |
|---|---|
| 只用一次 | 直接用普通节点更清楚 |
| 输入输出还在频繁变化 | 端口频繁改名会影响已有工作流 |
| 只是几个普通节点 | 过度封装会让排查更难 |
| 还没想清楚错误处理 | 自定义节点失败时更需要明确提示 |
一个实用标准是:同一段逻辑第三次出现时再考虑封装。第一次验证,第二次观察差异,第三次说明边界基本稳定。
创建模板
有两个入口可以创建模板:
- 资源管理器右键菜单:
工作流自定义节点模板(Custom Node)。 - 顶部菜单:
扩展 / 脚本图编辑器 / 创建工作流自定义节点模板。
模板会创建在:
assets/ScriptGraph/CustomNode/NewCustomNode.ts
建议立刻重命名,例如:
assets/ScriptGraph/CustomNode/MoveActor.ts
assets/ScriptGraph/CustomNode/PlayUiSound.ts
assets/ScriptGraph/CustomNode/OpenPanel.ts
文件名应当表达业务动作,不要保留 NewCustomNode。以后排查节点来源时,文件名、节点标题和节点类型应该能互相对应。
只使用公开入口
自定义节点从公开入口导入 API:
import {
defineCustomNode,
inline,
picker,
port,
bridge,
operation,
type CustomNodeBridgeFunction,
type CustomNodeValueChangeHandler,
type I18nText,
} from "db://script-graph/workflow/custom-node";
常用导入含义如下:
| API | 作用 |
|---|---|
defineCustomNode | 声明一个自定义节点 |
port | 创建执行、事件、数据端口 |
inline | 创建画布内可编辑控件 |
picker | 创建场景、节点、资源、组件等选择器 |
bridge | 绑定一段项目逻辑作为节点执行体 |
operation | 复用已有操作语义封装节点 |
CustomNodeBridgeFunction | 给 bridge 函数提供类型约束 |
CustomNodeValueChangeHandler | 给内联控件值变化逻辑提供类型约束 |
不要自己拼弱类型对象。使用这些 helper 的好处是:端口方向、端口类型、默认子类型、节点注册信息会保持一致。
节点文件的四个核心区块
一个自定义节点通常由四个部分组成:
| 区块 | 负责什么 | 新手常见问题 |
|---|---|---|
node | 节点本体信息:类型、标题、说明、颜色、图标 | node.type 重复或没有 Custom 前缀 |
sidebar | 左侧节点栏分类:一级、二级、组名和图标 | 分类太深或命名不统一 |
ports | 输入、输出、执行口、数据口、事件口 | 端口 ID 改来改去导致旧图失效 |
executor | 节点真正执行什么 | 输入没校验、异步没标记、输出端口名对不上 |
一个最小结构如下:
export default defineCustomNode({
node: {
type: "CustomPlayUiSound",
title: { zh: "播放 UI 音效", en: "Play UI Sound" },
description: { zh: "按音效名播放一次 UI 音效。", en: "Play one UI sound by name." },
subtitle: { zh: "音频", en: "Audio" },
icon: "volume-2",
color: "#38bdf8",
},
sidebar: {
category: "项目节点",
subcategory: "UI",
group: "音频",
icon: "volume-2",
iconColor: "#38bdf8",
},
ports: {
inputs: [
port.execIn({ name: { zh: "执行", en: "Execute" } }),
port.input({
id: "soundName",
name: { zh: "音效名", en: "Sound Name" },
variableType: "STRING",
inline: inline.input({ defaultValue: "click" }),
}),
],
outputs: [
port.execOut({ id: "out", name: { zh: "完成", en: "Done" } }),
],
},
executor: bridge(executePlayUiSound),
});
node.type 是最重要的稳定身份
node.type 必须遵守三条规则:
| 规则 | 说明 |
|---|---|
必须以 Custom 开头 | 避免和内置节点重名 |
| 项目内唯一 | 两个文件同名会被拒绝注册或产生诊断 |
| 发布后尽量不改 | 已有工作流靠它识别节点 |
推荐命名:
| 业务动作 | 推荐 type |
|---|---|
| 播放 UI 音效 | CustomPlayUiSound |
| 移动角色 | CustomMoveActor |
| 打开面板 | CustomOpenPanel |
| 生成伤害数字 | CustomSpawnDamageText |
不要手写节点实例 ID。系统会根据文件路径和 node.type 生成稳定身份。
标题、说明和默认注释
自定义节点会被搜索、拖拽、阅读和排查,所以文字信息要写给使用者看。
| 字段 | 建议写法 |
|---|---|
title | 用动词短语,说明节点做什么 |
subtitle | 写分类提示,例如“音频”“UI”“战斗” |
description | 写清输入、动作和结果 |
comment | 可选,给拖入画布后的默认备注 |
好的说明:
按音效名播放一次 UI 音效;如果音效名为空,则直接走完成出口。
不好的说明:
执行逻辑。
分类设计
sidebar 决定节点在左侧节点栏中的位置:
sidebar: {
category: "项目节点",
subcategory: "UI",
group: "音频",
icon: "volume-2",
iconColor: "#38bdf8",
}
建议分类不要超过三层。常见组织方式:
| 一级 | 二级 | 组 |
|---|---|---|
| 项目节点 | UI | 音频 |
| 项目节点 | 角色 | 移动 |
| 项目节点 | 战斗 | 伤害 |
| 项目节点 | 关卡 | 生成 |
如果一个节点不知道放哪里,说明它的职责可能还不够清晰。
端口设计原则
端口是自定义节点的对外契约。设计端口时优先考虑稳定和可读。
| 原则 | 说明 |
|---|---|
| ID 用英文小驼峰 | 例如 targetPath、soundName、distance |
| 名称用用户语言 | 中文界面显示“目标路径”“音效名” |
| 输入尽量少 | 能从上下文或默认值得到的,不要强制连线 |
| 输出只给必要值 | 不要把内部所有临时值都暴露出来 |
| 端口 ID 发布后少改 | 已有连线和默认值依赖端口 ID |
| 必填输入写清楚 | 没有目标就无法执行的输入要标明 |
执行端口:
port.execIn({ name: { zh: "执行", en: "Execute" } })
port.execOut({ id: "out", name: { zh: "完成", en: "Done" } })
数据输入:
port.input({
id: "distance",
name: { zh: "距离", en: "Distance" },
variableType: "NUMBER",
variableSubType: "SINGLE",
inline: inline.input({ defaultValue: 1, min: 0, step: 0.1 }),
})
数据输出:
port.output({
id: "appliedDistance",
name: { zh: "最终距离", en: "Applied Distance" },
variableType: "NUMBER",
})
数据端口如果不写 variableSubType,默认按单个值处理。数组、集合、映射等复杂形态再显式指定。
变量类型和子类型
常见数据大类:
variableType | 适合什么 |
|---|---|
STRING | 名称、路径、提示文本 |
NUMBER | 距离、速度、分数、时间 |
BOOLEAN | 开关、条件 |
NODE | 场景节点 |
COMPONENT | Button、Label、AudioSource 等组件 |
ASSET | Prefab、AudioClip、SpriteFrame 等资源 |
ENUM | 固定候选值 |
STRUCT | 一组字段组成的数据 |
REFERENCE | 类实例或外部对象引用 |
OBJECT | 兜底对象,尽量少用 |
常见子类型:
variableSubType | 含义 |
|---|---|
SINGLE | 单个值 |
ARRAY | 数组 |
MAP | 键值映射 |
SET | 集合 |
原则是:能写具体类型就不要写宽泛对象。类型越具体,连线错误越早被发现。
精确类型引用
当数据不是普通字符串、数字、布尔值时,通常还需要更精确的类型身份。比如“组件”需要知道是 Button 还是 Label,“枚举”需要知道是哪一个枚举文件,“结构体”需要知道是哪一个结构体。
可以用 variableTypeConfig 表达更精确的类型:
| kind | 用途 |
|---|---|
external | 外部类或引擎类型 |
enum | 工作流枚举 |
struct | 工作流结构体 |
workflowClass | 工作流类 |
初学自定义节点时,先用基础类型和选择器。等节点需要和枚举、结构体、组件强绑定时,再补精确类型。
内联控件
内联控件让输入端口不连接线时也能直接编辑默认值。
| helper | 用途 | 常见示例 |
|---|---|---|
inline.input | 文本或数字 | 速度、距离、音效名 |
inline.checkbox | 开关 | 是否循环、是否立即播放 |
inline.color | 颜色 | 文本颜色、特效颜色 |
inline.vector | 向量 | 位置、方向、缩放 |
inline.quaternion | 旋转 | 3D 旋转 |
inline.select | 固定选项 | 模式、策略、对齐方式 |
inline.matrix | 矩阵输入 | 高级变换数据 |
inline.tweenEase | 缓动曲线 | 缓动动画 |
示例:
port.input({
id: "loop",
name: { zh: "循环播放", en: "Loop" },
variableType: "BOOLEAN",
inline: inline.checkbox({ defaultValue: false }),
})
如果端口已连上上游数据,可以让控件隐藏:
inline.input({ defaultValue: 1, hideWhenConnected: true })
这样可以避免用户误以为“连线值”和“控件默认值”会同时生效。
选择器
选择器是高质量自定义节点的重要组成。能选择就不要手写字符串。
| helper | 用途 |
|---|---|
picker.nodePath | 选择场景节点路径 |
picker.sceneName | 选择场景名 |
picker.asset | 选择资源 |
picker.bundle | 选择资产包 |
picker.bundleDirectory | 选择资产包目录 |
picker.component | 选择组件类型 |
picker.enumType | 选择枚举类型 |
picker.assetType | 选择资源类型 |
picker.class | 选择类类型 |
资源选择可以加过滤:
port.input({
id: "prefab",
name: { zh: "Prefab", en: "Prefab" },
variableType: "ASSET",
inline: picker.asset({
assetFilter: {
extensions: [".prefab"],
ccType: "cc.Prefab",
},
}),
})
节点路径选择:
port.input({
id: "targetPath",
name: { zh: "目标路径", en: "Target Path" },
variableType: "STRING",
inline: picker.nodePath(),
})
场景名、节点路径、资源路径这三类输入最推荐用选择器。
onChange:让节点随输入变化
内联控件可以绑定值变化处理器。它适合做三类事情:
| 用途 | 示例 |
|---|---|
| 修改标题或说明 | 未选目标时显示“选择目标”,选中后显示真实动作 |
| 调整端口 | 选择“多目标”后增加数组输入 |
| 清理不再合法的连线 | 类型变化后移除旧连接 |
示例:
export const onTargetPathChanged: CustomNodeValueChangeHandler<string> = ({
value,
setTitle,
setDescription,
requestNodeInternalsRefresh,
}) => {
const hasPath = typeof value === "string" && value.trim().length > 0;
setTitle(
hasPath
? { zh: "移动目标", en: "Move Target" }
: { zh: "选择目标", en: "Select Target" },
);
setDescription(
hasPath
? { zh: "按配置移动目标节点。", en: "Move the selected target node." }
: { zh: "请先选择目标节点路径。", en: "Select a target node path first." },
);
requestNodeInternalsRefresh();
};
绑定到输入:
inline: picker.nodePath({
onChange: onTargetPathChanged,
})
如果值变化会影响端口结构,处理器可以读取当前输入输出、调用 setInputs 或 setOutputs 替换端口列表,并用 cleanupAffectedConnections() 清理不合法连线。
动态端口和动态类型
动态端口适合“数量由用户决定”的输入,例如多个目标、多段条件、多项输出。
port.input({
id: "targets",
name: { zh: "目标", en: "Target" },
variableType: "NODE",
dynamicPort: {
enabled: true,
baseId: "target",
baseName: { zh: "目标", en: "Target" },
minCount: 1,
maxCount: 8,
addButtonText: { zh: "添加目标", en: "Add Target" },
addButtonIcon: "plus",
},
})
动态类型绑定适合“输入是什么类型,输出就是什么类型”的节点。例如缓存节点、选择节点、数组取值节点。它需要主端口和依赖端口共享一个 groupId。
设计动态端口时要克制:
- 端口数量要有上限。
- 基准端口 ID 要稳定。
- 删除端口时要考虑旧连接会被清理。
- 同类动态端口的名称要可读,例如“目标 1”“目标 2”。
bridge 执行方式
bridge 适合直接写项目逻辑。函数接收输入对象和上下文,返回输出端口值和下一条执行出口:
export const executeMoveActor: CustomNodeBridgeFunction = async (inputs, context) => {
const targetPath = (inputs.targetPath as string | undefined)?.trim() ?? "";
const distance = Number(inputs.distance ?? context.getDefault("distance") ?? 0);
if (!targetPath) {
return {
outputs: {
resolvedTargetPath: "",
appliedDistance: 0,
},
next: "out",
};
}
return {
outputs: {
resolvedTargetPath: targetPath,
appliedDistance: distance,
},
next: "out",
};
};
bridge 上下文常用能力:
| 能力 | 用途 |
|---|---|
host | 当前组件工作流的宿主对象 |
flowId / nodeId | 记录或排查当前执行位置 |
args | 当前入口参数 |
eventBus.emit / eventBus.on | 发送或监听运行时事件 |
getDefault(portId) | 读取某个输入端口默认值 |
getLocalVariable(name) | 读取局部变量 |
setLocalVariable(name, value) | 写入局部变量 |
如果 bridge 中有等待过程,比如加载资源或等待外部回调,节点本体应标记:
node: {
type: "CustomLoadConfig",
title: { zh: "加载配置", en: "Load Config" },
description: { zh: "异步加载配置资源。", en: "Load config asset asynchronously." },
icon: "file-json",
color: "#f59e0b",
isAsync: true,
}
operation 和 bridge 怎么选
| 选择 | 适合场景 |
|---|---|
bridge | 项目侧业务逻辑、事件发送、变量读写、复杂组合 |
operation | 复用已有对象属性、方法或构造语义 |
新手优先用 bridge。当你只是想把某个已有对象的属性读写或方法调用包装成项目节点时,再考虑 operation。
限制可用 Flow
有些节点只适合某些工作流类型。例如 UI 组件节点通常只适合组件工作流,构造数据节点可以用于函数库或类方法。可以用 allowedFlowTypes 限制使用范围。
建议:
- 只依赖场景节点或组件的节点,限制在组件相关场景。
- 纯计算节点可以放开。
- 事件绑定节点要明确是否适合函数库或类方法。
限制越准确,用户越早发现使用位置不对。
完整示例:播放 UI 音效
下面示例展示一个完整节点:有执行输入、音效名、音量、循环开关、完成出口和播放结果。
import {
defineCustomNode,
inline,
port,
bridge,
type CustomNodeBridgeFunction,
} from "db://script-graph/workflow/custom-node";
export const executePlayUiSound: CustomNodeBridgeFunction = async (inputs, context) => {
const soundName = String(inputs.soundName ?? context.getDefault("soundName") ?? "").trim();
const volume = Number(inputs.volume ?? context.getDefault("volume") ?? 1);
const loop = Boolean(inputs.loop ?? context.getDefault("loop") ?? false);
if (!soundName) {
return {
outputs: {
played: false,
message: "soundName is empty",
},
next: "out",
};
}
await context.eventBus.emit("PlayUiSound", { soundName, volume, loop });
return {
outputs: {
played: true,
message: soundName,
},
next: "out",
};
};
export default defineCustomNode({
node: {
type: "CustomPlayUiSound",
title: { zh: "播放 UI 音效", en: "Play UI Sound" },
subtitle: { zh: "项目音频", en: "Project Audio" },
description: { zh: "发送播放 UI 音效事件,并输出是否已发送。", en: "Emit a UI sound event and output whether it was sent." },
icon: "volume-2",
color: "#38bdf8",
isAsync: true,
},
sidebar: {
category: "项目节点",
subcategory: "UI",
group: "音频",
icon: "volume-2",
iconColor: "#38bdf8",
},
ports: {
inputs: [
port.execIn({ name: { zh: "执行", en: "Execute" } }),
port.input({
id: "soundName",
name: { zh: "音效名", en: "Sound Name" },
variableType: "STRING",
inline: inline.input({ defaultValue: "click", placeholder: "click" }),
}),
port.input({
id: "volume",
name: { zh: "音量", en: "Volume" },
variableType: "NUMBER",
inline: inline.input({ defaultValue: 1, min: 0, max: 1, step: 0.1 }),
}),
port.input({
id: "loop",
name: { zh: "循环", en: "Loop" },
variableType: "BOOLEAN",
inline: inline.checkbox({ defaultValue: false }),
}),
],
outputs: [
port.execOut({ id: "out", name: { zh: "完成", en: "Done" } }),
port.output({
id: "played",
name: { zh: "已发送", en: "Played" },
variableType: "BOOLEAN",
}),
port.output({
id: "message",
name: { zh: "消息", en: "Message" },
variableType: "STRING",
}),
],
},
executor: bridge(executePlayUiSound),
});
这个例子有几个值得学习的点:
- 空音效名不会抛出不可控错误,而是输出
played: false。 - 音量和循环都有默认值。
- 节点通过事件总线发送播放请求,不直接假设具体音频组件在哪。
- 输出端口 ID 和返回对象字段完全一致。
调试自定义节点
节点没有出现在节点栏时,按顺序检查:
- 文件是否在
assets/ScriptGraph/CustomNode。 - 是否默认导出
defineCustomNode(...)。 node.type是否以Custom开头。node.type是否重复。- 导入路径是否只使用
db://script-graph/workflow/custom-node。 - 端口配置是否有缺失的
id、name或类型。 bridge和onChange是否是显式命名导出。
节点出现但运行结果不对时,按顺序检查:
- 执行端口是否接入。
- 输入字段名是否和端口 ID 一致。
- 返回对象里的输出字段是否和输出端口 ID 一致。
next是否写成了真实存在的执行输出端口 ID。- 异步节点是否标记
isAsync: true。 - 是否把空输入、空资源、空节点作为可控分支处理。
演进规则
自定义节点一旦被工作流使用,就要把它当成公开契约。
| 改动 | 风险 | 建议 |
|---|---|---|
| 改标题或描述 | 低 | 可以改,保持语义一致 |
| 改颜色或图标 | 低 | 可以改,但不要频繁变 |
| 新增可选输入 | 中 | 给默认值,不破坏旧图 |
| 新增输出 | 低 | 可以加,旧图不会受影响 |
| 改端口 ID | 高 | 尽量不要改,必要时新建节点类型 |
改 node.type | 极高 | 视为新节点 |
| 删除端口 | 高 | 先发布替代节点,再迁移旧图 |
如果一次改动会破坏旧图,推荐新建 CustomPlayUiSoundV2,保留旧节点,逐步迁移。
发布前检查清单
- 文件名、
node.type、标题三者语义一致。 node.type唯一并以Custom开头。- 每个输入都有清晰名称、类型和必要默认值。
- 每个输出都在 bridge 返回值中可能出现。
- 执行出口 ID 与
next对应。 - 空输入、空资源、找不到目标都有可控结果。
- 异步节点已标记
isAsync: true。 - 端口 ID 不含中文、空格或临时编号。
- 分类在左侧节点栏里容易找到。
- 工作流里至少用一次该节点并完成编译验证。
下一步
如果这个节点只服务当前项目,留在 assets/ScriptGraph/CustomNode 即可。如果它要跨项目复用,继续读 插件节点和关联插件完整指南。