mcpField
M
multiplayer-sync

multiplayer-sync

github.com/dcl-regenesislabs开发1737 次安装21 周前更新
代码仓库

关于 multiplayer-sync

面向 Decentraland 多人场景开发者的同步技能,介绍如何在玩家之间共享实体状态,并区分持久状态、即时消息和外部服务器通信。还包含自定义同步组件、玩家身份及连接状态处理。

主要功能

  1. 01同步实体组件与状态
  2. 02使用 MessageBus 发送即时消息
  3. 03通过 REST、signedFetch 或 WebSocket 通信
  4. 04定义自定义同步组件
  5. 05检测玩家身份与同步状态

适用场景

  1. 01同步多人可见的门或计分板
  2. 02广播聊天或音效等即时事件
  3. 03将数据持久化到外部服务器
  4. 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与服务器进行实时双向通信取决于连接实时游戏服务器、实时聊天、权威式多人游戏

决策流程:

  1. 是否需要让每位玩家都看到相同状态,包括晚加入的玩家?--> syncEntity
  2. 是否只需要向当前在场景中的玩家发送一次性事件?--> MessageBus
  3. 是否需要在所有玩家离开后仍保留状态,或需要服务器端验证?--> 通过 fetch 或 signedFetch 使用外部服务器
  4. 是否需要与服务器持续进行实时通信?--> WebSocket
  5. 可自由组合多种方式:使用 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.Booleantrue/false
Schemas.Int整数
Schemas.Float小数
Schemas.String文本字符串
Schemas.Int64大整数(时间戳)
Schemas.Vector33D 坐标
Schemas.Quaternion旋转
Schemas.Color3RGB 颜色
Schemas.Color4RGBA 颜色
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 技能

评论

支持 Markdown