关于 multiplayer-sync
面向 Decentraland 多人场景开发者的同步技能,介绍如何在玩家之间共享实体状态,并区分持久状态、即时消息和外部服务器通信。还包含自定义同步组件、玩家身份及连接状态处理。
主要功能
- 01同步实体组件与状态
- 02使用 MessageBus 发送即时消息
- 03通过 REST、signedFetch 或 WebSocket 通信
- 04定义自定义同步组件
- 05检测玩家身份与同步状态
适用场景
- 01同步多人可见的门或计分板
- 02广播聊天或音效等即时事件
- 03将数据持久化到外部服务器
- 04构建实时服务器通信
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dcl-regenesislabs/ opendcl --skill multiplayer-sync安装后在 Claude Code 中输入/multiplayer-sync确认已加载。
附带文件references/networking-patterns.md
详细介绍
Decentraland 中的多人同步
编写方式划分
syncEntity() 会在运行时添加 NetworkEntity 组件,而 NetworkEntity 不在 main-entities.ts 的受支持组件列表中。推荐的做法如下:
- 预定义的同步实体(门、开关、记分板等在创作时已知的实体):在
main-entities.ts中声明它们及其视觉组件。在src/index.ts中查找这些实体,并在那里调用syncEntity(entity, [...componentIds], SyncIds.NAME)。 - 动态生成的同步实体(投射物、掉落物、运行时创建的游戏对象):在
src/index.ts中使用engine.addEntity()创建,并立即调用syncEntity。
// main-entities.ts
door: {
components: {
Transform: { position: { x: 8, y: 1, z: 8 } },
MeshRenderer: { mesh: { $case: 'box', box: { uvs: [] } } }
}
}
// src/index.ts
import { engine, Transform, syncEntity } from '@dcl/sdk/network'
export function main() {
const door = engine.getEntityOrNullByName('door')
if (door) syncEntity(door, [Transform.componentId], SyncIds.DOOR)
}
Decentraland 场景本身就是多人场景——同一场景中的每位玩家共享相同的空间。**不过,默认情况下,玩家与环境的交互彼此独立——一位玩家所做的更改不会与其他玩家共享。**你可以选择通过 syncEntity 包装可变状态、通过 MessageBus 广播事件,或通过自己的后端进行持久化,以实现共享。
syncEntity 状态仅在至少有一位玩家留在场景中时持续存在。场景一旦无人,状态便会重置。若要在场景重置后保留状态,请将其持久化到自己的服务器。
运行时限制: Decentraland 在 QuickJS 沙箱中运行。不支持 Node.js API(
fs、http、path、process)。请使用fetch()和WebSocket进行网络通信。异步模式请参阅 scene-runtime 技能。
同步策略决策树
根据需求选择合适的联网方式:
| 策略 | 适用场景 | 持久化 | 示例 |
|---|---|---|---|
syncEntity | 所有玩家都能看到,且新加入玩家也能看到的共享状态 | 是 — 玩家加入或离开后状态仍然保留 | 门、开关、记分板、电梯 |
MessageBus | 只在当下有意义的临时事件 | 否 — 晚加入的玩家无法收到之前的消息 | 聊天消息、音效、粒子触发 |
fetch / REST API | 从外部服务器读取或向其写入数据 | 取决于服务器 | 排行榜、物品栏、外部游戏状态 |
signedFetch | 能够证明玩家身份的已验证请求 | 取决于服务器 | 领取奖励、提交经过验证的分数 |
WebSocket | 与服务器进行实时双向通信 | 取决于连接 | 实时游戏服务器、实时聊天、权威式多人游戏 |
决策流程:
- 是否需要让每位玩家都看到相同状态,包括晚加入的玩家?-->
syncEntity - 是否只需要向当前在场景中的玩家发送一次性事件?-->
MessageBus - 是否需要在所有玩家离开后仍保留状态,或需要服务器端验证?--> 通过
fetch或signedFetch使用外部服务器 - 是否需要与服务器持续进行实时通信?-->
WebSocket - 可自由组合多种方式:使用
syncEntity同步世界状态,使用MessageBus处理效果,并使用fetch实现持久化。
syncEntity 基础知识
导入和基本用法
import { engine, Transform, MeshRenderer, Material } from '@dcl/sdk/ecs'
import { syncEntity } from '@dcl/sdk/network'
import { Vector3, Color4 } from '@dcl/sdk/math'
签名:syncEntity(entity, componentIds[], syncId?)
entity— 要同步的实体componentIds[]— 要保持同步的组件 ID 数组(例如[Transform.componentId])syncId— 唯一的数字标识符(预定义实体必需,玩家生成的实体可选)
枚举同步 ID(预定义实体)
每个预定义的同步实体都必须具有唯一的数字 ID。使用枚举可避免冲突:
enum SyncIds {
DOOR = 1,
ELEVATOR = 2,
SCOREBOARD = 3
}
const door = engine.addEntity()
Transform.create(door, { position: Vector3.create(8, 1, 8) })
MeshRenderer.setBox(door)
syncEntity(door, [Transform.componentId, MeshRenderer.componentId], SyncIds.DOOR)
预定义实体(具有同步 ID)在创建它的玩家离开后仍会保留。玩家创建的实体(没有同步 ID)会在玩家断开连接时移除。
自动生成 ID(玩家生成的实体)
玩家在运行时创建的实体无需显式指定同步 ID:
function createProjectile() {
const projectile = engine.addEntity()
Transform.create(projectile, { position: Vector3.create(4, 1, 4) })
MeshRenderer.setSphere(projectile)
syncEntity(projectile, [Transform.componentId])
return projectile
}
自定义同步组件
定义自定义组件并在玩家之间同步:
import { engine, Schemas } from '@dcl/sdk/ecs'
import { syncEntity } from '@dcl/sdk/network'
const ScoreBoard = engine.defineComponent('scoreBoard', {
score: Schemas.Int,
playerName: Schemas.String,
lastUpdated: Schemas.Int64
})
const board = engine.addEntity()
ScoreBoard.create(board, { score: 0, playerName: '', lastUpdated: 0 })
syncEntity(board, [ScoreBoard.componentId])
function addScore(points: number) {
const data = ScoreBoard.getMutable(board)
data.score += points
data.lastUpdated = Date.now()
}
玩家专属数据
使用 PlayerIdentityData 区分玩家:
import { engine, PlayerIdentityData } from '@dcl/sdk/ecs'
engine.addSystem(() => {
for (const [entity] of engine.getEntitiesWith(PlayerIdentityData)) {
const data = PlayerIdentityData.get(entity)
console.log('Player:', data.address, 'Guest:', data.isGuest)
}
})
架构类型
自定义组件可用的架构类型:
| 类型 | 用途 |
|---|---|
Schemas.Boolean | true/false |
Schemas.Int | 整数 |
Schemas.Float | 小数 |
Schemas.String | 文本字符串 |
Schemas.Int64 | 大整数(时间戳) |
Schemas.Vector3 | 3D 坐标 |
Schemas.Quaternion | 旋转 |
Schemas.Color3 | RGB 颜色 |
Schemas.Color4 | RGBA 颜色 |
Schemas.Entity | 实体引用 |
Schemas.Array(innerType) | 值数组 |
Schemas.Map(valueType) | 键值映射 |
Schemas.Optional(innerType) | 可为空的值 |
Schemas.Enum(enumType) | 枚举值 |
父子同步关系
对于具有父子关系的同步实体,请使用 parentEntity(),而不是设置 Transform.parent:
import { syncEntity, parentEntity, getParent, getChildren, removeParent } from '@dcl/sdk/network'
const parent = engine.addEntity()
const child = engine.addEntity()
syncEntity(parent, [Transform.componentId], 1)
syncEntity(child, [Transform.componentId], 2)
// Use parentEntity() — NOT Transform.parent
parentEntity(child, parent)
const parentRef = getParent(child)
const childrenArray = Array.from(getChildren(parent))
// Remove parent relationship
removeParent(child)
连接状态
检查玩家是否已连接到同步房间:
import { isStateSyncronized } from '@dcl/sdk/network'
engine.addSystem(() => {
if (!isStateSyncronized()) return // wait for sync
// safe to read/write synced state
})
注意: SDK 中该函数拼写为 isStateSyncronized(不是“Synchronized”)。
MessageBus
在玩家之间发送自定义消息(即发即弃,不会持久保存):
import { MessageBus } from '@dcl/sdk/message-bus'
const bus = new MessageBus()
bus.on('hit', (data: { damage: number }) => {
console.log('Took damage:', data.damage)
})
bus.emit('hit', { damage: 10 })
syncEntity 与 MessageBus
syncEntity:状态会持久保存,后加入的玩家可获取当前状态,并自动解决冲突MessageBus:即发即弃,后加入的玩家无法收到过去的消息,适合短暂效果- 两者结合使用:用
syncEntity表示门的开关状态,用MessageBus播放门打开时的音效
REST API 调用(fetch)
所有网络调用都必须在 executeTask 中运行,因为 SDK 运行时不支持顶层 await。
import { executeTask } from '@dcl/sdk/ecs'
executeTask(async () => {
try {
const response = await fetch('https://api.example.com/data')
if (!response.ok) {
console.error('HTTP error:', response.status)
return
}
const data = await response.json()
console.log('Response:', data)
} catch (error) {
console.error('Network error:', error)
}
})
签名 Fetch(经过身份验证的请求)
signedFetch 会附加加密签名,以证明玩家身份:
import { signedFetch } from '~system/SignedFetch'
executeTask(async () => {
try {
const response = await signedFetch({
url: 'https://example.com/api/action',
init: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'claimReward', amount: 100 })
}
})
if (!response.ok) {
console.error('HTTP error:', response.status)
return
}
const result = JSON.parse(response.body)
console.log('Result:', result)
} catch (error) {
console.log('Request failed:', error)
}
})
WebSocket 连接
有关完整的 WebSocket 模式(重新连接、心跳、消息格式),请参阅 {baseDir}/references/networking-patterns.md。
基本连接
executeTask(async () => {
const ws = new WebSocket('wss://example.com/ws')
ws.onopen = () => {
console.log('Connected to WebSocket')
ws.send(JSON.stringify({ type: 'join', playerId: 'player123' }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
switch (msg.type) {
case 'gameState': handleGameState(msg); break
case 'playerJoin': handlePlayerJoin(msg); break
case 'playerLeave': handlePlayerLeave(msg); break
}
}
ws.onerror = (error) => console.error('WebSocket error:', error)
ws.onclose = () => console.log('Disconnected')
})
玩家进入/离开事件
检测玩家进入或离开场景:
import { onEnterScene, onLeaveScene } from '@dcl/sdk/src/players'
onEnterScene((player) => {
console.log('Player entered:', player.userId)
})
onLeaveScene((userId) => {
console.log('Player left:', userId)
})
多人游戏测试
打开多个浏览器窗口,在本地测试多人游戏。每个窗口都是一名独立玩家。
离线模式
适用于无需多人游戏的 Decentraland Worlds:
{
"worldConfiguration": {
"fixedAdapter": "offline:offline"
}
}
故障排除
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 玩家之间的状态未同步 | 缺少 syncEntity() 调用 | 所有需要共享的实体都必须调用 syncEntity(entity, [ComponentId1, ComponentId2]) |
| 同步 ID 冲突 | 两个实体使用了相同的数字同步 ID | 使用枚举为每个预定义的同步实体分配唯一 ID |
| 创建者离开后实体消失 | 未提供同步 ID | 为需要持久存在的实体,在 syncEntity() 中添加同步 ID(第三个参数) |
Date.now() 的值损坏 | 使用 Schemas.Number 表示时间戳 | 对超过 13 位的数字(例如 Date.now())使用 Schemas.Int64 |
| 加入时状态尚未就绪 | 同步完成前读取了同步状态 | 在系统中使用 if (!isStateSyncronized()) return 进行保护 |
| MessageBus 消息丢失 | 后加入的玩家期望收到过去的消息 | MessageBus 是即发即弃的。持久状态请使用 syncEntity |
需要服务器端验证或反作弊吗? 请参阅 authoritative-server 技能,了解无头服务器模式。
重要注意事项
- 必须显式同步实体:通过
syncEntity(entity, [componentIds])同步,并传入每个组件的componentId - CRDT 冲突解决:如果两名玩家同时更改同一组件,则以最后写入的值为准
- 没有服务端代码:Decentraland 场景完全在客户端运行,并通过 CRDT 同步
- 实体数量限制适用:每个同步的实体都会计入场景的实体预算
- 自定义架构必须具有确定性:所有客户端上相同的组件名称必须对应相同的架构
- 时间戳请使用
Schemas.Int64:Schemas.Number会损坏较大的数字(13 位以上)。对于Date.now()等值,请始终使用Schemas.Int64 - 如需支持验证和反作弊的服务端权威多人游戏,请参阅
authoritative-server技能