关于 script-components
介绍如何为 Creator Hub 场景中的实体编写 TypeScript 脚本组件。内容包括文件位置、类结构、可配置参数、执行顺序、资源引用和动作连接。
主要功能
- 01定义脚本类与生命周期方法
- 02在编辑器中配置实体参数
- 03通过 priority 控制更新顺序
- 04解析 layout JSON 中的参数
- 05引用资源和子实体
- 06向其他场景项目暴露动作
适用场景
- 01为 Creator Hub 实体编写脚本
- 02为场景项目配置可复用脚本
- 03设置脚本执行顺序
- 04将脚本动作连接到其他智能项目
MCP 相关
技能说明可通过 Creator Hub MCP 工具(如 `set_component` 和 `attach_script`)以编程方式配置脚本。场景打开时,建议先编写脚本文件,再使用 `attach_script`。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ decentraland/ sdk-skills --skill script-components安装后在 Claude Code 中输入/script-components确认已加载。
详细介绍
为 Creator Hub 编写脚本组件
本文介绍如何编写 .tsx 文件,以便在 Creator Hub 场景中实体的 Script 组件内使用。这些脚本会作为独立的类运行,并附加到各个实体上。
脚本文件必须放在哪里
Script 组件引用的脚本文件必须位于 assets/ 文件夹内——请使用 assets/scripts/(例如 assets/scripts/MyScript.ts)。不要将文件放在根目录下的 scripts/ 文件夹中。
原因:场景的 tsconfig.json 仅包含 src/**/* 和 assets/**/*:
"include": ["src/**/*.ts", "src/**/*.tsx", "assets/**/*.ts", "assets/**/*.tsx"]
根目录下 scripts/ 文件夹中的文件不在这些 glob 范围内,因此 TypeScript 类型检查器(以及构建过程中的类型检查步骤)不会检查该文件。Script 组件的 path 字段相对于项目根目录解析,因此必须与文件的实际位置一致——例如 "path": "assets/scripts/SitChair.ts"。
脚本结构
每个脚本都是一个导出的类,包含:
- 一个 构造函数,用于接收可配置参数(这些参数会显示在 Creator Hub UI 中)。
- 一个可选的
start()方法,在场景加载时调用一次。 - 一个可选的
update(dt: number)方法,每帧调用一次(约 30 FPS;执行顺序请参阅下文“脚本的运行时机”——默认情况下,脚本在所有常规系统之后更新)。
构造函数的前两个参数必须始终是 public src: string 和 public entity: Entity——请勿删除或调整它们的顺序。
import { engine, Entity, Transform } from '@dcl/sdk/ecs'
export class MyScript {
constructor(
public src: string,
public entity: Entity,
public speed: number = 1
) {}
start() {
console.log('Script started on entity:', this.entity)
}
update(dt: number) {
const transform = Transform.getMutable(this.entity)
transform.rotation.y += this.speed * dt
}
}
脚本的运行时机——priority 字段(重要)
Creator Hub Script 组件有一个 priority 字段(与构造函数参数分开——它在组件 UI 中设置,而不是在类中设置)。它默认为 0,这会带来一些不太明显的影响:
- 默认优先级
0= 脚本每帧最后运行。 构建时,@dcl/sdk-commands会按priority值对所有脚本分组,并通过engine.addSystem(updateLoop, Number(priority))为每组注册一个引擎系统。由于引擎系统按优先级从高到低运行(常规系统使用100000,UI 使用100000),优先级为0的脚本会在其他所有内容之后运行。 - 共享相同
priority的所有脚本共用一个系统回调,并在其中依次运行。因此,某个较重脚本的update()会拖慢该帧中同一优先级组的所有其他脚本。 - 若要让脚本的
update()在常规系统之前运行,请在 Script 组件中提高其priority(例如1000000)。这样也会为它分配专用系统。例如,要在update(dt)中驱动物理库(cannon.js),并让读取计算结果的系统先于它运行,就可以采用这种方式——这里没有单独的物理循环。
priority数值越高 = 执行越早——这与“优先级 1 = 最先执行”的假设相反。有关底层引擎规则,请参阅scene-runtime技能中的“系统执行顺序与优先级”一节。
构造函数参数
在构造函数中声明的参数会显示在 Creator Hub UI 中,并可针对每个实体进行配置。允许的类型:
| 类型 | 编辑器 UI |
|---|---|
string | 文本字段 |
number | 数字字段 |
boolean | 复选框 |
Entity | 实体选择器(允许用户从场景中选择另一个实体) |
ActionCallback | 操作选择器——请参阅“ActionCallback 参数” |
Slider<Min, Max, Step> | 滑块和数字输入框——请参阅下文 |
public 和 private 参数都会显示在 Creator Hub 中。请在代码中使用 this.<paramName> 访问这些值。
Slider<Min, Max, Step> — 使用滑块编辑的数字
public speed: Slider<0, 10, 0.5> = 1
-
运行时值是普通的
number— 该类型是纯别名(type Slider<Min, Max, Step = 1> = number),因此不会改变你的代码或序列化方式。它只会告知编辑器将其渲染为滑块。 -
Step默认为1—Slider<0, 100>的步长为 1。 -
无效声明会降级为普通数字字段(不会报错):缺少边界、
min >= max或step <= 0。负边界和负默认值均可使用。 -
超出范围的默认值会被限制在范围内;如果之后扩大或缩小边界,脚本重新加载时,存储的值会重新限制在新范围内。
-
从
@dcl/asset-packs导出,与ActionCallback一起导出。场景目前还不能导入它,因此 Creator Hub 的脚本模板声明了一个本地别名——将此行复制到脚本文件中:// A number edited with a slider in the Creator Hub UI: Slider<min, max, step> type Slider<Min extends number, Max extends number, Step extends number = 1> = number(它最终会放在
~sdk/script-utils中;在此之前,本地别名是受支持的用法。)
默认值
提供默认值,让脚本开箱即用:
constructor(
public src: string,
public entity: Entity,
public radius: number = 5,
public label: string = 'Hello',
public enabled: boolean = true,
) {}
可选参数
对用户可以留空的参数使用 ?:
constructor(
public src: string,
public entity: Entity,
public targetEntity?: Entity,
public message?: string,
) {}
参数提示
在构造函数正前方的 JSDoc 注释块中添加 @param 注解,即可在 Creator Hub UI 中显示提示:
/**
* @param startDate - The start date of the campaign in YYYY-MM-DD format
* @param endDate - The end date of the campaign in YYYY-MM-DD format
* @param wearableYOffset - How many meters above the ground the wearable should be displayed
*/
constructor(
public src: string,
public entity: Entity,
public startDate?: string,
public endDate?: string,
public wearableYOffset: number = 0.5,
) {}
layout 字段——参数的存储方式和运行时解析方式
asset-packs::Script 组件值的形状为 { value: [{ path: string, priority: number, layout?: string }] }。layout 字段是一个 JSON 字符串:
{"params":{"paramName":{"type":"string|number|boolean|action","value":<value>}}}
layout 还包含 actions(来自 @action 方法)和 events(来自 @event 标签,见下文);解析失败时还会包含一个 error 字符串。只有 params 会传入构造函数——但重写 layout 时请保留其他键,否则它们驱动的编辑器功能会消失。
运行时,@dcl/sdk-commands/dist/logic/runtime-script.js 会按如下方式解析参数:
- 使用
JSON.parse(layout)获取ScriptLayout对象。 - 使用
Object.values(layout.params)按插入顺序提取参数值。 - 将这些值按位置展开传入构造函数:
new ScriptClass(src, entity, ...params)。
关键注意事项——参数按位置传递,而非按名称传递。 layout JSON 中键的插入顺序必须与构造函数参数顺序一致(src 和 entity 之后)。不能跳过前面的参数来设置后面的参数——从 src/entity 之后开始的每个参数都必须按顺序提供。
type: "action" 的参数具有 value: {entity: Entity, action: string},并会通过 createActionCallback 转换为 ActionCallback 函数(调用该函数时会执行 getActionEvents(entity).emit(action, {}))。
对于通过 set_component 以编程方式设置 asset-packs::Script 参数的 Agent/MCP 工具,这一点很重要——必须提供完整且有序的 params 对象。(场景在 Creator Hub 中打开时,建议使用它的 MCP attach_script 工具——先写入脚本文件,再使用路径调用该工具,它会为你构建组件;请参阅 creator-hub-mcp。)下面是一个具有 14 个构造函数参数(位于 src/entity 之后)的脚本的 layout 示例:
{"params":{"colorA":{"type":"string","value":"#ff33e6"},"colorB":{"type":"string","value":"#33e6ff"},"rate":{"type":"number","value":35},"lifetime":{"type":"number","value":2.5},"sizeMin":{"type":"number","value":0.3},"sizeMax":{"type":"number","value":0.6},"speedMin":{"type":"number","value":2.5},"speedMax":{"type":"number","value":4},"gravity":{"type":"number","value":-0.3},"spin":{"type":"number","value":0},"shape":{"type":"string","value":"sphere"},"shapeSize":{"type":"number","value":0.4},"maxParticles":{"type":"number","value":150},"startPlaying":{"type":"boolean","value":true}}}
验证依据: @dcl/sdk-commands/dist/logic/runtime-script.js — resolveScriptParams 使用 Object.values(params).map(...),并将结果展开为 new ScriptClass(src, entity, ...params)。
使用 this.src 引用资源
如果脚本使用仅通过代码加载的其他资源(音频文件、纹理、模型等),这些资源不会自动包含在自定义物品文件夹中。你必须手动添加这些文件。
始终使用 this.src 构建捆绑资源文件的路径,因为该物品在其他场景中使用时,文件的实际位置可能不同:
import { AudioSource } from '@dcl/sdk/ecs'
start() {
AudioSource.create(this.entity, {
audioClipUrl: this.src + '/sounds/click.mp3',
playing: false
})
}
引用子实体
不要将属于同一自定义物品的实体作为 Entity 输入参数传入。实体 ID 在不同场景中并不稳定——在开发场景中有效的实体 ID,在用户的场景中可能并不存在。
相反,应在运行时遍历实体层级,并通过 子串 匹配来查找子实体,将其与 Name 组件的值进行匹配(例如 .startsWith(...) 或 .includes(...))。
最佳实践:匹配子实体名称中的子串,而不是精确名称,也不要使用逐实例构造函数参数。 当用户复制智能物品或向场景中放入多个副本时,Creator Hub 编辑器会自动为复制的子实体编号,例如 "Sit Spot"、"Sit Spot 2"、"Sit Spot 3",依此类推。子串匹配(name.includes('Sit Spot'))会自动在每个副本中找到匹配的子实体,无需任何逐实例配置。
- 精确名称匹配在第一个副本之后都会失效,因为其他副本的名称会自动添加后缀。
- 为子实体名称使用构造函数
string/Entity参数会迫使用户手动重命名或重新连接每个副本,这与可复用脚本物品的初衷背道而驰。
正因如此,以下示例使用 .startsWith('Needle')——一种子串匹配方式。
import { engine, Entity, Transform, Name } from '@dcl/sdk/ecs'
export class ClapMeter {
private needleEntities: Entity[] = []
constructor(
public src: string,
public entity: Entity,
) {}
start() {
for (const [childEntity, transform] of engine.getEntitiesWith(Transform)) {
if (transform.parent === this.entity) {
const nameComponent = Name.getOrNull(childEntity)
if (nameComponent && nameComponent.value.startsWith('Needle')) {
this.needleEntities.push(childEntity)
}
}
}
}
}
此模式使脚本具有可移植性:只要子实体的名称包含预期子串,它就能在任何场景中正常工作,也适用于任意数量的副本。
定义操作(@action)
如果脚本中有可能对场景中其他物品有用的函数,请在方法之前直接添加带有 @action 标记的 JSDoc 注释块(/** ... */)。然后为该实体添加 Action 组件并定义对应的操作。这样,其他智能物品(例如按钮)就可以选择并触发此操作。
重要:只能使用 @action JSDoc 标记,绝不能使用装饰器语法(在方法上方写 @action())。Creator Hub 解析器没有装饰器插件,因此使用装饰器会导致解析失败,所有参数和操作都会从 UI 中悄然消失。
@action 标记前的可选描述行会成为操作的描述,并显示在 Creator Hub UI 中。
import { engine, Entity } from '@dcl/sdk/ecs'
export class TreasureChest {
private isOpen = false
constructor(
public src: string,
public entity: Entity,
) {}
/**
* Opens the chest
* @action
*/
open() {
if (this.isOpen) return
this.isOpen = true
console.log('Chest opened!')
}
/**
* Closes the chest
* @action
*/
close() {
if (!this.isOpen) return
this.isOpen = false
console.log('Chest closed!')
}
}
使用 @action JSDoc 标记后,open 和 close 会出现在 Actions 组件的下拉列表中,其他智能物品或脚本也可以触发它们。
ActionCallback 参数
使用 ~sdk/script-utils 中的 ActionCallback 类型,让用户将编辑器中配置的操作连接为脚本上的回调。随后,用户可以在 Creator Hub UI 中将任意物品的任意操作分配给该回调。
import { Entity } from '@dcl/sdk/ecs'
import type { ActionCallback } from '~sdk/script-utils'
export class Padlock {
constructor(
public src: string,
public entity: Entity,
public onUnlock: ActionCallback,
) {}
/**
* @action
*/
solve() {
this.onUnlock()
}
}
未连接的 ActionCallback 参数值为 undefined(sdk-commands 修复 8da580d5)
用户从未在编辑器中分配的可选 ActionCallback,现在会解析为 undefined,因此可以使用直观的保护条件:
constructor(
public src: string,
public entity: Entity,
public onUnlock?: ActionCallback, // ActionCallback | undefined
) {}
solve() {
if (this.onUnlock) this.onUnlock() // safe: no-op when unwired
}
之前,未分配的参数会解析为一个真值占位符({ entity: 0, action: '' } 被包装成可调用对象),因此 if (this.onUnlock) 始终成立,而调用它会记录一条 console.error。如果你曾针对这种情况编写变通方案——例如检查 entity !== 0 或吞掉错误——请将其移除。在较旧版本的 @dcl/sdk-commands 中,旧行为仍然存在,因此仅使用真值判断并不能确保安全。
当没有连接任何操作时,行为仍应正常工作的话,请将这些参数声明为可选(?);非可选的 ActionCallback 表示没有连接操作时该物品无法正常工作。
在代码中调用其他脚本
使用 ~sdk/script-utils 中的运行时工具,调用其他 Script 组件实例上的方法:
import {
callScriptMethod,
getScriptInstance,
getAllScriptInstances,
getScriptInstancesByPath
} from '~sdk/script-utils'
callScriptMethod(entity, 'assets/scripts/Padlock.ts', 'solve', 123)
const instance = getScriptInstance(entity, 'assets/scripts/Padlock.ts')
const allOnEntity = getAllScriptInstances(entity)
const allByPath = getScriptInstancesByPath('assets/scripts/Padlock.ts')
Smart Items 现已基于 Script(creator-hub 658f4daf,PR #1354)
目录中的大多数 Smart Items 已从 Actions/Triggers 无代码图迁移到 asset-packs::Script 代码和带有 @action 标记的方法——这正是本文档所述的机制。检查或扩展此类物品时,意味着:
- 迁移后的项目不带
asset-packs::Triggers。 不要查找触发器图;行为位于Script组件指向的.ts文件中(Door.ts、Seat.ts、Button.ts、Lever.ts、Bell.ts、Teleport.ts、Sign.ts等)。 - 保留下来的
Actions是call_script_method指针,用于保留旧的公共操作名称,因此引用了“Open” / “Close” / “Open or Close” / “Sit Here”的现有场景仍可正常工作。其jsonPayload为{"scriptPath": "{assetPath}/Door.ts", "methodName": "open", "params": {}}。 - Script 的参数就是配置界面——迁移项目已移除
inspector::Config面板。未迁移的项目仍保留该面板:灯光、屏幕/媒体系列、camera、admin_toolkit、image、nft、text,以及健康/战斗系列。(不要声称所有项目都移除了 Config——事实并非如此。) - “当 X 发生时”钩子是可选的
ActionCallback参数。 已发布项目中的实际名称:onClick、onActivate/onDeactivate、onReachStart/onReachEnd(最常见,位于移动平台系列)、onRing(铃铛)和onOpen/onClose(开/关标志——不是门)。按照此技能的 ActionCallback 部分所述进行连接。 - 状态存储在同步的
asset-packs::States组件中,该组件列于core-schema::Sync-Components({"componentIds": ["asset-packs::States"]})。例如,门会在start()中创建States,其value: ['Open','Closed'], defaultValue: 'Closed';椅子的每个座位点都有['Free','Taken']。通过States读取和写入状态,不要使用脚本实例字段,否则状态不会同步。
未迁移(仍使用 Actions/Triggers):click_area、audio_stream、健康/战斗系列(first_aid、healing_pad、health_bar、respawn_pad、robot、spikes、sword、barrel、wooden_wall),以及 camera(有 Actions + Config,没有 Triggers)。video_player 已标记为 [DEPRECATED](已移至 deprecated 类别)——请改用视频屏幕系列中的项目。
新项目从一开始就以 Scripts 编写。 locomotion_settings(“Locomotion Settings”,utils,creator-hub cb097fff)是当前的参考示例,编写自己的项目之前值得一读:包含九个使用 SDK 默认值初始化的 Slider<Min,Max,Step> 参数,每个参数都有一个 @param 提示;start() 会将一个组件写入 engine.PlayerEntity(这是全局效果,不是单实体效果——该项目在世界中完全没有实体存在);包含两个 @action 方法(apply 会重新写入这些值,restoreDefaults 会调用 deleteFrom);还包含一个仅供编辑器使用的 asset-packs::Placeholder 立方体,让该项目可在视口中选中,但在世界中不可见。它不带 Actions、Triggers、Config 或 States。有关它所配置的内容,请参阅 player-avatar 技能。
较新的参考示例(位于 packs/smart_items/assets/ 下;以下行为来自项目的变更说明,并未在世界中重新验证):floor_pressure_button/FloorButton.ts——多人共享按钮:触发区域与圆盘大小相同;在默认的 all players 模式下,只要任何人站在按钮上,按钮就会保持按下状态,其 On Press / On Release 操作只会在导致状态变化的玩家客户端上运行一次(首次按下 / 最后离开),并通过 asset-packs::States 同步 press()/release() 按住状态;my player 模式则会创建仅对每个玩家私有的按钮。water_plane/Water.ts——运行时通过五层半透明的 MeshRenderer 构建水面(GLB 是仅供编辑器使用的 Placeholder),仅在缩放时重新适配触发区域,并在场景坐标中生成水花。lab_stair_platform/PistonPlatform.ts——将 startState 设为字符串字面量联合类型(编辑器会将其转换为枚举下拉菜单),且 Sync-Components 中仅有 States,因为 update() 会根据每个客户端同步的状态重放 Animator/AudioSource。
已移除 world_teleport,并将其合并到 teleport 中,后者新增了 world 参数。设置 world 时优先使用它:非空的 world 会切换 realm,否则会传送到 x/y 坐标。请更新任何引用 world_teleport 的场景或说明。
[已在 @dcl/sdk 7.30.1 中修复] 在实时预览期间添加第一个智能项,不再需要重启。 在 7.30.0 及更早版本中,isEditorScene() 会在创建 esbuild 上下文之前运行一次,其判断结果会被写入静态入口点。在 npm run start / Creator Hub 预览已经运行时向场景添加第一个智能项(或第一个 asset-packs::Script),会重新构建一个在没有 asset-packs 引导程序的情况下执行该项 Script 的捆绑包,因此 getComponents 会抛出 Component asset-packs::Actions not found,并且预览会一直无法恢复,直到重启。js-sdk-toolchain 52fa56cf(PR #1644,标签 7.30.1)会在每次监视重建时重新评估 isEditorScene()。
如果你仍在使用旧的“重启预览”临时解决方法,请在 7.30.1 及更高版本中移除它:监视重建会自行恢复。在较旧的固定版本中,重启仍然是解决方法;错误文本就是判断依据——这是捆绑缺陷,而不是组合中缺少组件,因此不要去实体树中查找 asset-packs::Actions。修复并未改变公开的 ~sdk/script-utils 接口(ActionCallback、getScriptInstance、getAllScriptInstances、callScriptMethod)。
trigger_area 现在也是一个 Script 项。 旧的 Actions/Triggers trigger_area 资源已在 creator-hub e43441b5 中删除;基于脚本的 trigger_area_prompt(显示为 “Trigger Area”,id 为 e9fa0eab-44de-4efe-af77-a71171a1a73f)是唯一保留的版本——过渡期间使用的“Trigger Area (Script)”标签已移除。编写自己的检测器之前,请先阅读该项中的 TriggerArea.tsx;它是下文 @event 模式的参考。TriggerArea 组件本身请参阅 add-interactivity。
@event — 为反应脚本声明事件
类(或 start() 函数)的 JSDoc 除了 @param 和 @action 外,还可以带有 @event <name> 标签:
/**
* @event enter
* @event exit
*/
export class TriggerAreaDetector { /* … */ }
- 检查器会解析这些标签(
ScriptInspector/parser.ts→extractEvents),并将其存储为 Script 的layoutJSON 中的events: string[]键,与params和actions并列。非空的events会让检查器显示 反应 部分,其中每个事件对应一个提示词按钮;点击按钮后会打开 AI 助手,并预先填入一句自然语言描述。请使用九个已识别事件名称之一,这样按钮和预填句子才会读起来自然(ScriptInspector.tsx中的EVENT_LABEL/EVENT_CLAUSE,已在 creator-hub0.52.0中验证):
@event 名称 | 按钮标签 | 预填子句 |
|---|---|---|
enter | 当玩家进入时… | when a player enters <name> |
exit | 当玩家离开时… | when a player leaves <name> |
click | 点击时… | when <name> is clicked |
open / close | 打开时… / 关闭时… | when <name> opens / closes |
activate / deactivate | 激活时… / 停用时… | when <name> is activated / deactivated |
toggle | 切换时… | when <name> is toggled |
unlock | 解锁时… | when <name> is unlocked |
不识别的名称仍可使用,但会回退到通用形式 When <event>… / when <name> fires "<event>",创作者读起来会很别扭。声明 @event 后,无代码反应界面便会自动出现。
- 标签可写在导出语句、类声明或构造函数的 JSDoc 中,三者任意其一均可。
- 名称必须匹配
[A-Za-z0-9_-]+;重复名称会被合并。
由此实现的检测器/反应脚本分工。 SDK 对每个 (entity, event) 对仅保留一个回调,因此第二个脚本若在同一实体上调用 triggerAreaEventsSystem.onTriggerEnter,会静默替换第一个脚本的回调。已发布的模式会避免这种情况:由一个脚本持有 SDK 回调并公开 onEvent(name, fn);同一实体上的反应脚本通过 ~sdk/script-utils 中的 getAllScriptInstances(entity) 找到它并订阅。反应脚本绝不能直接操作 triggerAreaEventsSystem。在 TriggerArea.tsx 中,检测器还会为已经位于区域内的对象向后来订阅者重放 enter 事件,并提供 isInside() 供“在区域内时”的逻辑使用。
⚠️ 手动编写 layout JSON 时,每次编辑都要保留 events。 仅根据 params 重建 layout 会将其丢弃,导致反应部分永久消失——这正是 creator-hub e43441b5 修复的 bug(编辑 Trigger Area 的 shape 曾导致其提示字段被清除)。应展开现有的 layout,只覆盖你要更改的那个参数。
值得了解的行为变化: 坐着的玩家走出超过 1.5 m 后,座位会自动腾空;sit() 会选择最近的空位;没有空位时,物品会显示其 takenMessage(默认为 "Seat is taken")。播放坐下动作前会先移动 avatar——如果顺序相反,动作就会被取消。