关于 npcs
为 Decentraland 场景创作者提供 NPC 实现指导,涵盖 GLB 对话型 NPC 与 AvatarShape 两种路径,并说明移动、动画和界面方面的注意事项。
主要功能
- 01NPC Toolkit 对话与移动
- 02路径和状态机行为
- 03AvatarShape 外观与表情
- 04AvatarNametag 标签牌
- 05NPC 动画与移动注意事项
适用场景
- 01创建带对话和路径行为的 GLB NPC
- 02制作穿戴 Decentraland 服饰的头像 NPC
- 03为 NPC 添加表情或名称标签
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ decentraland/ sdk-skills --skill npcs安装后在 Claude Code 中输入/npcs确认已加载。
附带文件references/npc-library.mdc
详细介绍
Decentraland 中的 NPC
有两种方案——根据 NPC 的用途进行选择:
| 方案 | 适用场景 |
|---|---|
NPC Toolkit(dcl-npc-toolkit) | GLB 模型,需要对话、行走和状态机行为 |
| AvatarShape | 需要呈现 Decentraland Avatar 的外观(可穿戴物品、表情) |
方案 1 — NPC Toolkit(基于 GLB)
该工具包开箱即提供对话 UI、沿路径移动、动画和交互功能。
安装:
npm i dcl-npc-toolkit
基本用法:
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 回复)
- 移动到指定位置和沿路径行走
- 状态管理(任务发布者、守卫、商店模式)
- 多人游戏注意事项
- 性能优化
注意事项(NPC Toolkit)
-
对话按钮标签会被截断。 对话按钮标签在固定宽度的区域内以
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: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——请勿将固定于世界坐标的对象设为faceUserNPC 的子对象。 原因(已通过检查dcl-npc-toolkit/dist/faceUserSystem.js确认):faceUser会通过TrackUserFlag和faceUserSystem,在每帧重写 NPC 实体的Transform.rotation,使其朝向玩家。放置在局部偏移位置的任何子对象都会继承此旋转,并随着玩家移动而绕 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]]。
方法 2 — AvatarShape(Decentraland 化身外观)
创建一个外观类似 Decentraland 玩家化身、穿戴任意可穿戴物品的 NPC。
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: 'guard-0001', // unique; its last 4 characters show beside the name
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 },
})
注意:
- 始终包含眉毛、嘴巴和眼睛可穿戴物品,否则化身不会渲染面部特征。
- 每个 AvatarShape 实体都必须设置唯一的
id。Explorer 会在名称后以淡色文本显示id的最后 4 个字符,就像未认领的玩家名称会显示其地址一样。NPC 没有个人资料,因此始终会显示后缀,也没有字段可以关闭它:id: 'guard-1'且name: 'Guard'会渲染为“Guard”,后面附加淡色的“rd-1”。请选择最后 4 个字符有意为之的 ID,例如guard-0001(少于 4 个字符的 ID 会显示0000)。移除整个标签是去掉后缀的唯一方法(unity-explorerNametagPlacementSystem、NametagElement.BuildName、main 2026-10)。 - 将
name: ''(空字符串)设为空值,以隐藏 NPC 头顶的名称标签,包括后缀。 - 移动
Transform位置会使 NPC 走向或跑向目的地(而非瞬移)。此修改确实生效,与只读的玩家Transform不同。你也可以使用Tween(Tween.Mode.Move)和TweenSequence控制移动,创建脚本化路径;化身会在补间动画期间播放行走动画。若要让 NPC 持续追逐玩家(或其他移动目标),请使用Tween.setMoveContinuous,不要每帧重新创建setMove补间动画,否则会出现明显抖动。请参阅 animations-tweens 技能(“陷阱 — 目标持续变化”)和79,-4-tween-following-cube测试场景。 - 使用
expressionTriggerTimestamp作为 Lamport 时间戳来重播相同的表情动作:第一次播放 = 0,第二次播放 = 1,依此类推。 - **克隆当前玩家的外观:**读取
getPlayer(),并将userData.wearables和userData.emotes直接传给AvatarShape.create。在最初几帧中,getPlayer()可能返回 null / 空的表情动作列表;请在 system 中轮询,直到userData.emotes.length > 0后再生成(已在测试场景 4,21 中验证此模式)。
NPC 名称上方的标签牌(AvatarNametag)
AvatarNametag 可用于任何带有 AvatarShape 的实体,而不仅限于玩家。将它附加到同一实体上:
import { AvatarNametag } from '@dcl/sdk/ecs'
AvatarNametag.create(npcEntity, { label: 'Shopkeeper' })
- 在
AvatarShape上设置name: '',即可只显示标签牌,不会在其下方显示空的原生名称框——只用头衔为 NPC 添加标签。 label为必填项,但可以只包含空白字符。label: ' '会绘制一个没有文字的纯色标签牌(空格越多,标签牌越宽),例如用于显示没有标题的队伍颜色徽章。labelColor、backgroundColor、borderColor是可选的Color3字段;未设置时会回退到原生名称标签颜色。- 移动端尚未实现此功能——移动端玩家看到的 NPC 不会带有标签牌,因此如果标题很重要,请通过其他方式保持其可见(例如使用
TextShape)。
字段表、边缘情况和注意事项:请参阅 player-avatar 技能(“自定义名称标签牌”)和 {baseDir}/../player-avatar/references/avatar-nametag.md。
在 AvatarShape NPC 上播放表情动作
AvatarShape.getMutable(npc).expressionTriggerId = 'wave'
AvatarShape.getMutable(npc).expressionTriggerTimestamp = 1
expressionTriggerId 可接受内置表情动作名称('robot'、'wave' 等)或场景表情动作 .glb 路径(例如 'animations/Snowball_Throw_emote.glb')——与 triggerSceneEmote 使用的 _emote.glb 后缀文件相同。你可以用这种方式在 NPC 上播放自定义表情动作(AvatarShape 实体没有与之对应的 triggerSceneEmote)。若要循环播放玩家自己的表情动作,请设置 expressionTriggerId = userData.emotes[i]。
在为 NPC 姿势(坐下、闲置变体)编写自定义动画片段之前,请先检查内置表情动作。 Explorer 内置了 21 个表情动作,expressionTriggerId 可按名称接受这些动作;坐姿动作会循环播放并保持姿势,无需重新触发(已在世界中通过 AvatarShape 验证,SDK 7.30.1,2026-10-12)。包含 ID 和循环标记的完整列表(唯一可信来源):{baseDir}/../avatar-animations/references/embedded-emotes.md。内置坐姿通常比手动编写的动画更自然。接下来,Creator Hub 的 Avatar Animations 包提供 45 个可下载的 _emote.glb 动画片段(凳子、坐垫、豆袋椅、矮墙、国际象棋、钓鱼、饮酒、制作物品……),expressionTriggerId 可通过文件路径播放这些动画片段;带预览的目录位于 {baseDir}/../avatar-animations/references/animation-catalog.md。只有在两者都不合适时,才编写自定义动画片段(avatar-animations)。
注意:连续重播相同的 expressionTriggerId 值时,需要递增 expressionTriggerTimestamp;每次设置不同的 ID,则无需修改时间戳即可重播。
自定义动画片段不会在 AvatarShape 上循环播放。 无论动画片段自身的循环标记如何,Explorer 都会将每个面向 AvatarShape 的场景表情动作请求设为 loop: false(AvatarShapeHandlerSystem 中的注释写道:“目前不支持在 SDK AvatarShapes 上循环播放场景表情动作”——unity-explorer main 2026-10)。只有上文提到的内置循环表情动作能够保持姿势。编写表情动作、通过 triggerSceneEmote({ loop: true }) 让玩家在实时场景中循环播放,以及在 AvatarShape 上触发表情动作,是三种不同的播放路径。要让自定义姿势持续保持:编写一个较长的静态动画片段,并在其结束前不久递增 expressionTriggerTimestamp 重新触发。请在世界中验证衔接处——尚未检查重新触发是否会产生明显跳变。
隐藏 AvatarShape 不等于改变位置。 将其移出视野(例如移到地下 60 m)会使其走到那里,而且会被看见,因为 Explorer 会对 AvatarShape 的移动进行插值(参见“移动 NPC 会取消表情动作”)。有效的方法是将 Transform.scale 设为接近零的值(0.001),之后再恢复(世界内测试,Unity Explorer 0.186,2026-10;尚未测试 AvatarShape 上的 VisibilityComponent)。Primitives、GLB、AvatarShape、名称标签、灯光和碰撞体的隐藏方式各不相同——不要假设一个“隐藏此实体”的方案适用于所有对象。
移动 NPC 会取消表情动作。 在表情动作播放期间写入 AvatarShape 的 Transform.position,或对其运行 Tween,都会导致表情动作中断,头像退回到行走动画周期——Explorer 会根据每帧的 Transform 差值重新计算运动混合(SDKAvatarShapesMotionSystem.UpdateAnimations;已对照 unity-explorer Explorer/Assets/DCL/Character/CharacterMotion/Systems/SDKAvatarShapesMotionSystem.cs 验证;这一现象由资产所有者报告,行为已在世界中观察到,Unity Explorer v0.186.0-alpha,SDK 7.30.1,2026-10-10)。要让 NPC 在表情动作播放期间移动——例如尖叫着沉入地板、向一侧瘫倒——请将位移烘焙到 _emote.glb 动画片段中的根节点或臀部动作里;Explorer 会在 AvatarShape 上播放该动画(世界内观察,同一构建版本和日期)。
NPC 不支持蒙版表情动作。 mask?: AvatarMask(仅上半身)只存在于 TriggerSceneEmoteRequest 中,即玩家自己的 triggerSceneEmote;PBAvatarShape 没有 mask 字段,因此 expressionTriggerId 始终作用于全身。已对照 @dcl/protocol decentraland/kernel/apis/restricted_actions.gen.d.ts 和 @dcl/ecs decentraland/sdk/components/avatar_shape.gen.d.ts 验证(7.30.1)。
同一会话中由资产所有者报告,未经验证——在据此进行设计前请先确认:NPC 首次通过 expressionTriggerId 指向场景 _emote.glb 时会出现可见延迟(加载动画片段);曾尝试在场景开始时,于屏幕外的 AvatarShape 上播放一次作为预热缓存的变通方法,效果尚未确认。
玩家副本
玩家的仿制形象——一排「你」、在商店里摆姿势的克隆体、会挥手回应的双胞胎——是一个由实时 getPlayer() 数据驱动的 AvatarShape(字段映射见 player-avatar 上方的克隆体要点),因此它穿戴的会是玩家身上的所有装扮。
AvatarShape 无法逐帧操控。 它的移动由引擎驱动(SDKAvatarShapesMotionSystem.cs,unity-explorer 主线 2026-10):每帧都会以 4 m/s 的速度,从当前位置朝编写的 Transform.position 移动;目标距离达到或超过 1.5 m 后速度会更快——这取决于帧率,因为速度每帧都会重置为 4,并以 100·dt 递增(60 fps 时约为 5.7 m/s,30 fps 时为 7.3 m/s,只有低于约 17 fps 时才会达到名义上的 10 m/s)——并且它会转向面对移动方向(slerp,6.5/s);只有到达后才会应用编写的旋转。在实体位置上添加 Tween 会抑制引擎移动,在实体旋转上添加 Tween 会抑制自动朝向,但一个实体只能持有一个 Tween,因此无法同时控制二者。将玩家的镜像位置传给它,只会得到一个以步行速度落后的分身,而且玩家后退时它会转身——这是所有者在 2026-10-09 报告的镜像分身问题(「延迟让感觉非常奇怪」、「倒着走时它会朝向另一边」);此前已尝试逐帧写入 Transform,以及以镜像速度持续移动的 tween。对于镜像分身、影子、回放幽灵,或任何必须逐帧匹配玩家姿势的对象,请使用带有 Animator 的绑定人形 GLB(根据玩家速度切换 idle / walk 动画片段),并在每帧直接写入它的 Transform。它不会穿戴玩家的外观,这是需要付出的代价。带动画片段的绑定人形模型:add-3d-models → references/model-catalog.md,查找 [anim: …] 列出 idle 和 walk 的 character/… 条目(例如 base-character、berserker-npc;arissa3 有 idle 和 running)。mannequin 返回的是没有动画片段的展示假人。
副本在外观上能有哪些区别,取决于可从 catalyst 读取的可穿戴物类别:
https://peer.decentraland.org/lambdas/collections/wearables?wearableId=<urn>&wearableId=<urn>
每个条目都会返回 data.category、data.hides、data.replaces 和 data.representations[].overrideHides / .overrideReplaces(已通过 curl 验证,2026-10-12:urn:decentraland:off-chain:base-avatars:red_bandana → category: "tiara"、replaces: ["mask","hat","helmet"])。可据此选择确实会显示出来的替换装扮。
同一会话中所有者报告的内容,尚未通过源代码验证——请将其视为线索,围绕这些内容设计前先在游戏内确认:
- 穿戴的皮肤会替换整个身体,因此新增的可穿戴物、头发、肤色和眼睛颜色都不会显示在副本上。
- 头盔会隐藏头发、头饰、耳环、眼镜和胡须。
AvatarShape无法强制渲染被隐藏的可穿戴物。
始终有效的备用方案: 改变副本的 Transform 缩放比例——例如,更高 1.07、更矮 0.93、更宽 1.10。它不受可穿戴物影响,并且在一排形象中一眼就能看出来。
假人模式(展示可穿戴物,不显示身体)
适用于店面和可穿戴物展示:
AvatarShape.create(mannequin, {
id: 'mannequin-0001',
name: 'Display',
wearables: ['urn:decentraland:matic:collections-v2:0x...:0'],
showOnlyWearables: true,
})
关于完整的 AvatarShape 字段参考、身体形状 URN 和常见基础可穿戴物 URN,请参阅 {baseDir}/../player-avatar/references/avatar-apis.md。
为 AvatarShape NPC 添加交互功能
AvatarShape 实体无法点击——它们没有碰撞体,因此指针事件无法直接在其上触发。若要让玩家与 AvatarShape NPC 交互,请使用以下方法之一:
方案 A — 添加 MeshCollider 以支持点击交互
在同一实体上附加一个不可见的碰撞体,以便 pointerEventsSystem 检测点击(请参阅 add-interactivity 技能):
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')
}
)
方案 B — 基于距离的交互
当玩家走近 NPC 时触发交互,而不是要求玩家点击:
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
}
})
示例场景
引擎团队针对 AvatarShape 方案的测试场景(使用真实引擎进行测试):
- 4,20-avatar-shape — 生成带有基础 Avatar 可穿戴物品和链上收藏可穿戴物品 URN 的 AvatarShape NPC;设置
name: ''可隐藏姓名标签。 - 4,19-avatar-shape-movement — 通过修改
Transform(走向目标)和使用Tween/TweenSequence移动 AvatarShape;通过 NPC 手上的id添加一个AvatarAttach方块。 - 4,21-avatar-shape-emotes — 使用
getPlayer().wearables/.emotes将本地玩家克隆到 NPC 上,然后通过expressionTriggerId循环播放表情动作。 - 4,22-avatar-shape-scene-emotes — 将
expressionTriggerId设置为_emote.glb路径,在 NPC 上播放自定义场景表情动作。
这些场景仅使用 AvatarShape,不使用 dcl-npc-toolkit。