mcpField
S
script-components

script-components

github.com/decentraland开发4285 次安装今天更新
代码仓库

关于 script-components

介绍如何为 Creator Hub 场景中的实体编写 TypeScript 脚本组件。内容包括文件位置、类结构、可配置参数、执行顺序、资源引用和动作连接。

主要功能

  1. 01定义脚本类与生命周期方法
  2. 02在编辑器中配置实体参数
  3. 03通过 priority 控制更新顺序
  4. 04解析 layout JSON 中的参数
  5. 05引用资源和子实体
  6. 06向其他场景项目暴露动作

适用场景

  1. 01为 Creator Hub 实体编写脚本
  2. 02为场景项目配置可复用脚本
  3. 03设置脚本执行顺序
  4. 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 会按如下方式解析参数:

  1. 使用 JSON.parse(layout) 获取 ScriptLayout 对象。
  2. 使用 Object.values(layout.params) 按插入顺序提取参数值。
  3. 将这些值按位置展开传入构造函数: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 的 layout JSON 中的 events: string[] 键,与 params 和 actions 并列。非空的 events 会让检查器显示 反应 部分,其中每个事件对应一个提示词按钮;点击按钮后会打开 AI 助手,并预先填入一句自然语言描述。请使用九个已识别事件名称之一,这样按钮和预填句子才会读起来自然(ScriptInspector.tsx 中的 EVENT_LABEL / EVENT_CLAUSE,已在 creator-hub 0.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——如果顺序相反,动作就会被取消。

评论

支持 Markdown