npcs

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

NPCs in Decentraland

Decentraland 中的 NPC

Two approaches — choose based on what the NPC needs to do:
ApproachUse when
NPC Toolkit (
dcl-npc-toolkit
)
GLB model, needs dialogue, walking, state machine behavior
AvatarShapeNeeds to look like a Decentraland avatar (wearables, expressions)

两种方式——请根据 NPC 需要执行的任务进行选择:
方式使用场景
NPC Toolkit (
dcl-npc-toolkit
)
GLB 模型,需要对话、行走和状态机行为
AvatarShape需要呈现 Decentraland 化身外观(wearables、表情)

Approach 1 — NPC Toolkit (GLB-based)

方式 1——NPC Toolkit(基于 GLB)

The toolkit handles dialogue UI, movement along paths, animations, and interaction out of the box.
Install:
bash
npm i dcl-npc-toolkit
Basic usage:
typescript
import * as npc from 'dcl-npc-toolkit'
import { Vector3, Quaternion } from '@dcl/sdk/math'

const dialogs: npc.Dialog[] = [{ text: 'Hello there!', isEndOfDialog: true }]

const npcEntity = npc.create(
  { position: Vector3.create(8, 0, 8), rotation: Quaternion.fromEulerDegrees(0, 180, 0) },
  {
    type: npc.NPCType.CUSTOM,
    model: { src: 'models/guard.glb' },
    idleAnim: 'Idle',
    walkingAnim: 'Walk',
    hoverText: 'Talk',
    onlyExternalTrigger: false,
    onActivate: () => {
      // called when player activates the NPC
      npc.talk(npcEntity, dialogs)
    },
  }
)
For full dialogue scripting, movement paths, state machines, and all config options, see
{baseDir}/references/npc-library.mdc
— it covers:
  • Dialogue types (talk, button choices, NPC responses)
  • Walking to positions and following paths
  • State management (quest giver, guard, shop patterns)
  • Multiplayer considerations
  • Performance optimization
该工具包开箱即用地处理对话 UI、沿路径移动、动画和交互。
安装:
bash
npm i dcl-npc-toolkit
基本用法:
typescript
import * as npc from 'dcl-npc-toolkit'
import { Vector3, Quaternion } from '@dcl/sdk/math'

const dialogs: npc.Dialog[] = [{ text: 'Hello there!', isEndOfDialog: true }]

const npcEntity = npc.create(
  { position: Vector3.create(8, 0, 8), rotation: Quaternion.fromEulerDegrees(0, 180, 0) },
  {
    type: npc.NPCType.CUSTOM,
    model: { src: 'models/guard.glb' },
    idleAnim: 'Idle',
    walkingAnim: 'Walk',
    hoverText: 'Talk',
    onlyExternalTrigger: false,
    onActivate: () => {
      // called when player activates the NPC
      npc.talk(npcEntity, dialogs)
    },
  }
)
如需了解完整的对话脚本、移动路径、状态机以及所有配置选项,请参阅
{baseDir}/references/npc-library.mdc
——其中涵盖:
  • 对话类型(交谈、按钮选项、NPC 响应)
  • 移动到指定位置和沿路径移动
  • 状态管理(任务发布者、守卫、商店模式)
  • 多人游戏注意事项
  • 性能优化

Gotchas (NPC Toolkit)

