npcs
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNPCs in Decentraland
Decentraland 中的 NPC
Two approaches — choose based on what the NPC needs to do:
| Approach | Use when |
|---|---|
NPC Toolkit ( | GLB model, needs dialogue, walking, state machine behavior |
| AvatarShape | Needs to look like a Decentraland avatar (wearables, expressions) |
两种方式——请根据 NPC 需要执行的任务进行选择:
| 方式 | 使用场景 |
|---|---|
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-toolkitBasic 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 — it covers:
{baseDir}/references/npc-library.mdc- 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 within a fixed-width slot (default font 16, slot ~217px scaled). Anything past ~15 characters is silently clipped — no ellipsis. Use short labels like
textWrap: 'nowrap',"Yes","No thanks","Tell me more". Avoid full sentences and trailing punctuation (e.g."Decline"renders as"I'm not interested."). To fit longer text, drop"I'm not interes"(e.g. 12) or setfontSizeon the button. Seesize"ButtonData fields".references/npc-library.mdc -
Opening dialogs on an entity not created viarequires
npc.createand a minimaladdDialog(entity)— see reference for the full setup.npcDataComponent.set(entity, ...) -
Speech bubbles needbefore
createDialogBubble(entity). Bubbles do not render question buttons; questions are HUD-only.talkBubble -
crashes the dialog UI unless you also set
createDialogWindow(). Symptom:npcDataComponentwhen the window opens. Why (verified against"Cannot read properties of undefined (reading 'theme')"):dcl-npc-toolkit/distonly callscreateDialogWindow(portrait, sound)(setsaddDialog(...)); it does NOT setnpcDialogComponent.npcDataComponentthen setsopenDialogWindow, theactiveNPCguard (npcDialogComponent) passes, andisActiveNpcSet()reachesgetTheme()— undefined on a standalone window. NPCs built withnpcDataComponent.get(activeNPC).theme/create()havecreateNPCset, so they never hit this. Fix — afternpcDataComponent, setcreateDialogWindowwith a valid theme and the minimal fields:npcDataComponenttypescriptimport { 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, }) -
— do NOT parent a fixed-world object to a
faceUser: trueNPC. Why (verified —faceUser):dcl-npc-toolkit/dist/faceUserSystem.jsrewrites the NPC entity'sfaceUserevery frame (viaTransform.rotation+TrackUserFlag) 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.faceUserSystem -
dcl-npc-toolkit must be statically imported from the entry point. The toolkit callsat module-load time.
engine.defineComponent(...)throwsengine.defineComponentif it runs after the engine seals (verified —"Engine is already sealed. No components can be added at this stage"). A dynamic@dcl/ecs/dist/engine/index.jsdefers that registration past the seal point. So any module that imports the toolkit must be reached via a staticawait import('./client-setup')fromimport— not loaded later viaindex.ts. 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]].await import()
-
按钮标签在视觉上会被截断。 对话按钮标签通过渲染在固定宽度的槽位中(默认字体为 16,缩放后槽位约为 217px)。超过约 15 个字符的内容会被静默裁剪——不会显示省略号。请使用简短标签,如
textWrap: 'nowrap'、"Yes"、"No thanks"、"Tell me more"。避免使用完整句子和末尾标点(例如,"Decline"会渲染成"I'm not interested.")。若要容纳更长的文本,请降低"I'm not interes"(例如设为 12),或在按钮上设置fontSize。请参阅size中的 "ButtonData fields"。references/npc-library.mdc -
要在并非通过创建的实体上打开对话,需要调用
npc.create并设置最简的addDialog(entity)——完整设置请参阅参考文档。npcDataComponent.set(entity, ...) -
使用之前,语音气泡需要先调用
talkBubble。气泡不会呈现问题按钮;问题仅显示在 HUD 中。createDialogBubble(entity) -
会导致对话 UI 崩溃,除非同时设置
createDialogWindow()。 症状:窗口打开时出现npcDataComponent。原因(已对照"Cannot read properties of undefined (reading 'theme')"验证):dcl-npc-toolkit/dist只会调用createDialogWindow(portrait, sound)(设置addDialog(...)),并不会设置npcDialogComponent。随后,npcDataComponent会设置openDialogWindow,activeNPC守卫(npcDialogComponent)通过,而isActiveNpcSet()会访问getTheme()——对于独立窗口,该值为 undefined。通过npcDataComponent.get(activeNPC).theme/create()创建的 NPC 已设置createNPC,因此不会遇到此问题。修复——在npcDataComponent之后,使用有效主题和最简字段设置createDialogWindow:npcDataComponenttypescriptimport { 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: trueNPC 的子对象。 原因(已对照faceUser验证):dcl-npc-toolkit/dist/faceUserSystem.js会每帧重写 NPC 实体的faceUser(通过Transform.rotation+TrackUserFlag),使 NPC 面向玩家。任何以局部偏移放置的子对象都会继承该旋转,并随着玩家移动围绕 NPC 公转,最终落在非预期位置(门后、被遮挡或无法点击)。修复:将此类对象以无父级方式生成在计算得到的世界位置(可基于门等不旋转的参考对象,加上 NPC 的位置,计算一个稳定的世界坐标点)。同样的注意事项也适用于任何每帧都会旋转其faceUserSystem的实体。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加载。在 authoritative-server 场景中,应保持客户端设置(其中会导入该工具包)为静态导入,并且只动态导入服务器专用分支。请参阅 [[authoritative-server]]。await import()
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.
- is required and must be unique per AvatarShape entity.
id - Set (empty string) to suppress the name tag above the NPC's head.
name: '' - Moving the 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 withTransform(Tween) +Tween.Mode.Movefor scripted paths; the avatar plays its walk animation along the tween.TweenSequence - Use as a Lamport timestamp to replay the same emote: first play = 0, second play = 1, etc.
expressionTriggerTimestamp - Clone the current player's look: read and pass
getPlayer()anduserData.wearablesstraight intouserData.emotes.AvatarShape.createmay return null / empty emotes on the first frames — poll in a system untilgetPlayer()before spawning (verified pattern in test scene 4,21).userData.emotes.length > 0
创建一个外观与 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——否则化身不会渲染面部特征。
- 为必填项,并且每个 AvatarShape 实体必须唯一。
id - 设置 (空字符串)可隐藏 NPC 头顶的名称标签。
name: '' - 修改 位置会使 NPC 走向或跑向目标位置(而不是瞬移)。这种修改确实会生效——这与只读的玩家
Transform不同。你也可以使用Transform(Tween)+Tween.Mode.Move驱动移动;在 Tween 过程中,化身会播放走路动画。TweenSequence - 使用 作为 Lamport 时间戳,可重复播放同一个 emote:首次播放 = 0,第二次播放 = 1,依此类推。
expressionTriggerTimestamp - 克隆当前玩家的外观: 读取 ,并将
getPlayer()和userData.wearables直接传给userData.emotes。在最初几帧中,AvatarShape.create可能返回 null 或空的 emotes——生成 NPC 前,请在 system 中轮询,直到getPlayer()(已在测试场景 4,21 中验证此模式)。userData.emotes.length > 0
Playing expressions on an AvatarShape NPC
在 AvatarShape NPC 上播放表情
typescript
AvatarShape.getMutable(npc).expressionTriggerId = 'wave'
AvatarShape.getMutable(npc).expressionTriggerTimestamp = 1expressionTriggerId'robot''wave'.glb'animations/Snowball_Throw_emote.glb'_emote.glbtriggerSceneEmotetriggerSceneEmoteexpressionTriggerId = userData.emotes[i]Note: replaying the same value back-to-back requires bumping ; setting a different id each time replays without touching the timestamp.
expressionTriggerIdexpressionTriggerTimestamptypescript
AvatarShape.getMutable(npc).expressionTriggerId = 'wave'
AvatarShape.getMutable(npc).expressionTriggerTimestamp = 1expressionTriggerId'robot''wave'.glb'animations/Snowball_Throw_emote.glb'triggerSceneEmote_emote.glbtriggerSceneEmoteexpressionTriggerId = userData.emotes[i]注意,连续两次回放相同的 值时,必须递增 ;每次设置不同的 id 则无需改动时间戳即可回放。
expressionTriggerIdexpressionTriggerTimestampMannequin 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 field reference, body shape URNs, and common base wearable URNs, see .
AvatarShape{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,
})如需完整的 字段参考、身体形状 URN 和常见基础 wearables URN,请参阅 。
AvatarShape{baseDir}/../player-avatar/references/avatar-apis.mdAdding 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 can detect clicks (see add-interactivity skill):
pointerEventsSystemtypescript
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 附加到同一实体,使 能够检测点击(请参阅 add-interactivity skill):
pointerEventsSystemtypescript
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 approach (exercised against the real engine):
AvatarShape- 4,20-avatar-shape — spawn AvatarShape NPCs with base-avatar wearables and with on-chain collection wearable URNs; to hide the name tag.
name: '' - 4,19-avatar-shape-movement — moving an AvatarShape by mutating its (walks to target) and by
Transform/Tween; anTweenSequencebox on the NPC's hand via itsAvatarAttach.id - 4,21-avatar-shape-emotes — clone the local player onto an NPC using /
getPlayer().wearables, then cycle emotes via.emotes.expressionTriggerId - 4,22-avatar-shape-scene-emotes — play custom scene emotes on an NPC by setting to an
expressionTriggerIdpath._emote.glb
These are -only scenes; they do not use .
AvatarShapedcl-npc-toolkit引擎团队针对 方式的测试场景(已使用真实引擎进行验证):
AvatarShape- 4,20-avatar-shape——生成带有 base-avatar wearables 和链上 collection wearable URN 的 AvatarShape NPC;使用 隐藏名称标签。
name: '' - 4,19-avatar-shape-movement——通过修改 (走向目标)和
Transform/Tween移动 AvatarShape;通过其TweenSequence将一个id盒子附加到 NPC 的手上。AvatarAttach - 4,21-avatar-shape-emotes——使用 /
getPlayer().wearables将本地玩家克隆到 NPC 上,然后通过.emotes循环播放 emotes。expressionTriggerId - 4,22-avatar-shape-scene-emotes——通过将 设置为
expressionTriggerId路径,在 NPC 上播放自定义场景 emotes。_emote.glb
这些场景仅使用 ,不使用 。
AvatarShapedcl-npc-toolkit