Script Graph

使用指南

高阶教程 / 自定义节点完整指南

系统讲解自定义节点的设计流程、模板结构、端口、类型、内联控件、bridge 执行、动态变化、排查和演进规则。

16

自定义节点适合把项目里反复出现的能力封装成一个稳定节点。它解决的不是“少放几个节点”,而是把一段已经明确的业务语义变成团队可以统一使用、统一命名、统一输入输出的能力。

这篇按真实制作顺序讲:先判断是否应该封装,再创建模板,然后设计节点信息、分类、端口、内联控件、执行逻辑、动态行为,最后处理排查、版本演进和发布前检查。

阅读前提和学习路线

阅读前建议已经完成:

本页的逻辑顺序是:

判断是否值得封装
  -> 设计稳定身份
  -> 设计标题、分类和端口
  -> 选择控件和选择器
  -> 编写执行逻辑
  -> 处理动态端口和类型
  -> 调试、演进、发布

如果你只是想把两三个节点暂时合成一块,先不要写自定义节点;如果你已经开始关心命名、端口稳定性、团队复用和错误提示,本页才真正有价值。

什么时候应该做自定义节点

先判断你的逻辑是否已经稳定。下面情况适合封装:

场景适合原因示例
同一段节点组合复制了多次复制越多,后续修改越容易漏查找角色节点、获取组件、播放动作
输入输出边界已经明确可以把复杂内部逻辑藏起来输入目标路径和距离,输出实际移动距离
团队需要统一用法避免每个人搭出不同版本统一播放 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 用英文小驼峰例如 targetPathsoundNamedistance
名称用用户语言中文界面显示“目标路径”“音效名”
输入尽量少能从上下文或默认值得到的,不要强制连线
输出只给必要值不要把内部所有临时值都暴露出来
端口 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场景节点
COMPONENTButton、Label、AudioSource 等组件
ASSETPrefab、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,
})

如果值变化会影响端口结构,处理器可以读取当前输入输出、调用 setInputssetOutputs 替换端口列表,并用 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,
}

operationbridge 怎么选

选择适合场景
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 和返回对象字段完全一致。

调试自定义节点

节点没有出现在节点栏时,按顺序检查:

  1. 文件是否在 assets/ScriptGraph/CustomNode
  2. 是否默认导出 defineCustomNode(...)
  3. node.type 是否以 Custom 开头。
  4. node.type 是否重复。
  5. 导入路径是否只使用 db://script-graph/workflow/custom-node
  6. 端口配置是否有缺失的 idname 或类型。
  7. bridgeonChange 是否是显式命名导出。

节点出现但运行结果不对时,按顺序检查:

  1. 执行端口是否接入。
  2. 输入字段名是否和端口 ID 一致。
  3. 返回对象里的输出字段是否和输出端口 ID 一致。
  4. next 是否写成了真实存在的执行输出端口 ID。
  5. 异步节点是否标记 isAsync: true
  6. 是否把空输入、空资源、空节点作为可控分支处理。

演进规则

自定义节点一旦被工作流使用,就要把它当成公开契约。

改动风险建议
改标题或描述可以改,保持语义一致
改颜色或图标可以改,但不要频繁变
新增可选输入给默认值,不破坏旧图
新增输出可以加,旧图不会受影响
改端口 ID尽量不要改,必要时新建节点类型
node.type极高视为新节点
删除端口先发布替代节点,再迁移旧图

如果一次改动会破坏旧图,推荐新建 CustomPlayUiSoundV2,保留旧节点,逐步迁移。

发布前检查清单

  • 文件名、node.type、标题三者语义一致。
  • node.type 唯一并以 Custom 开头。
  • 每个输入都有清晰名称、类型和必要默认值。
  • 每个输出都在 bridge 返回值中可能出现。
  • 执行出口 ID 与 next 对应。
  • 空输入、空资源、找不到目标都有可控结果。
  • 异步节点已标记 isAsync: true
  • 端口 ID 不含中文、空格或临时编号。
  • 分类在左侧节点栏里容易找到。
  • 工作流里至少用一次该节点并完成编译验证。

下一步

如果这个节点只服务当前项目,留在 assets/ScriptGraph/CustomNode 即可。如果它要跨项目复用,继续读 插件节点和关联插件完整指南