注意事项(NPC Toolkit)

  • Button labels are visually truncated. Dialog button labels render with
    textWrap: 'nowrap'
    in a fixed-width slot (default font 16, slot ~217px scaled). Anything past ~15 characters is silently clipped — no ellipsis. Use short labels like
    "Yes"
    ,
    "No thanks"
    ,
    "Tell me more"
    ,
    "Decline"
    . Avoid full sentences and trailing punctuation (e.g.
    "I'm not interested."
    renders as
    "I'm not interes"
    ). To fit longer text, drop
    fontSize
    (e.g. 12) or set
    size
    on the button. See
    references/npc-library.mdc
    "ButtonData fields".
  • Opening dialogs on an entity not created via
    npc.create
    requires
    addDialog(entity)
    and a minimal
    npcDataComponent.set(entity, ...)
    — see reference for the full setup.
  • Speech bubbles need
    createDialogBubble(entity)
    before
    talkBubble
    . Bubbles do not render question buttons; questions are HUD-only.
  • createDialogWindow()
    crashes the dialog UI unless you also set
    npcDataComponent
    .
    Symptom:
    "Cannot read properties of undefined (reading 'theme')"
    when the window opens. Why (verified against
    dcl-npc-toolkit/dist
    ):
    createDialogWindow(portrait, sound)
    only calls
    addDialog(...)
    (sets
    npcDialogComponent
    ); it does NOT set
    npcDataComponent
    .
    openDialogWindow
    then sets
    activeNPC
    , the
    npcDialogComponent
    guard (
    isActiveNpcSet()
    ) passes, and
    getTheme()
    reaches
    npcDataComponent.get(activeNPC).theme
    — undefined on a standalone window. NPCs built with
    create()
    /
    createNPC
    have
    npcDataComponent
    set, so they never hit this. Fix — after
    createDialogWindow
    , set
    npcDataComponent
    with a valid theme and the minimal fields:
    typescript
    import { createDialogWindow } from 'dcl-npc-toolkit'
    import { npcDataComponent } from 'dcl-npc-toolkit/dist/npc'
    import { lightTheme } from 'dcl-npc-toolkit/dist/ui'
    
    const window = createDialogWindow(portrait, sound)
    npcDataComponent.set(window, {
      introduced: false, inCooldown: false, coolDownDuration: 5,
      faceUser: undefined, walkingSpeed: 2, walkingAnim: undefined,
      pathData: undefined, currentPathData: [], manualStop: false,
      pathIndex: 0, state: 'standing', idleAnim: 'Idle', hasBubble: false,
      turnSpeed: 2, theme: lightTheme, bubbleXOffset: 0, bubbleYOffset: 0,
      lastPlayedAnim: 'Idle', volume: 0.5,
    })
  • faceUser: true
    — do NOT parent a fixed-world object to a
    faceUser
    NPC.
    Why (verified —
    dcl-npc-toolkit/dist/faceUserSystem.js
    ):
    faceUser
    rewrites the NPC entity's
    Transform.rotation
    every frame (via
    TrackUserFlag
    +
    faceUserSystem
    ) to look at the player. Any child placed at a local offset inherits that rotation and orbits the NPC as the player moves, landing in unintended places (behind a door, occluded, unclickable). Fix: spawn such objects unparented at a computed world position (derive a stable world spot from a non-rotating reference like a door, plus the NPC's position). The same caveat applies to any entity whose Transform you rotate every frame.
  • dcl-npc-toolkit must be statically imported from the entry point. The toolkit calls
    engine.defineComponent(...)
    at module-load time.
    engine.defineComponent
    throws
    "Engine is already sealed. No components can be added at this stage"
    if it runs after the engine seals (verified —
    @dcl/ecs/dist/engine/index.js
    ). A dynamic
    await import('./client-setup')
    defers that registration past the seal point. So any module that imports the toolkit must be reached via a static
    import
    from
    index.ts
    — not loaded later via
    await import()
    . In an authoritative-server scene, keep the client setup (which imports the toolkit) static and dynamically import only the server-only branch. See [[authoritative-server]].

  • 按钮标签在视觉上会被截断。 对话按钮标签通过
    textWrap: 'nowrap'
    渲染在固定宽度的槽位中(默认字体为 16,缩放后槽位约为 217px)。超过约 15 个字符的内容会被静默裁剪——不会显示省略号。请使用简短标签,如
    "Yes"
    、
    "No thanks"
    、
    "Tell me more"
    、
    "Decline"
    。避免使用完整句子和末尾标点(例如,
    "I'm not interested."
    会渲染成
    "I'm not interes"
    )。若要容纳更长的文本,请降低
    fontSize
    (例如设为 12),或在按钮上设置
    size
    。请参阅
    references/npc-library.mdc
    中的 "ButtonData fields"。
  • 要在并非通过
    npc.create
    创建的实体上打开对话,需要调用
    addDialog(entity)
    并设置最简的
    npcDataComponent.set(entity, ...)
    ——完整设置请参阅参考文档。
  • 使用
    talkBubble
    之前,语音气泡需要先调用
    createDialogBubble(entity)
    。气泡不会呈现问题按钮;问题仅显示在 HUD 中。
  • createDialogWindow()
    会导致对话 UI 崩溃,除非同时设置
    npcDataComponent
    。
    症状:窗口打开时出现
    "Cannot read properties of undefined (reading 'theme')"
    。原因(已对照
    dcl-npc-toolkit/dist
    验证):
    createDialogWindow(portrait, sound)
    只会调用
    addDialog(...)
    (设置
    npcDialogComponent
    ),并不会设置
    npcDataComponent
    。随后,
    openDialogWindow
    会设置
    activeNPC
    ,
    npcDialogComponent
    守卫(
    isActiveNpcSet()
    )通过,而
    getTheme()
    会访问
    npcDataComponent.get(activeNPC).theme
    ——对于独立窗口,该值为 undefined。通过
    create()
    /
    createNPC
    创建的 NPC 已设置
    npcDataComponent
    ,因此不会遇到此问题。修复——在
    createDialogWindow
    之后,使用有效主题和最简字段设置
    npcDataComponent
    :
    typescript
    import { createDialogWindow } from 'dcl-npc-toolkit'
    import { npcDataComponent } from 'dcl-npc-toolkit/dist/npc'
    import { lightTheme } from 'dcl-npc-toolkit/dist/ui'
    
    const window = createDialogWindow(portrait, sound)
    npcDataComponent.set(window, {
      introduced: false, inCooldown: false, coolDownDuration: 5,
      faceUser: undefined, walkingSpeed: 2, walkingAnim: undefined,
      pathData: undefined, currentPathData: [], manualStop: false,
      pathIndex: 0, state: 'standing', idleAnim: 'Idle', hasBubble: false,
      turnSpeed: 2, theme: lightTheme, bubbleXOffset: 0, bubbleYOffset: 0,
      lastPlayedAnim: 'Idle', volume: 0.5,
    })
  • faceUser: true
    ——切勿将固定在世界空间的对象设为
    faceUser
    NPC 的子对象。
    原因(已对照
    dcl-npc-toolkit/dist/faceUserSystem.js
    验证):
    faceUser
    会每帧重写 NPC 实体的
    Transform.rotation
    (通过
    TrackUserFlag
    +
    faceUserSystem
    ),使 NPC 面向玩家。任何以局部偏移放置的子对象都会继承该旋转,并随着玩家移动围绕 NPC 公转,最终落在非预期位置(门后、被遮挡或无法点击)。修复:将此类对象以无父级方式生成在计算得到的世界位置(可基于门等不旋转的参考对象,加上 NPC 的位置,计算一个稳定的世界坐标点)。同样的注意事项也适用于任何每帧都会旋转其
    Transform
    的实体。
  • dcl-npc-toolkit
    必须从入口点进行静态导入。
    该工具包会在模块加载时调用
    engine.defineComponent(...)
    。如果它在引擎完成封闭后运行,
    engine.defineComponent
    将抛出
    "Engine is already sealed. No components can be added at this stage"
    (已对照
    @dcl/ecs/dist/engine/index.js
    验证)。动态
    await import('./client-setup')
    会将注册推迟到封闭节点之后。因此,任何导入该工具包的模块都必须通过从
    index.ts
    发起的静态
    import
    到达,而不能稍后通过
    await import()
    加载。在 authoritative-server 场景中,应保持客户端设置(其中会导入该工具包)为静态导入,并且只动态导入服务器专用分支。请参阅 [[authoritative-server]]。

Approach 2 — AvatarShape (Decentraland avatar look)

方式 2——AvatarShape(Decentraland 化身外观)

Create an NPC that looks like a Decentraland player avatar, dressed in any wearables.
typescript
import { engine, Transform, AvatarShape } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'

const npc = engine.addEntity()
Transform.create(npc, { position: Vector3.create(8, 0, 8) })

AvatarShape.create(npc, {
  id: 'npc-1',               // unique identifier (required)
  name: 'Guard',             // display name shown above head
  bodyShape: 'urn:decentraland:off-chain:base-avatars:BaseMale', // or BaseFemale
  wearables: [
    'urn:decentraland:off-chain:base-avatars:eyebrows_00',
    'urn:decentraland:off-chain:base-avatars:mouth_00',
    'urn:decentraland:off-chain:base-avatars:eyes_00',
    'urn:decentraland:off-chain:base-avatars:blue_tshirt',
    'urn:decentraland:off-chain:base-avatars:brown_pants',
    'urn:decentraland:off-chain:base-avatars:classic_shoes',
    'urn:decentraland:off-chain:base-avatars:short_hair',
  ],
  hairColor: { r: 0.92, g: 0.76, b: 0.62 }, // RGB 0–1
  skinColor: { r: 0.94, g: 0.85, b: 0.6 },
})
Notes:
  • Always include eyebrows, mouth, and eyes wearables — the avatar won't render face features without them.
  • id
    is required and must be unique per AvatarShape entity.
  • Set
    name: ''
    (empty string) to suppress the name tag above the NPC's head.
  • Moving the
    Transform
    position causes the NPC to walk/run to the destination (it does not teleport). This mutation DOES take effect — unlike the read-only player
    Transform
    . You can also drive movement with
    Tween
    (
    Tween.Mode.Move
    ) +
    TweenSequence
    for scripted paths; the avatar plays its walk animation along the tween.
  • Use
    expressionTriggerTimestamp
    as a Lamport timestamp to replay the same emote: first play = 0, second play = 1, etc.
  • Clone the current player's look: read
    getPlayer()
    and pass
    userData.wearables
    and
    userData.emotes
    straight into
    AvatarShape.create
    .
    getPlayer()
    may return null / empty emotes on the first frames — poll in a system until
    userData.emotes.length > 0
    before spawning (verified pattern in test scene 4,21).
创建一个外观与 Decentraland 玩家化身相同、可穿戴任意 wearables 的 NPC。
typescript
import { engine, Transform, AvatarShape } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'

const npc = engine.addEntity()
Transform.create(npc, { position: Vector3.create(8, 0, 8) })

AvatarShape.create(npc, {
  id: 'npc-1',               // unique identifier (required)
  name: 'Guard',             // display name shown above head
  bodyShape: 'urn:decentraland:off-chain:base-avatars:BaseMale', // or BaseFemale
  wearables: [
    'urn:decentraland:off-chain:base-avatars:eyebrows_00',
    'urn:decentraland:off-chain:base-avatars:mouth_00',
    'urn:decentraland:off-chain:base-avatars:eyes_00',
    'urn:decentraland:off-chain:base-avatars:blue_tshirt',
    'urn:decentraland:off-chain:base-avatars:brown_pants',
    'urn:decentraland:off-chain:base-avatars:classic_shoes',
    'urn:decentraland:off-chain:base-avatars:short_hair',
  ],
  hairColor: { r: 0.92, g: 0.76, b: 0.62 }, // RGB 0–1
  skinColor: { r: 0.94, g: 0.85, b: 0.6 },
})
说明:
  • 始终包含 eyebrows、mouth 和 eyes wearables——否则化身不会渲染面部特征。
  • id
    为必填项,并且每个 AvatarShape 实体必须唯一。
  • 设置
    name: ''
    (空字符串)可隐藏 NPC 头顶的名称标签。
  • 修改
    Transform
    位置会使 NPC 走向或跑向目标位置(而不是瞬移)。这种修改确实会生效——这与只读的玩家
    Transform
    不同。你也可以使用
    Tween
    (
    Tween.Mode.Move
    )+
    TweenSequence
    驱动移动;在 Tween 过程中,化身会播放走路动画。
  • 使用
    expressionTriggerTimestamp
    作为 Lamport 时间戳,可重复播放同一个 emote:首次播放 = 0,第二次播放 = 1,依此类推。
  • 克隆当前玩家的外观: 读取
    getPlayer()
    ,并将
    userData.wearables
    和
    userData.emotes
    直接传给
    AvatarShape.create
    。在最初几帧中,
    getPlayer()
    可能返回 null 或空的 emotes——生成 NPC 前,请在 system 中轮询,直到
    userData.emotes.length > 0
    (已在测试场景 4,21 中验证此模式)。

Playing expressions on an AvatarShape NPC

在 AvatarShape NPC 上播放表情

typescript
AvatarShape.getMutable(npc).expressionTriggerId = 'wave'
AvatarShape.getMutable(npc).expressionTriggerTimestamp = 1
expressionTriggerId
accepts either a built-in emote name (
'robot'
,
'wave'
, ...) OR a scene-emote
.glb
path
(e.g.
'animations/Snowball_Throw_emote.glb'
) — the same
_emote.glb
-suffixed files used with
triggerSceneEmote
. This is how you play a custom emote on an NPC (there is no
triggerSceneEmote
equivalent for AvatarShape entities). To cycle through the player's own emotes, set
expressionTriggerId = userData.emotes[i]
.
Note: replaying the same
expressionTriggerId
value back-to-back requires bumping
expressionTriggerTimestamp
; setting a different id each time replays without touching the timestamp.
typescript
AvatarShape.getMutable(npc).expressionTriggerId = 'wave'
AvatarShape.getMutable(npc).expressionTriggerTimestamp = 1
expressionTriggerId
接受内置 emote 名称(
'robot'
、
'wave'
,……)或场景 emote 的
.glb
路径
(例如
'animations/Snowball_Throw_emote.glb'
)——也就是与
triggerSceneEmote
搭配使用的同一类以
_emote.glb
结尾的文件。可以通过这种方式在 NPC 上播放自定义 emote(AvatarShape 实体没有与
triggerSceneEmote
对应的方法)。要循环播放玩家自己的 emotes,请设置
expressionTriggerId = userData.emotes[i]
。
注意,连续两次回放相同的
expressionTriggerId
值时,必须递增
expressionTriggerTimestamp
;每次设置不同的 id 则无需改动时间戳即可回放。

Mannequin mode (show wearables without a body)

人台模式(仅显示 wearables,不显示身体)

Useful for storefronts and wearable displays:
typescript
AvatarShape.create(mannequin, {
  id: 'mannequin-1',
  name: 'Display',
  wearables: ['urn:decentraland:matic:collections-v2:0x...:0'],
  showOnlyWearables: true,
})
For the full
AvatarShape
field reference, body shape URNs, and common base wearable URNs, see
{baseDir}/../player-avatar/references/avatar-apis.md
.

适用于商店展示和 wearables 展示:
typescript
AvatarShape.create(mannequin, {
  id: 'mannequin-1',
  name: 'Display',
  wearables: ['urn:decentraland:matic:collections-v2:0x...:0'],
  showOnlyWearables: true,
})
如需完整的
AvatarShape
字段参考、身体形状 URN 和常见基础 wearables URN,请参阅
{baseDir}/../player-avatar/references/avatar-apis.md
。

Adding interactivity to AvatarShape NPCs

为 AvatarShape NPC 添加交互

AvatarShape entities are not clickable — they have no collider, so pointer events won't register on them directly. To let players interact with an AvatarShape NPC, use one of these approaches:
AvatarShape 实体不可点击——它们没有 collider,因此 pointer events 不会直接注册。若要允许玩家与 AvatarShape NPC 交互,请采用以下方式之一:

Option A — Add a MeshCollider for click interaction

方式 A——添加 MeshCollider 以实现点击交互

Attach an invisible collider to the same entity so
pointerEventsSystem
can detect clicks (see add-interactivity skill):
typescript
import { MeshCollider, pointerEventsSystem, InputAction } from '@dcl/sdk/ecs'

// invisible cylinder collider roughly matching avatar size.
// Pointer clicks require the CL_POINTER collider layer; MeshCollider defaults to
// all layers (which includes CL_POINTER), so an explicit layer is optional here.
// If you set a layer, include ColliderLayer.CL_POINTER or clicks won't register.
MeshCollider.setCylinder(npc)

pointerEventsSystem.onPointerDown(
  { entity: npc, opts: { button: InputAction.IA_POINTER, hoverText: 'Talk' } },
  () => {
    console.log('Player clicked NPC')
  }
)
将不可见的 collider 附加到同一实体,使
pointerEventsSystem
能够检测点击(请参阅 add-interactivity skill):
typescript
import { MeshCollider, pointerEventsSystem, InputAction } from '@dcl/sdk/ecs'

// invisible cylinder collider roughly matching avatar size.
// Pointer clicks require the CL_POINTER collider layer; MeshCollider defaults to
// all layers (which includes CL_POINTER), so an explicit layer is optional here.
// If you set a layer, include ColliderLayer.CL_POINTER or clicks won't register.
MeshCollider.setCylinder(npc)

pointerEventsSystem.onPointerDown(
  { entity: npc, opts: { button: InputAction.IA_POINTER, hoverText: 'Talk' } },
  () => {
    console.log('Player clicked NPC')
  }
)

Option B — Proximity-based interaction

方式 B——基于距离的交互

Trigger the interaction when the player walks near the NPC instead of requiring a click:
typescript
import { engine, Transform } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'

const INTERACT_DISTANCE = 4

engine.addSystem(() => {
  const playerPos = Transform.get(engine.PlayerEntity).position
  const npcPos = Transform.get(npc).position
  const dist = Vector3.distance(playerPos, npcPos)
  if (dist < INTERACT_DISTANCE) {
    // start dialogue or other interaction
  }
})

当玩家走近 NPC 时触发交互,而不要求点击:
typescript
import { engine, Transform } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'

const INTERACT_DISTANCE = 4

engine.addSystem(() => {
  const playerPos = Transform.get(engine.PlayerEntity).position
  const npcPos = Transform.get(npc).position
  const dist = Vector3.distance(playerPos, npcPos)
  if (dist < INTERACT_DISTANCE) {
    // start dialogue or other interaction
  }
})

Example scenes

示例场景

Engine-team test scenes for the
AvatarShape
approach (exercised against the real engine):
  • 4,20-avatar-shape — spawn AvatarShape NPCs with base-avatar wearables and with on-chain collection wearable URNs;
    name: ''
    to hide the name tag.
  • 4,19-avatar-shape-movement — moving an AvatarShape by mutating its
    Transform
    (walks to target) and by
    Tween
    /
    TweenSequence
    ; an
    AvatarAttach
    box on the NPC's hand via its
    id
    .
  • 4,21-avatar-shape-emotes — clone the local player onto an NPC using
    getPlayer().wearables
    /
    .emotes
    , then cycle emotes via
    expressionTriggerId
    .
  • 4,22-avatar-shape-scene-emotes — play custom scene emotes on an NPC by setting
    expressionTriggerId
    to an
    _emote.glb
    path.
These are
AvatarShape
-only scenes; they do not use
dcl-npc-toolkit
.
引擎团队针对
AvatarShape
方式的测试场景(已使用真实引擎进行验证):
  • 4,20-avatar-shape——生成带有 base-avatar wearables 和链上 collection wearable URN 的 AvatarShape NPC;使用
    name: ''
    隐藏名称标签。
  • 4,19-avatar-shape-movement——通过修改
    Transform
    (走向目标)和
    Tween
    /
    TweenSequence
    移动 AvatarShape;通过其
    id
    将一个
    AvatarAttach
    盒子附加到 NPC 的手上。
  • 4,21-avatar-shape-emotes——使用
    getPlayer().wearables
    /
    .emotes
    将本地玩家克隆到 NPC 上,然后通过
    expressionTriggerId
    循环播放 emotes。
  • 4,22-avatar-shape-scene-emotes——通过将
    expressionTriggerId
    设置为
    _emote.glb
    路径,在 NPC 上播放自定义场景 emotes。
这些场景仅使用
AvatarShape
,不使用
dcl-npc-toolkit
。